diff --git a/README.md b/README.md index df944f2..c231aa2 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ nExBot is a modular Tibia bot that automates hunting, healing, navigation, and a | **TargetBot** | AI combat with 9-stage priority scoring, behavior learning, wave prediction, and movement coordination | | **Hunt Analyzer** | Real-time session analytics — kills/hour, XP/hour, profit, Hunt Score, efficiency insights | | **Containers** | Auto-open, quiver management, and container role assignments | +| **Follow Player** | Party hunt companion — stays near leader while attacking monsters | | **Extras** | Anti-RS, alarms, equipment swapping, conditions, combo system, push max | --- @@ -96,6 +97,11 @@ Enable CaveBot and TargetBot, press **Start** (`Ctrl+Z`), and monitor progress i - **Monster Insights** — 12 SRP modules that learn monster behavior in real-time - **Movement coordination** — intent-based voting resolves wave avoidance, keep-distance, AoE positioning, and chase +### 👥 Follow Player — Party Hunt +- **Stay near leader** — attacks monsters but never walks past the party leader +- **Parallel mode** — walks toward leader while attacking (ASM stays active via forceWalk) +- **Lost leader recovery** — walks to last known position for up to 10 seconds + ### 🧭 CaveBot — Navigation - **Walking engine v4.0** — smooth autoWalk pipelining (5+ tiles), step pipelining (2-step lookahead), PathCursor preservation, adaptive recovery with path validation and exponential-decay blacklists - **15+ waypoint types** — goto, label, action, buy, sell, lure, standLure, depositor, travel, imbuing, tasker, withdraw @@ -158,6 +164,7 @@ _Loader.lua (entry point) | ⚔️ [AttackBot](docs/ATTACKBOT.md) | Attack spells, runes, AoE optimization | | 🧭 [CaveBot](docs/CAVEBOT.md) | Navigation, waypoints, supply management | | 🎯 [TargetBot](docs/TARGETBOT.md) | Combat AI, Monster Insights, movement | +| 👥 [Follow Player](docs/FOLLOW.md) | Party hunt companion — stay near leader | | 📦 [Containers](docs/CONTAINERS.md) | Container management, quiver system | | 📊 [Hunt Analyzer](docs/SMARTHUNT.md) | Session analytics and insights (SmartHunt) | | 🛠️ [Extras & Tools](docs/EXTRAS.md) | Safety, equipment, utilities | diff --git a/_Loader.lua b/_Loader.lua index afa8d8c..0df1c31 100644 --- a/_Loader.lua +++ b/_Loader.lua @@ -329,7 +329,6 @@ do if not nExBot._clientPrinted then nExBot._clientPrinted = true - print("[nExBot] Client detected: " .. tostring(nExBot.clientName) .. " (" .. tostring(nExBot.clientType) .. ")") end end @@ -349,7 +348,6 @@ local function autoDetectClient(attempt, maxAttempts) nExBot.isOpenTibiaBR = acl.isOpenTibiaBR() if newType ~= prevType or nExBot.clientName ~= prevName then - print("[nExBot] Client detected (late): " .. tostring(nExBot.clientName) .. " (" .. tostring(newType) .. ")") end if nExBot.isOpenTibiaBR then @@ -366,7 +364,6 @@ local function autoDetectClient(attempt, maxAttempts) table.insert(keys, k) end end - print("[nExBot] Client signals: signals=" .. table.concat(keys, ",")) end end return @@ -393,6 +390,8 @@ loadCategory("constants", { -- ============================================================================ loadCategory("utils", { "utils/shared", + "utils/shared_helpers", + "utils/storage_engine", "utils/ring_buffer", "utils/client_helper", "utils/safe_creature", @@ -444,7 +443,6 @@ loadCategory("features_legacy", { "pushmax", "combo", "HealBot", - "new_healer", "AttackBot", }) @@ -455,7 +453,6 @@ loadCategory("tools_legacy", { "ingame_editor", "Dropper", "Containers", - "container_opener", "quiver_manager", "quiver_label", "tools", diff --git a/cavebot/actions.lua b/cavebot/actions.lua index ef0d747..b815b81 100644 --- a/cavebot/actions.lua +++ b/cavebot/actions.lua @@ -5,7 +5,6 @@ local getClient = nExBot.Shared.getClient local getClientVersion = nExBot.Shared.getClientVersion local oldTibia = getClientVersion() < 960 -local nextTile = nil -- Throttle table for unknown floor-change minimap color warnings (once per tile+color) local warnedUnknownFloor = {} @@ -14,8 +13,6 @@ local warnedUnknownFloor = {} local DIR_MOD_LOOKUP = Directions.DIR_TO_OFFSET -- Direction-offset helper using Directions module -local nextPos = nil -- creature -local nextPosF = nil -- furniture local function modPos(dir) local mod = DIR_MOD_LOOKUP[dir] if mod then @@ -262,7 +259,6 @@ CaveBot.registerAction("delay", "#AAAAAA", function(value, retries, prev) local random local final - if #data == 2 then random = tonumber(data[2]:trim()) end @@ -334,16 +330,6 @@ end) The walkTo function now handles path caching internally. ]] --- Walk strategy enum -local WALK_STRATEGY = { - DIRECT = 1, - ATTACK_BLOCKER = 2, - FAILED = 3 -} - --- Direction offset lookup (reuse canonical table) -local DIR_OFFSET = DIR_MOD_LOOKUP - -- Check if path is blocked by attackable monster local function getBlockingMonster(playerPos, destPos, maxDist) -- Only check if we're close to destination @@ -361,7 +347,7 @@ local function getBlockingMonster(playerPos, destPos, maxDist) -- Check first step for blocking monster local dir = path[1] - local offset = DIR_OFFSET[dir] + local offset = DIR_MOD_LOOKUP[dir] if not offset then return nil end local checkPos = { @@ -468,25 +454,10 @@ CaveBot.registerAction("goto", "green", function(value, retries, prev) CaveBot.ensureNavigatorRoute(playerPos.z) end - -- ========== FLOOR CHECK ========== - if destPos.z ~= playerPos.z then - return false, true - end - - -- ========== FORWARD PASS CHECK ========== - -- If the navigator confirms the player has already passed this WP on the route, - -- advance immediately. This handles smooth walk-through transitions where A* paths - -- carry the player past a WP before the goto action's arrival check fires. - if WaypointNavigator and WaypointNavigator.hasPassedWaypoint then - local currentAction = ui and ui.list and ui.list:getFocusedChild() - local waypointIdx = currentAction and ui.list:getChildIndex(currentAction) or nil - if waypointIdx and WaypointNavigator.hasPassedWaypoint(playerPos, waypointIdx, destPos) then - CaveBot.clearWaypointTarget() - return true - end - end - - -- ========== FLOOR-CHANGE TILE DETECTION ========== + -- ========== FLOOR-CHANGE TILE DETECTION (before floor check) ========== + -- Allow goto to target floor-change tiles on different floors — the bot walks + -- there, steps on the tile, and waits for the Z transition. This is how the bot + -- recovers when stuck on the wrong floor (e.g. fell down a hole). local Client = getClient() local minimapColor = (Client and Client.getMinimapColor) and Client.getMinimapColor(destPos) or (g_map and g_map.getMinimapColor(destPos)) or 0 local isFloorChange = (FloorItems and FloorItems.isFloorChangeTile) and FloorItems.isFloorChangeTile(destPos) or false @@ -499,8 +470,6 @@ CaveBot.registerAction("goto", "green", function(value, retries, prev) elseif minimapColor == 212 or minimapColor == 213 then expectedFloorAfterChange = destPos.z + 1 end - -- Fallback: if minimap color didn't match known floor-change colors, - -- default to destPos.z so downstream logic always has a value if expectedFloorAfterChange == nil then expectedFloorAfterChange = destPos.z local warnKey = destPos.x .. "," .. destPos.y .. "," .. destPos.z .. ":" .. tostring(minimapColor) @@ -511,6 +480,26 @@ CaveBot.registerAction("goto", "green", function(value, retries, prev) end end + -- ========== FLOOR CHECK ========== + -- Non-floor-change WPs on a different floor → instantFail. + -- Floor-change WPs on a different floor → allowed (walk there, wait for Z change). + if destPos.z ~= playerPos.z and not isFloorChange then + return false, true + end + + -- ========== FORWARD PASS CHECK ========== + -- If the navigator confirms the player has already passed this WP on the route, + -- advance immediately. This handles smooth walk-through transitions where A* paths + -- carry the player past a WP before the goto action's arrival check fires. + if WaypointNavigator and WaypointNavigator.hasPassedWaypoint then + local currentAction = ui and ui.list and ui.list:getFocusedChild() + local waypointIdx = currentAction and ui.list:getChildIndex(currentAction) or nil + if waypointIdx and WaypointNavigator.hasPassedWaypoint(playerPos, waypointIdx, destPos) then + CaveBot.clearWaypointTarget() + return true + end + end + -- ========== ARRIVAL PRECISION ========== -- Adaptive: scale precision by distance to next goto WP to prevent zone overlap. -- Floor-change WPs keep precision=0 (must step on the exact tile). @@ -599,7 +588,6 @@ CaveBot.registerAction("goto", "green", function(value, retries, prev) -- Walk precision matches arrival precision minus 1: A* stops at the zone -- boundary rather than overshooting to the center. local walkParams = { - ignoreNonPathable = true, precision = isFloorChange and 0 or math.max(0, precision - 1), allowFloorChange = isFloorChange } diff --git a/cavebot/bank.lua b/cavebot/bank.lua index 835ed9e..5dfdfcc 100644 --- a/cavebot/bank.lua +++ b/cavebot/bank.lua @@ -84,9 +84,8 @@ CaveBot.Extensions.Bank.setup = function() }) end - onTalk(function(name, level, mode, text, channelId, pos) - if CaveBot.isOff() then return end + if not CaveBot or not CaveBot.isOff or CaveBot.isOff() then return end if mode == 51 and text:find("Your account balance is") then balance = getFirstNumberInText(text) end diff --git a/cavebot/buy_supplies.lua b/cavebot/buy_supplies.lua index 11f45af..4c3d10a 100644 --- a/cavebot/buy_supplies.lua +++ b/cavebot/buy_supplies.lua @@ -47,8 +47,8 @@ CaveBot.Extensions.BuySupplies.setup = function() table.insert(possibleItems, v.id) end - for id, values in pairs(Supplies.getItemsData()) do - id = tonumber(id) + for key, values in pairs(Supplies.getItemsData()) do + local id = tonumber(key) if table.find(possibleItems, id) then local max = values.max local current = player:getItemsCount(id) diff --git a/cavebot/cavebot.lua b/cavebot/cavebot.lua index dd5bb9a..558d277 100644 --- a/cavebot/cavebot.lua +++ b/cavebot/cavebot.lua @@ -1,3 +1,4 @@ +local zChanging = nExBot.zChanging or function() return false end local cavebotMacro = nil local config = nil @@ -273,9 +274,7 @@ CaveBot.clearWalkingState = function() walkState.lastVerifyTime = nil end --- ============================================================================ -- FORWARD DECLARATIONS (functions defined later but used early) --- ============================================================================ local findNearestGlobalWaypoint -- Defined in WAYPOINT FINDER section local findReachableWaypoint -- Unified search (TSP scoring), defined in WAYPOINT FINDER local checkStartupWaypoint -- Defined in STARTUP DETECTION section @@ -291,6 +290,7 @@ local waypointCacheValid = false local waypointCacheFloors = {} local startupWaypointFound = false local startupCheckTime = nil -- Set on first check to enforce 500ms delay +local startupFocusTarget = nil -- Fallback child ref when focusChild fails on startup --[[ WAYPOINT ENGINE @@ -300,9 +300,7 @@ local startupCheckTime = nil -- Set on first check to enforce 500ms delay No path prevalidation — the goto callback is the real validator. ]] --- ============================================================================ -- WAYPOINT ENGINE STATE --- ============================================================================ WaypointEngine = { -- State machine: NORMAL ↔ RECOVERING @@ -340,9 +338,7 @@ WaypointEngine = { lastTickTime = 0, } --- ============================================================================ -- BLACKLIST UTILITIES --- ============================================================================ local function isWaypointBlacklisted(child) if not child then return false end @@ -376,9 +372,7 @@ local function clearWaypointBlacklist() end end --- ============================================================================ -- STATE MACHINE (NORMAL ↔ RECOVERING) --- ============================================================================ local function transitionTo(newState) WaypointEngine.state = newState @@ -424,23 +418,22 @@ local function recordFailure() WaypointEngine.failureCount = WaypointEngine.failureCount + 1 end --- ============================================================================ -- RECOVERY --- ============================================================================ -- Focus a waypoint for recovery (cancel walk, reset retries) focusWaypointForRecovery = function(targetChild, targetIndex) if CaveBot.stopAutoWalk then CaveBot.stopAutoWalk() end - ui.list:focusChild(targetChild) + local ok, err = pcall(ui.list.focusChild, ui.list, targetChild) + if not ok then + startupFocusTarget = targetChild + end actionRetries = 0 WaypointEngine.recoveryJustFocused = true end --- ============================================================================ -- DRIFT DETECTION: Proactive nearest-WP refocus -- When the player drifts far from the current WP (e.g. after chasing a monster), -- find and focus the nearest reachable WP instead of walking all the way back. --- ============================================================================ local function maybeRefocusNearestWaypoint(playerPos) if not playerPos then return false end @@ -603,6 +596,52 @@ local function executeRecovery() return true end + -- Floor-change tile recovery: find nearest stair/rope/ladder on current floor + -- that leads to a floor with waypoints. Scans minimap colors for floor-change tiles. + local function findNearestFloorChangeTile(pPos, targetFloors) + local getMinimapColor = g_map and g_map.getMinimapColor + if not getMinimapColor then return nil end + local FC_COLORS = { [210]=true, [211]=true, [212]=true, [213]=true } + local best, bestDist = nil, math.huge + -- Scan 8-tile radius around player + for dx = -8, 8 do + for dy = -8, 8 do + local tilePos = {x = pPos.x + dx, y = pPos.y + dy, z = pPos.z} + local ok, color = pcall(getMinimapColor, tilePos) + if ok and FC_COLORS[color] then + local d = math.abs(dx) + math.abs(dy) + if d < bestDist then + bestDist = d + best = tilePos + end + end + end + end + return best + end + + -- Determine which floors have WPs + local floorsWithWPs = {} + for _, wp in pairs(waypointPositionCache) do + if wp.isGoto then floorsWithWPs[wp.z] = true end + end + local targetFloors = {} + for floorZ in pairs(floorsWithWPs) do + if floorZ ~= playerPos.z then targetFloors[#targetFloors + 1] = floorZ end + end + + if #targetFloors > 0 then + local fcTile = findNearestFloorChangeTile(playerPos, targetFloors) + if fcTile then + -- Walk to the floor-change tile; Z-change handler at top of macro will + -- transition to NORMAL after the actual floor change fires. Don't + -- transition here — if the walk stalls we stay in RECOVERING. + print("[CaveBot] Recovery: walking to floor-change tile at " .. fcTile.x .. "," .. fcTile.y .. "," .. fcTile.z) + CaveBot.walkTo(fcTile, 20, { allowFloorChange = true, precision = 0 }) + return true -- stay in RECOVERING until Z-change handler fires + end + end + -- Cross-floor fallback (adjacent floors ±1) child, idx = findReachableWaypoint(playerPos, { maxCandidates = 30, searchAllFloors = true }) if child then @@ -618,9 +657,7 @@ local function executeRecovery() return true end --- ============================================================================ -- MAIN ENGINE TICK (called from macro loop) --- ============================================================================ local function runWaypointEngine() local state = WaypointEngine.state @@ -665,9 +702,7 @@ local function initTargetBotCache() end end --- ============================================================================ -- EVENTBUS INTEGRATION: Instant waypoint arrival detection --- ============================================================================ -- Track current waypoint target for instant arrival detection local currentWaypointTarget = { @@ -762,6 +797,9 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking checkStartupWaypoint() end + -- Guard: skip action processing until startup check completes + if not startupWaypointFound then return end + -- WAYPOINT ENGINE: High-performance stuck detection and recovery if runWaypointEngine() then return -- Engine handled recovery, skip normal processing @@ -799,10 +837,18 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking return end end + + -- ASM BACKUP: block CaveBot while attack is in progress + if AttackStateMachine and AttackStateMachine.isActive and AttackStateMachine.isActive() then + if not (targetBotIsCaveBotAllowed and targetBotIsCaveBotAllowed()) then + safeResetWalking() + WaypointEngine.wasTargetBotBlocking = true + return + end + end -- BACKUP CHECK: If EventTargeting reports combat active, also pause if EventTargeting and EventTargeting.isCombatActive and EventTargeting.isCombatActive() then - -- Only pause if we're NOT allowed by TargetBot if not (targetBotIsCaveBotAllowed and targetBotIsCaveBotAllowed()) then safeResetWalking() WaypointEngine.wasTargetBotBlocking = true @@ -890,7 +936,18 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking if actionCount == 0 then return end -- Get current action (single call pattern) - local currentAction = uiList:getFocusedChild() or uiList:getFirstChild() + -- If getFocusedChild returns nil (focusChild may have failed on startup), + -- use startupFocusTarget override. Clear it once focus is restored. + local currentAction + local focusedChild = uiList:getFocusedChild() + if focusedChild then + currentAction = focusedChild + startupFocusTarget = nil + elseif startupFocusTarget then + currentAction = startupFocusTarget + else + currentAction = uiList:getFirstChild() + end if not currentAction then return end -- Z-MISMATCH GUARD: If focused WP is a goto on a different floor than player, @@ -1019,9 +1076,10 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking end -- Check if action changed focus during execution - local newFocused = uiList:getFocusedChild() + local newFocused = startupFocusTarget or uiList:getFocusedChild() if currentAction ~= newFocused then currentAction = newFocused or uiList:getFirstChild() + startupFocusTarget = nil actionRetries = 0 prevActionResult = true end @@ -1242,9 +1300,7 @@ CaveBot.blacklistWaypoint = function(child, ttl) end end --- ============================================================================ -- WAYPOINT FINDER UTILITIES --- ============================================================================ -- Parse position from any waypoint text that contains coordinates. -- Supports "goto:x,y,z[,precision]", "stand:x,y,z", "lure:x,y,z", "use:x,y,z", etc. @@ -1276,17 +1332,10 @@ local function parseWaypointPosition(text) end -- Legacy: parseGotoPosition for backward compatibility -local function parseGotoPosition(text) - if not text or not string.starts(text, "goto:") then return nil end - return parseWaypointPosition(text) -end - -- Distance functions: delegate to SSoT (constants/directions.lua) -- chebyshevDist is already resolved at forward-declaration above. --- ============================================================================ -- WAYPOINT CACHE (DRY: Single source of truth for waypoint positions) --- ============================================================================ -- Note: waypointPositionCache, waypointCacheValid, waypointCacheFloors declared at top @@ -1355,7 +1404,6 @@ findNearestSameFloorGoto = function(pp, floorZ, maxDist) return bestChild, bestIdx end --- ============================================================================ -- WAYPOINT FINDER (distance sort + path validation on top candidates) -- -- Phase 1: Collect candidates by Chebyshev distance. @@ -1363,7 +1411,6 @@ end -- This catches WPs behind walls without validating every single WP. -- Phase 3: Proximity guarantee — always validate the 3 closest WPs even if -- they exceed gotoMaxDistance, so very nearby WPs are never skipped. --- ============================================================================ findReachableWaypoint = function(playerPos, options) buildWaypointCache() @@ -1454,25 +1501,17 @@ findReachableWaypoint = function(playerPos, options) local shouldValidate = (rank <= PROXIMITY_GUARANTEE) or c.withinRange if not shouldValidate then goto skip_candidate end - -- Path validation: use strict findPath (no ignoreNonPathable) for top candidates + -- Path validation: strict findPath (no ignoreNonPathable). + -- Generous maxSteps (100) accounts for obstacle detours that make + -- the real path 2-5x longer than Chebyshev distance. local ps = getPS() if rank <= PATH_VALIDATE_COUNT and ps and ps.findPath then local path = ps.findPath(playerPos, c, { - maxSteps = math.min(math.floor(c.dist * 1.5) + 5, 50), + maxSteps = math.min(math.floor(c.dist * 3) + 10, 100), }) if path and #path > 0 then validated[#validated + 1] = c end - -- If strict fails, try with ignoreNonPathable as fallback - if not path or #path == 0 then - path = ps.findPath(playerPos, c, { - maxSteps = math.min(math.floor(c.dist * 1.5) + 5, 50), - ignoreNonPathable = true, - }) - if path and #path > 0 then - validated[#validated + 1] = c - end - end else -- Beyond validation budget: accept by distance (legacy behavior) if c.withinRange then @@ -1523,10 +1562,8 @@ findNearestGlobalWaypoint = function(playerPos, maxDist, options) }) end --- ============================================================================ -- STARTUP WAYPOINT DETECTION -- Finds nearest waypoint when bot starts (relog scenario) --- ============================================================================ -- Note: startupWaypointFound and startupCheckTime declared at top @@ -1546,28 +1583,7 @@ checkStartupWaypoint = function() buildWaypointCache() - -- Check if current focused waypoint is already reachable - local currentAction = ui.list:getFocusedChild() - if currentAction then - local currentIndex = ui.list:getChildIndex(currentAction) - local currentWp = waypointPositionCache[currentIndex] - - if currentWp and currentWp.z == playerPos.z then - local dist = chebyshevDist(playerPos, currentWp) - local maxDist = CaveBot.getMaxGotoDistance() - - if dist <= maxDist then - local path = findPath(playerPos, currentWp, maxDist, { ignoreNonPathable = true }) - if path then - -- Current waypoint is reachable, no need to search - startupWaypointFound = true - return - end - end - end - end - - -- Current waypoint not reachable - find nearest globally + -- Find nearest reachable waypoint globally local maxDist = CaveBot.getMaxGotoDistance() local nearestChild, nearestIndex = findNearestGlobalWaypoint(playerPos, maxDist, { maxCandidates = 10, @@ -1577,8 +1593,9 @@ checkStartupWaypoint = function() if nearestChild then print("[CaveBot] Startup: Found nearest reachable waypoint at index " .. nearestIndex) + startupWaypointFound = true -- Set BEFORE focusWaypointForRecovery (focusChild may crash) + startupFocusTarget = nearestChild focusWaypointForRecovery(nearestChild, nearestIndex) - startupWaypointFound = true return end @@ -1591,12 +1608,13 @@ checkStartupWaypoint = function() if extendedChild then print("[CaveBot] Startup: Found waypoint at extended range, index " .. extendedIndex) + startupWaypointFound = true + startupFocusTarget = extendedChild focusWaypointForRecovery(extendedChild, extendedIndex) else warn("[CaveBot] Startup: No reachable waypoint found. Bot may be stuck.") + startupWaypointFound = true end - - startupWaypointFound = true end -- Reset startup check (called on config change) diff --git a/cavebot/clear_tile.lua b/cavebot/clear_tile.lua index 8ff0b31..6e61b0b 100644 --- a/cavebot/clear_tile.lua +++ b/cavebot/clear_tile.lua @@ -10,7 +10,6 @@ CaveBot.Extensions.ClearTile.setup = function() local stand = false local pPos = player:getPosition() - for i, value in ipairs(data) do value = value:lower():trim() if value == "stand" then @@ -20,7 +19,6 @@ CaveBot.Extensions.ClearTile.setup = function() end end - if not #pos == 3 then warn("CaveBot[ClearTile]: invalid value. It should be position (x,y,z), is: " .. value) return false diff --git a/cavebot/config.lua b/cavebot/config.lua index eee5b3b..96161a7 100644 --- a/cavebot/config.lua +++ b/cavebot/config.lua @@ -18,8 +18,6 @@ CaveBot.Config.setup = function() add("ping", "Server ping", 100) add("walkDelay", "Walk delay", 10) add("ignoreFields", "Ignore fields", true) - add("walkingDebug", "Walking debug", false) -- Disabled by default for performance - add("skipBlocked", "Skip blocked path", false) add("mapClick", "Map click walking", true) add("useDelay", "Delay after use", 400) add("autoUseTools", "Auto use tools", true) @@ -120,7 +118,3 @@ CaveBot.Config.set = function(id, value) end end --- Convenience helper to toggle walking debug at runtime -CaveBot.setWalkingDebug = function(enabled) - CaveBot.Config.set("walkingDebug", enabled) -end \ No newline at end of file diff --git a/cavebot/d_withdraw.lua b/cavebot/d_withdraw.lua index c675892..69a50d9 100644 --- a/cavebot/d_withdraw.lua +++ b/cavebot/d_withdraw.lua @@ -26,7 +26,6 @@ CaveBot.Extensions.DWithdraw.setup = function() capLimit = tonumber(data[4]:trim()) end - -- cap check if freecap() < (capLimit or 200) then local Client = getClient() @@ -90,8 +89,7 @@ CaveBot.Extensions.DWithdraw.setup = function() CaveBot.PingDelay(2) - local Client = getClient() - local containers = (Client and Client.getContainers) and Client.getContainers() or (g_game and g_game.getContainers()) or {} + local containers = nExBot.Shared.getContainers() for i, container in pairs(containers) do if string.find(container:getName():lower(), "depot box") then for j, item in ipairs(container:getItems()) do diff --git a/cavebot/editor.lua b/cavebot/editor.lua index 7dc3bc0..7fe5154 100644 --- a/cavebot/editor.lua +++ b/cavebot/editor.lua @@ -1,4 +1,5 @@ CaveBot.Editor = {} +local zChanging = nExBot.zChanging or function() return false end CaveBot.Editor.Actions = {} -- also works as registerAction(action, params), then text == action @@ -177,7 +178,6 @@ CaveBot.Editor.show = function() CaveBot.Editor.ui:show() end - CaveBot.Editor.hide = function() CaveBot.Editor.ui:hide() end diff --git a/cavebot/minimap.lua b/cavebot/minimap.lua index 3afc478..fbb317d 100644 --- a/cavebot/minimap.lua +++ b/cavebot/minimap.lua @@ -3,7 +3,7 @@ local minimap = modules and modules.game_minimap and modules.game_minimap.minima -- Early exit if minimap not available if not minimap then - print("[Minimap] Minimap widget not available, skipping minimap integration") + warn("[Minimap] Minimap widget not available, skipping minimap integration") return end @@ -11,19 +11,15 @@ end local function safeAddCaveBotWaypoint(x, y, z) -- Check all required CaveBot components exist if not CaveBot then - print("[Minimap] CaveBot not loaded") return false end if not CaveBot.addAction then - print("[Minimap] CaveBot.addAction not available") return false end if not CaveBot.actionList then - print("[Minimap] CaveBot.actionList not initialized yet") return false end if not CaveBot.save then - print("[Minimap] CaveBot.save not available") return false end @@ -33,10 +29,9 @@ local function safeAddCaveBotWaypoint(x, y, z) end) if success then - print("[CaveBot] Added goto: " .. x .. "," .. y .. "," .. z) return true else - print("[Minimap] Error adding waypoint: " .. tostring(err)) + warn("[Minimap] Error adding waypoint: " .. tostring(err)) return false end end diff --git a/cavebot/recorder.lua b/cavebot/recorder.lua index cef7f91..d630052 100644 --- a/cavebot/recorder.lua +++ b/cavebot/recorder.lua @@ -25,6 +25,7 @@ CaveBot.Recorder = {} +local zChanging = nExBot.zChanging or function() return false end local isEnabled = nil local lastPos = nil -- last RECORDED position (the waypoint) local prevStepPos = nil -- position on the previous step (for direction tracking) @@ -34,9 +35,7 @@ local pendingCorner = nil -- position to record when a turn is confirmed local pendingTurnDir = nil -- direction of the pending turn {x, y} local pendingTurnCount = 0 -- steps taken in pending direction (for turnConfirmSteps) --- ============================================================================ -- CONFIGURATION --- ============================================================================ local config = { -- Adaptive distance thresholds (Euclidean) @@ -49,9 +48,7 @@ local config = { collinearTolerance = 0.15, -- ~8.6 degrees (dot product threshold: cos(8.6°) ≈ 0.989) } --- ============================================================================ -- GEOMETRY HELPERS --- ============================================================================ --- Euclidean distance between two positions. local function euclideanDist(a, b) @@ -88,9 +85,7 @@ end -- Track up to 2 previously recorded positions for collinear checks local prevRecorded = nil -- position recorded before lastPos --- ============================================================================ -- RECORDING LOGIC --- ============================================================================ local function addPosition(pos) -- Collinear check: if lastPos sits on the line from prevRecorded to pos, @@ -121,27 +116,37 @@ end local function setup() onPlayerPositionChange(function(newPos, oldPos) - if zChanging() then return end - if CaveBot.isOn() or not isEnabled then return end - - -- ======== FIRST STEP ======== - if not lastPos then - addPosition(oldPos) + -- Floor change / teleport detection runs BEFORE zChanging() guard + + if newPos and oldPos and (newPos.z ~= oldPos.z or math.abs(oldPos.x - newPos.x) > 1 or math.abs(oldPos.y - newPos.y) > 1) then + if not lastPos then + addPosition(oldPos) + prevStepPos = newPos + prevDirection = nil + pendingCorner = nil + pendingTurnDir = nil + pendingTurnCount = 0 + lastPos = oldPos + end + addStairs(oldPos) + -- Force-record landing tile (bypass collinearity check) + CaveBot.addAction("goto", newPos.x .. "," .. newPos.y .. "," .. newPos.z .. ",0", true) + prevRecorded = newPos + lastPos = newPos + stepsSinceLast = 0 prevStepPos = newPos - prevDirection = nil pendingCorner = nil pendingTurnDir = nil pendingTurnCount = 0 return end - -- ======== FLOOR CHANGE / TELEPORT ======== - if newPos.z ~= oldPos.z or math.abs(oldPos.x - newPos.x) > 1 or math.abs(oldPos.y - newPos.y) > 1 then - -- Record the pre-floor-change position with precision=0 - addStairs(oldPos) - -- Anchor destination: record newPos so the route has a starting point - -- on the new floor (floor change) or after the jump (same-floor teleport) - addPosition(newPos) + if zChanging() then return end + if CaveBot.isOn() or not isEnabled then return end + + -- ======== FIRST STEP ======== + if not lastPos then + addPosition(oldPos) prevStepPos = newPos prevDirection = nil pendingCorner = nil @@ -227,9 +232,7 @@ local function setup() end) end --- ============================================================================ -- PUBLIC API --- ============================================================================ CaveBot.Recorder.isOn = function() return isEnabled diff --git a/cavebot/tasker.lua b/cavebot/tasker.lua index e616d61..6f0b00f 100644 --- a/cavebot/tasker.lua +++ b/cavebot/tasker.lua @@ -112,7 +112,6 @@ CaveBot.Extensions.Tasker.setup = function() return true end - elseif marker == 3 then -- reporting task CaveBot.Conversation("hi", "report", "task") delay(talkDelay*3) diff --git a/cavebot/walking.lua b/cavebot/walking.lua index b72cd45..428be49 100644 --- a/cavebot/walking.lua +++ b/cavebot/walking.lua @@ -14,13 +14,11 @@ CaveBot.isNearFloorChangeTile CaveBot.getStepDuration(diagonal) CaveBot.isPlayerWalking() - CaveBot.doWalking() ]] --- ============================================================================ -- DEPENDENCIES --- ============================================================================ +local zChanging = nExBot.zChanging or function() return false end local PathUtils = PathUtils if not PathUtils then local ok, mod = pcall(require, "utils.path_utils") @@ -45,9 +43,7 @@ if not CaveBot.fullResetWalking then CaveBot.fullResetWalking = function() end e local getClient = nExBot.Shared.getClient --- ============================================================================ -- DIRECTION & TILE UTILITIES (thin delegates) --- ============================================================================ local Dirs = Directions or {} local DIR_TO_OFFSET = Dirs.DIR_TO_OFFSET or {} @@ -106,9 +102,7 @@ local function stopAutoWalk() if g_game and g_game.stop then g_game.stop() end end --- ============================================================================ -- KEYBOARD NUDGE (fallback when pathfinding fails) --- ============================================================================ local ADJACENT_DIRS = {} if Directions and Directions.ADJACENT then @@ -121,6 +115,7 @@ end local lastNudgeDir = nil local lastNudgeTime = 0 +local lastStepTime = 0 --- Try a single keyboard step toward dest. Returns "nudge" or false. local function tryKeyboardNudge(playerPos, dest) @@ -144,7 +139,7 @@ local function tryKeyboardNudge(playerPos, dest) local off = DIR_TO_OFFSET[d] if off then local target = {x = playerPos.x + off.x, y = playerPos.y + off.y, z = playerPos.z} - if not isFloorChangeTile(target) then + if not isFloorChangeTile(target) and PathUtils.isTileWalkable(target) then PS().walkStep(d) lastNudgeDir = d lastNudgeTime = now @@ -156,19 +151,15 @@ local function tryKeyboardNudge(playerPos, dest) return false end --- ============================================================================ -- MODULE STATE (minimal) --- ============================================================================ local lastWalkZ = nil local lastSafePos = nil local MAX_PATHFIND_DIST = 50 --- ============================================================================ -- CORE: FIND A WALKABLE PATH -- Tries cached cursor first, then strict, then relaxed. -- Always validates first step against canWalkDirection before accepting. --- ============================================================================ --- Check if a direction (or its smoothed variant) is physically walkable. --- Returns the walkable direction, or nil. @@ -228,13 +219,24 @@ local function findWalkablePath(playerPos, dest, opts) return path, false end - -- 3) RELAXED pathfinding (last resort, includes ignoreNonPathable) - local relaxedPath, wasRelaxed = PS().findPathRelaxed(playerPos, dest, { + -- 3) RELAXED pathfinding (last resort — respects walkability, allows creatures/unseen/fields) + local relaxedOpts = { maxSteps = maxSteps, - ignoreCreatures = opts.ignoreCreatures or false, + ignoreCreatures = true, ignoreFields = opts.ignoreFields or false, precision = opts.precision or 0, - }) + } + local relaxedPath = PS().findPath(playerPos, dest, relaxedOpts) + + if not (relaxedPath and #relaxedPath > 0 and resolveWalkableDir(relaxedPath[1])) then + relaxedOpts.allowUnseen = true + relaxedPath = PS().findPath(playerPos, dest, relaxedOpts) + end + + if not (relaxedPath and #relaxedPath > 0 and resolveWalkableDir(relaxedPath[1])) then + relaxedOpts.ignoreFields = true + relaxedPath = PS().findPath(playerPos, dest, relaxedOpts) + end if relaxedPath and #relaxedPath > 0 and resolveWalkableDir(relaxedPath[1]) then PS().setCursor(relaxedPath, dest) @@ -244,18 +246,16 @@ local function findWalkablePath(playerPos, dest, opts) local cur = PS().getCursor() if cur then cur.path = relaxedPath end end - return relaxedPath, wasRelaxed + return relaxedPath, true end -- No walkable path found return nil, false end --- ============================================================================ -- DISPATCH: KEYBOARD STEP vs AUTOWALK --- ============================================================================ -local KEYBOARD_THRESHOLD = 12 +local KEYBOARD_THRESHOLD = 2 --- Walk a single keyboard step along the path. Returns true on success. local function keyboardStep(path, playerPos, curIdx) @@ -265,8 +265,13 @@ local function keyboardStep(path, playerPos, curIdx) local walkDir = resolveWalkableDir(dir) if not walkDir then return false end + local isDiag = walkDir >= 4 + local stepDur = PS().rawStepDuration(isDiag) or 180 + if (now - lastStepTime) < stepDur then return "walking" end + PS().walkStep(walkDir) - PS().advanceCursor(1, PS().rawStepDuration(walkDir >= 4)) + lastStepTime = now + PS().advanceCursor(1, stepDur) return true end @@ -298,14 +303,12 @@ local function autoWalkDispatch(path, playerPos, curIdx, safeSteps, maxDist) end local precision = chunkSteps >= 10 and 1 or 0 - PS().autoWalk(chunkDest, maxDist, {ignoreNonPathable = true, precision = precision}) + PS().autoWalk(chunkDest, maxDist, {precision = precision}) PS().advanceCursor(chunkSteps, PS().rawStepDuration(false)) return true end --- ============================================================================ -- MAIN: CaveBot.walkTo --- ============================================================================ CaveBot.walkTo = function(dest, maxDist, params) local playerPos = pos() @@ -341,8 +344,8 @@ CaveBot.walkTo = function(dest, maxDist, params) return true end - -- Floor mismatch - if dest.z ~= playerPos.z then return false end + -- Floor mismatch (allow floor-change WPs to pass through to special handling below) + if dest.z ~= playerPos.z and not allowFloorChange then return false end -- Reset anti-zigzag for short walks if PS() ~= NOOP_PS and math.max(distX, distY) <= 5 then PS().resetDirectionState() end @@ -350,30 +353,46 @@ CaveBot.walkTo = function(dest, maxDist, params) -- ========== FLOOR-CHANGE PATH (special handling) ========== if allowFloorChange then if player:isWalking() then return true end - local manhattan = distX + distY + + -- For cross-floor floor-change tiles, walk to the same XY on the CURRENT floor + -- (the stairs/rope hole exists here, stepping on it triggers the Z change) + local walkDest = dest + if dest.z ~= playerPos.z then + walkDest = {x = dest.x, y = dest.y, z = playerPos.z} + end + + local fdX = math.abs(walkDest.x - playerPos.x) + local fdY = math.abs(walkDest.y - playerPos.y) + local manhattan = fdX + fdY if manhattan <= 3 then -- Close: precise keyboard steps - local fcPath = PS().findPath(playerPos, dest, {ignoreNonPathable = true, precision = 0}) + local fcPath = PS().findPath(playerPos, walkDest, {ignoreNonPathable = true, precision = 0}) if fcPath and #fcPath > 0 then local dir = fcPath[1] local smoothed = PS().smoothDirection(dir, true) or dir if canWalkDirection(smoothed) then PS().walkStep(smoothed) + return true elseif canWalkDirection(dir) then PS().walkStep(dir) + return true end end - return true + return false else -- Far: guarded autoWalk - local isSafe = PS().nativePathIsSafe(playerPos, dest, {ignoreNonPathable = true}) + local isSafe = PS().nativePathIsSafe(playerPos, walkDest, {ignoreNonPathable = true}) if isSafe then - PS().autoWalk(dest, maxDist, {ignoreNonPathable = true, precision = precision}) + PS().autoWalk(walkDest, maxDist, {precision = precision}) else - local dirToDest = getDirectionTo(playerPos, dest) + local dirToDest = getDirectionTo(playerPos, walkDest) if dirToDest and canWalkDirection(dirToDest) then - PS().walkStep(dirToDest) + local off = DIR_TO_OFFSET[dirToDest] + local target = off and {x = playerPos.x + off.x, y = playerPos.y + off.y, z = playerPos.z} + if target and PathUtils.isTileWalkable(target) then + PS().walkStep(dirToDest) + end end end return true @@ -470,9 +489,7 @@ CaveBot.walkTo = function(dest, maxDist, params) end end --- ============================================================================ -- CONVENIENCE & PUBLIC API --- ============================================================================ CaveBot.safeWalkTo = function(dest, maxDist, params) params = params or {} @@ -489,25 +506,6 @@ CaveBot.isPlayerWalking = function() return player and player.isWalking and player:isWalking() end -CaveBot.getWalkWaitTime = function() - if not CaveBot.isPlayerWalking() then return 0 end - if PS() == NOOP_PS then return 200 end - return PS().rawStepDuration(false) -end - -CaveBot.isPositionWalkable = function(checkPos, ignoreCreatures) - if PathUtils and PathUtils.isTileWalkable then - return PathUtils.isTileWalkable(checkPos, ignoreCreatures or false) - end - local Client = getClient() - local tile = (Client and Client.getTile) and Client.getTile(checkPos) or (g_map and g_map.getTile(checkPos)) - return tile and tile:isWalkable(ignoreCreatures or false) or false -end - -CaveBot.doWalking = function() - return player and player:isWalking() -end - CaveBot.resetWalking = function() lastWalkZ = nil if PS() then PS().fullReset() end @@ -522,9 +520,7 @@ CaveBot.stopAutoWalk = stopAutoWalk CaveBot.isFloorChangeTile = isFloorChangeTile CaveBot.isNearFloorChangeTile = isNearFloorChangeTile --- ============================================================================ -- EVENT: Position change (update safe pos, handle floor change) --- ============================================================================ onPlayerPositionChange(function(newPos, oldPos) if zChanging() then return end diff --git a/constants/directions.lua b/constants/directions.lua index 353f049..d5ec4cf 100644 --- a/constants/directions.lua +++ b/constants/directions.lua @@ -13,10 +13,8 @@ -- Declare as global (not local) so it's accessible after dofile Directions = Directions or {} --- ============================================================================ -- DIRECTION CONSTANTS (from OTClient) -- These should match the global constants defined by OTClient --- ============================================================================ -- Define locals if globals don't exist (for standalone testing) local _North = North or 0 @@ -28,9 +26,7 @@ local _SouthEast = SouthEast or 5 local _SouthWest = SouthWest or 6 local _NorthWest = NorthWest or 7 --- ============================================================================ -- DIRECTION TO OFFSET MAPPING --- ============================================================================ Directions.DIR_TO_OFFSET = { [_North] = { x = 0, y = -1 }, @@ -43,9 +39,7 @@ Directions.DIR_TO_OFFSET = { [_NorthWest] = { x = -1, y = -1 }, } --- ============================================================================ -- OFFSET TO DIRECTION MAPPING (String key for fast lookup) --- ============================================================================ Directions.OFFSET_TO_DIR = { ["0,-1"] = _North, @@ -58,9 +52,7 @@ Directions.OFFSET_TO_DIR = { ["-1,-1"] = _NorthWest, } --- ============================================================================ -- DIRECTION ARRAYS --- ============================================================================ -- Cardinal directions only (4 directions) Directions.CARDINAL = { _North, _East, _South, _West } @@ -86,9 +78,7 @@ Directions.ADJACENT_OFFSETS = { { x = -1, y = -1 }, -- NorthWest } --- ============================================================================ -- OPPOSITE DIRECTIONS --- ============================================================================ Directions.OPPOSITE = { [_North] = _South, @@ -101,9 +91,7 @@ Directions.OPPOSITE = { [_NorthWest] = _SouthEast, } --- ============================================================================ -- ADJACENT DIRECTIONS (for "similar direction" checks) --- ============================================================================ Directions.ADJACENT = { [_North] = {[_NorthEast] = true, [_NorthWest] = true}, @@ -116,9 +104,7 @@ Directions.ADJACENT = { [_NorthWest] = {[_West] = true, [_North] = true}, } --- ============================================================================ -- DIRECTION NAMES (for debugging) --- ============================================================================ Directions.NAMES = { [_North] = "North", @@ -131,9 +117,7 @@ Directions.NAMES = { [_NorthWest] = "NorthWest", } --- ============================================================================ -- HELPER FUNCTIONS --- ============================================================================ --[[ Get offset for direction diff --git a/constants/floor_items.lua b/constants/floor_items.lua index 7afe13b..de42db0 100644 --- a/constants/floor_items.lua +++ b/constants/floor_items.lua @@ -13,9 +13,7 @@ -- Declare as global (not local) so it's accessible after dofile FloorItems = FloorItems or {} --- ============================================================================ -- MINIMAP COLORS FOR FLOOR CHANGE --- ============================================================================ FloorItems.FLOOR_CHANGE_COLORS = { [210] = true, -- Stairs up @@ -24,9 +22,7 @@ FloorItems.FLOOR_CHANGE_COLORS = { [213] = true, -- Ladder } --- ============================================================================ -- FLOOR CHANGE ITEMS (Stairs, Ramps, Ladders, Holes, Teleports) --- ============================================================================ FloorItems.FLOOR_CHANGE = { -- ═══════════════════════════════════════════════════════════════════════ @@ -134,9 +130,7 @@ FloorItems.FLOOR_CHANGE = { [1958] = true, -- City teleport } --- ============================================================================ -- FIELD ITEMS (Fire, Energy, Poison, Magic Walls) --- ============================================================================ FloorItems.FIELDS = { -- ═══════════════════════════════════════════════════════════════════════ @@ -178,9 +172,7 @@ for id, _ in pairs(FloorItems.FIELDS) do FloorItems.FIELD_ITEMS[id] = true end --- ============================================================================ -- HELPER FUNCTIONS --- ============================================================================ --[[ Check if item ID is a floor-change item diff --git a/constants/food_items.lua b/constants/food_items.lua index 07f5a70..b119133 100644 --- a/constants/food_items.lua +++ b/constants/food_items.lua @@ -13,10 +13,8 @@ -- Declare as global (not local) so it's accessible after dofile FoodItems = FoodItems or {} --- ============================================================================ -- FOOD ITEMS WITH REGENERATION TIME (seconds) -- Higher value = more filling --- ============================================================================ FoodItems.FOODS = { -- ═══════════════════════════════════════════════════════════════════════ @@ -95,10 +93,8 @@ for id, _ in pairs(FoodItems.FOODS) do FoodItems.FOOD_IDS[id] = true end --- ============================================================================ -- FOOD PRIORITY (what to eat first) -- Higher priority = eat first (lower regen time foods first) --- ============================================================================ local priorityCache = nil @@ -118,9 +114,7 @@ local function buildPriorityCache() return priorityCache end --- ============================================================================ -- HELPER FUNCTIONS --- ============================================================================ --[[ Check if item ID is food diff --git a/core/AttackBot.lua b/core/AttackBot.lua index ed99091..fa9076c 100644 --- a/core/AttackBot.lua +++ b/core/AttackBot.lua @@ -22,9 +22,7 @@ local patternCategory = 1 local pattern = 1 local mainWindow --- ============================================================================ -- BOTCORE INTEGRATION --- ============================================================================ -- Local analytics wrapper (for fallback if BotCore not available) local attackAnalytics = storage.attackAnalytics or { @@ -800,12 +798,8 @@ end setPatternText() setCategoryText() end - panel.previousSource.onClick = function() - warn("[AttackBot] TODO, reserved for future use.") - end - panel.nextSource.onClick = function() - warn("[AttackBot] TODO, reserved for future use.") - end + panel.previousSource.onClick = function() end + panel.nextSource.onClick = function() end panel.previousRange.onClick = function() local t = patterns[patternCategory] if pattern == 1 then @@ -883,7 +877,6 @@ end end end - -- refreshing values function refreshAttacks() if not currentSettings.attackTable then return end @@ -1031,7 +1024,6 @@ end currentSettings.AntiRsRange = value end - -- window elements mainWindow.closeButton.onClick = function() showSettings = false @@ -1152,9 +1144,7 @@ end mainWindow:focus() end --- ============================================================================ -- COOLDOWN MANAGEMENT (use ClientHelper for DRY) --- ============================================================================ local cooldowns = {} @@ -1319,7 +1309,6 @@ function getPattern(category, pattern, safe) return spellPatterns[category][pattern][safe] end - function getMonstersInArea(category, posOrCreature, pattern, minHp, maxHp, safePattern, monsterNamesTable) -- monsterNamesTable can be nil local monsters = 0 @@ -1514,9 +1503,7 @@ else end) end --- ============================================================================ -- SIMPLIFIED ATTACKBOT - HIGH PERFORMANCE & ACCURACY --- ============================================================================ -- Per-tick cache for expensive computations local lastAutoRotate = 0 diff --git a/core/Conditions.lua b/core/Conditions.lua index a468f25..1cd4979 100644 --- a/core/Conditions.lua +++ b/core/Conditions.lua @@ -27,7 +27,7 @@ Panel if not HealBotConfig[panelName] then HealBotConfig[panelName] = { enabled = false, - curePosion = false, + curePoison = false, poisonCost = 20, cureCurse = false, curseCost = 80, @@ -56,6 +56,11 @@ Panel end local config = HealBotConfig[panelName] + -- Legacy typo migration: old profiles saved "curePosion" instead of "curePoison" + if config.curePosion ~= nil and config.curePoison == nil then + config.curePoison = config.curePosion + config.curePosion = nil + end ui.title:setOn(config.enabled) ui.title.onClick = function(widget) @@ -70,8 +75,6 @@ Panel conditionsWindow:focus() end - - local rootWidget = g_ui.getRootWidget() if rootWidget then conditionsWindow = UI.createWindow('ConditionsWindow', rootWidget) diff --git a/core/Containers.lua b/core/Containers.lua index 29a938d..1f2f3ae 100644 --- a/core/Containers.lua +++ b/core/Containers.lua @@ -1,48 +1,10 @@ ---[[ - Container Panel - Advanced Container Management System v12.3 - - Features: - - Open All: Opens main BP + all nested containers (auto-minimized) - - Reopen: Closes all and reopens from back slot - - Close All: Closes all open containers - - Min/Max: Minimizes/Maximizes all containers - - Setup: Configure containers with custom names and item sorting - - Sort Items: Automatically moves items to designated containers - - Keep Open: Force containers to stay open - - Rename: Custom names for container windows - - All operations use BFS (Breadth-First Search) to find nested containers. - Containers are auto-minimized after opening for cleaner UI. - - Architecture: DRY, SOLID, SRP principles with pure functions where possible. - - v12.3 Changes: - - FIXED: Main backpack not minimizing when auto-minimize enabled - - FIXED: Quiver not opening - now uses OTClient slot constants - - FIXED: Container count check using pairs() instead of # operator - - IMPROVED: Always minimize when auto-minimize is enabled (not just during processing) - - v12.2 Changes: - - FIXED: Infinite loop when opening/closing containers recursively - - FIXED: Quiver not opening on right hand slot for OpenTibiaBR - - IMPROVED: Container tracking now uses itemId to prevent re-opens - - IMPROVED: Scanner cooldowns prevent over-scanning same container - - IMPROVED: Grace periods increased for better stability - - IMPROVED: Max opens per item type to prevent infinite loops -]] setDefaultTab("Tools") local panelName = "containerPanel" --- ============================================================================ --- CONSTANTS --- ============================================================================ local PURSE_ITEM_ID = 23396 local LOOT_BAG_ITEM_ID = 23721 --- ============================================================================ --- DEFAULT CONFIGURATION --- ============================================================================ local DEFAULT_CONTAINER_LIST = { { name = "Main Backpack", @@ -62,7 +24,6 @@ local DEFAULT_CONTAINER_LIST = { } } --- Default config structure local DEFAULT_CONFIG = { purse = true, autoMinimize = true, @@ -75,18 +36,12 @@ local DEFAULT_CONFIG = { windowHeight = 200 } --- Alias shared deepClone (DRY) local deepClone = nExBot.Shared.deepClone --- ============================================================================ --- STORAGE & STATE (Per-Character with CharacterDB) --- ============================================================================ --- Internal state (not persisted) local _configData = nil local _saveTimer = nil --- Schedule save to CharacterDB (debounced) local function scheduleSave() if not CharacterDB or not CharacterDB.isReady or not CharacterDB.isReady() then return end if not _configData then return end @@ -97,34 +52,26 @@ local function scheduleSave() end) end --- Initialize config from CharacterDB with migration from legacy storage local function initConfig() local cfg = {} - -- Try to load from CharacterDB first if CharacterDB and CharacterDB.isReady and CharacterDB.isReady() then cfg = CharacterDB.getModule("containers") or {} - -- Migration: if CharacterDB has never been initialized (no _migrated flag) - -- and legacy storage has data, migrate once if not cfg._migrated and storage[panelName] then local legacy = storage[panelName] if legacy.containerList and #legacy.containerList > 0 then - -- Migrate from legacy storage cfg = deepClone(legacy) end - -- Mark as migrated so we don't overwrite user deletions cfg._migrated = true CharacterDB.setModule("containers", cfg) end else - -- Fallback to legacy storage (CharacterDB not ready yet) if storage[panelName] and type(storage[panelName]) == "table" then cfg = storage[panelName] end end - -- Ensure all required fields exist (migration for old configs) for key, defaultValue in pairs(DEFAULT_CONFIG) do if cfg[key] == nil then cfg[key] = type(defaultValue) == "table" and deepClone(defaultValue) or defaultValue @@ -135,7 +82,6 @@ local function initConfig() return cfg end --- Create a proxy that auto-saves to CharacterDB on changes local function createConfigProxy() return setmetatable({}, { __index = function(t, k) @@ -158,18 +104,15 @@ local function createConfigProxy() }) end --- Initialize config now initConfig() local config = createConfigProxy() --- Force save function (call after modifying nested tables like containerList) local function saveConfig() if CharacterDB and CharacterDB.isReady and CharacterDB.isReady() and _configData then CharacterDB.setModule("containers", _configData) end end --- Forward declaration for UI sync functions local syncUIWithConfig local refreshContainerList @@ -269,7 +212,6 @@ Panel ]]) containerUI:setId(panelName) --- Set tooltips programmatically for better control containerUI.openAll:setTooltip("When enabled, automatically opens all containers on re-login\n(Toggle ON to enable auto-open on each login)") containerUI.setupBtn:setTooltip("Configure container names, sorting rules, and behavior") containerUI.reopenAll:setTooltip("Close all containers and reopen from back slot") @@ -279,7 +221,6 @@ containerUI.maximizeAll:setTooltip("Maximize all container windows") containerUI.purseSwitch:setTooltip("Also open the purse when reopening") containerUI.autoMinSwitch:setTooltip("Automatically minimize containers after opening") --- Sync UI with config (call on init and when CharacterDB becomes ready) syncUIWithConfig = function() if containerUI then containerUI.openAll:setOn(config.autoOpenOnLogin == true) @@ -288,20 +229,14 @@ syncUIWithConfig = function() end end --- Initial sync syncUIWithConfig() --- Delayed re-sync to ensure CharacterDB is ready --- (In case the player wasn't fully available at init time) schedule(500, function() if CharacterDB and CharacterDB.isReady and CharacterDB.isReady() then - -- Reinitialize config from CharacterDB initConfig() syncUIWithConfig() - -- Refresh setup window if it exists if setupWindow then if refreshContainerList then refreshContainerList() end - -- Sync setup window checkboxes setupWindow.sortEnabled:setChecked(config.sortEnabled == true) setupWindow.forceOpen:setChecked(config.forceOpen == true) setupWindow.renameEnabled:setChecked(config.renameEnabled == true) @@ -310,227 +245,22 @@ schedule(500, function() end end) --- ============================================================================ --- SETUP WINDOW UI DEFINITION --- ============================================================================ -g_ui.loadUIFromString([[ -ContainerEntry < Label - background-color: alpha - text-offset: 20 2 - focusable: true - height: 18 - font: verdana-11px-rounded - - CheckBox - id: enabled - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - width: 15 - height: 15 - margin-left: 2 - - $focus: - background-color: #00000066 - - Button - id: minimize - !text: tr('M') - anchors.right: nested.left - anchors.verticalCenter: parent.verticalCenter - margin-right: 2 - width: 16 - height: 16 - - Button - id: nested - !text: tr('N') - anchors.right: remove.left - anchors.verticalCenter: parent.verticalCenter - margin-right: 2 - width: 16 - height: 16 - - Button - id: remove - !text: tr('X') - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - margin-right: 20 - width: 16 - height: 16 - -ContainerSetupWindow < MainWindow - !text: tr('Container Setup') - size: 550 220 - @onEscape: self:hide() - - TextList - id: containerList - anchors.left: parent.left - anchors.top: parent.top - anchors.bottom: separator.top - width: 210 - margin-bottom: 8 - margin-top: 3 - margin-left: 3 - vertical-scrollbar: containerListScrollBar - - VerticalScrollBar - id: containerListScrollBar - anchors.top: containerList.top - anchors.bottom: containerList.bottom - anchors.right: containerList.right - step: 18 - pixels-scroll: true - - VerticalSeparator - id: sep - anchors.top: parent.top - anchors.left: containerList.right - anchors.bottom: separator.top - margin-top: 3 - margin-bottom: 8 - margin-left: 8 - - Label - id: lblName - anchors.left: sep.right - anchors.top: sep.top - width: 65 - text: Name: - margin-left: 10 - margin-top: 3 - font: verdana-11px-rounded - - TextEdit - id: containerName - anchors.left: lblName.right - anchors.top: sep.top - anchors.right: parent.right - margin-right: 8 - font: verdana-11px-rounded - - Label - id: lblContainer - anchors.left: lblName.left - anchors.top: containerName.bottom - width: 65 - text: Container: - margin-top: 8 - font: verdana-11px-rounded - - BotItem - id: containerId - anchors.left: containerName.left - anchors.top: lblContainer.top - margin-top: -3 - - Button - id: addContainer - anchors.left: containerId.right - anchors.top: containerId.top - margin-left: 8 - text: Add/Update - width: 90 - height: 20 - font: verdana-11px-rounded - - Label - id: lblItems - anchors.left: lblName.left - anchors.top: containerId.bottom - width: 65 - text: Items: - margin-top: 8 - font: verdana-11px-rounded - - BotContainer - id: itemsList - anchors.left: containerName.left - anchors.top: lblItems.top - anchors.right: parent.right - anchors.bottom: separator.top - margin-right: 8 - margin-bottom: 8 - margin-top: -3 - - HorizontalSeparator - id: separator - anchors.right: parent.right - anchors.left: parent.left - anchors.bottom: closeBtn.top - margin-bottom: 8 - - CheckBox - id: sortEnabled - anchors.left: parent.left - anchors.bottom: parent.bottom - text: Sort Items - tooltip: Automatically move items to designated containers - width: 80 - height: 15 - margin-left: 8 - font: verdana-11px-rounded - - CheckBox - id: forceOpen - anchors.left: prev.right - anchors.bottom: parent.bottom - text: Keep Open - tooltip: Force containers to stay open - width: 85 - height: 15 - margin-left: 10 - font: verdana-11px-rounded - - CheckBox - id: renameEnabled - anchors.left: prev.right - anchors.bottom: parent.bottom - text: Rename - tooltip: Rename container windows with custom names - width: 70 - height: 15 - margin-left: 10 - font: verdana-11px-rounded - - CheckBox - id: lootBag - anchors.left: prev.right - anchors.bottom: parent.bottom - text: Loot Bag - tooltip: Also manage loot bag - width: 75 - height: 15 - margin-left: 10 - font: verdana-11px-rounded +do + local path = nExBot.paths.base .. "/core/Containers.otui" + local content = nil + if g_resources and g_resources.readFileContents then + content = g_resources.readFileContents(path) + end + if content then + g_ui.loadUIFromString(content) + else + warn("[Containers] Failed to load Containers.otui from " .. path) + end +end - Button - id: closeBtn - !text: tr('Close') - font: verdana-11px-rounded - anchors.right: parent.right - anchors.bottom: parent.bottom - size: 50 20 - - ResizeBorder - id: bottomResizeBorder - anchors.fill: separator - height: 3 - minimum: 180 - maximum: 350 - margin-left: 3 - margin-right: 3 - background: #ffffff44 -]]) - --- ============================================================================ --- SETUP WINDOW INSTANCE AND LOGIC --- ============================================================================ local setupWindow = nil local selectedContainerIndex = nil --- Pure function: Extract item IDs from container items table local function extractItemIds(items) local ids = {} for _, entry in ipairs(items) do @@ -543,7 +273,6 @@ local function extractItemIds(items) return ids end --- Pure function: Find container entry by item ID local function findContainerByItemId(list, itemId) for index, entry in ipairs(list) do if entry.itemId == itemId then @@ -553,17 +282,6 @@ local function findContainerByItemId(list, itemId) return nil, nil end --- Pure function: Check if item should go to container -local function shouldItemGoToContainer(itemId, containerEntry) - if not containerEntry or not containerEntry.items then return false end - local items = extractItemIds(containerEntry.items) - for _, id in ipairs(items) do - if id == itemId then return true end - end - return false -end - --- Refresh the container list UI refreshContainerList = function() if not setupWindow then return end @@ -575,14 +293,12 @@ refreshContainerList = function() label:setText(entry.name or "Container") label.enabled:setChecked(entry.enabled) - -- Color coding for buttons label.minimize:setColor(entry.minimize and '#00FF00' or '#FF6666') label.minimize:setTooltip(entry.minimize and 'Opens Minimized' or 'Opens Normal') label.nested:setColor(entry.openNested and '#00FF00' or '#FF6666') label.nested:setTooltip(entry.openNested and 'Opens Nested' or 'No Nested') - -- Selection handler label.onMouseRelease = function() selectedContainerIndex = index setupWindow.containerId:setItemId(entry.itemId or 0) @@ -591,24 +307,20 @@ refreshContainerList = function() list:focusChild(label) end - -- Toggle enabled - immediately trigger sorting when activated label.enabled.onClick = function() entry.enabled = not entry.enabled label.enabled:setChecked(entry.enabled) saveConfig() -- Persist to CharacterDB - -- Trigger immediate processing when rule is enabled - if entry.enabled and sortingMacro and (config.sortEnabled or config.forceOpen) then + if entry.enabled and sortingMacro and (config.sortEnabled or config.forceOpen) and not isLootLocked() then sortingMacro:setOn() end end - -- Toggle minimize - apply immediately to open containers label.minimize.onClick = function() entry.minimize = not entry.minimize label.minimize:setColor(entry.minimize and '#00FF00' or '#FF6666') label.minimize:setTooltip(entry.minimize and 'Opens Minimized' or 'Opens Normal') saveConfig() -- Persist to CharacterDB - -- Apply minimize state to currently open containers of this type if entry.enabled and entry.itemId then for _, container in pairs(g_game.getContainers()) do local containerItem = container:getContainerItem() @@ -624,13 +336,11 @@ refreshContainerList = function() end end - -- Toggle nested - trigger container opening if enabled label.nested.onClick = function() entry.openNested = not entry.openNested label.nested:setColor(entry.openNested and '#00FF00' or '#FF6666') label.nested:setTooltip(entry.openNested and 'Opens Nested' or 'No Nested') saveConfig() -- Persist to CharacterDB - -- Trigger nested container opening if enabled if ContainerBFS and ContainerBFS.isActive() and entry.enabled and entry.openNested and entry.itemId then for _, container in pairs(g_game.getContainers()) do local containerItem = container:getContainerItem() @@ -650,7 +360,6 @@ refreshContainerList = function() end end - -- Remove entry label.remove.onClick = function() table.remove(config.containerList, index) refreshContainerList() @@ -660,7 +369,6 @@ refreshContainerList = function() end end --- Initialize setup window local function initSetupWindow() if setupWindow then return end @@ -678,12 +386,10 @@ local function initSetupWindow() setupWindow = win - -- Set height BEFORE hide to avoid geometry callback saving 0 local h = tonumber(config.windowHeight) if not h or h < 150 then h = 220 end setupWindow:setHeight(h) - -- Save window height on resize setupWindow.onGeometryChange = function(widget, old, new) if new.height >= 150 and old.height > 0 and new.height ~= old.height then config.windowHeight = new.height @@ -692,19 +398,16 @@ local function initSetupWindow() setupWindow:hide() - -- Close button setupWindow.closeBtn.onClick = function() setupWindow:hide() end - -- Checkboxes setupWindow.sortEnabled:setChecked(config.sortEnabled) setupWindow.sortEnabled.onClick = function(widget) config.sortEnabled = not config.sortEnabled widget:setChecked(config.sortEnabled) saveConfig() -- Persist to CharacterDB - -- Trigger immediate sorting when enabled - if config.sortEnabled and sortingMacro then + if config.sortEnabled and sortingMacro and not isLootLocked() then sortingMacro:setOn() end end @@ -714,8 +417,7 @@ local function initSetupWindow() config.forceOpen = not config.forceOpen widget:setChecked(config.forceOpen) saveConfig() -- Persist to CharacterDB - -- Trigger immediate check when enabled - if config.forceOpen and sortingMacro then + if config.forceOpen and sortingMacro and not isLootLocked() then sortingMacro:setOn() end end @@ -734,7 +436,6 @@ local function initSetupWindow() saveConfig() -- Persist to CharacterDB end - -- Add/Update container button setupWindow.addContainer.onClick = function() local itemId = setupWindow.containerId:getItemId() local name = setupWindow.containerName:getText() @@ -755,11 +456,9 @@ local function initSetupWindow() local items = setupWindow.itemsList:getItems() or {} if existingIndex then - -- Update existing config.containerList[existingIndex].name = name config.containerList[existingIndex].items = items else - -- Add new config.containerList[#config.containerList + 1] = { name = name, enabled = true, @@ -770,7 +469,6 @@ local function initSetupWindow() } end - -- Clear inputs setupWindow.containerId:setItemId(0) setupWindow.containerName:setText("") setupWindow.itemsList:setItems({}) @@ -779,19 +477,16 @@ local function initSetupWindow() refreshContainerList() saveConfig() -- Persist to CharacterDB - -- Trigger immediate sorting when rule is added/updated - if config.sortEnabled and sortingMacro then + if config.sortEnabled and sortingMacro and not isLootLocked() then sortingMacro:setOn() end end - -- Items list change handler UI.Container(function() if selectedContainerIndex and config.containerList[selectedContainerIndex] then config.containerList[selectedContainerIndex].items = setupWindow.itemsList:getItems() saveConfig() -- Persist to CharacterDB - -- Trigger immediate sorting when items list changes - if config.sortEnabled and sortingMacro then + if config.sortEnabled and sortingMacro and not isLootLocked() then sortingMacro:setOn() end end @@ -800,44 +495,14 @@ local function initSetupWindow() refreshContainerList() end --- Sync setup window checkboxes with current config -local function syncSetupWindowCheckboxes() - if not setupWindow then return end - setupWindow.sortEnabled:setChecked(config.sortEnabled == true) - setupWindow.forceOpen:setChecked(config.forceOpen == true) - setupWindow.renameEnabled:setChecked(config.renameEnabled == true) - setupWindow.lootBag:setChecked(config.lootBag == true) -end ---[[ - Container Opening System v5 - Event-Driven with Deep Nesting Support - - Key Improvements: - 1. EventBus integration for instant container open detection - 2. Proper depth tracking with level-by-level processing - 3. Exponential backoff on failures - 4. Queue-based processing for reliable ordering - 5. Container ID tracking to prevent duplicate opens - - Algorithm: - 1. Open main backpack, wait for container:open event - 2. Scan all open containers for nested containers - 3. Queue nested containers for opening (tracks container item IDs to prevent duplicates) - 4. Process queue one at a time, wait for container:open event - 5. When container opens, re-scan for more nested containers - 6. Repeat until queue is empty and no more nested containers found -]] - --- Helper: Check if container name should be excluded from operations --- (defined early so ContainerOpener can use it) local function isExcludedContainer(containerName) if not containerName then return false end local name = containerName:lower() return name:find("depot") or name:find("inbox") or name:find("quiver") + or name:find("dead") or name:find("remains") or name:find("body of") end --- Helper: Get container window from game_containers module --- (defined early so ContainerOpener can use it) local function getContainerWindow(containerId) local gameContainers = modules.game_containers if gameContainers then @@ -864,8 +529,6 @@ local function getContainerWindow(containerId) return nil end --- Helper: Get configured entry for a container by its item ID --- (defined early so ContainerOpener can use it) local function getContainerConfig(itemId) for _, entry in ipairs(config.containerList) do if entry.enabled and entry.itemId == itemId then @@ -875,9 +538,6 @@ local function getContainerConfig(itemId) return nil end --- ============================================================================ --- SHARED UI HELPERS (DRY — single definition for minimize/maximize) --- ============================================================================ local function minimizeWindow(window) if not window then return end @@ -920,9 +580,16 @@ local function applyRename(container) end end --- ============================================================================ --- QUIVER HELPERS --- ============================================================================ +local ContainerBFS + +local function schedulePendingTimeout(pending, stateGuard, onTimeoutFn) + schedule(ContainerBFS.SAFETY_TIMEOUT, function() + if ContainerBFS.pendingOpen ~= pending then return end + if ContainerBFS.state ~= stateGuard then return end + if onTimeoutFn then onTimeoutFn() end + end) +end + local function isQuiverOpen() for _, container in pairs(g_game.getContainers()) do @@ -937,21 +604,13 @@ local function isQuiverOpen() return false end -local function getInventoryItemSafe(slotId) - local player = g_game.getLocalPlayer() - if not player then return nil end - if player.getInventoryItem then - local ok, item = pcall(function() return player:getInventoryItem(slotId) end) - if ok and item then return item end - end - return nil -end - local function getQuiverItem() local player = g_game.getLocalPlayer() if not player then return nil, nil end - local rightSlot = InventorySlotRight or 6 + -- Right hand slot (quiver is equipped here for paladins) + -- OTClient: InventorySlotRight = 5 (not 6 — slot 6 is left hand) + local rightSlot = 5 if player.getInventoryItem then local ok, item = pcall(function() return player:getInventoryItem(rightSlot) end) if ok and item then @@ -967,6 +626,7 @@ local function getQuiverItem() end end + -- Ammo slot (some quiver types go here) local ammoSlot = InventorySlotAmmo or 10 if player.getInventoryItem then local ok, item = pcall(function() return player:getInventoryItem(ammoSlot) end) @@ -982,6 +642,24 @@ local function getQuiverItem() if okC and isC then return item, "ammo_global" end end end + + -- Fallback: scan all inventory slots for quiver by item ID range + local QUIVER_IDS = { [35847]=true, [35848]=true, [35849]=true, [35850]=true, + [35851]=true, [35852]=true, [35853]=true, [35854]=true, + [35855]=true, [35856]=true, [35857]=true, [35858]=true, + [35859]=true, [35860]=true } + if player.getInventoryItem then + for slot = 0, 10 do + local ok, item = pcall(function() return player:getInventoryItem(slot) end) + if ok and item then + local okId, itemId = pcall(function() return item:getId() end) + if okId and QUIVER_IDS[itemId] then + return item, "slot_" .. slot + end + end + end + end + return nil, nil end @@ -1004,9 +682,6 @@ local function openQuiverWithRetry(attempts) schedule(300, function() openQuiverWithRetry(attempts - 1) end) end --- ============================================================================ --- CLIENT SERVICE HELPERS --- ============================================================================ local getClient = nExBot.Shared.getClient @@ -1043,15 +718,13 @@ local function hasEnhancedAPIs() return Client and Client.isOpenTibiaBR and Client.isOpenTibiaBR() end --- ============================================================================ --- CONTAINER BFS — Event-Driven State Machine --- Replaces ContainerTracker + ContainerQueue + ContainerScanner + ContainerOpener --- States: IDLE → OPENING_MAIN → RUNNING → (finish) → IDLE --- ============================================================================ -local ContainerBFS = { +local MAX_OPEN_CONTAINERS = 19 -- server limit is typically 20 + +ContainerBFS = { state = "IDLE", queue = {}, -- array of {parentId, slot, itemId} + queueIdx = 1, -- index into queue for O(1) pops (replaces table.remove(queue,1)) opened = {}, -- set: "parentId:slot" -> true openedTypes = {}, -- itemId -> count (prevents infinite loops) pendingOpen = nil, -- {entry, ts} or nil @@ -1066,6 +739,7 @@ local ContainerBFS = { function ContainerBFS.reset() ContainerBFS.state = "IDLE" ContainerBFS.queue = {} + ContainerBFS.queueIdx = 1 ContainerBFS.opened = {} ContainerBFS.openedTypes = {} ContainerBFS.pendingOpen = nil @@ -1077,7 +751,6 @@ function ContainerBFS.isActive() return ContainerBFS.state ~= "IDLE" end --- Scan a container's items and enqueue nested containers for opening function ContainerBFS.scanContainer(container) if not container then return end local name = container:getName() or "" @@ -1106,7 +779,6 @@ function ContainerBFS.scanContainer(container) end end --- Handle paged containers — seek to unvisited pages and scan them function ContainerBFS.handlePages(container) if not container or not container.hasPages or not container:hasPages() then return end @@ -1136,7 +808,6 @@ function ContainerBFS.handlePages(container) end end --- Queue an item for opening (used by forward refs and onAddItem) function ContainerBFS.queueItem(item, containerId, slotIndex, prioritize) if not item then return false end local ok, isC = pcall(function() return item:isContainer() end) @@ -1160,11 +831,9 @@ function ContainerBFS.queueItem(item, containerId, slotIndex, prioritize) return true end --- Open the next container in the queue (called from event handler, not timer chain) function ContainerBFS.openNext() if ContainerBFS.state ~= "RUNNING" then return end - -- Respect timing between opens local t = getNow() local elapsed = t - ContainerBFS.lastOpenTime if elapsed < ContainerBFS.OPEN_DELAY then @@ -1172,15 +841,21 @@ function ContainerBFS.openNext() return end - -- Pop entries until we find a valid one - while #ContainerBFS.queue > 0 do - local entry = table.remove(ContainerBFS.queue, 1) + local openCount = 0 + for _ in pairs(g_game.getContainers()) do openCount = openCount + 1 end + if openCount >= MAX_OPEN_CONTAINERS then + ContainerBFS.state = "PAUSED" + return + end + + while ContainerBFS.queueIdx <= #ContainerBFS.queue do + local entry = ContainerBFS.queue[ContainerBFS.queueIdx] + ContainerBFS.queueIdx = ContainerBFS.queueIdx + 1 local parent = g_game.getContainer(entry.parentId) if parent then local items = parent:getItems() local item = items[entry.slot] - -- Verify item is still a container at that slot if not item or not item:isContainer() then item = nil for idx, candidate in ipairs(items) do @@ -1200,7 +875,6 @@ function ContainerBFS.openNext() end if item then - -- Set pending and open local pending = { entry = entry, ts = getNow() } ContainerBFS.pendingOpen = pending ContainerBFS.lastOpenTime = getNow() @@ -1212,29 +886,36 @@ function ContainerBFS.openNext() g_game.open(item, nil) end - -- Refresh parent for OpenTibiaBR if hasEnhancedAPIs() then schedule(100, function() refreshContainer(parent) end) end - -- Safety timeout: if container doesn't open in time, skip and move on schedule(ContainerBFS.SAFETY_TIMEOUT, function() - if ContainerBFS.pendingOpen == pending then - ContainerBFS.pendingOpen = nil - ContainerBFS.openNext() + if ContainerBFS.pendingOpen ~= pending then return end + ContainerBFS.pendingOpen = nil + local stillOpen = g_game.getContainer(entry.parentId) + if stillOpen then + for idx, candidate in ipairs(stillOpen:getItems()) do + if candidate and candidate:isContainer() and candidate:getId() == entry.itemId then + local key = entry.parentId .. ":" .. idx + if not ContainerBFS.opened[key] then + local retry = { parentId = entry.parentId, slot = idx, itemId = entry.itemId } + ContainerBFS.queue[#ContainerBFS.queue + 1] = retry + end + end + end + ContainerBFS.scanContainer(stillOpen) end + ContainerBFS.openNext() end) return -- Wait for onContainerOpen or safety timeout end end - -- Parent gone or item invalid — skip, loop continues end - -- Queue is empty → finish ContainerBFS.finish() end --- Called from onContainerOpen when a new container window opens function ContainerBFS.onContainerOpened(container) if not container then return end @@ -1259,21 +940,20 @@ function ContainerBFS.onContainerOpened(container) if hasEnhancedAPIs() then refreshContainer(container) end ContainerBFS.scanContainer(container) ContainerBFS.handlePages(container) + local parent = g_game.getContainer(pending.entry.parentId) + if parent then ContainerBFS.scanContainer(parent) end ContainerBFS.openNext() end end end --- Finish the BFS process function ContainerBFS.finish() local prevState = ContainerBFS.state ContainerBFS.state = "IDLE" ContainerBFS.pendingOpen = nil - -- Don't apply finalization if we never actually ran if prevState == "IDLE" then return end - -- Apply minimize to all open containers if config.autoMinimize then schedule(100, function() for _, c in pairs(g_game.getContainers()) do @@ -1282,7 +962,6 @@ function ContainerBFS.finish() end) end - -- Apply renaming if config.renameEnabled then schedule(150, function() for _, c in pairs(g_game.getContainers()) do @@ -1291,35 +970,29 @@ function ContainerBFS.finish() end) end - -- Callback local cb = ContainerBFS.onCompleteCallback ContainerBFS.onCompleteCallback = nil if cb then schedule(50, cb) end - -- Emit event if EventBus and EventBus.emit then EventBus.emit("containers:open_all_complete") end end --- Start BFS: scan all open containers, then begin opening queued entries function ContainerBFS.start(onComplete) ContainerBFS.reset() ContainerBFS.onCompleteCallback = onComplete requestContainerSync() - -- Scan all currently open containers for nested items for _, c in pairs(g_game.getContainers()) do if hasEnhancedAPIs() then refreshContainer(c) end ContainerBFS.scanContainer(c) end - -- If we already found items to open, go straight to RUNNING if #ContainerBFS.queue > 0 then ContainerBFS.state = "RUNNING" ContainerBFS.openNext() else - -- Nothing queued — containers may be empty or all already open ContainerBFS.state = "IDLE" if onComplete then schedule(50, onComplete) end if EventBus and EventBus.emit then @@ -1328,15 +1001,11 @@ function ContainerBFS.start(onComplete) end end --- Stop BFS function ContainerBFS.stop() ContainerBFS.state = "IDLE" ContainerBFS.pendingOpen = nil end --- ============================================================================ --- FORCE OPEN COOLDOWN (Simple cooldown for sorting macro's force-open) --- ============================================================================ local _forceOpenCooldown = {} -- itemId -> timestamp local FORCE_OPEN_COOLDOWN_MS = 2000 @@ -1351,13 +1020,7 @@ local function markForceOpen(itemId) _forceOpenCooldown[itemId] = getNow() end --- ============================================================================ --- CONTAINER EVENT HANDLERS --- ============================================================================ --- Helper: check if TargetBot looting is actively using container windows. --- Uses isActive() (not isLocked()) so forceOpen stays suppressed while --- corpses remain in the loot queue, preventing the open/close loop. local function isLootLocked() return TargetBot and TargetBot.Looting and TargetBot.Looting.isActive and TargetBot.Looting.isActive() end @@ -1365,17 +1028,20 @@ end onContainerOpen(function(container, previousContainer) if not container then return end - -- Drive the BFS state machine ContainerBFS.onContainerOpened(container) - -- Apply minimize and rename applyMinimize(container) applyRename(container) - -- Trigger sorting macro (suppress during active looting to prevent container fights) if sortingMacro and not isLootLocked() then sortingMacro:setOn() end - -- If BFS active and config says open nested: prioritize same-type children + -- When BFS is opening main backpack, try quiver once backpack is confirmed open + if ContainerBFS.isActive() and ContainerBFS.state == "OPENING_MAIN" then + if not isQuiverOpen() then + schedule(100, function() openQuiverWithRetry(3) end) + end + end + if ContainerBFS.isActive() then local containerItem = container:getContainerItem() local itemId = containerItem and containerItem:getId() or 0 @@ -1392,6 +1058,11 @@ onContainerOpen(function(container, previousContainer) end) onContainerClose(function(container) + if ContainerBFS.state == "PAUSED" then + ContainerBFS.state = "RUNNING" + schedule(50, function() ContainerBFS.openNext() end) + end + if container and not container.lootContainer and not isLootLocked() then if sortingMacro and (config.sortEnabled or config.forceOpen) then sortingMacro:setOn() @@ -1400,7 +1071,6 @@ onContainerClose(function(container) end) onAddItem(function(container, slot, item, oldItem) - -- If BFS active and a new container item appears, queue it if item and ContainerBFS.isActive() and container then local ok, isC = pcall(function() return item:isContainer() end) if ok and isC then @@ -1429,55 +1099,16 @@ onPlayerInventoryChange(function(slot, item, oldItem) end end) --- ============================================================================ --- PUBLIC API --- ============================================================================ --- Open all containers: open main BP if needed, then BFS -local function openAllContainers() - local hasMainBP = false - for _ in pairs(g_game.getContainers()) do hasMainBP = true; break end - - if hasMainBP then - -- Main BP already open, start BFS directly - ContainerBFS.start() - schedule(200, function() openQuiverWithRetry(3) end) - else - -- Open main backpack from back slot - local bpItem = getBack() - if not bpItem then - warn("[Container Panel] No backpack in back slot!") - return - end - g_game.open(bpItem) - -- Use OPENING_MAIN state: BFS waits for first container:open event - ContainerBFS.reset() - ContainerBFS.state = "OPENING_MAIN" - -- Safety: if main BP doesn't open in 3s, abort - local pending = {} - ContainerBFS.pendingOpen = pending - schedule(3000, function() - if ContainerBFS.pendingOpen == pending and ContainerBFS.state == "OPENING_MAIN" then - ContainerBFS.state = "IDLE" - ContainerBFS.pendingOpen = nil - end - end) - schedule(400, function() openQuiverWithRetry(5) end) - end -end - --- Reopen all backpacks: close all → open from back slot → BFS function reopenBackpacks(onComplete) if EventBus and EventBus.emit then EventBus.emit("containers:close_all") end - -- Close all containers for _, container in pairs(g_game.getContainers()) do g_game.close(container) end - -- After close, open main BP and start BFS schedule(300, function() local bpItem = getBack() if not bpItem then @@ -1487,7 +1118,6 @@ function reopenBackpacks(onComplete) end g_game.open(bpItem) - -- Handle purse if config.purse then schedule(300, function() local purseItem = getPurse() @@ -1495,27 +1125,19 @@ function reopenBackpacks(onComplete) end) end - -- Open quiver - schedule(400, function() openQuiverWithRetry(5) end) + schedule(600, function() openQuiverWithRetry(5) end) - -- Use OPENING_MAIN state: BFS waits for first container:open event ContainerBFS.reset() ContainerBFS.state = "OPENING_MAIN" ContainerBFS.onCompleteCallback = onComplete - -- Safety timeout local pending = {} ContainerBFS.pendingOpen = pending - schedule(3000, function() - if ContainerBFS.pendingOpen == pending and ContainerBFS.state == "OPENING_MAIN" then - ContainerBFS.finish() - end + schedulePendingTimeout(pending, "OPENING_MAIN", function() + ContainerBFS.finish() end) end) end --- ============================================================================ --- BUTTON HANDLERS --- ============================================================================ containerUI.openAll.onClick = function(widget) config.autoOpenOnLogin = not config.autoOpenOnLogin @@ -1567,9 +1189,6 @@ containerUI.autoMinSwitch.onClick = function(widget) saveConfig() end --- ============================================================================ --- AUTO-OPEN ON RE-LOGIN --- ============================================================================ local lastKnownHealth = 0 local hasTriggeredThisSession = false @@ -1596,6 +1215,11 @@ local function triggerAutoOpen() end onPlayerHealthChange(function(healthPercent) + if healthPercent == 0 then + hasTriggeredThisSession = false + lastKnownHealth = 0 + return + end if not config.autoOpenOnLogin then return end if lastKnownHealth == 0 and healthPercent > 0 and not hasTriggeredThisSession then hasTriggeredThisSession = true @@ -1604,14 +1228,6 @@ onPlayerHealthChange(function(healthPercent) lastKnownHealth = healthPercent end) -onPlayerHealthChange(function(healthPercent) - if healthPercent == 0 then - hasTriggeredThisSession = false - lastKnownHealth = 0 - end -end) - --- Initial startup check schedule(1000, function() if not config.autoOpenOnLogin then return end if hasTriggeredThisSession then return end @@ -1626,9 +1242,6 @@ schedule(1000, function() end end) --- ============================================================================ --- ITEM SORTING SYSTEM --- ============================================================================ local function moveItemToContainer(item, destContainer) if not item or not destContainer then return false end @@ -1652,9 +1265,15 @@ local function findDestinationForItem(itemId) return nil end +local cachedContainers = nil +local function getCachedContainers() + if not cachedContainers then cachedContainers = g_game.getContainers() end + return cachedContainers +end + local function isContainerOpen(itemId) if not itemId then return false end - for _, container in pairs(g_game.getContainers()) do + for _, container in pairs(getCachedContainers()) do local containerItem = container:getContainerItem() if containerItem and containerItem:getId() == itemId then return true @@ -1676,7 +1295,7 @@ local function openConfiguredContainer(itemId) end end - for _, container in pairs(g_game.getContainers()) do + for _, container in pairs(getCachedContainers()) do for _, item in ipairs(container:getItems()) do if item:isContainer() and item:getId() == itemId then markForceOpen(itemId) @@ -1696,25 +1315,28 @@ local function openConfiguredContainer(itemId) return false end --- ============================================================================ --- SORTING MACRO (runs periodically, paused during BFS) --- ============================================================================ sortingMacro = macro(300, function(m) + cachedContainers = nil -- reset per-tick cache + if not config.sortEnabled and not config.forceOpen then m:setOff() + cachedContainers = nil return end - -- Don't interfere during container BFS - if ContainerBFS.isActive() then return end + if ContainerBFS.isActive() then + cachedContainers = nil + return + end - -- Don't interfere during active looting - if isLootLocked() then return end + if isLootLocked() then + cachedContainers = nil + return + end - -- Item sorting if config.sortEnabled then - for _, container in pairs(getContainers()) do + for _, container in pairs(getCachedContainers()) do local containerName = container:getName() if not isExcludedContainer(containerName) then local containerItemId = container:getContainerItem():getId() @@ -1734,7 +1356,6 @@ sortingMacro = macro(300, function(m) end end - -- Force open containers (early return above already guards loot lock) if config.forceOpen then for _, entry in ipairs(config.containerList) do if entry.enabled then @@ -1747,7 +1368,6 @@ sortingMacro = macro(300, function(m) end end - -- Force open purse if config.purse then local purseContainer = getContainerByItem(PURSE_ITEM_ID) if not purseContainer and not isContainerOpen(PURSE_ITEM_ID) then @@ -1762,7 +1382,6 @@ sortingMacro = macro(300, function(m) end end - -- Force open loot bag if config.lootBag then local lootBagContainer = getContainerByItem(LOOT_BAG_ITEM_ID) if not lootBagContainer and not isContainerOpen(LOOT_BAG_ITEM_ID) then @@ -1783,6 +1402,6 @@ sortingMacro = macro(300, function(m) end end - -- Nothing to do m:setOff() + cachedContainers = nil end) diff --git a/core/Containers.otui b/core/Containers.otui new file mode 100644 index 0000000..82de13d --- /dev/null +++ b/core/Containers.otui @@ -0,0 +1,208 @@ +ContainerEntry < Label + background-color: alpha + text-offset: 20 2 + focusable: true + height: 18 + font: verdana-11px-rounded + + CheckBox + id: enabled + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 15 + height: 15 + margin-left: 2 + + $focus: + background-color: #00000066 + + Button + id: minimize + !text: tr('M') + anchors.right: nested.left + anchors.verticalCenter: parent.verticalCenter + margin-right: 2 + width: 16 + height: 16 + + Button + id: nested + !text: tr('N') + anchors.right: remove.left + anchors.verticalCenter: parent.verticalCenter + margin-right: 2 + width: 16 + height: 16 + + Button + id: remove + !text: tr('X') + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + margin-right: 20 + width: 16 + height: 16 + +ContainerSetupWindow < MainWindow + !text: tr('Container Setup') + size: 550 220 + @onEscape: self:hide() + + TextList + id: containerList + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: separator.top + width: 210 + margin-bottom: 8 + margin-top: 3 + margin-left: 3 + vertical-scrollbar: containerListScrollBar + + VerticalScrollBar + id: containerListScrollBar + anchors.top: containerList.top + anchors.bottom: containerList.bottom + anchors.right: containerList.right + step: 18 + pixels-scroll: true + + VerticalSeparator + id: sep + anchors.top: parent.top + anchors.left: containerList.right + anchors.bottom: separator.top + margin-top: 3 + margin-bottom: 8 + margin-left: 8 + + Label + id: lblName + anchors.left: sep.right + anchors.top: sep.top + width: 65 + text: Name: + margin-left: 10 + margin-top: 3 + font: verdana-11px-rounded + + TextEdit + id: containerName + anchors.left: lblName.right + anchors.top: sep.top + anchors.right: parent.right + margin-right: 8 + font: verdana-11px-rounded + + Label + id: lblContainer + anchors.left: lblName.left + anchors.top: containerName.bottom + width: 65 + text: Container: + margin-top: 8 + font: verdana-11px-rounded + + BotItem + id: containerId + anchors.left: containerName.left + anchors.top: lblContainer.top + margin-top: -3 + + Button + id: addContainer + anchors.left: containerId.right + anchors.top: containerId.top + margin-left: 8 + text: Add/Update + width: 90 + height: 20 + font: verdana-11px-rounded + + Label + id: lblItems + anchors.left: lblName.left + anchors.top: containerId.bottom + width: 65 + text: Items: + margin-top: 8 + font: verdana-11px-rounded + + BotContainer + id: itemsList + anchors.left: containerName.left + anchors.top: lblItems.top + anchors.right: parent.right + anchors.bottom: separator.top + margin-right: 8 + margin-bottom: 8 + margin-top: -3 + + HorizontalSeparator + id: separator + anchors.right: parent.right + anchors.left: parent.left + anchors.bottom: closeBtn.top + margin-bottom: 8 + + CheckBox + id: sortEnabled + anchors.left: parent.left + anchors.bottom: parent.bottom + text: Sort Items + tooltip: Automatically move items to designated containers + width: 80 + height: 15 + margin-left: 8 + font: verdana-11px-rounded + + CheckBox + id: forceOpen + anchors.left: prev.right + anchors.bottom: parent.bottom + text: Keep Open + tooltip: Force containers to stay open + width: 85 + height: 15 + margin-left: 10 + font: verdana-11px-rounded + + CheckBox + id: renameEnabled + anchors.left: prev.right + anchors.bottom: parent.bottom + text: Rename + tooltip: Rename container windows with custom names + width: 70 + height: 15 + margin-left: 10 + font: verdana-11px-rounded + + CheckBox + id: lootBag + anchors.left: prev.right + anchors.bottom: parent.bottom + text: Loot Bag + tooltip: Also manage loot bag + width: 75 + height: 15 + margin-left: 10 + font: verdana-11px-rounded + + Button + id: closeBtn + !text: tr('Close') + font: verdana-11px-rounded + anchors.right: parent.right + anchors.bottom: parent.bottom + size: 50 20 + + ResizeBorder + id: bottomResizeBorder + anchors.fill: separator + height: 3 + minimum: 180 + maximum: 350 + margin-left: 3 + margin-right: 3 + background: #ffffff44 diff --git a/core/Dropper.lua b/core/Dropper.lua index 15da013..ec802dc 100644 --- a/core/Dropper.lua +++ b/core/Dropper.lua @@ -73,21 +73,13 @@ Panel ]]) edit:hide() --- Profile storage helpers -local function getProfileSetting(key) - if ProfileStorage then - return ProfileStorage.get(key) - end - return storage[key] -end - -local function setProfileSetting(key, value) - if ProfileStorage then - ProfileStorage.set(key, value) - else - storage[key] = value - end +local SharedHelpers = nExBot.SharedHelpers +if not SharedHelpers then + warn("[Dropper] SharedHelpers not loaded") + return end +local getProfileSetting = SharedHelpers.getProfileSetting +local setProfileSetting = SharedHelpers.setProfileSetting -- Load dropper config from profile storage local config = getProfileSetting("dropper") or { diff --git a/core/Equipper.lua b/core/Equipper.lua index 5a10918..16daef5 100644 --- a/core/Equipper.lua +++ b/core/Equipper.lua @@ -10,9 +10,7 @@ if serviceLoadOk and serviceResult then EquipperService = serviceResult end --- ============================================================================ -- UI SETUP --- ============================================================================ local ui = setupUI([[ Panel @@ -37,9 +35,7 @@ Panel ]]) ui:setId(panelName) --- ============================================================================ -- STORAGE & STATE (Per-Character with CharacterDB) --- ============================================================================ -- Default config structure local DEFAULT_CONFIG = { @@ -159,9 +155,7 @@ local EquipState = { INVENTORY_CACHE_TTL = 300, -- ms before inventory cache expires } --- ============================================================================ -- CACHE MANAGEMENT --- ============================================================================ -- Invalidate rules cache when rules change local function invalidateRulesCache() @@ -180,9 +174,7 @@ local function getCachedRules() return EquipState.rulesCache end --- ============================================================================ -- RULE NORMALIZATION (precompute slot plans) --- ============================================================================ -- Delegate normalization to EquipperService for testability and clarity local function normalizeRule(rule) @@ -241,9 +233,7 @@ local function getEnabledRules() return out end --- ============================================================================ -- UI SWITCH SYNC (Per-Character State) --- ============================================================================ -- Sync switch state with config (call on init and when CharacterDB becomes ready) local function syncSwitchState() @@ -622,9 +612,7 @@ local function loadRuleToSlots(data) end end --- ============================================================================ -- RULES LIST UI (Fixed - proper sync between UI and config.rules) --- ============================================================================ -- Forward declare refreshRules local refreshRules @@ -896,8 +884,6 @@ bossPanel.add.onClick = function() saveConfig() -- Persist to CharacterDB end - - local function finalCheck(first,relation,second) if relation == "-" then return first @@ -908,9 +894,7 @@ local function finalCheck(first,relation,second) end end --- ============================================================================ -- SLOT / INVENTORY HELPERS (pure-ish, cached per tick) --- ============================================================================ -- Delegate slot/inventory/context helpers to EquipperService when available local SLOT_MAP = (EquipperService and EquipperService.SLOT_MAP) or { @@ -1032,9 +1016,7 @@ local function equipSlot(slotIdx, itemId) return slotHasItemId(slotIdx, itemId) or ok2 end --- ============================================================================ -- CONDITIONS (table-driven) --- ============================================================================ -- Delegate condition evaluation to EquipperService when available, fallback to local map local LOCAL_CONDITIONS = { @@ -1082,10 +1064,7 @@ local function rulePasses(rule, ctx) return mainOk end --- ============================================================================ --- ============================================================================ -- ACTION PLANNING (pure decision-making) --- ============================================================================ local function computeAction(rule, ctx, inventoryIndex) -- Delegate pure decision making to service for testability/consistency @@ -1144,7 +1123,6 @@ local function computeAction(rule, ctx, inventoryIndex) return nil, missing end - local function markChild(child) if mainWindow:isVisible() then local children = listPanel.list:getChildren() @@ -1158,9 +1136,7 @@ local function markChild(child) end end --- ============================================================================ -- EVENT SUBSCRIPTIONS - Listen for condition changes --- ============================================================================ -- Helper to trigger equipment re-check (just sets flag, no immediate processing) local function triggerEquipCheck() @@ -1252,10 +1228,8 @@ else if onStatesChange then onStatesChange(function() triggerEquipCheck() end) end end --- ============================================================================ -- MAIN EQUIPMENT MACRO -- Single point of equipment checking - runs throttled checks --- ============================================================================ EquipManager = macro(300, function() if not config.enabled then return end @@ -1264,10 +1238,8 @@ EquipManager = macro(300, function() throttledEquipCheck() end) --- ============================================================================ -- EVENT-DRIVEN EQUIPMENT MANAGEMENT -- Listen to equipment changes to invalidate cache --- ============================================================================ if EventBus then EventBus.on("equipment:change", function(slotId, slotName, currentId, lastId, item) diff --git a/core/HealBot.lua b/core/HealBot.lua index bf931f3..29ad1d2 100644 --- a/core/HealBot.lua +++ b/core/HealBot.lua @@ -223,7 +223,7 @@ setDefaultTab("HP") -- healPanelName already defined at top of file local ui = setupUI([[ Panel - height: 38 + height: 55 BotSwitch id: title @@ -237,10 +237,19 @@ Panel id: settings anchors.top: prev.top anchors.left: prev.right - anchors.right: parent.right margin-left: 3 height: 17 - text: Setup + width: 55 + text: Self + + Button + id: allySetup + anchors.top: prev.top + anchors.left: prev.right + margin-left: 3 + height: 17 + width: 50 + text: Ally Button id: 1 @@ -389,6 +398,15 @@ ui.settings.onClick = function(widget) end end +local friendHealerWindow +ui.allySetup.onClick = function(widget) + if friendHealerWindow then + friendHealerWindow:show() + friendHealerWindow:raise() + friendHealerWindow:focus() + end +end + -- Converter functions already defined at top of file local rootWidget = g_ui.getRootWidget() @@ -589,7 +607,6 @@ if rootWidget then loadSettings() end - -- public functions HealBot = {} -- global table @@ -644,9 +661,7 @@ end Pre-caches stat functions and uses O(1) condition lookups. ]] --- ============================================================================ -- BOTCORE INTEGRATION --- ============================================================================ -- Use BotCore for stats (single source of truth) local function getStats() @@ -654,7 +669,7 @@ local function getStats() return BotCore.Stats.getAll() end -- Fallback for standalone testing - local localPlayer = g_game.getLocalPlayer() + local localPlayer = ClientService and ClientService.getLocalPlayer and ClientService.getLocalPlayer() if not localPlayer then return { hp = 0, maxHp = 1, hpPercent = 0, mp = 0, maxMp = 1, mpPercent = 0, burst = 0 } end local hp = localPlayer:getHealth() local maxHp = localPlayer:getMaxHealth() @@ -706,24 +721,7 @@ local cachedLocalPlayer = nil local lastPlayerCheck = 0 local PLAYER_CHECK_INTERVAL = 1000 -- Revalidate player reference every 1s --- Get cached local player (with periodic revalidation) -local function getLocalPlayerCached() - if not cachedLocalPlayer or (now - lastPlayerCheck) > PLAYER_CHECK_INTERVAL then - cachedLocalPlayer = g_game.getLocalPlayer() - lastPlayerCheck = now - end - return cachedLocalPlayer -end - -- Update stats (delegates to BotCore if available) -local function updateCachedStats() - if BotCore and BotCore.Stats then - BotCore.Stats.update() - return - end - -- Fallback handled by getStats() -end - -- Analytics helpers (redirect to BotCore.Analytics if available) local function appendLog(entry) if BotCore and BotCore.Analytics then @@ -824,12 +822,9 @@ HealBot.resetAnalytics = function() analytics.log = {} end - - -- Subscribe to EventBus for instant reaction to stat changes -- Note: BotCore handles event-driven stat updates, we just need to reset flags - -- Fast spell macro (driven by HealBot on/off state) -- Main healing macro loop (keeps heal engine ticking) local _lastApplyToggle = 0 @@ -901,7 +896,6 @@ end syncHealMacro() - -- Initialize stats on load (BotCore handles this if available) if BotCore and BotCore.Stats then BotCore.Stats.update() @@ -920,4 +914,610 @@ end validateStartup() +-- ALLY HEALING UI (merged from new_healer.lua) + +local allyPanelName = "newHealer" + +if not storage[allyPanelName] or not storage[allyPanelName].priorities then + storage[allyPanelName] = nil +end + +if not storage[allyPanelName] then + storage[allyPanelName] = { + enabled = false, + customPlayers = {}, + vocations = {}, + groups = {}, + priorities = { + {name="Custom Spell", enabled=false, custom=true}, + {name="Exura Gran Sio", enabled=true, strong = true}, + {name="Exura Tio Sio", enabled=true, medium = true}, + {name="Exura Sio", enabled=true, normal = true}, + {name="Exura Gran Mas Res", enabled=true, area = true}, + {name="Health Item", enabled=true, health=true}, + {name="Mana Item", enabled=true, mana=true} + }, + settings = { + {type="HealItem", text="Mana Item ", value=268}, + {type="HealScroll", text="Item Range: ", value=6}, + {type="HealItem", text="Health Item ", value=3160}, + {type="HealScroll", text="Mas Res Players: ", value=2}, + {type="HealScroll", text="Heal Friend at: ", value=80}, + {type="HealScroll", text="Use Gran Sio at: ", value=40}, + {type="HealScroll", text="Use Tio Sio at: ", value=65}, + {type="HealScroll", text="Min Player HP%: ", value=80}, + {type="HealScroll", text="Min Player MP%: ", value=50}, + }, + conditions = { + knights = true, + paladins = true, + druids = false, + sorcerers = false, + monks = false, + party = true, + guild = false, + friends = false + } + } +end + +local allyConfig = storage[allyPanelName] + +local function normalizeAllySettings(settings) + if type(settings) ~= "table" then + settings = {} + end + local hasTio = false + for i = 1, #settings do + local text = settings[i] and settings[i].text + if text and text:find("Tio Sio") then + hasTio = true + break + end + end + if not hasTio then + table.insert(settings, 7, {type="HealScroll", text="Use Tio Sio at: ", value=65}) + end + return settings +end + +local function getAllySettingValue(idx, default) + local entry = allyConfig.settings and allyConfig.settings[idx] + if entry and entry.value ~= nil then + return entry.value + end + return default +end + +allyConfig.settings = normalizeAllySettings(allyConfig.settings) + +-- CharacterDB integration for ally config +local function loadAllyCustomPlayers() + if not CharacterDB or not CharacterDB.isReady or not CharacterDB.isReady() then + return + end + local charPlayers = CharacterDB.get("friendHealer.customPlayers") + if charPlayers and type(charPlayers) == "table" then + local hasEntries = false + for _ in pairs(charPlayers) do hasEntries = true; break end + if hasEntries then + allyConfig.customPlayers = charPlayers + end + elseif allyConfig.customPlayers then + local hasEntries = false + for _ in pairs(allyConfig.customPlayers) do hasEntries = true; break end + if hasEntries then + CharacterDB.set("friendHealer.customPlayers", allyConfig.customPlayers) + end + end + local charConditions = CharacterDB.get("friendHealer.conditions") + if charConditions and type(charConditions) == "table" then + for k, v in pairs(charConditions) do + allyConfig.conditions[k] = v + end + end +end + +schedule(500, loadAllyCustomPlayers) + +local function saveAllyCustomPlayers() + if CharacterDB and CharacterDB.isReady and CharacterDB.isReady() then + CharacterDB.set("friendHealer.customPlayers", allyConfig.customPlayers) + end +end + +-- Build config for BotCore +local function buildAllyBotCoreConfig() + local bcConfig = { + enabled = allyConfig.enabled, + customPlayers = allyConfig.customPlayers or {}, + conditions = allyConfig.conditions or {}, + settings = { + manaItem = getAllySettingValue(1, 268), + itemRange = getAllySettingValue(2, 6), + healthItem = getAllySettingValue(3, 3160), + masResPlayers = getAllySettingValue(4, 2), + healAt = getAllySettingValue(5, 80), + granSioAt = getAllySettingValue(6, 40), + tioSioAt = getAllySettingValue(7, 65), + minPlayerHp = getAllySettingValue(8, 80), + minPlayerMp = getAllySettingValue(9, 50), + }, + useSio = false, + useGranSio = false, + useTioSio = false, + useMasRes = false, + useHealthItem = false, + useManaItem = false, + customSpell = false, + customSpellName = nil + } + for _, p in ipairs(allyConfig.priorities or {}) do + if p.enabled then + if p.strong then bcConfig.useGranSio = true end + if p.medium then bcConfig.useTioSio = true end + if p.normal then bcConfig.useSio = true end + if p.area then bcConfig.useMasRes = true end + if p.health then bcConfig.useHealthItem = true end + if p.mana then bcConfig.useManaItem = true end + if p.custom then + bcConfig.customSpell = true + bcConfig.customSpellName = p.name + end + end + end + return bcConfig +end + +local function initAllyBotCoreHealer() + if BotCore and BotCore.FriendHealer and BotCore.FriendHealer.init then + local bcConfig = buildAllyBotCoreConfig() + BotCore.FriendHealer.init(bcConfig) + if BotCore.FriendHealer.setEnabled then + BotCore.FriendHealer.setEnabled(allyConfig.enabled) + end + return true + end + return false +end + +local function updateAllyBotCoreConfig() + if BotCore and BotCore.FriendHealer and BotCore.FriendHealer.init then + local bcConfig = buildAllyBotCoreConfig() + BotCore.FriendHealer.init(bcConfig) + if BotCore.FriendHealer.syncHealEngineSpells then + BotCore.FriendHealer.syncHealEngineSpells() + end + end +end + +-- FriendHealer window +local friendHealerMacro = nil + +local function syncAllyHealerState() + if BotCore and BotCore.FriendHealer and BotCore.FriendHealer.setEnabled then + BotCore.FriendHealer.setEnabled(allyConfig.enabled) + if BotCore.FriendHealer.syncHealEngineSpells then + BotCore.FriendHealer.syncHealEngineSpells() + end + end + if HealEngine and HealEngine.setFriendHealingEnabled then + HealEngine.setFriendHealingEnabled(allyConfig.enabled) + end + if friendHealerMacro and friendHealerMacro.setOn then + friendHealerMacro:setOn(allyConfig.enabled) + end +end + +local rootW = g_ui.getRootWidget() +if rootW then + friendHealerWindow = UI.createWindow('FriendHealer', rootW) + friendHealerWindow:hide() + friendHealerWindow:setId(allyPanelName) + + friendHealerWindow.closeButton.onClick = function(widget) + friendHealerWindow:hide() + end + + initAllyBotCoreHealer() + syncAllyHealerState() + + local allyConditions = friendHealerWindow.conditions + local allyTargetSettings = friendHealerWindow.targetSettings + local allyCustomList = friendHealerWindow.customList + local allyPriority = friendHealerWindow.priority + + -- Custom players list + local function createAllyPlayerEntry(name, health) + local widget = UI.createWidget("HealerPlayerEntry", allyCustomList.playerList.list) + widget.remove.onClick = function() + allyConfig.customPlayers[name] = nil + widget:destroy() + saveAllyCustomPlayers() + updateAllyBotCoreConfig() + end + widget:setText("["..health.."%] "..name) + return widget + end + + for name, health in pairs(allyConfig.customPlayers) do + createAllyPlayerEntry(name, health) + end + + allyCustomList.playerList.onDoubleClick = function() + allyCustomList.playerList:hide() + end + + local function clearAllyFields() + allyCustomList.addPanel.name:setText("friend name") + allyCustomList.addPanel.health:setText("1") + allyCustomList.playerList:show() + end + + local properCase = nExBot and nExBot.Shared and nExBot.Shared.properCase or function(str) + local words = {} + for word in str:gmatch("%S+") do + words[#words + 1] = word:sub(1,1):upper() .. word:sub(2) + end + return table.concat(words, " ") + end + + allyCustomList.addPanel.add.onClick = function() + local rawName = allyCustomList.addPanel.name:getText() + local name = properCase(rawName) + local health = tonumber(allyCustomList.addPanel.health:getText()) + + if not health then + clearAllyFields() + return warn("[HealBot] Ally: Please enter health percent value!") + end + + if name:len() == 0 or name:lower() == "friend name" then + clearAllyFields() + return warn("[HealBot] Ally: Please enter friend name to be added!") + end + + if allyConfig.customPlayers[name] or allyConfig.customPlayers[name:lower()] then + clearAllyFields() + return warn("[HealBot] Ally: Player already added to custom list.") + else + allyConfig.customPlayers[name] = health + createAllyPlayerEntry(name, health) + saveAllyCustomPlayers() + updateAllyBotCoreConfig() + end + clearAllyFields() + end + +local function validateAlly(widget, category) + local list = widget:getParent() + local label = list:getParent().title + category = category or 0 + if category == 2 and not (storage.extras and storage.extras.checkPlayer) then + label:setColor("#d9321f") + label:setTooltip("! WARNING ! Turn on check players in extras to use this feature!") + return + else + label:setColor("#dfdfdf") + label:setTooltip("") + end + local checked = false + for i, child in ipairs(list:getChildren()) do + if category == 1 and child.enabled:isChecked() or child:isChecked() then + checked = true + end + end + if not checked then + label:setColor("#d9321f") + label:setTooltip("! WARNING ! No category selected!") + else + label:setColor("#dfdfdf") + label:setTooltip("") + end + end + + + local function bindAllyConditionCheckbox(widget, conditionKey, category) + widget:setChecked(allyConfig.conditions[conditionKey]) + widget.onClick = function(w) + allyConfig.conditions[conditionKey] = not allyConfig.conditions[conditionKey] + w:setChecked(allyConfig.conditions[conditionKey]) + validateAlly(w, category or 0) + updateAllyBotCoreConfig() + if CharacterDB and CharacterDB.isReady and CharacterDB.isReady() then + CharacterDB.set("friendHealer.conditions", allyConfig.conditions) + end + end + end + + + local function setAllyCrementalButtons() + local children = allyPriority.list:getChildren() + local count = #children + for i, child in ipairs(children) do + if i == 1 then + child.increment:disable() + elseif i == count then + child.decrement:disable() + else + child.increment:enable() + child.decrement:enable() + end + end + end + + + local function createAllyPriorityWidget(action, index) + local widget = UI.createWidget("PriorityEntry", allyPriority.list) + + widget:setText(action.name) + widget.increment.onClick = function() + local idx = allyPriority.list:getChildIndex(widget) + local tbl = allyConfig.priorities + + allyPriority.list:moveChildToIndex(widget, idx-1) + tbl[idx], tbl[idx-1] = tbl[idx-1], tbl[idx] + setAllyCrementalButtons() + updateAllyBotCoreConfig() + end + widget.decrement.onClick = function() + local idx = allyPriority.list:getChildIndex(widget) + local tbl = allyConfig.priorities + + allyPriority.list:moveChildToIndex(widget, idx+1) + tbl[idx], tbl[idx+1] = tbl[idx+1], tbl[idx] + setAllyCrementalButtons() + updateAllyBotCoreConfig() + end + widget.enabled:setChecked(action.enabled) + widget:setColor(action.enabled and "#98BF64" or "#dfdfdf") + widget.enabled.onClick = function() + action.enabled = not action.enabled + widget:setColor(action.enabled and "#98BF64" or "#dfdfdf") + widget.enabled:setChecked(action.enabled) + validateAlly(widget, 1) + updateAllyBotCoreConfig() + end + + if action.custom then + widget.remove:show() + widget.remove.onClick = function() + local idx = allyPriority.list:getChildIndex(widget) + table.remove(allyConfig.priorities, idx) + widget:destroy() + setAllyCrementalButtons() + validateAlly(allyPriority.list:getFirstChild(), 1) + updateAllyBotCoreConfig() + end + widget.onDoubleClick = function() + local window = modules.client_textedit.show(widget, {title = "Custom Spell", description = "Enter below formula for a custom healing spell"}) + schedule(50, function() + window:raise() + window:focus() + end) + end + widget.onTextChange = function(w, text) + action.name = text + updateAllyBotCoreConfig() + end + widget:setTooltip("Double click to edit. X to remove.") + end + + return widget + end + + bindAllyConditionCheckbox(allyTargetSettings.vocations.box.knights, "knights", 2) + bindAllyConditionCheckbox(allyTargetSettings.vocations.box.paladins, "paladins", 2) + bindAllyConditionCheckbox(allyTargetSettings.vocations.box.druids, "druids", 2) + bindAllyConditionCheckbox(allyTargetSettings.vocations.box.sorcerers, "sorcerers", 2) + bindAllyConditionCheckbox(allyTargetSettings.vocations.box.monks, "monks", 2) + + bindAllyConditionCheckbox(allyTargetSettings.groups.box.friends, "friends") + bindAllyConditionCheckbox(allyTargetSettings.groups.box.party, "party") + bindAllyConditionCheckbox(allyTargetSettings.groups.box.guild, "guild") + + validateAlly(allyTargetSettings.vocations.box.knights) + validateAlly(allyTargetSettings.groups.box.friends) + validateAlly(allyTargetSettings.vocations.box.sorcerers, 2) + + -- Conditions settings + for i, setting in ipairs(allyConfig.settings) do + local widget = UI.createWidget(setting.type, allyConditions.box) + local text = setting.text + local val = setting.value + widget.text:setText(text) + + if setting.type == "HealScroll" then + widget.text:setText(widget.text:getText()..val) + if not (text:find("Range") or text:find("Mas Res")) then + widget.text:setText(widget.text:getText().."%") + end + widget.scroll:setValue(val) + widget.scroll.onValueChange = function(scroll, value) + setting.value = value + widget.text:setText(text..value) + if not (text:find("Range") or text:find("Mas Res")) then + widget.text:setText(widget.text:getText().."%") + end + updateAllyBotCoreConfig() + end + if text:find("Range") or text:find("Mas Res") then + widget.scroll:setMaximum(10) + end + else + widget.item:setItemId(val) + widget.item:setShowCount(false) + widget.item.onItemChange = function(w) + setting.value = w:getItemId() + updateAllyBotCoreConfig() + end + end + end + + for i, action in ipairs(allyConfig.priorities) do + createAllyPriorityWidget(action, i) + + if i == #allyConfig.priorities then + validateAlly(allyPriority.list:getFirstChild(), 1) + setAllyCrementalButtons() + end + end + + allyPriority.addSpellButton.onClick = function() + local newSpell = { + name = "Custom Spell " .. (#allyConfig.priorities + 1), + enabled = true, + custom = true + } + table.insert(allyConfig.priorities, newSpell) + local widget = createAllyPriorityWidget(newSpell, #allyConfig.priorities) + setAllyCrementalButtons() + updateAllyBotCoreConfig() + + schedule(100, function() + local window = modules.client_textedit.show(widget, {title = "Custom Spell", description = "Enter below formula for a custom healing spell"}) + schedule(50, function() + window:raise() + window:focus() + end) + end) + end + + -- Sync HealEngine friend spells from config + schedule(100, function() + initAllyBotCoreHealer() + syncAllyHealerState() + if HealEngine and HealEngine.setFriendSpells then + local friendSpells = {} + local healAt = getAllySettingValue(5, 80) + local granSioAt = getAllySettingValue(6, 40) + + for i, action in ipairs(allyConfig.priorities or {}) do + if action.enabled then + if action.strong then + table.insert(friendSpells, { + name = "exura gran sio", + hp = granSioAt, + mpCost = 140, + cd = 1100, + prio = 1 + }) + end + if action.medium then + local tioSioAt = getAllySettingValue(7, 65) + table.insert(friendSpells, { + name = "exura tio sio", + hp = tioSioAt, + mpCost = 120, + cd = 1100, + prio = 2 + }) + end + if action.normal then + table.insert(friendSpells, { + name = "exura sio", + hp = healAt, + mpCost = 100, + cd = 1100, + prio = 3 + }) + end + if action.custom and action.name and action.name ~= "Custom Spell" then + table.insert(friendSpells, { + name = action.name, + hp = healAt, + mpCost = 50, + cd = 1100, + prio = 3 + }) + end + end + end + + if #friendSpells > 0 then + HealEngine.setFriendSpells(friendSpells) + end + end + end) + + -- Legacy macro for friend healing (only when BotCore unavailable) + friendHealerMacro = macro(100, function() + if not allyConfig.enabled then return end + + local useBotCore = BotCore and BotCore.FriendHealer + if useBotCore and BotCore.FriendHealer then + local actionTaken = BotCore.FriendHealer.tick() + if actionTaken then return end + end + if useBotCore then return end + + if modules and modules.game_cooldown and modules.game_cooldown.isGroupCooldownIconActive(2) then + return + end + + local minHp = getAllySettingValue(8, 80) + local minMp = getAllySettingValue(9, 50) + if hppercent() <= minHp or manapercent() <= minMp then return end + + local healTarget = {creature=nil, hp=100} + local inMasResRange = 0 + + local spectators = {} + if getSpectators then + local ok, specs = pcall(getSpectators) + if ok and specs then spectators = specs end + end + + for _, spec in ipairs(spectators) do + if spec:isPlayer() and not spec:isLocalPlayer() and spec:canShoot() then + local name = spec:getName() + local curHp = spec:getHealthPercent() + local dist = distanceFromPlayer and distanceFromPlayer(spec:getPosition()) or 99 + + if curHp and curHp < 100 then + local isCustom = allyConfig.customPlayers and allyConfig.customPlayers[name] + if not (isCustom and curHp > isCustom) then + if dist then + inMasResRange = (dist <= 3) and inMasResRange + 1 or inMasResRange + if curHp < healTarget.hp then + healTarget = {creature = spec, hp = curHp} + end + end + end + end + end + end + + if healTarget.creature then + -- Delegate to HealEngine for spell selection + if HealEngine and HealEngine.evaluateAlly then + local spellList = {} + local healAt = getAllySettingValue(5, 80) + if allyConfig.priorities then + for _, action in ipairs(allyConfig.priorities) do + if action.enabled then + if action.strong then + table.insert(spellList, {name="exura gran sio", hp=getAllySettingValue(6, 40), mpCost=140, cd=1100, prio=1}) + elseif action.medium then + table.insert(spellList, {name="exura tio sio", hp=getAllySettingValue(7, 65), mpCost=120, cd=1100, prio=2}) + elseif action.normal then + table.insert(spellList, {name="exura sio", hp=healAt, mpCost=100, cd=1100, prio=3}) + elseif action.custom and action.name then + table.insert(spellList, {name=action.name, hp=healAt, mpCost=50, cd=1100, prio=3}) + end + end + end + end + local engineAction = HealEngine.evaluateAlly(healTarget.creature, healTarget.hp, spellList) + if engineAction then + HealEngine.execute(engineAction) + return + end + end + end + end) + + syncAllyHealerState() +end + UI.Separator() \ No newline at end of file diff --git a/core/acl/adapters/base.lua b/core/acl/adapters/base.lua index 3de3107..9a6ddc2 100644 --- a/core/acl/adapters/base.lua +++ b/core/acl/adapters/base.lua @@ -16,12 +16,10 @@ local BaseAdapter = {} --- ========================================================================= -- PROXY FACTORY -- Creates a table whose __index transparently calls `backend.method(...)` -- with a nil-safe guard (returns `defaultRet` when the backend or method -- is absent). --- ========================================================================= local function createProxy(backend, defaultRet) local proxy = {} @@ -49,10 +47,8 @@ local function createProxy(backend, defaultRet) return proxy end --- ========================================================================= -- DOMAIN TABLES -- Each resolves lazily against the corresponding OTClient global. --- ========================================================================= BaseAdapter.game = createProxy(function() return g_game end, nil) BaseAdapter.map = createProxy(function() return g_map end, nil) @@ -77,9 +73,7 @@ function BaseAdapter.modules.getTerminal() return modules and modules.client_terminal or nil end --- ========================================================================= -- UTILS (pure functions — no state, no globals dependency) --- ========================================================================= BaseAdapter.utils = {} @@ -101,9 +95,7 @@ function BaseAdapter.utils.isInRange(pos1, pos2, rangeX, rangeY) and pos1.z == pos2.z end --- ========================================================================= -- CALLBACKS (base registration — adapters override individual events) --- ========================================================================= BaseAdapter.callbacks = {} BaseAdapter._registeredCallbacks = {} @@ -133,11 +125,9 @@ function BaseAdapter.callbacks.emit(eventType, ...) end end --- ========================================================================= -- CALLBACK WRAPPERS (generated from ICallbacks interface list) -- Each wraps the bot-sandbox native `onXxx` global if present, else -- falls back to internal registration. --- ========================================================================= local CALLBACK_NAMES = { "onTalk", "onTextMessage", "onLoginAdvice", @@ -172,16 +162,12 @@ for _, name in ipairs(CALLBACK_NAMES) do end end --- ========================================================================= -- ADAPTER METADATA --- ========================================================================= BaseAdapter.NAME = "Base" BaseAdapter.VERSION = "2.0.0" --- ========================================================================= -- GLOBAL EXPORT (sandbox workaround — dofile may not propagate return) --- ========================================================================= ACL_BaseAdapter = BaseAdapter diff --git a/core/acl/adapters/opentibiabr.lua b/core/acl/adapters/opentibiabr.lua index 177b926..0907c53 100644 --- a/core/acl/adapters/opentibiabr.lua +++ b/core/acl/adapters/opentibiabr.lua @@ -24,9 +24,7 @@ vs the original 1138). ]] --- ========================================================================= -- LOAD BASE --- ========================================================================= local BaseAdapter do @@ -34,9 +32,7 @@ do BaseAdapter = (ok and type(res) == "table" and res) or ACL_BaseAdapter or {} end --- ========================================================================= -- ADAPTER TABLE — inherits everything from Base via metatable --- ========================================================================= local A = {} @@ -72,9 +68,7 @@ A.VERSION = "2.0.0" -- Sandbox global export ACL_LoadedAdapter = A --- ========================================================================= -- HELPER: Generate a thin wrapper for a g_game method --- ========================================================================= local function gameMethod(name, ...) if g_game and type(g_game[name]) == "function" then @@ -82,9 +76,7 @@ local function gameMethod(name, ...) end end --- ========================================================================= -- GAME OVERRIDES --- ========================================================================= function A.game.forceWalk(direction) if g_game and g_game.forceWalk then return g_game.forceWalk(direction) end @@ -138,9 +130,7 @@ function A.game.closeNPCTrade() return gameMethod("closeNPCTrade") en function A.game.inspectionNormalObject(thing) return gameMethod("inspectionNormalObject", thing) end function A.game.inspectionObject(iType, id, count) return gameMethod("inspectionObject", iType, id, count or 1) end --- ========================================================================= -- STASH --- ========================================================================= function A.stash.withdraw(itemId, count) return gameMethod("stashWithdraw", itemId, count) end function A.stash.stowItem(item, count) return gameMethod("stashStowItem", item, count) end @@ -148,18 +138,14 @@ function A.stash.stowAll(item) return gameMethod("stashStowAll", ite function A.stash.open() return gameMethod("openStash") end function A.stash.search(itemId) return gameMethod("requestStashSearch", itemId) end --- ========================================================================= -- IMBUEMENT --- ========================================================================= function A.imbuement.apply(slotId, imbuId, protection) return gameMethod("applyImbuement", slotId, imbuId, protection or false) end function A.imbuement.clear(slotId) return gameMethod("clearImbuement", slotId) end function A.imbuement.requestWindow(item) return gameMethod("requestImbuingWindow", item) end function A.imbuement.closeWindow() return gameMethod("closeImbuingWindow") end --- ========================================================================= -- PREY --- ========================================================================= function A.prey.action(slotId, actionType, bonusType, monsterIdx) return gameMethod("preyAction", slotId, actionType, bonusType or 0, monsterIdx or 0) @@ -168,18 +154,14 @@ function A.prey.requestData() return gameMethod("requestPreyData") function A.prey.selectCreature(slot, idx) return gameMethod("selectPreyCreature", slot, idx) end function A.prey.refreshMonsters(slot) return gameMethod("refreshPreyMonsters", slot) end --- ========================================================================= -- FORGE --- ========================================================================= function A.forge.request(action, ...) return gameMethod("forgeRequest", action, ...) end function A.forge.fuse(a, b, core) return gameMethod("forgeFuse", a, b, core or false) end function A.forge.transfer(donor, recv, core) return gameMethod("forgeTransfer", donor, recv, core or false) end function A.forge.open() return gameMethod("openForge") end --- ========================================================================= -- MARKET --- ========================================================================= function A.market.browse(cat, voc) return gameMethod("browseMarket", cat or 0, voc or 0) end function A.market.createOffer(t, id, amt, price, anon) return gameMethod("createMarketOffer", t, id, amt, price, anon or false) end @@ -187,9 +169,7 @@ function A.market.cancelOffer(offerId) return gameMethod("cancelMarketOffer" function A.market.acceptOffer(offerId, amt)return gameMethod("acceptMarketOffer", offerId, amt) end function A.market.requestInfo(itemId) return gameMethod("requestMarketInfo", itemId) end --- ========================================================================= -- BESTIARY / BOSSTIARY --- ========================================================================= function A.bestiary.request() return gameMethod("requestBestiary") end function A.bestiary.requestOverview(race) return gameMethod("requestBestiaryOverview", race) end @@ -197,25 +177,19 @@ function A.bestiary.search(text) return gameMethod("requestBestiarySea function A.bosstiary.requestInfo() return gameMethod("requestBosstiaryInfo") end function A.bosstiary.requestSlotInfo() return gameMethod("requestBossSlootInfo") end --- ========================================================================= -- GAME CONFIG --- ========================================================================= function A.gameConfig.get() return g_gameConfig or nil end function A.gameConfig.loadFonts(path) return g_gameConfig and g_gameConfig.loadFonts and g_gameConfig.loadFonts(path) end --- ========================================================================= -- PAPERDOLLS --- ========================================================================= function A.paperdolls.isAvailable() return g_paperdolls ~= nil end function A.paperdolls.get(id) return g_paperdolls and g_paperdolls.get and g_paperdolls.get(id) end function A.paperdolls.getAll() return g_paperdolls and g_paperdolls.getAll and g_paperdolls.getAll() or {} end function A.paperdolls.clear() return g_paperdolls and g_paperdolls.clear and g_paperdolls.clear() end --- ========================================================================= -- MAP OVERRIDES --- ========================================================================= function A.map.getSpectators(pos, multifloor) if not g_map then return {} end @@ -299,9 +273,7 @@ function A.map.cleanTile(pos) return g_map and g_map.cleanTile and g_map.cleanTile(pos) end --- ========================================================================= -- COOLDOWN --- ========================================================================= function A.cooldown.isCooldownIconActive(iconId) local m = modules.game_cooldown @@ -313,9 +285,7 @@ function A.cooldown.isGroupCooldownIconActive(groupId) return m and m.isGroupCooldownIconActive and m.isGroupCooldownIconActive(groupId) or false end --- ========================================================================= -- BOT --- ========================================================================= function A.bot.getConfigName() local bm = modules.game_bot @@ -335,9 +305,7 @@ function A.bot.getConfigPath() return name and ("/bot/" .. name) or nil end --- ========================================================================= -- UTILS (inherits base, adds OTBR specifics) --- ========================================================================= function A.utils.getCreatureByName(name, caseSensitive) local p = g_game and g_game.getLocalPlayer and g_game.getLocalPlayer() @@ -399,9 +367,7 @@ function A.utils.itemAmount(itemId, subType) return count end --- ========================================================================= -- INIT / TERMINATE --- ========================================================================= function A.init() if nExBot and nExBot.showDebug then diff --git a/core/acl/adapters/otcv8.lua b/core/acl/adapters/otcv8.lua index 6c8ff32..e44e985 100644 --- a/core/acl/adapters/otcv8.lua +++ b/core/acl/adapters/otcv8.lua @@ -10,9 +10,7 @@ - findPath via g_map.findPath(start, goal, maxSteps, maxComplexity) ]] --- ========================================================================= -- LOAD BASE (pcall-guarded — sandbox dofile may not propagate returns) --- ========================================================================= local BaseAdapter do @@ -20,9 +18,7 @@ do BaseAdapter = (ok and type(res) == "table" and res) or ACL_BaseAdapter or {} end --- ========================================================================= -- ADAPTER TABLE — inherits everything from Base via metatable --- ========================================================================= local A = {} @@ -50,9 +46,7 @@ A._registeredCallbacks = BaseAdapter._registeredCallbacks or {} A.NAME = "OTCv8" A.VERSION = "2.0.0" --- ========================================================================= -- OTCv8-SPECIFIC GAME OVERRIDES --- ========================================================================= function A.game.moveRaw(thing, toPosition, count) if g_game and g_game.moveRaw then @@ -71,9 +65,7 @@ function A.game.autoWalk(destination, maxSteps, options) return false end --- ========================================================================= -- OTCv8-SPECIFIC MAP OVERRIDES --- ========================================================================= function A.map.getSpectators(pos, multifloor) if not g_map then return {} end @@ -103,9 +95,7 @@ function A.map.findPath(startPos, goalPos, options) return nil end --- ========================================================================= -- COOLDOWN --- ========================================================================= function A.cooldown.isCooldownIconActive(iconId) local m = modules.game_cooldown @@ -117,9 +107,7 @@ function A.cooldown.isGroupCooldownIconActive(groupId) return m and m.isGroupCooldownIconActive and m.isGroupCooldownIconActive(groupId) or false end --- ========================================================================= -- BOT --- ========================================================================= function A.bot.getConfigName() local bm = modules.game_bot @@ -136,9 +124,7 @@ function A.bot.getConfigPath() return name and ("/bot/" .. name) or nil end --- ========================================================================= -- UTILS (inherits base, adds OTCv8 specifics) --- ========================================================================= function A.utils.getCreatureByName(name, caseSensitive) local p = g_game and g_game.getLocalPlayer and g_game.getLocalPlayer() @@ -196,9 +182,7 @@ function A.utils.itemAmount(itemId, subType) return count end --- ========================================================================= -- INIT --- ========================================================================= function A.init() if nExBot and nExBot.showDebug then diff --git a/core/acl/compat.lua b/core/acl/compat.lua index 1e4fc39..3f2acc9 100644 --- a/core/acl/compat.lua +++ b/core/acl/compat.lua @@ -8,9 +8,7 @@ local Client = ClientService --- ========================================================================= -- SAFECALL ENHANCEMENTS (kept — these are used by other modules) --- ========================================================================= if SafeCall then local origUseWith = SafeCall.useWith @@ -35,9 +33,7 @@ if SafeCall then end end --- ========================================================================= -- EXPORT (empty — no GameWrapper/MapWrapper needed) --- ========================================================================= local Compat = {} ACLCompat = Compat diff --git a/core/acl/init.lua b/core/acl/init.lua index 52a8c18..28348f6 100644 --- a/core/acl/init.lua +++ b/core/acl/init.lua @@ -23,9 +23,7 @@ ACL.ClientType = { ACL.currentClient = ACL.ClientType.UNKNOWN ACL.clientName = "Unknown" --- ========================================================================= -- DETECTION (scored signal table) --- ========================================================================= local _detected = false local _clientType = ACL.ClientType.UNKNOWN @@ -112,9 +110,7 @@ local function detectClient(force) return _clientType end --- ========================================================================= -- PUBLIC DETECTION API --- ========================================================================= function ACL.getClientType() return detectClient() end function ACL.getClientName() detectClient(); return ACL.clientName end @@ -123,9 +119,7 @@ function ACL.isOpenTibiaBR() return detectClient() == ACL.ClientType.O function ACL.refreshDetection() _detected = false; return detectClient(true) end function ACL.getDetectionInfo() detectClient(); return ACL.lastDetection end --- ========================================================================= -- ADAPTER LOADING --- ========================================================================= local adapter = nil local adapterLoaded = false @@ -176,9 +170,7 @@ local function loadAdapter() return adapter end --- ========================================================================= -- LAZY ACCESS — metatabled so ACL.game / ACL.map / etc resolve to adapter --- ========================================================================= setmetatable(ACL, { __index = function(t, key) @@ -191,9 +183,7 @@ setmetatable(ACL, { end, }) --- ========================================================================= -- INIT (called by _Loader.lua) --- ========================================================================= local _lateDetectionDone = false diff --git a/core/acl/interfaces.lua b/core/acl/interfaces.lua index 31fc5ca..557bb9c 100644 --- a/core/acl/interfaces.lua +++ b/core/acl/interfaces.lua @@ -12,9 +12,7 @@ local Interfaces = {} --- ========================================================================= -- INTERFACE DEFINITIONS (arrays for readability, converted to sets below) --- ========================================================================= local RAW = {} @@ -81,9 +79,7 @@ RAW.IModules = { "getGameInterface", "getConsole", "getCooldown", "getBot", "getTerminal", } --- ========================================================================= -- Convert to Sets for O(1) lookup + expose arrays for iteration --- ========================================================================= for ifaceName, methods in pairs(RAW) do local set = {} @@ -92,9 +88,7 @@ for ifaceName, methods in pairs(RAW) do Interfaces[ifaceName .. "_set"] = set -- set form (O(1) lookup) end --- ========================================================================= -- VALIDATION --- ========================================================================= --- Validate one domain table against an interface. --- @param domain table e.g. adapter.game diff --git a/core/alarms.lua b/core/alarms.lua index 7e9ce52..60b668d 100644 --- a/core/alarms.lua +++ b/core/alarms.lua @@ -67,7 +67,6 @@ local parents = window.settingsList } - -- type addAlarm = function(id, title, defaultValue, alarmType, parent, tooltip) local widget = UI.createWidget(widgets[alarmType], parents[parent]) @@ -125,7 +124,6 @@ addAlarm("creatureDetected", "Creature Detected", false, 1, 1) addAlarm("playerDetected", "Player Detected", false, 1, 1) addAlarm("creatureName", "Creature Name:", "", 3, 1, "You can add a name or part of it, that if found in any visible creature name will trigger alert.\nYou can add many, just separate them by comma.") - local lastCall = now local function alarm(file, windowText) if now - lastCall < 2000 then return end -- 2s delay diff --git a/core/analyzer.lua b/core/analyzer.lua index 4cea181..0e37dbc 100644 --- a/core/analyzer.lua +++ b/core/analyzer.lua @@ -19,13 +19,11 @@ local BOSSES = { {"Urmahlulu", "Urmahlullu"} } --- ============================================================================ -- SESSION MANAGEMENT: Reset hunt data on bot restart/login -- -- This fixes the issue of cached hunts from past sessions persisting. -- Session-specific data is cleared on each bot initialization. -- Persistent data (boss cooldowns, custom prices, settings) is preserved. --- ============================================================================ -- Clear session-specific hunt data (runs on every bot load) local function resetHuntSession() @@ -220,7 +218,6 @@ local function clipboardData() local totalWaste, totalLoot, totalBalance = getSumStats() local final = "" - local first = "Session data: From " .. HuntingSessionStart .." to ".. os.date('%Y-%m-%d, %H:%M:%S') local second = "Session: " .. sessionTime() local third = "Loot Type: Market" @@ -389,7 +386,6 @@ label:setColor('#ED7117') local suppliesByRefill = UI.createWidget("AnalyzerItemsPanel", statsWindow.contentsPanel) UI.Separator(statsWindow.contentsPanel) - --huntig local sessionTimeLabel = UI.DualLabel("Session:", "00:00h", {}, huntingWindow.contentsPanel).right local xpGainLabel = UI.DualLabel("XP Gain:", "0", {}, huntingWindow.contentsPanel).right @@ -406,7 +402,6 @@ local killedList = UI.createWidget("AnalyzerListPanel", huntingWindow.contentsPa UI.DualLabel("Looted items:", "", {maxWidth = 200}, huntingWindow.contentsPanel) local lootList = UI.createWidget("AnalyzerListPanel", huntingWindow.contentsPanel) - --party UI.Button("Copy to Clipboard", function() clipboardData() end, partyHuntWindow.contentsPanel) UI.Button("Reset Sessions", function() @@ -567,7 +562,6 @@ end, dropTrackerWindow.contentsPanel) UI.Separator(dropTrackerWindow.contentsPanel) createTrackedItems() - --loot local lootInLootAnalyzerLabel = UI.DualLabel("Gold Value:", "0", {}, lootWindow.contentsPanel).right local lootHourInLootAnalyzerLabel = UI.DualLabel("Per Hour:", "0", {}, lootWindow.contentsPanel).right @@ -580,9 +574,6 @@ local lootGraph = UI.createWidget("AnalyzerGraph", lootWindow.contentsPanel) lootGraph:setTitle("Loot/h") drawGraph(lootGraph, 0) - - - --supplies local suppliesInSuppliesAnalyzerLabel = UI.DualLabel("Gold Value:", "0", {}, supplyWindow.contentsPanel).right local suppliesHourInSuppliesAnalyzerLabel = UI.DualLabel("Per Hour:", "0", {}, supplyWindow.contentsPanel).right @@ -595,9 +586,6 @@ local supplyGraph = UI.createWidget("AnalyzerGraph", supplyWindow.contentsPanel) supplyGraph:setTitle("Waste/h") drawGraph(supplyGraph, 0) - - - -- impact --- damage @@ -628,7 +616,6 @@ if top3 and top3.left then top3.left:setWidth(135) end if top4 and top4.left then top4.left:setWidth(135) end if top5 and top5.left then top5.left:setWidth(135) end - --- healing UI.Separator(impactWindow.contentsPanel) local title3 = UI.DualLabel("Healing", "", {}, impactWindow.contentsPanel).left @@ -642,12 +629,6 @@ local healGraph = UI.createWidget("AnalyzerGraph", impactWindow.contentsPanel) healGraph:setTitle("HPS") drawGraph(healGraph, 0) - - - - - - --xp local xpGrainInXpLabel = UI.DualLabel("XP Gain:", "0", {}, xpWindow.contentsPanel).right local xpHourInXpLabel = UI.DualLabel("XP/h:", "0", {}, xpWindow.contentsPanel).right @@ -661,15 +642,7 @@ local xpGraph = UI.createWidget("AnalyzerGraph", xpWindow.contentsPanel) drawGraph(xpGraph, 0) - - - ---############################################# --############################################# UI DONE ---############################################# ---############################################# ---############################################# ---############################################# setDefaultTab("Main") -- first, the variables @@ -871,7 +844,6 @@ if BotServer._websocket then widget:setId(widgetName) widget.lastUpdate = now - local t = membersData[name] widget.name:setText(name) widget.name:setColor("white") @@ -923,7 +895,6 @@ if BotServer._websocket then end) end - function hightlightText(widget, color, duration) for i=0,duration do schedule(i * 250, function() @@ -1012,7 +983,6 @@ onTextMessage(function(mode, text) local childName = child.name childName = childName and childName:getText() - if childName and formattedLoot:find(childName) then trackedLoot[tostring(child.item:getItemId())] = trackedLoot[tostring(child.item:getItemId())] + (amount or 1) child.drops:setText("Loot Drops: "..trackedLoot[tostring(child.item:getItemId())]) @@ -1149,7 +1119,6 @@ local function getFrame(v) end end - displayCondition = function(menuPosition, lookThing, useThing, creatureThing) if lookThing and not lookThing:isCreature() and not lookThing:isNotMoveable() and lookThing:isPickupable() then return true @@ -1402,18 +1371,6 @@ macro(500, function() end end) -function getPanelHeight(panel) - - local elements = panel.List:getChildCount() - if elements == 0 then - return 0 - else - local rows = math.ceil(elements/5) - local height = rows * 35 - return height - end -end - function refreshLoot() lootItems:destroyChildren() @@ -1590,12 +1547,6 @@ onTextMessage(function(mode, text) refreshWaste() end end) - -function hourVal(v) - v = v or 0 - return (v/uptime)*3600 -end - function bottingStats() lootWorth = 0 wasteWorth = 0 @@ -1643,17 +1594,28 @@ function bottingLabels(lootWorth, wasteWorth, balance) return balanceDesc, hourDesc end -function reportStats() +function getHuntingData() local lootWorth, wasteWorth, balance = bottingStats() - local balanceDesc, hourDesc = bottingLabels(lootWorth, wasteWorth, balance) - - local a, b, c + return totalDmg, totalHeal, lootWorth, wasteWorth, balance +end - a = "Session Time: " .. sessionTime() .. ", Exp Gained: " .. format_thousand(expGained()) .. ", Exp/h: " .. expPerHour() - b = " | Balance: " .. balanceDesc .. " (" .. hourDesc .. ")" - c = a..b +function hourVal(v) + v = v or 0 + if not uptime or uptime <= 0 then return 0 end + return (v/uptime)*3600 +end - return c +function avgTable(t) + if type(t) ~= 'table' then return 0 end + local val, count = 0, 0 + for _,v in pairs(t) do + if type(v) == 'number' then + val = val + v + count = count + 1 + end + end + if count == 0 then return 0 end + return val / count end function damageHour() @@ -1681,7 +1643,6 @@ function wasteHour() end end - function lootHour() local lootWorth, wasteWorth, balance = bottingStats() if uptime < 5*60 then @@ -1691,26 +1652,6 @@ function lootHour() end end -function getHuntingData() - local lootWorth, wasteWorth, balance = bottingStats() - return totalDmg, totalHeal, lootWorth, wasteWorth, balance -end - -function avgTable(t) - if type(t) ~= 'table' then return 0 end - local val = 0 - - for i,v in pairs(t) do - val = val + v - end - - if #t == 0 then - return 0 - else - return val/#t - end -end - --bestdps/hps local bestDPS = 0 local bestHPS = 0 @@ -1743,7 +1684,6 @@ macro(500, function() lootInLootAnalyzerLabel:setText(format_thousand(lootWorth)) lootHourInLootAnalyzerLabel:setText(format_thousand(lootHour())) - --supply window suppliesInSuppliesAnalyzerLabel:setText(format_thousand(wasteWorth)) suppliesHourInSuppliesAnalyzerLabel:setText(format_thousand(wasteHour())) @@ -1780,7 +1720,6 @@ macro(500, function() progressBar:setPercent(percent) end - --stats totalRounds:setText(nExBot.CaveBotData.rounds) avRoundTime:setText(niceTimeFormat(avgTable(nExBot.CaveBotData.time),true)) diff --git a/core/bot_core/actions.lua b/core/bot_core/actions.lua index 585e93d..f6eeebb 100644 --- a/core/bot_core/actions.lua +++ b/core/bot_core/actions.lua @@ -9,9 +9,7 @@ local Actions = {} --- ============================================================================ -- SPELL EXECUTION --- ============================================================================ -- Cast a spell with optional delay tracking -- spell: spell words (string) @@ -50,9 +48,7 @@ function Actions.castSpell(spell, delay, options) return true end --- ============================================================================ -- ITEM/POTION EXECUTION --- ============================================================================ -- Use an item like a hotkey (works without open backpack) -- itemId: item ID to use @@ -130,9 +126,7 @@ function Actions.useRune(runeId, target) return false end --- ============================================================================ -- ITEM VISIBILITY --- ============================================================================ -- Check if item is visible (in open container) function Actions.isItemVisible(itemId) @@ -157,9 +151,7 @@ function Actions.canUseItem(itemId, requireVisible) return true end --- ============================================================================ -- UNIFIED ACTION EXECUTION --- ============================================================================ -- Execute an action (spell or item) -- action: { type = "spell"|"potion"|"rune", id = ..., target = ... } @@ -179,9 +171,7 @@ function Actions.execute(action) return false end --- ============================================================================ -- ATTACK BOT SPECIFIC --- ============================================================================ -- Execute attack action with category handling -- category: 1=targeted spell, 2=area rune, 3=targeted rune, 4=empowerment, 5=absolute spell diff --git a/core/bot_core/analytics.lua b/core/bot_core/analytics.lua index 624800a..62e8369 100644 --- a/core/bot_core/analytics.lua +++ b/core/bot_core/analytics.lua @@ -16,9 +16,7 @@ local SafeCall = SafeCall or require("core.safe_call") local BoundedPush = BoundedPush local TrimArray = TrimArray --- ============================================================================ -- PRIVATE STATE --- ============================================================================ -- Unified analytics structure local _data = storage.botCoreAnalytics or { @@ -86,9 +84,7 @@ end -- Persist to storage storage.botCoreAnalytics = _data --- ============================================================================ -- PRIVATE HELPERS --- ============================================================================ -- Append to log with rotation (using TrimArray for O(1) amortized) local function appendLog(entry) @@ -105,9 +101,7 @@ local function incrementCounter(tbl, key) tbl[strKey] = (tbl[strKey] or 0) + 1 end --- ============================================================================ -- PUBLIC API: Session Management --- ============================================================================ function Analytics.startSession() _data.session.startTime = now or os.time() * 1000 @@ -129,9 +123,7 @@ function Analytics.getSessionDuration() return currentTime - _data.session.startTime end --- ============================================================================ -- PUBLIC API: Healing Analytics --- ============================================================================ -- Record a healing spell cast function Analytics.recordHealSpell(spellName, manaCost, hpBefore, hpAfter) @@ -172,9 +164,7 @@ function Analytics.recordPotion(itemId, hpBefore, hpAfter) }) end --- ============================================================================ -- PUBLIC API: Attack Analytics --- ============================================================================ -- Record an attack spell cast function Analytics.recordAttackSpell(spellName, category) @@ -219,9 +209,7 @@ function Analytics.recordAttack(category, idOrFormula) end end --- ============================================================================ -- PUBLIC API: Support Analytics --- ============================================================================ function Analytics.recordSupportSpell(spellName) incrementCounter(_data.support.spells, spellName) @@ -233,9 +221,7 @@ function Analytics.recordSupportSpell(spellName) }) end --- ============================================================================ -- PUBLIC API: Data Getters --- ============================================================================ function Analytics.getHealingData() return _data.healing @@ -257,9 +243,7 @@ function Analytics.getAll() return _data end --- ============================================================================ -- PUBLIC API: Reset --- ============================================================================ function Analytics.resetHealing() _data.healing = { @@ -285,9 +269,7 @@ function Analytics.resetAll() _data.session = { startTime = 0, startXp = 0, isActive = false } end --- ============================================================================ -- COMPATIBILITY: Legacy API for existing bots --- ============================================================================ -- HealBot compatibility Analytics.HealBot = { diff --git a/core/bot_core/attack_system.lua b/core/bot_core/attack_system.lua index d569964..c8dd6a2 100644 --- a/core/bot_core/attack_system.lua +++ b/core/bot_core/attack_system.lua @@ -29,9 +29,7 @@ BotCore.AttackSystem = BotCore.AttackSystem or {} local AttackSystem = BotCore.AttackSystem --- ============================================================================ -- CONSTANTS --- ============================================================================ local RUNE_COOLDOWN_MS = 2000 -- Default rune exhaustion local SPELL_COOLDOWN_MS = 2000 -- Default attack spell cooldown @@ -48,9 +46,7 @@ local ATTACK_TYPE = { BASIC = 5 } --- ============================================================================ -- PRIVATE STATE --- ============================================================================ local _state = { -- Last attack timestamps @@ -74,9 +70,7 @@ local _state = { enabled = true } --- ============================================================================ -- COOLDOWN INTEGRATION (Uses BotCore.Cooldown as single source of truth) --- ============================================================================ -- Check if attack group cooldown is active local function isAttackGroupOnCooldown() @@ -115,9 +109,7 @@ local function canAttack(lastTime, delay) return currentTime >= lastTime + (delay or SPELL_COOLDOWN_MS) end --- ============================================================================ -- HOTKEY-STYLE ITEM USAGE (High-performance, works without open backpack) --- ============================================================================ -- Use item on target using hotkey-style API -- @param itemId: rune/potion ID @@ -165,16 +157,14 @@ local function useItemOnTarget(itemId, target, subType) return false end --- ============================================================================ -- PURE FUNCTIONS: Attack Planning --- ============================================================================ -- Count monsters in range for AOE attacks -- @param centerPos: center position for AOE -- @param radius: AOE radius -- @return number of monsters, boolean hasPlayers local function countMonstersInRange(centerPos, radius) - local creatures = g_map.getSpectatorsInRange(centerPos, false, radius, radius) + local creatures = getSpectators(centerPos, false, radius, radius) or {} local monsterCount = 0 local hasPlayers = false @@ -215,9 +205,7 @@ local function isInPz() return false end --- ============================================================================ -- ATTACK EXECUTION --- ============================================================================ -- Execute AOE spell attack -- @param spellText: spell incantation @@ -390,9 +378,7 @@ function AttackSystem.executeSingleRune(runeId, target, delay, config) return false end --- ============================================================================ -- HIGH-LEVEL ATTACK PLANNING --- ============================================================================ --[[ Plan the best attack action based on current combat state. @@ -537,9 +523,7 @@ function AttackSystem.attack(target, config) return AttackSystem.executeAction(action, config) end --- ============================================================================ -- EVENTBUS INTEGRATION --- ============================================================================ -- Setup EventBus listeners for reactive attacks function AttackSystem.setupEventListeners() @@ -580,9 +564,7 @@ function AttackSystem.setupEventListeners() end, 40) end --- ============================================================================ -- PUBLIC API --- ============================================================================ -- Initialize attack system function AttackSystem.init(config) @@ -632,9 +614,7 @@ function AttackSystem.cleanup() _state.subscriptions = {} end --- ============================================================================ -- BACKWARDS COMPATIBILITY BRIDGE --- ============================================================================ -- These functions maintain compatibility with existing TargetBot code -- while using the new optimized implementation under the hood diff --git a/core/bot_core/conditions.lua b/core/bot_core/conditions.lua index f8adeda..e626729 100644 --- a/core/bot_core/conditions.lua +++ b/core/bot_core/conditions.lua @@ -9,9 +9,7 @@ local ConditionChecker = {} --- ============================================================================ -- PURE COMPARISON FUNCTIONS --- ============================================================================ -- Compare value with sign (pure function) local function compare(current, sign, target) @@ -27,9 +25,7 @@ local function compare(current, sign, target) return false end --- ============================================================================ -- PUBLIC API --- ============================================================================ -- Check single condition against stats cache -- origin: "HP%", "HP", "MP%", "MP", "burst" @@ -120,9 +116,7 @@ function ConditionChecker.hasMana(amount, stats) return false end --- ============================================================================ -- CONDITION ENTRY EVALUATION --- ============================================================================ -- Evaluate a heal/attack entry condition -- entry: { origin, sign, value, enabled, cost, ... } diff --git a/core/bot_core/cooldown.lua b/core/bot_core/cooldown.lua index 9754f58..20eb185 100644 --- a/core/bot_core/cooldown.lua +++ b/core/bot_core/cooldown.lua @@ -41,9 +41,7 @@ local _cache = { -- Reference to OTClient cooldown module local _cooldownModule = nil --- ============================================================================ -- PRIVATE FUNCTIONS --- ============================================================================ -- Lazy load cooldown module local function getCooldownModule() @@ -99,9 +97,7 @@ local function updateSpellCooldown(spellId) return isOnCooldown end --- ============================================================================ -- PUBLIC API: Group Cooldowns --- ============================================================================ -- Check if attack group (1) is on cooldown function CooldownManager.isAttackOnCooldown() @@ -134,9 +130,7 @@ function CooldownManager.isGroupOnCooldown(groupId) return _cache.groups[groupId] end --- ============================================================================ -- PUBLIC API: Spell Cooldowns --- ============================================================================ -- Check if specific spell is on cooldown function CooldownManager.isSpellOnCooldown(spellId) @@ -159,9 +153,7 @@ function CooldownManager.canCastSpell(spellId, groupId) return true end --- ============================================================================ -- PUBLIC API: Potion/Item Exhausted --- ============================================================================ -- Mark potion as used (start 1s exhausted) function CooldownManager.markPotionUsed() @@ -182,9 +174,7 @@ function CooldownManager.getPotionCooldown() return remaining > 0 and remaining or 0 end --- ============================================================================ -- PUBLIC API: Healing Cooldown (Shared between HealBot and FriendHealer) --- ============================================================================ -- Mark healing action as used (for shared exhaustion tracking) -- This is called by both HealEngine and FriendHealer to prevent conflicts @@ -201,9 +191,7 @@ function CooldownManager.isHealingExhausted() return currentTime < _cache.healingExhaustedUntil end --- ============================================================================ -- PUBLIC API: Generic Action Check --- ============================================================================ -- Unified check for any action type -- actionType: "spell", "potion", "rune" @@ -226,9 +214,7 @@ function CooldownManager.canPerformAction(actionType, options) return true end --- ============================================================================ -- EVENT HANDLERS: React to OTClient events --- ============================================================================ -- Hook into cooldown events for instant updates (if available) function CooldownManager.init() diff --git a/core/bot_core/creatures.lua b/core/bot_core/creatures.lua index 6d88cc6..7d42b10 100644 --- a/core/bot_core/creatures.lua +++ b/core/bot_core/creatures.lua @@ -11,12 +11,11 @@ - Friend/enemy lookup optimization ]] +local zChanging = nExBot.zChanging or function() return false end local Creatures = {} BotCore.Creatures = Creatures --- ============================================================================ -- CONFIGURATION --- ============================================================================ local CACHE_TTL = 100 -- Cache TTL in ms local FRIEND_CACHE_TTL = 5000 -- Friend list cache TTL @@ -33,9 +32,29 @@ local cache = { enemyListTime = 0 } --- ============================================================================ +-- Tick-level getSpectators cache +local _spectatorsCache = nil +local _spectatorsCacheKey = nil +local _spectatorsCacheTime = 0 +local SPECTATORS_CACHE_TTL = 200 + +local function getCachedSpectators(...) + local args = {...} + local nargs = #args + local keyParts = {tostring(nargs)} + for i = 1, nargs do keyParts[#keyParts + 1] = tostring(args[i]) end + local key = table.concat(keyParts, ":") + local t = now or (os.time() * 1000) + if _spectatorsCache and _spectatorsCacheKey == key and (t - _spectatorsCacheTime) < SPECTATORS_CACHE_TTL then + return _spectatorsCache + end + _spectatorsCache = getSpectators(...) or {} + _spectatorsCacheKey = key + _spectatorsCacheTime = t + return _spectatorsCache +end + -- SHAPE CONSTANTS (exported for external use) --- ============================================================================ Creatures.SHAPE = { SQUARE = 1, -- Chebyshev distance (default Tibia range) @@ -56,9 +75,7 @@ local CONE_DIRECTIONS = { -- Cache client version (doesn't change) local isOldClient = g_game.getClientVersion() < 960 --- ============================================================================ -- PURE FUNCTIONS --- ============================================================================ -- Pure function: Check if position is within shape -- @param dx: x distance from center (absolute) @@ -109,9 +126,7 @@ end -- Export for external use Creatures.isInShape = isInShape --- ============================================================================ -- CACHE MANAGEMENT --- ============================================================================ local function invalidateCache() cache.monsters = {} @@ -130,9 +145,7 @@ if onPlayerPositionChange then end) end --- ============================================================================ -- MONSTER COUNTING --- ============================================================================ -- Get monster count with caching and shape support -- @param range: maximum range (default 10) @@ -167,7 +180,7 @@ function Creatures.getMonsterCount(range, options) local count = 0 local px, py = center.x, center.y - for _, spec in pairs(getSpectators(multifloor)) do + for _, spec in pairs(getCachedSpectators(multifloor)) do if spec:isMonster() and (isOldClient or spec:getType() < 3) then if not filter or filter(spec) then local specPos = spec:getPosition() @@ -208,9 +221,7 @@ function Creatures.getMonstersCone(range, spread, multifloor) return Creatures.getMonsterCount(range, {shape = Creatures.SHAPE.CONE, coneAngle = spread or 1, multifloor = multifloor}) end --- ============================================================================ -- PLAYER COUNTING --- ============================================================================ -- Get player count (non-party, non-local) with caching -- @param range: maximum range (default 10) @@ -229,7 +240,7 @@ function Creatures.getPlayerCount(range, multifloor) local playerPos = player:getPosition() local px, py = playerPos.x, playerPos.y - for _, spec in pairs(getSpectators(multifloor)) do + for _, spec in pairs(getCachedSpectators(multifloor)) do if spec:isPlayer() and not spec:isLocalPlayer() then local specPos = spec:getPosition() if specPos then @@ -251,9 +262,7 @@ function Creatures.getPlayerCount(range, multifloor) return count end --- ============================================================================ -- DISTANCE UTILITIES --- ============================================================================ -- Get distance from player to a position -- @param coords: position table with x, y, z @@ -263,9 +272,7 @@ function Creatures.distanceFromPlayer(coords) return getDistanceBetween(pos(), coords) end --- ============================================================================ -- TARGET UTILITIES --- ============================================================================ -- Get current target creature -- @return creature or nil @@ -296,9 +303,7 @@ function Creatures.isTargetInRange(range) return dist ~= nil and dist <= (range or 1) end --- ============================================================================ -- CREATURES IN AREA (pattern-based) --- ============================================================================ -- Get creatures in area by pattern -- @param pos: center position @@ -312,7 +317,7 @@ function Creatures.getInArea(centerPos, pattern, creatureType) local monsters = 0 local players = 0 - for _, spec in pairs(getSpectators(centerPos, pattern)) do + for _, spec in pairs(getCachedSpectators(centerPos, pattern)) do if spec ~= player then specs = specs + 1 if spec:isMonster() and (isOldClient or spec:getType() < 3) then @@ -332,9 +337,7 @@ function Creatures.getInArea(centerPos, pattern, creatureType) end end --- ============================================================================ -- SAFETY CHECKS --- ============================================================================ -- Check if area is safe (no non-friend players) -- @param range: check range @@ -347,7 +350,7 @@ function Creatures.isSafe(range, multifloor, padding) padding = false end - for _, spec in pairs(getSpectators(multifloor)) do + for _, spec in pairs(getCachedSpectators(multifloor)) do if spec:isPlayer() and not spec:isLocalPlayer() and not isFriend(spec:getName()) then local specPos = spec:getPosition() if specPos then @@ -368,9 +371,92 @@ function Creatures.isSafe(range, multifloor, padding) return true end --- ============================================================================ +-- NEARBY CREATURE QUERIES (merged from creature_cache.lua) + +-- Get nearby creatures (replaces CreatureCache.getNearby) +-- Returns creature objects from spectators, with tick-level caching +-- @param rangeX: horizontal range (default 14) +-- @param rangeY: vertical range (default 11) +-- @return array of creature objects +function Creatures.getNearby(rangeX, rangeY) + rangeX = rangeX or 14 + rangeY = rangeY or rangeX + local specs = getSpectators(rangeX <= 14 and rangeY <= 11 and false or nil) or {} + local result = {} + local ppos = pos() + local rx2, ry2 = rangeX, rangeY + for _, spec in pairs(specs) do + if not spec:isLocalPlayer() then + local spos = spec:getPosition() + if spos and math.abs(spos.x - ppos.x) <= rx2 and math.abs(spos.y - ppos.y) <= ry2 then + result[#result + 1] = spec + end + end + end + return result +end + +-- Get nearest monster to a position (replaces CreatureCache.getNearestMonster) +-- @param pos: center position {x, y, z} +-- @param maxRange: maximum search range (default 50) +-- @return creature, distance or nil +function Creatures.getNearestMonster(pos, maxRange) + if not pos then return nil, nil end + maxRange = maxRange or 50 + + local nearest = nil + local nearestDist = maxRange + 1 + local specs = getSpectators(false) or {} + + for _, spec in pairs(specs) do + if spec:isMonster() and (isOldClient or spec:getType() < 3) then + local specPos = spec:getPosition() + if specPos then + local dist = math.max( + math.abs(specPos.x - pos.x), + math.abs(specPos.y - pos.y) + ) + if dist < nearestDist then + nearestDist = dist + nearest = spec + end + end + end + end + + return nearest, nearestDist +end + +-- Get monsters within range of a position (replaces CreatureCache.getMonstersInRange) +-- @param pos: center position {x, y, z} +-- @param range: maximum distance (default 10) +-- @return array of creature objects +function Creatures.getMonstersInRange(pos, range) + if not pos then return {} end + range = range or 10 + + local result = {} + local specs = getSpectators(false) or {} + + for _, spec in pairs(specs) do + if spec:isMonster() and (isOldClient or spec:getType() < 3) then + local specPos = spec:getPosition() + if specPos then + local dist = math.max( + math.abs(specPos.x - pos.x), + math.abs(specPos.y - pos.y) + ) + if dist <= range then + result[#result + 1] = spec + end + end + end + end + + return result +end + -- INITIALIZATION --- ============================================================================ -- Log successful load if logInfo then diff --git a/core/bot_core/friend_healer.lua b/core/bot_core/friend_healer.lua index 1797521..8e45326 100644 --- a/core/bot_core/friend_healer.lua +++ b/core/bot_core/friend_healer.lua @@ -45,9 +45,7 @@ local SC = SafeCreature -- Module version for debugging FriendHealerEnhanced.VERSION = "3.0.1" --- ============================================================================ -- CONSTANTS --- ============================================================================ local SELF_CRITICAL_HP = 30 -- Below this: NEVER heal friends local SELF_LOW_HP = 50 -- Below this: NEVER heal friends @@ -77,9 +75,7 @@ local RUNE_IDS = { INTENSE_HEALING = 3152, -- IH rune } --- ============================================================================ -- PRIVATE STATE --- ============================================================================ local _state = { -- Cached friend list { name = { creature, lastHp, lastUpdate, priority } } @@ -111,9 +107,7 @@ local _state = { spellCount = 0 } --- ============================================================================ -- COOLDOWN INTEGRATION --- ============================================================================ -- Check if healing group cooldown is active (group 2) local function isHealingGroupOnCooldown() @@ -161,9 +155,7 @@ local function markHealingUsed() end end --- ============================================================================ -- HOTKEY-STYLE ITEM USAGE --- ============================================================================ -- Use potion on friend using hotkey-style API -- @param potionId: potion item ID @@ -269,9 +261,7 @@ local function castHealSpellOnFriend(spellName, friendName, manaCost) return false end --- ============================================================================ -- PURE FUNCTIONS: Targeting --- ============================================================================ -- Get self HP percent local function getSelfHpPercent() @@ -379,9 +369,7 @@ local function calculateUrgency(hpPercent, distance) return math.max(0, math.min(100, urgency)) end --- ============================================================================ -- HEALING ACTIONS (Fully integrated with UI config) --- ============================================================================ -- Count friends in range for area heals (improved with safe API calls) local function countFriendsInRange(config, maxRange) @@ -442,7 +430,6 @@ function FriendHealerEnhanced.planHealAction(friend, friendHp, config) local settings = config.settings or {} local selfHp = getSelfHpPercent() local selfMp = getSelfMpPercent() - local currentMana = mana and mana() or 0 -- Safely get friend properties local okName, friendName = pcall(function() return friend:getName() end) @@ -473,77 +460,47 @@ function FriendHealerEnhanced.planHealAction(friend, friendHp, config) return nil end - -- ========== PRIORITY 1: Custom Spell (user-defined spell like "exura" variations) ========== - if config.customSpell and config.customSpellName and friendHp < healAt then - local customSpell = config.customSpellName - local manaCost = 100 -- Default, could be configurable - if currentMana >= manaCost and not isHealingGroupOnCooldown() and distance <= 7 then - return { - type = "spell", - spell = customSpell, - targetName = friendName, - manaCost = manaCost, - urgency = calculateUrgency(friendHp, distance), - source = "customSpell" - } + -- ========== DELEGATE SPELL SELECTION TO HEALENGINE ========== + -- Build spell list from config priorities and let HealEngine pick the best + if HealEngine and HealEngine.evaluateAlly then + local spellList = {} + if config.customSpell and config.customSpellName and friendHp < healAt and distance <= 7 then + table.insert(spellList, {name = config.customSpellName, key = config.customSpellName:lower(), hp = healAt, mpCost = 100, cd = 1100, prio = 1}) end - end - - -- ========== PRIORITY 2: Exura Gran Sio (strong single target heal) ========== - if config.useGranSio and friendHp < granSioAt then - local manaCost = 140 - if currentMana >= manaCost and not isHealingGroupOnCooldown() and distance <= 7 then - return { - type = "spell", - spell = "exura gran sio", - targetName = friendName, - manaCost = manaCost, - urgency = calculateUrgency(friendHp, distance), - source = "granSio" - } + if config.useGranSio and friendHp < granSioAt and distance <= 7 then + table.insert(spellList, {name = "exura gran sio", key = "exura_gran_sio", hp = granSioAt, mpCost = 140, cd = 1100, prio = 2}) end - end - - -- ========== PRIORITY 3: Exura Tio Sio (medium single target heal) ========== - if config.useTioSio and friendHp < tioSioAt then - local manaCost = 120 - if currentMana >= manaCost and not isHealingGroupOnCooldown() and distance <= 7 then - return { - type = "spell", - spell = "exura tio sio", - targetName = friendName, - manaCost = manaCost, - urgency = calculateUrgency(friendHp, distance), - source = "tioSio" - } + if config.useTioSio and friendHp < tioSioAt and distance <= 7 then + table.insert(spellList, {name = "exura tio sio", key = "exura_tio_sio", hp = tioSioAt, mpCost = 120, cd = 1100, prio = 3}) end - end - - -- ========== PRIORITY 4: Exura Sio (normal single target heal) ========== - if config.useSio and friendHp < healAt then - local manaCost = 100 - if currentMana >= manaCost and not isHealingGroupOnCooldown() and distance <= 7 then - return { - type = "spell", - spell = "exura sio", - targetName = friendName, - manaCost = manaCost, - urgency = calculateUrgency(friendHp, distance), - source = "sio" - } + if config.useSio and friendHp < healAt and distance <= 7 then + table.insert(spellList, {name = "exura sio", key = "exura_sio", hp = healAt, mpCost = 100, cd = 1100, prio = 4}) + end + + if #spellList > 0 then + local engineAction = HealEngine.evaluateAlly(friend, friendHp, spellList) + if engineAction then + return { + type = "spell", + spell = engineAction.name, + targetName = friendName, + manaCost = engineAction.mana or 100, + urgency = calculateUrgency(friendHp, distance), + source = "healEngine" + } + end end end - -- ========== PRIORITY 4: Exura Gran Mas Res (area heal, requires min players) ========== + -- ========== EXURA GRAN MAS RES (area heal, requires min players) ========== if config.useMasRes and friendHp < healAt then local friendsNeedingHeal = countFriendsInRange(config, 7) if friendsNeedingHeal >= masResPlayers then - local manaCost = 150 - if currentMana >= manaCost and not isHealingGroupOnCooldown() then + if not isHealingGroupOnCooldown() then return { type = "area_spell", spell = "exura gran mas res", - manaCost = manaCost, + manaCost = 150, friendCount = friendsNeedingHeal, urgency = calculateUrgency(friendHp, distance), source = "masRes" @@ -552,9 +509,8 @@ function FriendHealerEnhanced.planHealAction(friend, friendHp, config) end end - -- ========== PRIORITY 5: Health Item / UH Rune (hotkey-style) ========== + -- ========== HEALTH ITEM / UH RUNE (hotkey-style) ========== if config.useHealthItem and friendHp < healAt then - -- Safely check if we can shoot the friend local okShoot, canShoot = pcall(function() return friend:canShoot() end) if not isRuneOnCooldown() and distance <= itemRange and (not okShoot or canShoot) then return { @@ -568,11 +524,8 @@ function FriendHealerEnhanced.planHealAction(friend, friendHp, config) end end - -- ========== PRIORITY 6: Mana Item (for supporting mage friends) ========== - -- Note: Mana potions typically require close range (distance <= 1) + -- ========== MANA ITEM (for supporting mage friends) ========== if config.useManaItem then - -- Only use mana items if friend's mana is low - -- For now, only use on friends explicitly marked for mana support if config.manaFriends and config.manaFriends[friendName] then if not isPotionOnCooldown() and distance <= 1 then return { @@ -659,9 +612,7 @@ function FriendHealerEnhanced.executeAction(action) return false end --- ============================================================================ -- MAIN TICK AND SCANNING --- ============================================================================ -- Find best friend to heal from spectators (improved with safe API calls) function FriendHealerEnhanced.findBestTarget(config) @@ -753,9 +704,7 @@ function FriendHealerEnhanced.tick() return false end --- ============================================================================ -- EVENTBUS INTEGRATION (Improved for accuracy and performance) --- ============================================================================ -- DRY: Reuse SafeCreature instead of duplicating pcall wrappers local safeGetName = SC.getName @@ -882,9 +831,7 @@ function FriendHealerEnhanced.setupEventListeners() end, 30) end --- ============================================================================ -- PUBLIC API --- ============================================================================ function FriendHealerEnhanced.init(config) _state.config = config @@ -927,9 +874,7 @@ function FriendHealerEnhanced.cleanup() _state.friends = {} end --- ============================================================================ -- BACKWARD COMPATIBILITY (for new_healer.lua integration) --- ============================================================================ -- Event handler: Friend health changed (legacy API - EventBus handles this internally) function FriendHealerEnhanced.onFriendHealthChange(creature, newHpPercent, oldHpPercent) diff --git a/core/bot_core/init.lua b/core/bot_core/init.lua index afbaa75..7c6ce11 100644 --- a/core/bot_core/init.lua +++ b/core/bot_core/init.lua @@ -29,10 +29,9 @@ BotCore = BotCore or {} BotCore.version = "1.0.0" BotCore.initialized = false --- ============================================================================ -- COMPONENT LOADING (order matters for dependencies) --- ============================================================================ +local zChanging = (nExBot and nExBot.zChanging) or function() return false end local basePath = "/core/bot_core/" -- Core managers (no dependencies) @@ -63,9 +62,7 @@ dofile(basePath .. "attack_system.lua") -- Exposes as both BotCore.FriendHealer and BotCore.FriendHealerEnhanced for compatibility dofile(basePath .. "friend_healer.lua") --- ============================================================================ -- HIGH-PERFORMANCE TICK HANDLER --- ============================================================================ -- Single tick handler - runs at 50ms for critical healing response local function onBotCoreTick() @@ -83,9 +80,7 @@ if macro then macro(50, onBotCoreTick) end --- ============================================================================ -- EVENT-DRIVEN UPDATES (instant response) --- ============================================================================ -- Hook into EventBus for instant stat updates if EventBus then @@ -125,9 +120,7 @@ if onManaChange then end) end --- ============================================================================ -- EXHAUSTED EVENT HANDLING --- ============================================================================ -- Hook into exhausted events for graceful handling if onSpellCooldown then @@ -148,9 +141,7 @@ if onGroupSpellCooldown then end) end --- ============================================================================ -- INITIALIZATION --- ============================================================================ -- Initialize cooldown event hooks if BotCore.Cooldown and BotCore.Cooldown.init then @@ -160,9 +151,7 @@ end -- Mark as initialized BotCore.initialized = true --- ============================================================================ -- PUBLIC HELPERS (convenience functions) --- ============================================================================ -- Quick access using Priority engine (safety-first) function BotCore.canHeal() @@ -210,20 +199,14 @@ function BotCore.checkCondition(origin, sign, value) return false end --- Safety check: Should heal immediately? (non-configurable) +-- Safety check: Should heal immediately? (HealEngine handles its own priority) +-- Delegates to HealEngine for actual decisions; this is a simple fallback function BotCore.shouldHealNow() - if BotCore.Priority then - return BotCore.Priority.shouldHealNow() - end - -- Fallback: heal if HP < 50% return BotCore.hpPercent() < 50 end -- Is emergency healing needed? (HP < 30%) function BotCore.isEmergency() - if BotCore.Priority then - return BotCore.Priority.getCurrentPriority() == BotCore.Priority.PRIORITY.EMERGENCY_HEAL - end return BotCore.hpPercent() < 30 end diff --git a/core/bot_core/items.lua b/core/bot_core/items.lua index f893cb5..d703a19 100644 --- a/core/bot_core/items.lua +++ b/core/bot_core/items.lua @@ -14,9 +14,7 @@ local Items = {} BotCore.Items = Items --- ============================================================================ -- HOTKEY-STYLE ITEM USAGE --- ============================================================================ -- Use item on self (like pressing a hotkey) - works without open backpack -- @param itemId: item ID to use @@ -86,9 +84,7 @@ function Items.useOn(itemId, target, subType) return false end --- ============================================================================ -- ITEM COUNT AND FINDING --- ============================================================================ -- Get item count in inventory and containers -- @param itemId: item ID to count @@ -106,9 +102,7 @@ function Items.has(itemId, minAmount) return Items.count(itemId) >= minAmount end --- ============================================================================ -- CONTAINER UTILITIES --- ============================================================================ -- Get container by name -- @param name: container name @@ -153,9 +147,7 @@ function Items.isContainerFull(container) return container:getCapacity() <= #container:getItems() end --- ============================================================================ -- EQUIP STATE DETECTION --- ============================================================================ -- Mapping of inactive -> active item IDs (rings, amulets, etc.) local INACTIVE_TO_ACTIVE = { @@ -229,9 +221,7 @@ function Items.isEquipped(itemId) return false end --- ============================================================================ -- GROUND ITEM UTILITIES --- ============================================================================ -- Find item on ground (current floor) -- @param itemId: item ID to find @@ -274,9 +264,7 @@ function Items.drop(itemIdOrObject) return true end --- ============================================================================ -- TILE ITEM CHECKS --- ============================================================================ -- Check if item is on a specific tile -- @param itemId: item ID to find @@ -303,9 +291,7 @@ function Items.isOnTile(itemId, tileOrPos) return false end --- ============================================================================ -- INITIALIZATION --- ============================================================================ if logInfo then logInfo("[BotCore] Items module loaded") diff --git a/core/bot_core/position.lua b/core/bot_core/position.lua index f82350c..f1909b5 100644 --- a/core/bot_core/position.lua +++ b/core/bot_core/position.lua @@ -17,9 +17,7 @@ local Position = {} local SafeCall = SafeCall or require("core.safe_call") BotCore.Position = Position --- ============================================================================ -- PRE-COMPUTED DATA --- ============================================================================ -- Direction offsets for adjacent tiles (8 directions) local NEAR_TILE_DIRS = { @@ -40,9 +38,7 @@ local CARDINAL_DIRS = { -- Reusable position table local tempPos = {x = 0, y = 0, z = 0} --- ============================================================================ -- POSITION CREATION --- ============================================================================ -- Create a position table from coordinates -- @param x: x coordinate @@ -72,9 +68,7 @@ function Position.playerXYZ() return p.x, p.y, p.z end --- ============================================================================ -- DISTANCE CALCULATIONS --- ============================================================================ -- Get distance between two positions (Chebyshev - max of dx, dy) -- @param pos1: first position @@ -110,9 +104,7 @@ function Position.euclidean(pos1, pos2) return math.sqrt(dx * dx + dy * dy) end --- ============================================================================ -- TILE UTILITIES --- ============================================================================ -- Get tiles adjacent to a position (8 directions) -- @param centerPos: center position or creature @@ -185,9 +177,7 @@ function Position.getTile(posOrXYZ, y, z) return g_map.getTile(posOrXYZ) end --- ============================================================================ -- TILE ANALYSIS --- ============================================================================ -- Check if tile is walkable -- @param tile: tile object @@ -222,9 +212,7 @@ function Position.isStairs(tileOrPos) return color >= 210 and color <= 213 end --- ============================================================================ -- BEST TILE FINDING --- ============================================================================ -- Find best tile for area spell/rune by creature count -- @param pattern: pattern string @@ -261,9 +249,7 @@ function Position.getBestTileByPattern(pattern, creatureType, maxDist, safe) return best or false end --- ============================================================================ -- PATH UTILITIES --- ============================================================================ -- Check if path exists to target -- @param targetPos: target position @@ -290,9 +276,7 @@ function Position.getPath(targetPos, maxNodes, options) return findPath(pos(), targetPos, maxNodes, options) end --- ============================================================================ -- POSITION COMPARISON --- ============================================================================ -- Check if two positions are equal -- @param pos1: first position @@ -316,9 +300,7 @@ function Position.isInPz() return SafeCall.isInPz() end --- ============================================================================ -- INITIALIZATION --- ============================================================================ if logInfo then logInfo("[BotCore] Position module loaded") diff --git a/core/bot_core/priority.lua b/core/bot_core/priority.lua index a28f041..d0954fa 100644 --- a/core/bot_core/priority.lua +++ b/core/bot_core/priority.lua @@ -1,40 +1,17 @@ --[[ - BotCore: Priority Engine + BotCore: Priority Engine (simplified) - High-performance action priority system with built-in healing priority. - Healing ALWAYS takes precedence over attacks - this is non-configurable - for player safety. - - Priority Order (hardcoded, cannot be disabled): - 1. Emergency Healing (HP < 30%) - 2. Critical Healing (HP < 50%) - 3. Normal Healing - 4. Mana Recovery - 5. Attack Actions - 6. Support Actions - - Principles: Performance, Safety-First, Non-Configurable Priority + HealEngine handles all healing priority decisions independently. + This module only manages action exhaustion/cooldowns for attack vs support. ]] local PriorityEngine = {} --- ============================================================================ --- CONSTANTS (hardcoded for safety) --- ============================================================================ - --- Emergency thresholds disabled per user request -local EMERGENCY_HP_THRESHOLD = 0 -- HP% below this = emergency (disabled) -local CRITICAL_HP_THRESHOLD = 0 -- HP% below this = critical (disabled) -local LOW_MANA_THRESHOLD = 20 -- MP% below this = need mana - -- Priority levels (lower = higher priority) +-- Healing priority is managed internally by HealEngine local PRIORITY = { - EMERGENCY_HEAL = 1, - CRITICAL_HEAL = 2, - NORMAL_HEAL = 3, - MANA_RECOVERY = 4, - ATTACK = 5, - SUPPORT = 6 + ATTACK = 1, + SUPPORT = 2 } -- Exhausted state tracking @@ -58,9 +35,7 @@ local _exhaustedState = { -- Action queue for current tick local _actionQueue = {} --- ============================================================================ -- EXHAUSTED HANDLING --- ============================================================================ -- Check if action type is exhausted function PriorityEngine.isExhausted(actionType) @@ -111,77 +86,31 @@ function PriorityEngine.trackSuccessAction(actionType) end end --- ============================================================================ --- PRIORITY DETERMINATION --- ============================================================================ - --- Get current priority based on player state (pure function) -function PriorityEngine.getCurrentPriority() - if not BotCore or not BotCore.Stats then - return PRIORITY.SUPPORT -- Safe default - end - - local hpPercent = BotCore.Stats.getHpPercent() - local mpPercent = BotCore.Stats.getMpPercent() - - -- Health-based healing priority disabled by user request; skip emergency/critical checks - - - -- LOW MANA: Need mana for heals - if mpPercent < LOW_MANA_THRESHOLD then - return PRIORITY.MANA_RECOVERY - end - - -- Normal operation - return PRIORITY.ATTACK -end - --- Check if healing should be attempted NOW (ignores other actions) -function PriorityEngine.shouldHealNow() - local priority = PriorityEngine.getCurrentPriority() - return priority <= PRIORITY.CRITICAL_HEAL -end +-- EXHAUSTION / COOLDOWN CHECKS --- Check if can perform attack (only if healing not urgent) +-- Check if can perform attack function PriorityEngine.canAttack() - local priority = PriorityEngine.getCurrentPriority() - - -- Never attack if healing is critical - if priority < PRIORITY.ATTACK then - return false - end - - -- Check exhausted if PriorityEngine.isExhausted("attack") then return false end - - -- Check cooldown via BotCore if BotCore and BotCore.Cooldown then if BotCore.Cooldown.isAttackOnCooldown() then return false end end - return true end --- Check if can use healing (always allowed if not exhausted) +-- Check if can use healing (HealEngine handles priority, we just check exhaustion) function PriorityEngine.canHeal() - -- Healing is NEVER blocked by priority (safety first) - - -- Only check exhausted if PriorityEngine.isExhausted("healing") then return false end - - -- Check cooldown via BotCore if BotCore and BotCore.Cooldown then if BotCore.Cooldown.isHealingOnCooldown() then return false end end - return true end @@ -190,73 +119,44 @@ function PriorityEngine.canUsePotion() if PriorityEngine.isExhausted("potion") then return false end - if BotCore and BotCore.Cooldown then if not BotCore.Cooldown.canUsePotion() then return false end end - return true end --- ============================================================================ -- ACTION EXECUTION WITH PRIORITY --- ============================================================================ -- Execute action with priority check --- Returns: true if executed, false if blocked function PriorityEngine.executeWithPriority(actionType, actionFn) - -- Healing always executes (safety) if actionType == "heal" or actionType == "healing" then if not PriorityEngine.canHeal() then return false end - local success = actionFn() - if success then - PriorityEngine.trackSuccessAction("healing") - else - PriorityEngine.trackFailedAction("healing") - end + if success then PriorityEngine.trackSuccessAction("healing") + else PriorityEngine.trackFailedAction("healing") end return success end - - -- Potions if actionType == "potion" then - if not PriorityEngine.canUsePotion() then - return false - end - + if not PriorityEngine.canUsePotion() then return false end local success = actionFn() - if success then - PriorityEngine.markExhausted("potion", 1000) -- 1s potion cooldown - end + if success then PriorityEngine.markExhausted("potion", 1000) end return success end - - -- Attacks - check priority first if actionType == "attack" then - if not PriorityEngine.canAttack() then - return false - end - + if not PriorityEngine.canAttack() then return false end local success = actionFn() - if success then - PriorityEngine.trackSuccessAction("attack") - else - PriorityEngine.trackFailedAction("attack") - end + if success then PriorityEngine.trackSuccessAction("attack") + else PriorityEngine.trackFailedAction("attack") end return success end - - -- Default: just execute return actionFn() end --- ============================================================================ -- GRACEFUL EXHAUSTED RECOVERY --- ============================================================================ -- Handle exhausted event from OTClient function PriorityEngine.onExhausted(groupId, remainingMs) @@ -270,27 +170,17 @@ end -- Get status for debugging function PriorityEngine.getStatus() return { - currentPriority = PriorityEngine.getCurrentPriority(), exhausted = { healing = PriorityEngine.getExhaustedRemaining("healing"), attack = PriorityEngine.getExhaustedRemaining("attack"), potion = PriorityEngine.getExhaustedRemaining("potion") - }, - thresholds = { - emergency = EMERGENCY_HP_THRESHOLD, - critical = CRITICAL_HP_THRESHOLD, - lowMana = LOW_MANA_THRESHOLD } } end --- ============================================================================ -- CONSTANTS EXPORT (read-only) --- ============================================================================ PriorityEngine.PRIORITY = PRIORITY -PriorityEngine.EMERGENCY_HP = EMERGENCY_HP_THRESHOLD -PriorityEngine.CRITICAL_HP = CRITICAL_HP_THRESHOLD -- Export for global access BotCore = BotCore or {} diff --git a/core/bot_core/stats.lua b/core/bot_core/stats.lua index 49c5761..763f85a 100644 --- a/core/bot_core/stats.lua +++ b/core/bot_core/stats.lua @@ -30,15 +30,14 @@ local _cachedPlayer = nil local _lastPlayerCheck = 0 local PLAYER_CHECK_INTERVAL = 1000 -- Revalidate every 1s --- ============================================================================ -- PRIVATE FUNCTIONS --- ============================================================================ -- Get cached local player (with periodic revalidation) local function getLocalPlayerCached() + if not ClientService then return nil end local currentTime = now or os.time() * 1000 if not _cachedPlayer or (currentTime - _lastPlayerCheck) > PLAYER_CHECK_INTERVAL then - _cachedPlayer = g_game.getLocalPlayer() + _cachedPlayer = ClientService.getLocalPlayer() _lastPlayerCheck = currentTime end return _cachedPlayer @@ -50,9 +49,7 @@ local function safePercent(current, max) return math.floor((current / max) * 100) end --- ============================================================================ -- PUBLIC API --- ============================================================================ -- Update all stats once per tick (call from main loop) function StatsManager.update() @@ -100,9 +97,7 @@ function StatsManager.update() return _cache end --- ============================================================================ -- PURE GETTERS (no side effects, read from cache) --- ============================================================================ function StatsManager.getHp() return _cache.hp end function StatsManager.getMaxHp() return _cache.maxHp end diff --git a/core/bot_database.lua b/core/bot_database.lua index 499672a..686f1eb 100644 --- a/core/bot_database.lua +++ b/core/bot_database.lua @@ -1,516 +1,95 @@ ---[[ - ═══════════════════════════════════════════════════════════════════════════ - BOT DATABASE - Unified State Management System - ═══════════════════════════════════════════════════════════════════════════ - - Architecture Principles: - - SRP: Single Responsibility - One module for ALL persistent state - - DRY: Single source of truth for all bot settings - - SOLID: Open for extension, closed for modification - - Pure Functions: No side effects in getters - - Performance: Lazy loading, batch saves, memory caching - - Design: - - All state in ONE JSON file per profile (BotDatabase.json) - - Synchronous reads (cached in memory) - - Debounced writes (batched for performance) - - Schema validation with defaults - - Migration support for old configs - - Usage: - BotDB.get("macros.autoMount") -- Read value - BotDB.set("macros.autoMount", true) -- Write value (auto-saved) - BotDB.toggle("macros.autoMount") -- Toggle boolean - BotDB.registerMacro(macroRef, "autoMount") -- Setup macro with persistence -]]-- - --- ═══════════════════════════════════════════════════════════════════════════ --- CONFIGURATION --- ═══════════════════════════════════════════════════════════════════════════ - -local CONFIG = { - SAVE_DEBOUNCE_MS = 500, -- Batch saves within this window - MAX_FILE_SIZE = 10 * 1024 * 1024, -- 10MB max - SCHEMA_VERSION = 1, -- For future migrations -} - --- ═══════════════════════════════════════════════════════════════════════════ --- SCHEMA DEFINITION (Single Source of Truth for All Defaults) --- ═══════════════════════════════════════════════════════════════════════════ - -local SCHEMA = { - version = CONFIG.SCHEMA_VERSION, - - -- Macro toggle states - macros = { - exchangeMoney = false, - autoTradeMsg = false, - autoHaste = false, - autoMount = false, - manaTraining = false, - eatFood = false, - antiRs = false, - holdTarget = false, - exetaLowHp = false, - exetaIfPlayer = false, - depotWithdraw = false, - quiverManager = false, - fishing = false, - }, - - -- Tool settings - tools = { - manaTraining = { - spell = "exura", - minManaPercent = 80, +local StorageEngine = nExBot.StorageEngine +if not StorageEngine then + warn("[BotDatabase] StorageEngine not loaded") + return +end +local engine = StorageEngine.new({ + filename = "BotDatabase.json", + pathStrategy = "profile", + debounceMs = 500, + maxFileSize = 10 * 1024 * 1024, + defaults = { + version = 1, + macros = { + exchangeMoney = false, autoTradeMsg = false, autoHaste = false, + autoMount = false, manaTraining = false, eatFood = false, + antiRs = false, holdTarget = false, exetaLowHp = false, + exetaIfPlayer = false, depotWithdraw = false, quiverManager = false, + fishing = false, }, - autoTradeMessage = "nExBot is online!", - fishing = { - dropFish = true, + tools = { + manaTraining = { spell = "exura", minManaPercent = 80 }, + autoTradeMessage = "nExBot is online!", + fishing = { dropFish = true }, }, + dropper = { enabled = false, trashItems = {}, useItems = {}, capItems = {} }, + autoEquip = {}, + supplies = { eatFromCorpses = false, sellItems = {} }, + analytics = { showOnStartup = false }, }, - - -- Dropper settings - dropper = { - enabled = false, - trashItems = {}, - useItems = {}, - capItems = {}, - }, - - -- Equipment settings - autoEquip = {}, - - -- Supply settings - supplies = { - eatFromCorpses = false, - sellItems = {}, - }, - - -- Analytics settings - analytics = { - showOnStartup = false, - }, -} - --- ═══════════════════════════════════════════════════════════════════════════ --- PURE UTILITY FUNCTIONS --- ═══════════════════════════════════════════════════════════════════════════ - --- Alias shared deepClone (DRY) -local deepClone = nExBot.Shared.deepClone - --- Get nested value by dot-separated path (pure function) --- Example: getPath({a = {b = 1}}, "a.b") => 1 -local function getPath(obj, path) - if type(obj) ~= "table" or type(path) ~= "string" then - return nil - end - - local current = obj - for key in path:gmatch("[^%.]+") do - if type(current) ~= "table" then return nil end - current = current[key] - end - return current -end - --- Set nested value by dot-separated path (returns new table, no mutation) --- Example: setPath({a = {b = 1}}, "a.b", 2) => {a = {b = 2}} -local function setPath(obj, path, value) - if type(path) ~= "string" then return obj end - - local result = deepClone(obj) or {} - local current = result - local keys = {} - - for key in path:gmatch("[^%.]+") do - keys[#keys + 1] = key - end - - for i = 1, #keys - 1 do - local key = keys[i] - if type(current[key]) ~= "table" then - current[key] = {} - end - current = current[key] - end - - if #keys > 0 then - current[keys[#keys]] = value - end - - return result -end - --- Validate and sanitize data against schema (ensures no sparse arrays) -local function sanitizeData(data, schema) - if type(schema) ~= "table" then - return data ~= nil and data or schema - end - - local result = {} - - -- Copy all schema keys with defaults - for k, v in pairs(schema) do - if type(v) == "table" then - local dataValue = (type(data) == "table") and data[k] or nil - result[k] = sanitizeData(dataValue, v) - else - -- Use data value if exists, otherwise schema default - if type(data) == "table" and data[k] ~= nil then - result[k] = data[k] - else - result[k] = v - end - end - end - - -- Preserve any extra keys from data that aren't in schema - if type(data) == "table" then - for k, v in pairs(data) do - if result[k] == nil then - result[k] = deepClone(v) - end - end - end - - return result -end - --- ═══════════════════════════════════════════════════════════════════════════ --- FILE I/O (Isolated side effects) --- ═══════════════════════════════════════════════════════════════════════════ - --- Get config paths -local configName = modules.game_bot.contentsPanel.config:getCurrentOption().text -local profileNum = g_settings.getNumber('profile') or 1 -local basePath = "/bot/" .. configName .. "/nExBot_configs/profile_" .. profileNum .. "/" -local dbFile = basePath .. "BotDatabase.json" - --- Ensure directory exists -local function ensureDir() - if not g_resources.directoryExists(basePath) then - g_resources.makeDir(basePath) - end -end - --- Read file (isolated I/O) -local function readFile() - if not g_resources.fileExists(dbFile) then - return nil - end - - local status, content = pcall(function() - return g_resources.readFileContents(dbFile) - end) - - if not status or not content then - return nil - end - - local parseStatus, data = pcall(function() - return json.decode(content) - end) - - if not parseStatus or type(data) ~= "table" then - return nil - end - - return data -end - --- Write file (isolated I/O) -local function writeFile(data) - local status, content = pcall(function() - return json.encode(data, 2) - end) - - if not status or #content > CONFIG.MAX_FILE_SIZE then - return false - end - - ensureDir() - - local writeStatus = pcall(function() - g_resources.writeFileContents(dbFile, content) - end) - - return writeStatus -end - --- ═══════════════════════════════════════════════════════════════════════════ --- DATABASE STATE (Module-private state) --- ═══════════════════════════════════════════════════════════════════════════ +}) -local _cache = nil -- In-memory cache -local _dirty = false -- Has unsaved changes -local _saveScheduled = false -- Is a save pending -local _initialized = false -- Has been loaded - --- ═══════════════════════════════════════════════════════════════════════════ --- INTERNAL FUNCTIONS --- ═══════════════════════════════════════════════════════════════════════════ - --- Load database into cache (only once) -local function load() - if _initialized then return _cache end - - local rawData = readFile() - _cache = sanitizeData(rawData, SCHEMA) - _initialized = true - - return _cache -end - --- Schedule a debounced save -local function scheduleSave() - if _saveScheduled then return end - _saveScheduled = true - - schedule(CONFIG.SAVE_DEBOUNCE_MS, function() - _saveScheduled = false - if _dirty and _cache then - writeFile(_cache) - _dirty = false - end - end) -end - --- ═══════════════════════════════════════════════════════════════════════════ --- PUBLIC API: BotDB --- ═══════════════════════════════════════════════════════════════════════════ - -BotDB = {} - --- Get a value by path (pure read, no side effects) --- @param path: dot-separated path like "macros.autoMount" --- @return: the value or nil -function BotDB.get(path) - local data = load() - if not path then return data end - return getPath(data, path) -end - --- Set a value by path (triggers debounced save) --- @param path: dot-separated path like "macros.autoMount" --- @param value: the value to set -function BotDB.set(path, value) - local data = load() - _cache = setPath(data, path, value) - _dirty = true - scheduleSave() -end - --- Toggle a boolean value --- @param path: dot-separated path --- @return: the new value -function BotDB.toggle(path) - local current = BotDB.get(path) - local newValue = not current - BotDB.set(path, newValue) - return newValue -end - --- Get with default fallback --- @param path: dot-separated path --- @param default: value to return if path is nil -function BotDB.getOr(path, default) - local value = BotDB.get(path) - if value == nil then return default end - return value -end - --- Batch update multiple values (single save) --- @param updates: table of {path = value} pairs -function BotDB.batch(updates) - if type(updates) ~= "table" then return end - - local data = load() - for path, value in pairs(updates) do - data = setPath(data, path, value) - end - _cache = data - _dirty = true - scheduleSave() -end - --- Force save immediately (bypasses debounce) -function BotDB.save() - if _cache then - writeFile(_cache) - _dirty = false - end -end - --- Force reload from disk -function BotDB.reload() - _initialized = false - _cache = nil - load() -end - --- Get the schema (for documentation/validation) -function BotDB.getSchema() - return deepClone(SCHEMA) -end - --- ═══════════════════════════════════════════════════════════════════════════ --- MACRO REGISTRATION SYSTEM (Simplified - KISS Principle) --- Uses OTClient's NATIVE storage._macros[name] for persistence --- OTClient already handles this automatically for NAMED macros! --- ═══════════════════════════════════════════════════════════════════════════ +BotDB = engine local _registeredMacros = {} ---[[ - Register a macro for automatic state persistence. - - IMPORTANT: OTClient already persists state for NAMED macros automatically! - When you create: macro(500, "Auto Mount", function() ... end) - OTClient stores state in: storage._macros["Auto Mount"] - - This function: - 1. Ensures storage._macros table exists - 2. Explicitly initializes state to false if never set (prevents "start as true") - 3. Applies saved state immediately - 4. Tracks macro for programmatic access - - @param macroRef: the macro object from macro() - @param key: unique key (used for _registeredMacros lookup, not storage) - @param onEnable: optional callback when macro is turned ON -]]-- function BotDB.registerMacro(macroRef, key, onEnable) - if not macroRef then return end - if not storage then return end - - -- Ensure storage._macros exists (OTClient creates this automatically, but be safe) - if not storage._macros then - storage._macros = {} - end - - -- Get the macro's display name (used by OTClient for persistence) + if not macroRef or not storage then return end + if not storage._macros then storage._macros = {} end local macroName = macroRef.name - if not macroName or macroName == "" then - -- For unnamed macros, use the key - macroName = key - end - - -- CRITICAL: Initialize to false if never set (prevents "start as true" bug) - -- OTClient sets enabled=true for unnamed macros by default - if storage._macros[macroName] == nil then - storage._macros[macroName] = false - end - - -- Get the saved state - local savedState = (storage._macros[macroName] == true) - - -- Apply saved state - this uses OTClient's native setOn which updates storage._macros + if not macroName or macroName == "" then macroName = key end + if storage._macros[macroName] == nil then storage._macros[macroName] = false end + local saved = (storage._macros[macroName] == true) if macroRef.setOn then - if savedState then - macroRef:setOn() - else - macroRef:setOff() - end - end - - -- Fire onEnable callback if restoring to ON state - if savedState and onEnable then - schedule(100, onEnable) + if saved then macroRef:setOn() else macroRef:setOff() end end - - -- Track registered macro for programmatic access + if saved and onEnable then schedule(100, onEnable) end _registeredMacros[key] = macroRef end --- Get registered macro by key -function BotDB.getMacro(key) - return _registeredMacros[key] -end +function BotDB.getMacro(key) return _registeredMacros[key] end --- Get current state of a registered macro function BotDB.getMacroState(key) - local macro = _registeredMacros[key] - if macro and macro.isOn then - return macro:isOn() - end + local m = _registeredMacros[key] + if m and m.isOn then return m:isOn() end return false end --- Manually set macro state (for programmatic control) function BotDB.setMacroState(key, enabled) - local macro = _registeredMacros[key] - if not macro then return end - - if enabled then - if macro.setOn then macro:setOn() end - else - if macro.setOff then macro:setOff() end - end + local m = _registeredMacros[key] + if not m then return end + if enabled then if m.setOn then m:setOn() end else if m.setOff then m:setOff() end end end --- ═══════════════════════════════════════════════════════════════════════════ --- MIGRATION SYSTEM --- One-time migration from legacy storage keys to new format --- ═══════════════════════════════════════════════════════════════════════════ - local function migrateOldData() if not storage then return end - - -- Ensure storage._macros exists - if not storage._macros then - storage._macros = {} - end - - -- Migrate old *Enabled keys to storage._macros format - -- Maps: old key -> macro display name - local legacyMappings = { - exchangeMoneyEnabled = "Exchange Money", - autoTradeMsgEnabled = "Send message on trade", - autoHasteEnabled = "Auto Haste", - autoMountEnabled = "Auto Mount", - manaTrainingEnabled = "Mana Training", - eatFoodEnabled = "Eat Food", - fishingEnabled = "Fishing", - followPlayerEnabled = "Follow Player", + if not storage._macros then storage._macros = {} end + local legacy = { + exchangeMoneyEnabled = "Exchange Money", autoTradeMsgEnabled = "Send message on trade", + autoHasteEnabled = "Auto Haste", autoMountEnabled = "Auto Mount", + manaTrainingEnabled = "Mana Training", eatFoodEnabled = "Eat Food", + fishingEnabled = "Fishing", followPlayerEnabled = "Follow Player", } - - for oldKey, macroName in pairs(legacyMappings) do - -- Only migrate if old key exists and macro state not already set - if storage[oldKey] ~= nil and storage._macros[macroName] == nil then - storage._macros[macroName] = (storage[oldKey] == true) + for old, name in pairs(legacy) do + if storage[old] ~= nil and storage._macros[name] == nil then + storage._macros[name] = (storage[old] == true) end end - - -- Also migrate from macro_* format (previous implementation) - local macroKeyMappings = { - macro_exchangeMoney = "Exchange Money", - macro_autoTradeMsg = "Send message on trade", - macro_autoHaste = "Auto Haste", - macro_autoMount = "Auto Mount", - macro_manaTraining = "Mana Training", - macro_eatFood = "Eat Food", - macro_fishing = "Fishing", - macro_followPlayer = "Follow Player", + local macroKeys = { + macro_exchangeMoney = "Exchange Money", macro_autoTradeMsg = "Send message on trade", + macro_autoHaste = "Auto Haste", macro_autoMount = "Auto Mount", + macro_manaTraining = "Mana Training", macro_eatFood = "Eat Food", + macro_fishing = "Fishing", macro_followPlayer = "Follow Player", } - - for oldKey, macroName in pairs(macroKeyMappings) do - if storage[oldKey] ~= nil and storage._macros[macroName] == nil then - storage._macros[macroName] = (storage[oldKey] == true) + for old, name in pairs(macroKeys) do + if storage[old] ~= nil and storage._macros[name] == nil then + storage._macros[name] = (storage[old] == true) end end end --- ═══════════════════════════════════════════════════════════════════════════ --- INITIALIZATION --- ═══════════════════════════════════════════════════════════════════════════ - --- Load BotDB file immediately (for profile-level settings) -load() - --- Run migration for macro states (per-character via storage) +engine.load() migrateOldData() --- Export globally nExBot = nExBot or {} nExBot.BotDB = BotDB diff --git a/core/cavebot_control_panel.lua b/core/cavebot_control_panel.lua index 765a640..76b93ec 100644 --- a/core/cavebot_control_panel.lua +++ b/core/cavebot_control_panel.lua @@ -1,35 +1,18 @@ setDefaultTab("Cave") -g_ui.loadUIFromString([[ -CaveBotControlPanel < Panel - margin-top: 5 - layout: - type: verticalBox - fit-children: true - - HorizontalSeparator - - Label - text-align: center - text: CaveBot Control Panel - font: verdana-11px-rounded - margin-top: 3 - - HorizontalSeparator - - Panel - id: buttons - margin-top: 2 - layout: - type: grid - cell-size: 86 20 - cell-spacing: 1 - flow: true - fit-children: true - - HorizontalSeparator - margin-top: 3 -]]) +do + local path = nExBot.paths.base .. "/core/cavebot_control_panel.otui" + local content = nil + if g_resources and g_resources.readFileContents then + content = g_resources.readFileContents(path) + end + if content then + g_ui.loadUIFromString(content) + else + warn("[CaveBot] Failed to load cavebot_control_panel.otui from " .. path) + return + end +end local panel = UI.createWidget("CaveBotControlPanel") @@ -44,20 +27,16 @@ storage.caveBot = { local forceRefill = UI.Button("Force Refill", function(widget) storage.caveBot.forceRefill = true - print("[CaveBot] Going back on refill on next supply check.") end, panel.buttons) local backStop = UI.Button("Back & Stop", function(widget) storage.caveBot.backStop = true - print("[CaveBot] Going back to city on next supply check and turning off CaveBot on depositer action.") end, panel.buttons) local backTrainers = UI.Button("To Trainers", function(widget) storage.caveBot.backTrainers = true - print("[CaveBot] Going back to city on next supply check and going to label 'toTrainers' on depositer action.") end, panel.buttons) local backOffline = UI.Button("Offline", function(widget) storage.caveBot.backOffline = true - print("[CaveBot] Going back to city on next supply check and going to label 'toOfflineTraining' on depositer action.") end, panel.buttons) \ No newline at end of file diff --git a/core/cavebot_control_panel.otui b/core/cavebot_control_panel.otui new file mode 100644 index 0000000..a05ea69 --- /dev/null +++ b/core/cavebot_control_panel.otui @@ -0,0 +1,28 @@ +CaveBotControlPanel < Panel + margin-top: 5 + layout: + type: verticalBox + fit-children: true + + HorizontalSeparator + + Label + text-align: center + text: CaveBot Control Panel + font: verdana-11px-rounded + margin-top: 3 + + HorizontalSeparator + + Panel + id: buttons + margin-top: 2 + layout: + type: grid + cell-size: 86 20 + cell-spacing: 1 + flow: true + fit-children: true + + HorizontalSeparator + margin-top: 3 diff --git a/core/character_db.lua b/core/character_db.lua index 8bfd6d7..d1a81ec 100644 --- a/core/character_db.lua +++ b/core/character_db.lua @@ -1,413 +1,50 @@ ---[[ - ═══════════════════════════════════════════════════════════════════════════ - CHARACTER DATABASE - Per-Character State Management System - ═══════════════════════════════════════════════════════════════════════════ - - Purpose: - - Provides per-CHARACTER storage isolation for multi-client setups - - Each character gets their own JSON file for module configs - - Prevents configuration overlap between characters - - Architecture: - - Extends BotDB pattern with character-specific file paths - - File path: /bot/{config}/nExBot_configs/characters/{charName}/CharacterDB.json - - Lazy initialization (waits for player to be available) - - Debounced writes for performance - - Usage: - CharacterDB.get("equipper.rules") -- Read value - CharacterDB.set("equipper.rules", {}) -- Write value (auto-saved) - CharacterDB.getModule("equipper") -- Get entire module config - CharacterDB.setModule("equipper", config) -- Set entire module config - - Modules: - - equipper: EQ Manager rules and settings - - containers: Container setup configuration -]]-- - --- ═══════════════════════════════════════════════════════════════════════════ --- CONFIGURATION --- ═══════════════════════════════════════════════════════════════════════════ - -local CONFIG = { - SAVE_DEBOUNCE_MS = 500, -- Batch saves within this window - MAX_FILE_SIZE = 5 * 1024 * 1024, -- 5MB max - SCHEMA_VERSION = 1, -} - --- ═══════════════════════════════════════════════════════════════════════════ --- SCHEMA DEFINITION (Defaults for each module) --- ═══════════════════════════════════════════════════════════════════════════ - -local SCHEMA = { - version = CONFIG.SCHEMA_VERSION, - - -- Macro toggle states (per-character) - macros = { - exchangeMoney = false, - autoTradeMsg = false, - autoHaste = false, - autoMount = false, - manaTraining = false, - eatFood = false, - antiRs = false, - holdTarget = false, - exetaLowHp = false, - exetaIfPlayer = false, - depotWithdraw = false, - quiverManager = false, - fishing = false, - }, - - -- Tool settings (per-character) - tools = { - manaTraining = { - spell = "exura", - minManaPercent = 80, +local StorageEngine = nExBot.StorageEngine +if not StorageEngine then + warn("[CharacterDB] StorageEngine not loaded") + return +end +local engine = StorageEngine.new({ + filename = "CharacterDB.json", + pathStrategy = "character", + debounceMs = 500, + maxFileSize = 5 * 1024 * 1024, + defaults = { + version = 1, + macros = { + exchangeMoney = false, autoTradeMsg = false, autoHaste = false, + autoMount = false, manaTraining = false, eatFood = false, + antiRs = false, holdTarget = false, exetaLowHp = false, + exetaIfPlayer = false, depotWithdraw = false, quiverManager = false, + fishing = false, }, - fishing = { - dropFish = true, + tools = { + manaTraining = { spell = "exura", minManaPercent = 80 }, + fishing = { dropFish = true }, + followPlayer = { enabled = false, playerName = "" }, }, - followPlayer = { - enabled = false, - playerName = "", + equipper = { enabled = false, rules = {}, bosses = {}, activeRule = nil }, + containers = { + purse = true, autoMinimize = true, autoOpenOnLogin = false, + sortEnabled = false, forceOpen = false, renameEnabled = false, + lootBag = false, containerList = {}, windowHeight = 200, }, }, - - -- EQ Manager configuration - equipper = { - enabled = false, - rules = {}, - bosses = {}, - activeRule = nil, - }, - - -- Container Panel configuration - containers = { - purse = true, - autoMinimize = true, - autoOpenOnLogin = false, - sortEnabled = false, - forceOpen = false, - renameEnabled = false, - lootBag = false, - containerList = {}, - windowHeight = 200, - }, -} - --- ═══════════════════════════════════════════════════════════════════════════ --- PURE UTILITY FUNCTIONS --- ═══════════════════════════════════════════════════════════════════════════ - --- Alias shared deepClone (DRY) -local deepClone = nExBot.Shared.deepClone - --- Get value by dot-path -local function getPath(data, path) - if not data or not path then return nil end - local parts = {} - for part in path:gmatch("[^%.]+") do - table.insert(parts, part) - end - local current = data - for _, key in ipairs(parts) do - if type(current) ~= "table" then return nil end - current = current[key] - end - return current -end - --- Set value by dot-path -local function setPath(data, path, value) - if not path then return data end - local result = deepClone(data or {}) - local parts = {} - for part in path:gmatch("[^%.]+") do - table.insert(parts, part) - end - local current = result - for i = 1, #parts - 1 do - local key = parts[i] - if type(current[key]) ~= "table" then - current[key] = {} - end - current = current[key] - end - current[parts[#parts]] = value - return result -end - --- Sanitize data against schema -local function sanitizeData(data, schema) - local result = {} - for k, v in pairs(schema) do - if type(v) == "table" and not (v[1] ~= nil) then - local dataValue = type(data) == "table" and data[k] or nil - result[k] = sanitizeData(dataValue, v) - else - if type(data) == "table" and data[k] ~= nil then - result[k] = data[k] - else - result[k] = v - end - end - end - -- Preserve extra keys from data not in schema - if type(data) == "table" then - for k, v in pairs(data) do - if result[k] == nil then - result[k] = deepClone(v) - end - end - end - return result -end - --- Sanitize character name for filesystem -local function sanitizeCharName(name) - if not name then return "unknown" end - -- Replace invalid filesystem characters - return name:gsub("[/\\:*?\"<>|]", "_"):lower() -end - --- ═══════════════════════════════════════════════════════════════════════════ --- FILE I/O (Isolated side effects) --- ═══════════════════════════════════════════════════════════════════════════ - -local configName = modules.game_bot.contentsPanel.config:getCurrentOption().text -local _cache = nil -local _dirty = false -local _initialized = false -local _saveTimer = nil -local _charName = nil -local _basePath = nil -local _dbFile = nil - --- Get character name safely -local function getCharName() - if _charName then return _charName end - - local localPlayer = g_game.getLocalPlayer() - if localPlayer and localPlayer:getName() then - _charName = sanitizeCharName(localPlayer:getName()) - return _charName - end - return nil -end - --- Build paths for current character -local function buildPaths() - local charName = getCharName() - if not charName then return false end - - _basePath = "/bot/" .. configName .. "/nExBot_configs/characters/" .. charName .. "/" - _dbFile = _basePath .. "CharacterDB.json" - return true -end - --- Ensure directory exists -local function ensureDir() - if not _basePath then return end - if not g_resources.directoryExists(_basePath) then - -- Create parent directories - local parentPath = "/bot/" .. configName .. "/nExBot_configs/characters/" - if not g_resources.directoryExists(parentPath) then - g_resources.makeDir(parentPath) - end - g_resources.makeDir(_basePath) - end -end +}) --- Read file -local function readFile() - if not _dbFile then return nil end - if not g_resources.fileExists(_dbFile) then return nil end - - local status, content = pcall(function() - return g_resources.readFileContents(_dbFile) - end) - - if not status or not content then return nil end - - local parseStatus, data = pcall(function() - return json.decode(content) - end) - - if not parseStatus or type(data) ~= "table" then return nil end - return data -end - --- Write file -local function writeFile(data) - if not _dbFile or not data then return false end - - local encodeStatus, content = pcall(function() - return json.encode(data, 2) - end) - - if not encodeStatus or not content then return false end - if #content > CONFIG.MAX_FILE_SIZE then return false end - - ensureDir() - - local writeStatus = pcall(function() - g_resources.writeFileContents(_dbFile, content) - end) - - return writeStatus -end +CharacterDB = engine --- Load data from file -local function load() - if _initialized and _cache then return _cache end - - if not buildPaths() then - -- Player not available yet, return empty schema but don't cache it - -- This allows the next call to retry buildPaths - return deepClone(SCHEMA) - end - - local fileData = readFile() - _cache = sanitizeData(fileData, SCHEMA) - _initialized = true - return _cache -end - --- Schedule debounced save -local _saveScheduled = false - -local function scheduleSave() - if _saveScheduled then return end - _saveScheduled = true - - schedule(CONFIG.SAVE_DEBOUNCE_MS, function() - _saveScheduled = false - if _dirty and _cache then - writeFile(_cache) - _dirty = false +if g_game.getLocalPlayer() then + engine.load() +else + local function onReady() + if g_game.getLocalPlayer() then + engine.load() end - end) -end - --- ═══════════════════════════════════════════════════════════════════════════ --- PUBLIC API: CharacterDB --- ═══════════════════════════════════════════════════════════════════════════ - -CharacterDB = {} - --- Check if CharacterDB is ready (player is logged in) -function CharacterDB.isReady() - return getCharName() ~= nil -end - --- Get current character name -function CharacterDB.getCharacterName() - return _charName or getCharName() -end - --- Get a value by path -function CharacterDB.get(path) - local data = load() - if not path then return data end - return getPath(data, path) -end - --- Set a value by path (triggers debounced save) -function CharacterDB.set(path, value) - -- Ensure paths are built before saving - if not buildPaths() then - -- Player not available, can't save per-character data - return false end - - local data = load() - _cache = setPath(data, path, value) - _dirty = true - scheduleSave() - return true -end - --- Get with default fallback -function CharacterDB.getOr(path, default) - local value = CharacterDB.get(path) - if value == nil then return default end - return value -end - --- Get entire module configuration -function CharacterDB.getModule(moduleName) - return CharacterDB.get(moduleName) or deepClone(SCHEMA[moduleName] or {}) -end - --- Set entire module configuration -function CharacterDB.setModule(moduleName, config) - CharacterDB.set(moduleName, config) -end - --- Batch update multiple values -function CharacterDB.batch(updates) - if type(updates) ~= "table" then return end - - local data = load() - for path, value in pairs(updates) do - data = setPath(data, path, value) - end - _cache = data - _dirty = true - scheduleSave() -end - --- Force save immediately -function CharacterDB.save() - if _cache then - writeFile(_cache) - _dirty = false - end -end - --- Force reload from disk -function CharacterDB.reload() - _initialized = false - _cache = nil - _charName = nil - load() -end - --- Get the schema -function CharacterDB.getSchema() - return deepClone(SCHEMA) -end - --- ═══════════════════════════════════════════════════════════════════════════ --- MIGRATION: Import from legacy storage --- ═══════════════════════════════════════════════════════════════════════════ - -function CharacterDB.migrateFromStorage(moduleName, legacyKey) - if not storage then return false end - if not storage[legacyKey] then return false end - - -- Check if we already have data (don't overwrite) - local existing = CharacterDB.get(moduleName) - if existing and next(existing.rules or existing.containerList or existing) then - -- Already has data, skip migration - return false - end - - -- Migrate legacy data - CharacterDB.setModule(moduleName, deepClone(storage[legacyKey])) - return true -end - --- ═══════════════════════════════════════════════════════════════════════════ --- INITIALIZATION --- ═══════════════════════════════════════════════════════════════════════════ - --- Try to initialize immediately if player is available -if g_game.getLocalPlayer() then - load() + schedule(500, onReady) + schedule(1500, onReady) + schedule(3000, onReady) end --- Export globally nExBot = nExBot or {} nExBot.CharacterDB = CharacterDB diff --git a/core/client_service.lua b/core/client_service.lua index 7b420da..af948fa 100644 --- a/core/client_service.lua +++ b/core/client_service.lua @@ -1,16 +1,9 @@ --[[ nExBot Client Service - + Provides a unified interface for client operations using the ACL. - This is the primary service that bot modules should use instead - of directly accessing g_game, g_map, etc. - - Principles Applied: - - SRP: Single responsibility - client abstraction - - DIP: Depends on ACL abstraction, not concrete clients - - DRY: Centralizes all client access - - KISS: Simple, intuitive API - + Bot modules should use this instead of directly accessing g_game, g_map, etc. + Usage: local Client = require("client_service") Client.attack(creature) @@ -18,2270 +11,409 @@ Client.getSpectators() ]] --- Try to load ACL local ACL = nil local aclLoaded = false local function loadACL() if aclLoaded then return ACL end - - local status, result = pcall(function() - return dofile("/core/acl/init.lua") - end) - + local status, result = pcall(function() return dofile("/core/acl/init.lua") end) if status and result then ACL = result ACL.init() else warn("[ClientService] Failed to load ACL, using direct client access") end - aclLoaded = true return ACL end --- Client Service -local ClientService = {} - --- Service metadata +ClientService = {} ClientService.NAME = "ClientService" -ClientService.VERSION = "1.0.0" - --------------------------------------------------------------------------------- --- INITIALIZATION --------------------------------------------------------------------------------- +ClientService.VERSION = "2.0.0" function ClientService.init() loadACL() return true end -function ClientService.getClientType() - local acl = loadACL() - if acl then - return acl.getClientType() - end - if nExBot and nExBot.clientDetection and nExBot.clientDetection.type then - return nExBot.clientDetection.type - end - return 0 -- UNKNOWN -end +-------------------------------------------------------------------------------- +-- GENERIC DISPATCH +-------------------------------------------------------------------------------- -function ClientService.getClientName() - local acl = loadACL() - if acl then - return acl.getClientName() - end - if nExBot and nExBot.clientDetection and nExBot.clientDetection.name then - return nExBot.clientDetection.name +local function makeDelegate(methodName, source, default, opts) + opts = opts or {} + local aclPath = opts.aclPath or source or "game" + local aclKey = opts.aclKey or methodName + return function(...) + local acl = loadACL() + if acl then + local aclSection = acl[aclPath] + if aclSection and aclSection[aclKey] then + return aclSection[aclKey](...) + end + end + if source == "game" or source == "both" then + if g_game and g_game[methodName] then + return g_game[methodName](...) + end + end + if source == "map" or source == "both" then + if g_map and g_map[methodName] then + return g_map[methodName](...) + end + end + if source == "ui" then + if g_ui and g_ui[methodName] then + return g_ui[methodName](...) + end + end + if source == "resources" then + if g_resources and g_resources[methodName] then + return g_resources[methodName](...) + end + end + if source == "window" then + if g_window and g_window[methodName] then + return g_window[methodName](...) + end + end + if source == "platform" then + if g_platform and g_platform[methodName] then + return g_platform[methodName](...) + end + end + if source == "keyboard" then + if g_keyboard and g_keyboard[methodName] then + return g_keyboard[methodName](...) + end + end + if source == "settings" then + if g_settings and g_settings[methodName] then + return g_settings[methodName](...) + end + end + if source == "callback" then + local globalFn = rawget(_G, methodName) + if type(globalFn) == "function" then + return globalFn(...) + end + end + if source == "callback_acl" then + -- Callbacks use acl.callbacks first, then global + if acl then + local cb = acl.callbacks and acl.callbacks[methodName] + if type(cb) == "function" then return cb(...) end + end + local globalFn = rawget(_G, methodName) + if type(globalFn) == "function" then return globalFn(...) end + end + return default + end +end + +-------------------------------------------------------------------------------- +-- METHODS TABLE +-- { name, source, default, opts } +-- source: "game"|"map"|"ui"|"resources"|"window"|"platform"|"keyboard"|"settings"|"callback"|"callback_acl"|"both" +-- opts: { aclPath, aclKey } for ACL overrides +-------------------------------------------------------------------------------- + +local methods = { + -- Client detection + { "getClientType", "custom" }, + { "getClientName", "custom" }, + { "isOTCv8", "custom" }, + { "isOpenTibiaBR", "custom" }, + + -- Connection state + { "isOnline", "game", false }, + { "isDead", "game", false }, + { "isAttacking", "game", false }, + { "isFollowing", "game", false }, + + -- Player + { "getLocalPlayer", "game", nil }, + + -- Combat + { "attack", "game" }, + { "cancelAttack", "game" }, + { "follow", "game" }, + { "cancelFollow", "game" }, + { "cancelAttackAndFollow", "game" }, + { "getAttackingCreature", "game", nil }, + { "getFollowingCreature", "game", nil }, + + -- Movement + { "walk", "game", false }, + { "autoWalk", "game", false }, + { "turn", "game" }, + { "stop", "game" }, + + -- Items + { "move", "game" }, + { "use", "game" }, + { "useWith", "game" }, + { "useInventoryItem", "game" }, + { "look", "game" }, + { "rotate", "game" }, + { "equipItem", "game" }, + + -- Containers + { "open", "game" }, + { "openParent", "game" }, + { "close", "game" }, + { "getContainer", "game", nil }, + { "getContainers", "game", {} }, + { "seekInContainer", "game" }, + + -- Communication + { "talk", "game" }, + { "talkChannel", "game" }, + { "talkPrivate", "game" }, + + -- Protocol + { "getClientVersion", "game", 0 }, + { "getProtocolVersion", "game", 0 }, + { "getFeature", "game", false }, + { "enableFeature", "game" }, + { "getPing", "game", 0 }, + { "getUnjustifiedPoints", "game", {} }, + + -- Combat settings + { "getChaseMode", "game", 0 }, + { "getFightMode", "game", 0 }, + { "setChaseMode", "game" }, + { "setFightMode", "game" }, + { "isSafeFight", "game", false }, + { "setSafeFight", "game" }, + + -- Outfit + { "requestOutfit", "game" }, + { "changeOutfit", "game" }, + { "requestOutfitChange", "game" }, + { "mountCreature", "game" }, + { "requestMounts", "game" }, + + -- Mount + { "mount", "game" }, + { "dismount", "game" }, + + -- Party + { "partyInvite", "game" }, + { "partyJoin", "game" }, + { "partyLeave", "game" }, + { "requestPartySharedExperience", "game" }, + { "passPartyLeadership", "game" }, + + -- Logout + { "safeLogout", "game" }, + { "forceLogout", "game" }, + + -- Inventory + { "useInventoryItemWith", "game" }, + { "findPlayerItem", "game" }, + { "findItemInContainers", "game" }, + { "equipItemId", "game" }, + + -- Stash + { "stashWithdraw", "game" }, + { "stashStowItem", "game" }, + { "stashStowAll", "game" }, + { "openStash", "game" }, + { "requestStashSearch", "game" }, + + -- Quick loot + { "sendQuickLoot", "game" }, + { "quickLootCorpse", "game" }, + { "setQuickLootFallback", "game" }, + + -- Imbuement + { "imbuementDurations", "game", {} }, + { "applyImbuement", "game" }, + { "clearImbuement", "game" }, + { "requestImbuingWindow", "game" }, + { "closeImbuingWindow", "game" }, + + -- Prey + { "preyAction", "game" }, + { "requestPreyData", "game" }, + { "selectPreyCreature", "game" }, + { "refreshPreyMonsters", "game" }, + + -- Forge + { "forgeRequest", "game" }, + { "forgeFuse", "game" }, + { "forgeTransfer", "game" }, + { "openForge", "game" }, + + -- Market + { "browseMarket", "game" }, + { "createMarketOffer", "game" }, + { "cancelMarketOffer", "game" }, + { "acceptMarketOffer", "game" }, + { "requestMarketInfo", "game" }, + + -- Modal / Browse / Inspection + { "answerModalDialog", "game" }, + { "browseField", "game" }, + { "inspectionNormalObject", "game" }, + { "inspectionObject", "game" }, + + -- Container enhanced + { "refreshContainer", "game" }, + { "requestContainerQueue", "game" }, + { "openContainerAt", "game" }, + + -- NPC Trade + { "buyItem", "game" }, + { "sellItem", "game" }, + { "requestNPCTrade", "game" }, + { "closeNPCTrade", "game" }, + + -- Blessings + { "requestBless", "game" }, + + -- Cyclopedia + { "requestCyclopediaMapData", "game" }, + { "requestCharacterInfo", "game" }, + + -- Walk config + { "forceWalk", "game" }, + { "setScheduleLastWalk", "game" }, + { "setWalkFirstStepDelay", "game" }, + { "setWalkTurnDelay", "game" }, + { "setWalkSpeedMultiplier", "game" }, + { "getWalkSpeedMultiplier", "game", 1.0 }, + { "getWalkMaxSteps", "game", 10 }, + { "setWalkMaxSteps", "game" }, + + -- Map + { "getTile", "map", nil }, + { "isSightClear", "map", false }, + { "getTiles", "map", {} }, + { "getMinimapColor", "map", 0 }, + { "getSpectatorsInRange", "map", {} }, + { "isTileWalkable", "map" }, + { "getCreatureById", "map", nil }, + { "isAwareOfPosition", "map", false }, + { "findItemsById", "map", {} }, + { "getTilesInRange", "map", {} }, + { "cleanTile", "map" }, + { "setMinimapColor", "map" }, + { "findEveryPath", "map", {} }, + { "getSpectatorsInRangeEx", "map", {} }, + { "getSightSpectators", "map", {} }, + { "getSpectatorsByPattern", "map", {} }, + + -- Cooldown (acl.cooldown) + { "isCooldownActive", "custom", false, { aclPath = "cooldown" } }, + { "isGroupCooldownActive", "custom", false, { aclPath = "cooldown" } }, + + -- UI + { "importStyle", "ui" }, + { "createWidget", "ui" }, + { "getRootWidget", "ui", nil }, + { "loadUI", "ui" }, + { "loadUIFromString", "ui" }, + + -- Resources + { "fileExists", "resources", false }, + { "directoryExists", "resources", false }, + { "makeDir", "resources" }, + { "readFileContents", "resources", nil }, + { "writeFileContents", "resources" }, + { "listDirectoryFiles", "resources", {} }, + { "deleteFile", "resources" }, + + -- Window + { "setWindowTitle", "window" }, + { "flashWindow", "window" }, + { "setClipboardText", "window" }, + + -- Platform + { "openUrl", "platform" }, + + -- Keyboard + { "isKeyPressed", "keyboard", false }, + + -- Settings + { "getSettingNumber", "settings" }, + { "setSettingNumber", "settings" }, + + -- Callbacks (acl.callbacks → global) + { "onCreatureAppear", "callback_acl" }, + { "onCreatureDisappear", "callback_acl" }, + { "onPlayerPositionChange", "callback_acl" }, + { "onTalk", "callback_acl" }, + { "onTextMessage", "callback_acl" }, + { "onContainerOpen", "callback_acl" }, + { "onSpellCooldown", "callback_acl" }, + { "onGroupSpellCooldown", "callback_acl" }, + { "onUse", "callback_acl" }, + { "onUseWith", "callback_acl" }, + { "onCreatureHealthPercentChange", "callback_acl" }, + { "onContainerClose", "callback_acl" }, + { "onContainerUpdateItem", "callback_acl" }, + { "onAddItem", "callback_acl" }, + { "onRemoveItem", "callback_acl" }, + { "onImbuementWindow", "callback_acl" }, + { "onForgeResult", "callback_acl" }, + { "onPreyData", "callback_acl" }, + { "onMarketBrowse", "callback_acl" }, + { "onMarketOffer", "callback_acl" }, + { "onStashAction", "callback_acl" }, + { "onBestiaryData", "callback_acl" }, + { "onModalDialog", "callback_acl" }, + { "onAttackingCreatureChange", "callback_acl" }, + { "onInventoryChange", "callback_acl" }, + { "onManaChange", "callback_acl" }, + { "onStatesChange", "callback_acl" }, + { "onWalk", "callback_acl" }, + { "onAddThing", "callback_acl" }, + { "onRemoveThing", "callback_acl" }, + + -- Bestiary (acl.bestiary → g_game) + { "requestBestiary", "game", nil, { aclPath = "bestiary", aclKey = "request" } }, + { "requestBestiaryOverview", "game", nil, { aclPath = "bestiary", aclKey = "requestOverview" } }, + { "requestBestiarySearch", "game", nil, { aclPath = "bestiary", aclKey = "search" } }, + + -- Bosstiary (acl.bosstiary → g_game) + { "requestBosstiaryInfo", "game", nil, { aclPath = "bosstiary", aclKey = "requestInfo" } }, + { "requestBossSlootInfo", "game", nil, { aclPath = "bosstiary", aclKey = "requestSlotInfo" } }, +} + +-- Register all table-driven methods +for _, def in ipairs(methods) do + local name, source, default, opts = def[1], def[2], def[3], def[4] + if source ~= "custom" then + ClientService[name] = makeDelegate(name, source, default, opts) end - return "Unknown" end -function ClientService.isOTCv8() - local acl = loadACL() - if acl then - return acl.isOTCv8() - end - if nExBot and nExBot.clientDetection and nExBot.clientDetection.type then - return nExBot.clientDetection.type == 1 - end - return true -- Default to OTCv8 -end +-------------------------------------------------------------------------------- function ClientService.isOpenTibiaBR() local acl = loadACL() - if acl then - return acl.isOpenTibiaBR() - end + if acl then return acl.isOpenTibiaBR() end if nExBot and nExBot.clientDetection and nExBot.clientDetection.type then return nExBot.clientDetection.type == 2 end - return false end +-- MODULE ACCESS -------------------------------------------------------------------------------- --- GAME OPERATIONS (delegated to ACL or direct) --------------------------------------------------------------------------------- - --- Connection state -function ClientService.isOnline() - local acl = loadACL() - if acl and acl.game then - return acl.game.isOnline() - end - return g_game and g_game.isOnline and g_game.isOnline() or false -end - -function ClientService.isDead() - local acl = loadACL() - if acl and acl.game then - return acl.game.isDead() - end - return g_game and g_game.isDead and g_game.isDead() or false -end - -function ClientService.isAttacking() - local acl = loadACL() - if acl and acl.game then - return acl.game.isAttacking() - end - return g_game and g_game.isAttacking and g_game.isAttacking() or false -end - -function ClientService.isFollowing() - local acl = loadACL() - if acl and acl.game then - return acl.game.isFollowing() - end - return g_game and g_game.isFollowing and g_game.isFollowing() or false -end - --- Player access -function ClientService.getLocalPlayer() - local acl = loadACL() - if acl and acl.game then - return acl.game.getLocalPlayer() - end - return g_game and g_game.getLocalPlayer and g_game.getLocalPlayer() or nil -end - --- Combat -function ClientService.attack(creature) - local acl = loadACL() - if acl and acl.game then - return acl.game.attack(creature) - end - if g_game and g_game.attack then - return g_game.attack(creature) - end -end - -function ClientService.cancelAttack() - local acl = loadACL() - if acl and acl.game then - return acl.game.cancelAttack() - end - if g_game and g_game.cancelAttack then - return g_game.cancelAttack() - end -end - -function ClientService.follow(creature) - local acl = loadACL() - if acl and acl.game then - return acl.game.follow(creature) - end - if g_game and g_game.follow then - return g_game.follow(creature) - end -end - -function ClientService.cancelFollow() - local acl = loadACL() - if acl and acl.game then - return acl.game.cancelFollow() - end - if g_game and g_game.cancelFollow then - return g_game.cancelFollow() - end -end - -function ClientService.cancelAttackAndFollow() - local acl = loadACL() - if acl and acl.game then - return acl.game.cancelAttackAndFollow() - end - if g_game and g_game.cancelAttackAndFollow then - return g_game.cancelAttackAndFollow() - end -end - -function ClientService.getAttackingCreature() - local acl = loadACL() - if acl and acl.game then - return acl.game.getAttackingCreature() - end - return g_game and g_game.getAttackingCreature and g_game.getAttackingCreature() or nil -end - -function ClientService.getFollowingCreature() - local acl = loadACL() - if acl and acl.game then - return acl.game.getFollowingCreature() - end - return g_game and g_game.getFollowingCreature and g_game.getFollowingCreature() or nil -end - --- Movement -function ClientService.walk(direction) - local acl = loadACL() - if acl and acl.game then - return acl.game.walk(direction) - end - if g_game and g_game.walk then - return g_game.walk(direction) - end - return false -end - -function ClientService.autoWalk(destination, maxSteps, options) - local acl = loadACL() - if acl and acl.game and acl.game.autoWalk then - return acl.game.autoWalk(destination, maxSteps, options) - end - if g_game and g_game.autoWalk then - return g_game.autoWalk(destination, maxSteps, options) - end - return false -end - -function ClientService.turn(direction) - local acl = loadACL() - if acl and acl.game then - return acl.game.turn(direction) - end - if g_game and g_game.turn then - return g_game.turn(direction) - end -end - -function ClientService.stop() - local acl = loadACL() - if acl and acl.game then - return acl.game.stop() - end - if g_game and g_game.stop then - return g_game.stop() - end -end - --- Items -function ClientService.move(thing, toPosition, count) - local acl = loadACL() - if acl and acl.game then - return acl.game.move(thing, toPosition, count) - end - if g_game and g_game.move then - return g_game.move(thing, toPosition, count or 1) - end -end - -function ClientService.use(thing) - local acl = loadACL() - if acl and acl.game then - return acl.game.use(thing) - end - if g_game and g_game.use then - return g_game.use(thing) - end -end - -function ClientService.useWith(item, target) - local acl = loadACL() - if acl and acl.game then - return acl.game.useWith(item, target) - end - if g_game and g_game.useWith then - return g_game.useWith(item, target) - end -end - -function ClientService.useInventoryItem(itemId) - local acl = loadACL() - if acl and acl.game then - return acl.game.useInventoryItem(itemId) - end - if g_game and g_game.useInventoryItem then - return g_game.useInventoryItem(itemId) - end -end - -function ClientService.look(thing) - local acl = loadACL() - if acl and acl.game then - return acl.game.look(thing) - end - if g_game and g_game.look then - return g_game.look(thing) - end -end - -function ClientService.rotate(thing) - local acl = loadACL() - if acl and acl.game then - return acl.game.rotate(thing) - end - if g_game and g_game.rotate then - return g_game.rotate(thing) - end -end - -function ClientService.equipItem(item) - local acl = loadACL() - if acl and acl.game then - return acl.game.equipItem(item) - end - if g_game and g_game.equipItem then - return g_game.equipItem(item) - end -end - --- Containers -function ClientService.open(item, previousContainer) - local acl = loadACL() - if acl and acl.game then - return acl.game.open(item, previousContainer) - end - if g_game and g_game.open then - return g_game.open(item, previousContainer) - end -end - -function ClientService.openParent(container) - local acl = loadACL() - if acl and acl.game then - return acl.game.openParent(container) - end - if g_game and g_game.openParent then - return g_game.openParent(container) - end -end - -function ClientService.close(container) - local acl = loadACL() - if acl and acl.game then - return acl.game.close(container) - end - if g_game and g_game.close then - return g_game.close(container) - end -end - -function ClientService.getContainer(id) - local acl = loadACL() - if acl and acl.game then - return acl.game.getContainer(id) - end - return g_game and g_game.getContainer and g_game.getContainer(id) or nil -end - -function ClientService.getContainers() - local acl = loadACL() - if acl and acl.game then - return acl.game.getContainers() - end - return g_game and g_game.getContainers and g_game.getContainers() or {} -end - --- Communication -function ClientService.talk(message) - local acl = loadACL() - if acl and acl.game then - return acl.game.talk(message) - end - if g_game and g_game.talk then - return g_game.talk(message) - end -end - -function ClientService.talkChannel(mode, channelId, message) - local acl = loadACL() - if acl and acl.game then - return acl.game.talkChannel(mode, channelId, message) - end - if g_game and g_game.talkChannel then - return g_game.talkChannel(mode, channelId, message) - end -end - -function ClientService.talkPrivate(mode, receiver, message) - local acl = loadACL() - if acl and acl.game then - return acl.game.talkPrivate(mode, receiver, message) - end - if g_game and g_game.talkPrivate then - return g_game.talkPrivate(mode, receiver, message) - end -end --- Protocol info -function ClientService.getClientVersion() - local acl = loadACL() - if acl and acl.game then - return acl.game.getClientVersion() - end - return g_game and g_game.getClientVersion and g_game.getClientVersion() or 0 -end - -function ClientService.getProtocolVersion() - local acl = loadACL() - if acl and acl.game then - return acl.game.getProtocolVersion() - end - return g_game and g_game.getProtocolVersion and g_game.getProtocolVersion() or 0 -end - -function ClientService.getFeature(feature) - local acl = loadACL() - if acl and acl.game then - return acl.game.getFeature(feature) - end - return g_game and g_game.getFeature and g_game.getFeature(feature) or false -end - -function ClientService.enableFeature(feature) - local acl = loadACL() - if acl and acl.game then - return acl.game.enableFeature(feature) - end - if g_game and g_game.enableFeature then - return g_game.enableFeature(feature) - end -end - -function ClientService.getPing() - local acl = loadACL() - if acl and acl.game then - return acl.game.getPing() - end - return g_game and g_game.getPing and g_game.getPing() or 0 -end - -function ClientService.getUnjustifiedPoints() - local acl = loadACL() - if acl and acl.game then - return acl.game.getUnjustifiedPoints() - end - return g_game and g_game.getUnjustifiedPoints and g_game.getUnjustifiedPoints() or {} -end - --- Combat settings -function ClientService.getChaseMode() - local acl = loadACL() - if acl and acl.game then - return acl.game.getChaseMode() - end - return g_game and g_game.getChaseMode and g_game.getChaseMode() or 0 -end - -function ClientService.getFightMode() - local acl = loadACL() - if acl and acl.game then - return acl.game.getFightMode() - end - return g_game and g_game.getFightMode and g_game.getFightMode() or 0 -end - -function ClientService.setChaseMode(mode) - local acl = loadACL() - if acl and acl.game then - return acl.game.setChaseMode(mode) - end - if g_game and g_game.setChaseMode then - return g_game.setChaseMode(mode) - end -end - -function ClientService.setFightMode(mode) - local acl = loadACL() - if acl and acl.game then - return acl.game.setFightMode(mode) - end - if g_game and g_game.setFightMode then - return g_game.setFightMode(mode) - end -end - -function ClientService.isSafeFight() - local acl = loadACL() - if acl and acl.game then - return acl.game.isSafeFight() - end - return g_game and g_game.isSafeFight and g_game.isSafeFight() or false -end - -function ClientService.setSafeFight(safe) - local acl = loadACL() - if acl and acl.game then - return acl.game.setSafeFight(safe) - end - if g_game and g_game.setSafeFight then - return g_game.setSafeFight(safe) - end -end - --- Outfit -function ClientService.requestOutfit() - local acl = loadACL() - if acl and acl.game then - return acl.game.requestOutfit() - end - if g_game and g_game.requestOutfit then - return g_game.requestOutfit() - end -end - -function ClientService.changeOutfit(outfit) - local acl = loadACL() - if acl and acl.game then - return acl.game.changeOutfit(outfit) - end - if g_game and g_game.changeOutfit then - return g_game.changeOutfit(outfit) - end +function ClientService.getModule(name) + if modules and modules[name] then return modules[name] end + return nil end -------------------------------------------------------------------------------- --- MAP OPERATIONS +-- EXPORT -------------------------------------------------------------------------------- -function ClientService.getTile(pos) - local acl = loadACL() - if acl and acl.map then - return acl.map.getTile(pos) - end - return g_map and g_map.getTile and g_map.getTile(pos) or nil -end - -function ClientService.getSpectators(pos, multifloor) - local acl = loadACL() - if acl and acl.map and acl.map.getSpectators then - return acl.map.getSpectators(pos, multifloor) - end - - -- Fallback to bot context - if getSpectators then - return getSpectators(pos, multifloor) or {} - end - - -- Last resort: g_map - if g_map and g_map.getSpectators then - return g_map.getSpectators(pos, multifloor) or {} - end - - return {} -end - -function ClientService.isSightClear(fromPos, toPos, floorCheck) - local acl = loadACL() - if acl and acl.map then - return acl.map.isSightClear(fromPos, toPos, floorCheck) - end - return g_map and g_map.isSightClear and g_map.isSightClear(fromPos, toPos, floorCheck) or false -end - -function ClientService.findPath(startPos, goalPos, options) - local acl = loadACL() - if acl and acl.map and acl.map.findPath then - return acl.map.findPath(startPos, goalPos, options) - end - if g_map and g_map.findPath then - return g_map.findPath(startPos, goalPos, options and options.maxSteps or 50) - end - return nil -end - --------------------------------------------------------------------------------- --- COOLDOWN OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.isCooldownActive(iconId) - local acl = loadACL() - if acl and acl.cooldown and acl.cooldown.isCooldownIconActive then - return acl.cooldown.isCooldownIconActive(iconId) - end - - local cooldownModule = modules and modules.game_cooldown - if cooldownModule and cooldownModule.isCooldownIconActive then - return cooldownModule.isCooldownIconActive(iconId) - end - - return false -end - -function ClientService.isGroupCooldownActive(groupId) - local acl = loadACL() - if acl and acl.cooldown and acl.cooldown.isGroupCooldownIconActive then - return acl.cooldown.isGroupCooldownIconActive(groupId) - end - - local cooldownModule = modules and modules.game_cooldown - if cooldownModule and cooldownModule.isGroupCooldownIconActive then - return cooldownModule.isGroupCooldownIconActive(groupId) - end - - return false -end - --------------------------------------------------------------------------------- --- UTILITY FUNCTIONS --------------------------------------------------------------------------------- - -function ClientService.getPos(x, y, z) - local acl = loadACL() - if acl and acl.utils then - return acl.utils.getPos(x, y, z) - end - - local player = ClientService.getLocalPlayer() - if player then - local pos = player:getPosition() - pos.x = x or pos.x - pos.y = y or pos.y - pos.z = z or pos.z - return pos - end - return {x = x or 0, y = y or 0, z = z or 0} -end - -function ClientService.getDistanceBetween(pos1, pos2) - if not pos1 or not pos2 then return 999 end - return math.max(math.abs(pos1.x - pos2.x), math.abs(pos1.y - pos2.y)) -end - -function ClientService.isSamePosition(pos1, pos2) - if not pos1 or not pos2 then return false end - return pos1.x == pos2.x and pos1.y == pos2.y and pos1.z == pos2.z -end - -function ClientService.isInRange(pos1, pos2, rangeX, rangeY) - if not pos1 or not pos2 then return false end - rangeY = rangeY or rangeX - return math.abs(pos1.x - pos2.x) <= rangeX and - math.abs(pos1.y - pos2.y) <= rangeY and - pos1.z == pos2.z -end - --- Find creature by name -function ClientService.getCreatureByName(name, caseSensitive) - local acl = loadACL() - if acl and acl.utils and acl.utils.getCreatureByName then - return acl.utils.getCreatureByName(name, caseSensitive) - end - - -- Fallback implementation - local player = ClientService.getLocalPlayer() - if not player then return nil end - - local spectators = ClientService.getSpectators(player:getPosition(), true) - for _, creature in ipairs(spectators) do - if caseSensitive then - if creature:getName() == name then - return creature - end - else - if creature:getName():lower() == name:lower() then - return creature - end - end - end - - return nil -end - --- Find item -function ClientService.findItem(itemId, subType) - local acl = loadACL() - if acl and acl.utils and acl.utils.findItem then - return acl.utils.findItem(itemId, subType) - end - - -- Fallback to global findItem if available - if findItem then - return findItem(itemId, subType) - end - - -- Manual search - local player = ClientService.getLocalPlayer() - if player then - for slot = 1, 10 do - local item = player:getInventoryItem(slot) - if item and item:getId() == itemId then - if not subType or item:getSubType() == subType then - return item - end - end - end - end - - for _, container in pairs(ClientService.getContainers()) do - for _, item in ipairs(container:getItems()) do - if item:getId() == itemId then - if not subType or item:getSubType() == subType then - return item - end - end - end - end - - return nil -end - --- Count items -function ClientService.itemAmount(itemId, subType) - local acl = loadACL() - if acl and acl.utils and acl.utils.itemAmount then - return acl.utils.itemAmount(itemId, subType) - end - - -- Fallback to global itemAmount if available - if itemAmount then - return itemAmount(itemId, subType) - end - - local count = 0 - local player = ClientService.getLocalPlayer() - if player then - for slot = 1, 10 do - local item = player:getInventoryItem(slot) - if item and item:getId() == itemId then - if not subType or item:getSubType() == subType then - count = count + item:getCount() - end - end - end - end - - for _, container in pairs(ClientService.getContainers()) do - for _, item in ipairs(container:getItems()) do - if item:getId() == itemId then - if not subType or item:getSubType() == subType then - count = count + item:getCount() - end - end - end - end - - return count -end - --------------------------------------------------------------------------------- --- CALLBACK REGISTRATION --------------------------------------------------------------------------------- - -function ClientService.onCreatureAppear(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onCreatureAppear then - return acl.callbacks.onCreatureAppear(callback) - end - if onCreatureAppear then - return onCreatureAppear(callback) - end -end - -function ClientService.onCreatureDisappear(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onCreatureDisappear then - return acl.callbacks.onCreatureDisappear(callback) - end - if onCreatureDisappear then - return onCreatureDisappear(callback) - end -end - -function ClientService.onPlayerPositionChange(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onPlayerPositionChange then - return acl.callbacks.onPlayerPositionChange(callback) - end - if onPlayerPositionChange then - return onPlayerPositionChange(callback) - end -end - -function ClientService.onTalk(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onTalk then - return acl.callbacks.onTalk(callback) - end - if onTalk then - return onTalk(callback) - end -end - -function ClientService.onTextMessage(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onTextMessage then - return acl.callbacks.onTextMessage(callback) - end - if onTextMessage then - return onTextMessage(callback) - end -end - -function ClientService.onContainerOpen(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onContainerOpen then - return acl.callbacks.onContainerOpen(callback) - end - if onContainerOpen then - return onContainerOpen(callback) - end -end - -function ClientService.onSpellCooldown(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onSpellCooldown then - return acl.callbacks.onSpellCooldown(callback) - end - if onSpellCooldown then - return onSpellCooldown(callback) - end -end - -function ClientService.onGroupSpellCooldown(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onGroupSpellCooldown then - return acl.callbacks.onGroupSpellCooldown(callback) - end - if onGroupSpellCooldown then - return onGroupSpellCooldown(callback) - end -end - --------------------------------------------------------------------------------- --- EXTENDED GAME OPERATIONS (Added for full module compatibility) --------------------------------------------------------------------------------- - --- Inventory operations -function ClientService.useInventoryItemWith(itemId, target) - local acl = loadACL() - if acl and acl.game and acl.game.useInventoryItemWith then - return acl.game.useInventoryItemWith(itemId, target) - end - if g_game and g_game.useInventoryItemWith then - return g_game.useInventoryItemWith(itemId, target) - end -end - -function ClientService.findPlayerItem(itemId, subType) - local acl = loadACL() - if acl and acl.game and acl.game.findPlayerItem then - return acl.game.findPlayerItem(itemId, subType) - end - if g_game and g_game.findPlayerItem then - return g_game.findPlayerItem(itemId, subType) - end - -- Fallback implementation - return ClientService.findItem(itemId, subType) -end - -function ClientService.findItemInContainers(itemId, subType) - local acl = loadACL() - if acl and acl.game and acl.game.findItemInContainers then - return acl.game.findItemInContainers(itemId, subType) - end - if g_game and g_game.findItemInContainers then - return g_game.findItemInContainers(itemId, subType) - end - -- Fallback: search containers manually - for _, container in pairs(ClientService.getContainers()) do - for _, item in ipairs(container:getItems()) do - if item:getId() == itemId then - if not subType or item:getSubType() == subType then - return item - end - end - end - end - return nil -end - -function ClientService.equipItemId(itemId, destSlot) - local acl = loadACL() - if acl and acl.game and acl.game.equipItemId then - return acl.game.equipItemId(itemId, destSlot) - end - if g_game and g_game.equipItemId then - return g_game.equipItemId(itemId, destSlot) - end -end - --- Mount operations -function ClientService.mount(mount) - local acl = loadACL() - if acl and acl.game and acl.game.mount then - return acl.game.mount(mount) - end - if g_game and g_game.mount then - return g_game.mount(mount) - end -end - -function ClientService.dismount() - local acl = loadACL() - if acl and acl.game and acl.game.dismount then - return acl.game.dismount() - end - if g_game and g_game.dismount then - return g_game.dismount() - end -end - --- Party operations -function ClientService.partyInvite(creatureId) - local acl = loadACL() - if acl and acl.game and acl.game.partyInvite then - return acl.game.partyInvite(creatureId) - end - if g_game and g_game.partyInvite then - return g_game.partyInvite(creatureId) - end -end - -function ClientService.partyJoin(creatureId) - local acl = loadACL() - if acl and acl.game and acl.game.partyJoin then - return acl.game.partyJoin(creatureId) - end - if g_game and g_game.partyJoin then - return g_game.partyJoin(creatureId) - end -end - -function ClientService.partyLeave() - local acl = loadACL() - if acl and acl.game and acl.game.partyLeave then - return acl.game.partyLeave() - end - if g_game and g_game.partyLeave then - return g_game.partyLeave() - end -end - --- Seek in container -function ClientService.seekInContainer(container, index) - local acl = loadACL() - if acl and acl.game and acl.game.seekInContainer then - return acl.game.seekInContainer(container, index) - end - if g_game and g_game.seekInContainer then - return g_game.seekInContainer(container, index) - end -end - --- Logout -function ClientService.safeLogout() - local acl = loadACL() - if acl and acl.game and acl.game.safeLogout then - return acl.game.safeLogout() - end - if g_game and g_game.safeLogout then - return g_game.safeLogout() - end -end - -function ClientService.forceLogout() - local acl = loadACL() - if acl and acl.game and acl.game.forceLogout then - return acl.game.forceLogout() - end - if g_game and g_game.forceLogout then - return g_game.forceLogout() - end -end - --- Say (alias for talk) -function ClientService.say(message) - return ClientService.talk(message) -end - --- Talk local (OTCv8 specific) -function ClientService.talkLocal(message) - local acl = loadACL() - if acl and acl.game and acl.game.talkLocal then - return acl.game.talkLocal(message) - end - if g_game and g_game.talkLocal then - return g_game.talkLocal(message) - end - -- Fallback to regular talk - return ClientService.talk(message) -end - --------------------------------------------------------------------------------- --- EXTENDED MAP OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.getTiles(floor) - local acl = loadACL() - if acl and acl.map and acl.map.getTiles then - return acl.map.getTiles(floor) - end - return g_map and g_map.getTiles and g_map.getTiles(floor) or {} -end - -function ClientService.getMinimapColor(pos) - local acl = loadACL() - if acl and acl.map and acl.map.getMinimapColor then - return acl.map.getMinimapColor(pos) - end - return g_map and g_map.getMinimapColor and g_map.getMinimapColor(pos) or 0 -end - -function ClientService.getSpectatorsInRange(pos, xRange, yRange, multifloor) - local acl = loadACL() - if acl and acl.map and acl.map.getSpectatorsInRange then - return acl.map.getSpectatorsInRange(pos, xRange, yRange, multifloor) - end - if g_map and g_map.getSpectatorsInRange then - return g_map.getSpectatorsInRange(pos, xRange, yRange, multifloor) or {} - end - -- Fallback to getSpectators - return ClientService.getSpectators(pos, multifloor) -end - -function ClientService.isTileWalkable(pos) - local acl = loadACL() - if acl and acl.map and acl.map.isTileWalkable then - return acl.map.isTileWalkable(pos) - end - if g_map and g_map.isTileWalkable then - return g_map.isTileWalkable(pos) - end - -- Fallback: check tile directly - local tile = ClientService.getTile(pos) - return tile and tile:isWalkable() or false -end - --------------------------------------------------------------------------------- --- UI OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.importStyle(path) - local acl = loadACL() - if acl and acl.ui and acl.ui.importStyle then - return acl.ui.importStyle(path) - end - if g_ui and g_ui.importStyle then - return g_ui.importStyle(path) - end -end - -function ClientService.createWidget(widgetType, parent) - local acl = loadACL() - if acl and acl.ui and acl.ui.createWidget then - return acl.ui.createWidget(widgetType, parent) - end - if g_ui and g_ui.createWidget then - return g_ui.createWidget(widgetType, parent) - end -end - -function ClientService.getRootWidget() - local acl = loadACL() - if acl and acl.ui and acl.ui.getRootWidget then - return acl.ui.getRootWidget() - end - return g_ui and g_ui.getRootWidget and g_ui.getRootWidget() or nil -end - -function ClientService.loadUI(path, parent) - local acl = loadACL() - if acl and acl.ui and acl.ui.loadUI then - return acl.ui.loadUI(path, parent) - end - if g_ui and g_ui.loadUI then - return g_ui.loadUI(path, parent) - end -end - -function ClientService.loadUIFromString(str, parent) - local acl = loadACL() - if acl and acl.ui and acl.ui.loadUIFromString then - return acl.ui.loadUIFromString(str, parent) - end - if g_ui and g_ui.loadUIFromString then - return g_ui.loadUIFromString(str, parent) - end -end - --------------------------------------------------------------------------------- --- RESOURCE OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.fileExists(path) - if g_resources and g_resources.fileExists then - return g_resources.fileExists(path) - end - return false -end - -function ClientService.directoryExists(path) - if g_resources and g_resources.directoryExists then - return g_resources.directoryExists(path) - end - return false -end - -function ClientService.makeDir(path) - if g_resources and g_resources.makeDir then - return g_resources.makeDir(path) - end -end - -function ClientService.readFileContents(path) - if g_resources and g_resources.readFileContents then - return g_resources.readFileContents(path) - end - return nil -end - -function ClientService.writeFileContents(path, contents) - if g_resources and g_resources.writeFileContents then - return g_resources.writeFileContents(path, contents) - end -end - -function ClientService.listDirectoryFiles(path, recursive, showHidden) - if g_resources and g_resources.listDirectoryFiles then - return g_resources.listDirectoryFiles(path, recursive, showHidden) or {} - end - return {} -end - -function ClientService.deleteFile(path) - if g_resources and g_resources.deleteFile then - return g_resources.deleteFile(path) - end -end - --------------------------------------------------------------------------------- --- WINDOW OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.setWindowTitle(title) - if g_window and g_window.setTitle then - return g_window.setTitle(title) - end -end - -function ClientService.flashWindow() - if g_window and g_window.flash then - return g_window.flash() - end -end - -function ClientService.setClipboardText(text) - if g_window and g_window.setClipboardText then - return g_window.setClipboardText(text) - end -end - --------------------------------------------------------------------------------- --- PLATFORM OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.openUrl(url) - if g_platform and g_platform.openUrl then - return g_platform.openUrl(url) - end -end - --------------------------------------------------------------------------------- --- KEYBOARD OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.isKeyPressed(key) - if g_keyboard and g_keyboard.isKeyPressed then - return g_keyboard.isKeyPressed(key) - end - return false -end - --------------------------------------------------------------------------------- --- SETTINGS OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.getSettingNumber(key, default) - if g_settings and g_settings.getNumber then - return g_settings.getNumber(key) or default - end - return default -end - -function ClientService.setSettingNumber(key, value) - if g_settings and g_settings.setNumber then - return g_settings.setNumber(key, value) - end -end - --------------------------------------------------------------------------------- --- MODULE ACCESS --------------------------------------------------------------------------------- - -function ClientService.getModule(name) - if modules and modules[name] then - return modules[name] - end - return nil -end - -function ClientService.getGameInterface() - return ClientService.getModule("game_interface") -end - -function ClientService.getConsole() - return ClientService.getModule("game_console") -end - -function ClientService.getCooldown() - return ClientService.getModule("game_cooldown") -end - -function ClientService.getBot() - return ClientService.getModule("game_bot") -end - -function ClientService.getTerminal() - return ClientService.getModule("client_terminal") -end - -function ClientService.getTextMessage() - return ClientService.getModule("game_textmessage") -end - -function ClientService.getWalking() - return ClientService.getModule("game_walking") -end - -function ClientService.getInventory() - return ClientService.getModule("game_inventory") -end - -function ClientService.getContainersModule() - return ClientService.getModule("game_containers") -end - -function ClientService.getSkills() - return ClientService.getModule("game_skills") -end - --------------------------------------------------------------------------------- --- ADDITIONAL CALLBACKS --------------------------------------------------------------------------------- - -function ClientService.onUse(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onUse then - return acl.callbacks.onUse(callback) - end - if onUse then - return onUse(callback) - end -end - -function ClientService.onUseWith(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onUseWith then - return acl.callbacks.onUseWith(callback) - end - if onUseWith then - return onUseWith(callback) - end -end - -function ClientService.onCreatureHealthPercentChange(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onCreatureHealthPercentChange then - return acl.callbacks.onCreatureHealthPercentChange(callback) - end - if onCreatureHealthPercentChange then - return onCreatureHealthPercentChange(callback) - end -end - -function ClientService.onContainerClose(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onContainerClose then - return acl.callbacks.onContainerClose(callback) - end - if onContainerClose then - return onContainerClose(callback) - end -end - -function ClientService.onContainerUpdateItem(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onContainerUpdateItem then - return acl.callbacks.onContainerUpdateItem(callback) - end - if onContainerUpdateItem then - return onContainerUpdateItem(callback) - end -end - -function ClientService.onAddItem(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onAddItem then - return acl.callbacks.onAddItem(callback) - end - if onAddItem then - return onAddItem(callback) - end -end - -function ClientService.onRemoveItem(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onRemoveItem then - return acl.callbacks.onRemoveItem(callback) - end - if onRemoveItem then - return onRemoveItem(callback) - end -end - --------------------------------------------------------------------------------- --- OPENTIBIABR ENHANCED OPERATIONS --- These functions expose OpenTibiaBR-specific features through the ACL --------------------------------------------------------------------------------- - --- Force walk (more reliable walking) -function ClientService.forceWalk(direction) - local acl = loadACL() - if acl and acl.game and acl.game.forceWalk then - return acl.game.forceWalk(direction) - end - if g_game and g_game.forceWalk then - return g_game.forceWalk(direction) - end - -- Fallback to normal walk - return ClientService.walk(direction) -end - --- Schedule last walk -function ClientService.setScheduleLastWalk(schedule) - local acl = loadACL() - if acl and acl.game and acl.game.setScheduleLastWalk then - return acl.game.setScheduleLastWalk(schedule) - end - if g_game and g_game.setScheduleLastWalk then - return g_game.setScheduleLastWalk(schedule) - end -end - --- Walk speed configuration -function ClientService.setWalkFirstStepDelay(delay) - local acl = loadACL() - if acl and acl.game and acl.game.setWalkFirstStepDelay then - return acl.game.setWalkFirstStepDelay(delay) - end - if g_game and g_game.setWalkFirstStepDelay then - return g_game.setWalkFirstStepDelay(delay) - end -end - -function ClientService.setWalkTurnDelay(delay) - local acl = loadACL() - if acl and acl.game and acl.game.setWalkTurnDelay then - return acl.game.setWalkTurnDelay(delay) - end - if g_game and g_game.setWalkTurnDelay then - return g_game.setWalkTurnDelay(delay) - end -end - -function ClientService.setWalkSpeedMultiplier(multiplier) - local acl = loadACL() - if acl and acl.game and acl.game.setWalkSpeedMultiplier then - return acl.game.setWalkSpeedMultiplier(multiplier) - end - if g_game and g_game.setWalkSpeedMultiplier then - return g_game.setWalkSpeedMultiplier(multiplier) - end -end - -function ClientService.getWalkSpeedMultiplier() - local acl = loadACL() - if acl and acl.game and acl.game.getWalkSpeedMultiplier then - return acl.game.getWalkSpeedMultiplier() - end - if g_game and g_game.getWalkSpeedMultiplier then - return g_game.getWalkSpeedMultiplier() - end - return 1.0 -end - -function ClientService.getWalkMaxSteps() - local acl = loadACL() - if acl and acl.game and acl.game.getWalkMaxSteps then - return acl.game.getWalkMaxSteps() - end - if g_game and g_game.getWalkMaxSteps then - return g_game.getWalkMaxSteps() - end - return 10 -end - -function ClientService.setWalkMaxSteps(steps) - local acl = loadACL() - if acl and acl.game and acl.game.setWalkMaxSteps then - return acl.game.setWalkMaxSteps(steps) - end - if g_game and g_game.setWalkMaxSteps then - return g_game.setWalkMaxSteps(steps) - end -end - --------------------------------------------------------------------------------- --- STASH OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.stashWithdraw(itemId, count) - local acl = loadACL() - if acl and acl.game and acl.game.stashWithdraw then - return acl.game.stashWithdraw(itemId, count) - end - if g_game and g_game.stashWithdraw then - return g_game.stashWithdraw(itemId, count) - end -end - -function ClientService.stashStowItem(item, count) - local acl = loadACL() - if acl and acl.game and acl.game.stashStowItem then - return acl.game.stashStowItem(item, count) - end - if g_game and g_game.stashStowItem then - return g_game.stashStowItem(item, count) - end -end - -function ClientService.stashStowAll(item) - local acl = loadACL() - if acl and acl.game and acl.game.stashStowAll then - return acl.game.stashStowAll(item) - end - if g_game and g_game.stashStowAll then - return g_game.stashStowAll(item) - end -end - -function ClientService.openStash() - local acl = loadACL() - if acl and acl.game and acl.game.openStash then - return acl.game.openStash() - end - if g_game and g_game.openStash then - return g_game.openStash() - end -end - -function ClientService.requestStashSearch(itemId) - local acl = loadACL() - if acl and acl.game and acl.game.requestStashSearch then - return acl.game.requestStashSearch(itemId) - end - if g_game and g_game.requestStashSearch then - return g_game.requestStashSearch(itemId) - end -end - --------------------------------------------------------------------------------- --- QUICK LOOT OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.sendQuickLoot(pos) - local acl = loadACL() - if acl and acl.game and acl.game.sendQuickLoot then - return acl.game.sendQuickLoot(pos) - end - if g_game and g_game.sendQuickLoot then - return g_game.sendQuickLoot(pos) - end -end - -function ClientService.quickLootCorpse(tile) - local acl = loadACL() - if acl and acl.game and acl.game.quickLootCorpse then - return acl.game.quickLootCorpse(tile) - end - if g_game and g_game.quickLootCorpse then - return g_game.quickLootCorpse(tile) - end -end - -function ClientService.setQuickLootFallback(enabled) - local acl = loadACL() - if acl and acl.game and acl.game.setQuickLootFallback then - return acl.game.setQuickLootFallback(enabled) - end - if g_game and g_game.setQuickLootFallback then - return g_game.setQuickLootFallback(enabled) - end -end - --------------------------------------------------------------------------------- --- IMBUEMENT OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.imbuementDurations() - local acl = loadACL() - if acl and acl.game and acl.game.imbuementDurations then - return acl.game.imbuementDurations() - end - if g_game and g_game.imbuementDurations then - return g_game.imbuementDurations() - end - return {} -end - -function ClientService.applyImbuement(slotId, imbuementId, usedProtection) - local acl = loadACL() - if acl and acl.game and acl.game.applyImbuement then - return acl.game.applyImbuement(slotId, imbuementId, usedProtection) - end - if g_game and g_game.applyImbuement then - return g_game.applyImbuement(slotId, imbuementId, usedProtection or false) - end -end - -function ClientService.clearImbuement(slotId) - local acl = loadACL() - if acl and acl.game and acl.game.clearImbuement then - return acl.game.clearImbuement(slotId) - end - if g_game and g_game.clearImbuement then - return g_game.clearImbuement(slotId) - end -end - -function ClientService.requestImbuingWindow(item) - local acl = loadACL() - if acl and acl.game and acl.game.requestImbuingWindow then - return acl.game.requestImbuingWindow(item) - end - if g_game and g_game.requestImbuingWindow then - return g_game.requestImbuingWindow(item) - end -end - -function ClientService.closeImbuingWindow() - local acl = loadACL() - if acl and acl.game and acl.game.closeImbuingWindow then - return acl.game.closeImbuingWindow() - end - if g_game and g_game.closeImbuingWindow then - return g_game.closeImbuingWindow() - end -end - --------------------------------------------------------------------------------- --- PREY OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.preyAction(slotId, actionType, bonusType, monsterIndex) - local acl = loadACL() - if acl and acl.game and acl.game.preyAction then - return acl.game.preyAction(slotId, actionType, bonusType, monsterIndex) - end - if g_game and g_game.preyAction then - return g_game.preyAction(slotId, actionType, bonusType or 0, monsterIndex or 0) - end -end - -function ClientService.requestPreyData() - local acl = loadACL() - if acl and acl.game and acl.game.requestPreyData then - return acl.game.requestPreyData() - end - if g_game and g_game.requestPreyData then - return g_game.requestPreyData() - end -end - -function ClientService.selectPreyCreature(slotId, creatureIndex) - local acl = loadACL() - if acl and acl.game and acl.game.selectPreyCreature then - return acl.game.selectPreyCreature(slotId, creatureIndex) - end - if g_game and g_game.selectPreyCreature then - return g_game.selectPreyCreature(slotId, creatureIndex) - end -end - -function ClientService.refreshPreyMonsters(slotId) - local acl = loadACL() - if acl and acl.game and acl.game.refreshPreyMonsters then - return acl.game.refreshPreyMonsters(slotId) - end - if g_game and g_game.refreshPreyMonsters then - return g_game.refreshPreyMonsters(slotId) - end -end - --------------------------------------------------------------------------------- --- FORGE OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.forgeRequest(action, ...) - local acl = loadACL() - if acl and acl.game and acl.game.forgeRequest then - return acl.game.forgeRequest(action, ...) - end - if g_game and g_game.forgeRequest then - return g_game.forgeRequest(action, ...) - end -end - -function ClientService.forgeFuse(firstItem, secondItem, usedCore) - local acl = loadACL() - if acl and acl.game and acl.game.forgeFuse then - return acl.game.forgeFuse(firstItem, secondItem, usedCore) - end - if g_game and g_game.forgeFuse then - return g_game.forgeFuse(firstItem, secondItem, usedCore or false) - end -end - -function ClientService.forgeTransfer(donorItem, receiverItem, usedCore) - local acl = loadACL() - if acl and acl.game and acl.game.forgeTransfer then - return acl.game.forgeTransfer(donorItem, receiverItem, usedCore) - end - if g_game and g_game.forgeTransfer then - return g_game.forgeTransfer(donorItem, receiverItem, usedCore or false) - end -end - -function ClientService.openForge() - local acl = loadACL() - if acl and acl.game and acl.game.openForge then - return acl.game.openForge() - end - if g_game and g_game.openForge then - return g_game.openForge() - end -end - --------------------------------------------------------------------------------- --- MARKET OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.browseMarket(category, vocation) - local acl = loadACL() - if acl and acl.game and acl.game.browseMarket then - return acl.game.browseMarket(category, vocation) - end - if g_game and g_game.browseMarket then - return g_game.browseMarket(category or 0, vocation or 0) - end -end - -function ClientService.createMarketOffer(offerType, itemId, amount, price, anonymous) - local acl = loadACL() - if acl and acl.game and acl.game.createMarketOffer then - return acl.game.createMarketOffer(offerType, itemId, amount, price, anonymous) - end - if g_game and g_game.createMarketOffer then - return g_game.createMarketOffer(offerType, itemId, amount, price, anonymous or false) - end -end - -function ClientService.cancelMarketOffer(offerId) - local acl = loadACL() - if acl and acl.game and acl.game.cancelMarketOffer then - return acl.game.cancelMarketOffer(offerId) - end - if g_game and g_game.cancelMarketOffer then - return g_game.cancelMarketOffer(offerId) - end -end - -function ClientService.acceptMarketOffer(offerId, amount) - local acl = loadACL() - if acl and acl.game and acl.game.acceptMarketOffer then - return acl.game.acceptMarketOffer(offerId, amount) - end - if g_game and g_game.acceptMarketOffer then - return g_game.acceptMarketOffer(offerId, amount) - end -end - -function ClientService.requestMarketInfo(itemId) - local acl = loadACL() - if acl and acl.game and acl.game.requestMarketInfo then - return acl.game.requestMarketInfo(itemId) - end - if g_game and g_game.requestMarketInfo then - return g_game.requestMarketInfo(itemId) - end -end - --------------------------------------------------------------------------------- --- MODAL DIALOG OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.answerModalDialog(dialogId, buttonId, choiceId) - local acl = loadACL() - if acl and acl.game and acl.game.answerModalDialog then - return acl.game.answerModalDialog(dialogId, buttonId, choiceId) - end - if g_game and g_game.answerModalDialog then - return g_game.answerModalDialog(dialogId, buttonId, choiceId or 0) - end -end - --------------------------------------------------------------------------------- --- BROWSE/INSPECTION OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.browseField(pos) - local acl = loadACL() - if acl and acl.game and acl.game.browseField then - return acl.game.browseField(pos) - end - if g_game and g_game.browseField then - return g_game.browseField(pos) - end -end - -function ClientService.inspectionNormalObject(thing) - local acl = loadACL() - if acl and acl.game and acl.game.inspectionNormalObject then - return acl.game.inspectionNormalObject(thing) - end - if g_game and g_game.inspectionNormalObject then - return g_game.inspectionNormalObject(thing) - end -end - -function ClientService.inspectionObject(inspectionType, id, count) - local acl = loadACL() - if acl and acl.game and acl.game.inspectionObject then - return acl.game.inspectionObject(inspectionType, id, count) - end - if g_game and g_game.inspectionObject then - return g_game.inspectionObject(inspectionType, id, count or 1) - end -end - --------------------------------------------------------------------------------- --- CONTAINER OPERATIONS (Enhanced) --------------------------------------------------------------------------------- - -function ClientService.refreshContainer(container) - local acl = loadACL() - if acl and acl.game and acl.game.refreshContainer then - return acl.game.refreshContainer(container) - end - if g_game and g_game.refreshContainer then - return g_game.refreshContainer(container) - end -end - -function ClientService.requestContainerQueue() - local acl = loadACL() - if acl and acl.game and acl.game.requestContainerQueue then - return acl.game.requestContainerQueue() - end - if g_game and g_game.requestContainerQueue then - return g_game.requestContainerQueue() - end -end - -function ClientService.openContainerAt(thing, pos) - local acl = loadACL() - if acl and acl.game and acl.game.openContainerAt then - return acl.game.openContainerAt(thing, pos) - end - if g_game and g_game.openContainerAt then - return g_game.openContainerAt(thing, pos) - end -end - --------------------------------------------------------------------------------- --- NPC TRADE OPERATIONS (Enhanced) --------------------------------------------------------------------------------- - -function ClientService.buyItem(item, amount, ignoreCapacity, buyWithBackpack) - local acl = loadACL() - if acl and acl.game and acl.game.buyItem then - return acl.game.buyItem(item, amount, ignoreCapacity, buyWithBackpack) - end - if g_game and g_game.buyItem then - return g_game.buyItem(item, amount or 1, ignoreCapacity or false, buyWithBackpack or false) - end -end - -function ClientService.sellItem(item, amount, ignoreEquipped) - local acl = loadACL() - if acl and acl.game and acl.game.sellItem then - return acl.game.sellItem(item, amount, ignoreEquipped) - end - if g_game and g_game.sellItem then - return g_game.sellItem(item, amount or 1, ignoreEquipped or false) - end -end - -function ClientService.requestNPCTrade(creature) - local acl = loadACL() - if acl and acl.game and acl.game.requestNPCTrade then - return acl.game.requestNPCTrade(creature) - end - if g_game and g_game.requestNPCTrade then - return g_game.requestNPCTrade(creature) - end -end - -function ClientService.closeNPCTrade() - local acl = loadACL() - if acl and acl.game and acl.game.closeNPCTrade then - return acl.game.closeNPCTrade() - end - if g_game and g_game.closeNPCTrade then - return g_game.closeNPCTrade() - end -end - --------------------------------------------------------------------------------- --- BLESSINGS --------------------------------------------------------------------------------- - -function ClientService.requestBless() - local acl = loadACL() - if acl and acl.game and acl.game.requestBless then - return acl.game.requestBless() - end - if g_game and g_game.requestBless then - return g_game.requestBless() - end -end - --------------------------------------------------------------------------------- --- OUTFIT OPERATIONS (Enhanced) --------------------------------------------------------------------------------- - -function ClientService.requestOutfitChange() - local acl = loadACL() - if acl and acl.game and acl.game.requestOutfitChange then - return acl.game.requestOutfitChange() - end - if g_game and g_game.requestOutfitChange then - return g_game.requestOutfitChange() - end -end - -function ClientService.mountCreature(mount) - local acl = loadACL() - if acl and acl.game and acl.game.mountCreature then - return acl.game.mountCreature(mount) - end - if g_game and g_game.mountCreature then - return g_game.mountCreature(mount) - end -end - -function ClientService.requestMounts() - local acl = loadACL() - if acl and acl.game and acl.game.requestMounts then - return acl.game.requestMounts() - end - if g_game and g_game.requestMounts then - return g_game.requestMounts() - end -end - --------------------------------------------------------------------------------- --- CYCLOPEDIA OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.requestCyclopediaMapData(pos, zoomLevel) - local acl = loadACL() - if acl and acl.game and acl.game.requestCyclopediaMapData then - return acl.game.requestCyclopediaMapData(pos, zoomLevel) - end - if g_game and g_game.requestCyclopediaMapData then - return g_game.requestCyclopediaMapData(pos, zoomLevel or 0) - end -end - -function ClientService.requestCharacterInfo(type) - local acl = loadACL() - if acl and acl.game and acl.game.requestCharacterInfo then - return acl.game.requestCharacterInfo(type) - end - if g_game and g_game.requestCharacterInfo then - return g_game.requestCharacterInfo(type or 0) - end -end - --------------------------------------------------------------------------------- --- PARTY OPERATIONS (Enhanced) --------------------------------------------------------------------------------- - -function ClientService.requestPartySharedExperience(enable) - local acl = loadACL() - if acl and acl.game and acl.game.requestPartySharedExperience then - return acl.game.requestPartySharedExperience(enable) - end - if g_game and g_game.requestPartySharedExperience then - return g_game.requestPartySharedExperience(enable) - end -end - -function ClientService.passPartyLeadership(creature) - local acl = loadACL() - if acl and acl.game and acl.game.passPartyLeadership then - return acl.game.passPartyLeadership(creature) - end - if g_game and g_game.passPartyLeadership then - return g_game.passPartyLeadership(creature) - end -end - --------------------------------------------------------------------------------- --- ENHANCED MAP OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.findEveryPath(startPos, destinations, maxSteps, flags) - local acl = loadACL() - if acl and acl.map and acl.map.findEveryPath then - return acl.map.findEveryPath(startPos, destinations, maxSteps, flags) - end - if g_map and g_map.findEveryPath then - return g_map.findEveryPath(startPos, destinations, maxSteps or 50, flags or 0) - end - return {} -end - -function ClientService.getSpectatorsInRangeEx(pos, multifloor, minRangeX, maxRangeX, minRangeY, maxRangeY) - local acl = loadACL() - if acl and acl.map and acl.map.getSpectatorsInRangeEx then - return acl.map.getSpectatorsInRangeEx(pos, multifloor, minRangeX, maxRangeX, minRangeY, maxRangeY) - end - if g_map and g_map.getSpectatorsInRangeEx then - return g_map.getSpectatorsInRangeEx(pos, multifloor, minRangeX, maxRangeX, minRangeY, maxRangeY) or {} - end - return ClientService.getSpectators(pos, multifloor) -end - -function ClientService.getSightSpectators(pos, multifloor) - local acl = loadACL() - if acl and acl.map and acl.map.getSightSpectators then - return acl.map.getSightSpectators(pos, multifloor) - end - if g_map and g_map.getSightSpectators then - return g_map.getSightSpectators(pos, multifloor) or {} - end - return ClientService.getSpectators(pos, multifloor) -end - -function ClientService.getSpectatorsByPattern(pos, pattern, width, height, firstFloor, lastFloor) - local acl = loadACL() - if acl and acl.map and acl.map.getSpectatorsByPattern then - return acl.map.getSpectatorsByPattern(pos, pattern, width, height, firstFloor, lastFloor) - end - if g_map and g_map.getSpectatorsByPattern then - return g_map.getSpectatorsByPattern(pos, pattern, width, height, firstFloor, lastFloor) or {} - end - return {} -end - -function ClientService.getCreatureById(creatureId) - local acl = loadACL() - if acl and acl.map and acl.map.getCreatureById then - return acl.map.getCreatureById(creatureId) - end - if g_map and g_map.getCreatureById then - return g_map.getCreatureById(creatureId) - end - return nil -end - -function ClientService.isAwareOfPosition(pos) - local acl = loadACL() - if acl and acl.map and acl.map.isAwareOfPosition then - return acl.map.isAwareOfPosition(pos) - end - if g_map and g_map.isAwareOfPosition then - return g_map.isAwareOfPosition(pos) - end - return false -end - -function ClientService.findItemsById(itemId, multifloor) - local acl = loadACL() - if acl and acl.map and acl.map.findItemsById then - return acl.map.findItemsById(itemId, multifloor) - end - if g_map and g_map.findItemsById then - return g_map.findItemsById(itemId, multifloor or false) or {} - end - return {} -end - -function ClientService.getTilesInRange(pos, rangeX, rangeY, multifloor) - local acl = loadACL() - if acl and acl.map and acl.map.getTilesInRange then - return acl.map.getTilesInRange(pos, rangeX, rangeY, multifloor) - end - if g_map and g_map.getTilesInRange then - return g_map.getTilesInRange(pos, rangeX, rangeY, multifloor or false) or {} - end - -- Fallback implementation - local tiles = {} - for x = pos.x - rangeX, pos.x + rangeX do - for y = pos.y - rangeY, pos.y + rangeY do - local tile = ClientService.getTile({x = x, y = y, z = pos.z}) - if tile then - table.insert(tiles, tile) - end - end - end - return tiles -end - -function ClientService.cleanTile(pos) - local acl = loadACL() - if acl and acl.map and acl.map.cleanTile then - return acl.map.cleanTile(pos) - end - if g_map and g_map.cleanTile then - return g_map.cleanTile(pos) - end -end - -function ClientService.setMinimapColor(pos, color, description) - local acl = loadACL() - if acl and acl.map and acl.map.setMinimapColor then - return acl.map.setMinimapColor(pos, color, description) - end - if g_map and g_map.setMinimapColor then - return g_map.setMinimapColor(pos, color, description) - end -end - --------------------------------------------------------------------------------- --- BESTIARY OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.requestBestiary() - local acl = loadACL() - if acl and acl.bestiary and acl.bestiary.request then - return acl.bestiary.request() - end - if g_game and g_game.requestBestiary then - return g_game.requestBestiary() - end -end - -function ClientService.requestBestiaryOverview(raceName) - local acl = loadACL() - if acl and acl.bestiary and acl.bestiary.requestOverview then - return acl.bestiary.requestOverview(raceName) - end - if g_game and g_game.requestBestiaryOverview then - return g_game.requestBestiaryOverview(raceName) - end -end - -function ClientService.requestBestiarySearch(text) - local acl = loadACL() - if acl and acl.bestiary and acl.bestiary.search then - return acl.bestiary.search(text) - end - if g_game and g_game.requestBestiarySearch then - return g_game.requestBestiarySearch(text) - end -end - --------------------------------------------------------------------------------- --- BOSSTIARY OPERATIONS --------------------------------------------------------------------------------- - -function ClientService.requestBosstiaryInfo() - local acl = loadACL() - if acl and acl.bosstiary and acl.bosstiary.requestInfo then - return acl.bosstiary.requestInfo() - end - if g_game and g_game.requestBosstiaryInfo then - return g_game.requestBosstiaryInfo() - end -end - -function ClientService.requestBossSlootInfo() - local acl = loadACL() - if acl and acl.bosstiary and acl.bosstiary.requestSlotInfo then - return acl.bosstiary.requestSlotInfo() - end - if g_game and g_game.requestBossSlootInfo then - return g_game.requestBossSlootInfo() - end -end - --------------------------------------------------------------------------------- --- ADDITIONAL CALLBACKS FOR OPENTIBIABR --------------------------------------------------------------------------------- - -function ClientService.onImbuementWindow(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onImbuementWindow then - return acl.callbacks.onImbuementWindow(callback) - end - if onImbuementWindow then - return onImbuementWindow(callback) - end -end - -function ClientService.onForgeResult(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onForgeResult then - return acl.callbacks.onForgeResult(callback) - end - if onForgeResult then - return onForgeResult(callback) - end -end - -function ClientService.onPreyData(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onPreyData then - return acl.callbacks.onPreyData(callback) - end - if onPreyData then - return onPreyData(callback) - end -end - -function ClientService.onMarketBrowse(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onMarketBrowse then - return acl.callbacks.onMarketBrowse(callback) - end - if onMarketBrowse then - return onMarketBrowse(callback) - end -end - -function ClientService.onMarketOffer(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onMarketOffer then - return acl.callbacks.onMarketOffer(callback) - end - if onMarketOffer then - return onMarketOffer(callback) - end -end - -function ClientService.onStashAction(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onStashAction then - return acl.callbacks.onStashAction(callback) - end - if onStashAction then - return onStashAction(callback) - end -end - -function ClientService.onBestiaryData(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onBestiaryData then - return acl.callbacks.onBestiaryData(callback) - end - if onBestiaryData then - return onBestiaryData(callback) - end -end - -function ClientService.onModalDialog(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onModalDialog then - return acl.callbacks.onModalDialog(callback) - end - if onModalDialog then - return onModalDialog(callback) - end -end - -function ClientService.onAttackingCreatureChange(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onAttackingCreatureChange then - return acl.callbacks.onAttackingCreatureChange(callback) - end - if onAttackingCreatureChange then - return onAttackingCreatureChange(callback) - end -end - -function ClientService.onInventoryChange(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onInventoryChange then - return acl.callbacks.onInventoryChange(callback) - end - if onInventoryChange then - return onInventoryChange(callback) - end -end - -function ClientService.onManaChange(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onManaChange then - return acl.callbacks.onManaChange(callback) - end - if onManaChange then - return onManaChange(callback) - end -end - -function ClientService.onStatesChange(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onStatesChange then - return acl.callbacks.onStatesChange(callback) - end - if onStatesChange then - return onStatesChange(callback) - end -end - -function ClientService.onWalk(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onWalk then - return acl.callbacks.onWalk(callback) - end - if onWalk then - return onWalk(callback) - end -end - -function ClientService.onAddThing(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onAddThing then - return acl.callbacks.onAddThing(callback) - end - if onAddThing then - return onAddThing(callback) - end -end - -function ClientService.onRemoveThing(callback) - local acl = loadACL() - if acl and acl.callbacks and acl.callbacks.onRemoveThing then - return acl.callbacks.onRemoveThing(callback) - end - if onRemoveThing then - return onRemoveThing(callback) - end -end - --- Make it globally accessible (use rawset to avoid errors if _G doesn't exist) -if rawset then - pcall(function() rawset(_G, 'ClientService', ClientService) end) -end - --- Also export as global in bot environment ClientService = ClientService --- Global helper function for easy access (DRY pattern) --- This replaces the duplicated local getClient() in each file -function getClient() - return ClientService -end - --- Make getClient globally accessible too -if rawset then - pcall(function() rawset(_G, 'getClient', getClient) end) -end +function getClient() return ClientService end return ClientService diff --git a/core/combo.lua b/core/combo.lua index 8ecbb0b..090b454 100644 --- a/core/combo.lua +++ b/core/combo.lua @@ -1,6 +1,8 @@ setDefaultTab("Main") +local zChanging = nExBot.zChanging or function() return false end local SafeCall = SafeCall or require("core.safe_call") local panelName = "combobot" + local ui = setupUI([[ Panel height: 19 @@ -34,29 +36,32 @@ if not storage[panelName] then followLeaderEnabled = false, attackLeaderTargetEnabled = false, attackSpellEnabled = false, - attackItemToggle = false, + attackItemEnabled = false, sayLeader = "", shootLeader = "", castLeader = "", sayPhrase = "", spell = "", - serverLeader = "", item = 3155, attack = "", follow = "", commandsEnabled = true, - serverEnabled = false, - serverLeaderTarget = false, - serverTriggers = true } end local config = storage[panelName] +local function canUseAttackItem() + return config.attackItemEnabled and config.item and config.item > 100 and findItem and findItem(config.item) +end + +local leaderTarget = nil +local startCombo = false + ui.title:setOn(config.enabled) ui.title.onClick = function(widget) -config.enabled = not config.enabled -widget:setOn(config.enabled) + config.enabled = not config.enabled + widget:setOn(config.enabled) end ui.combos.onClick = function(widget) @@ -70,46 +75,21 @@ if rootWidget then comboWindow = UI.createWindow('ComboWindow', rootWidget) comboWindow:hide() - -- bot item - comboWindow.actions.attackItem:setItemId(config.item) comboWindow.actions.attackItem.onItemChange = function(widget) config.item = widget:getItemId() end - -- switches - comboWindow.actions.commandsToggle:setOn(config.commandsEnabled) comboWindow.actions.commandsToggle.onClick = function(widget) config.commandsEnabled = not config.commandsEnabled widget:setOn(config.commandsEnabled) end - comboWindow.server.botServerToggle:setOn(config.serverEnabled) - comboWindow.server.botServerToggle.onClick = function(widget) - config.serverEnabled = not config.serverEnabled - widget:setOn(config.serverEnabled) - end - - comboWindow.server.Triggers:setOn(config.serverTriggers) - comboWindow.server.Triggers.onClick = function(widget) - config.serverTriggers = not config.serverTriggers - widget:setOn(config.serverTriggers) - end - - comboWindow.server.targetServerLeaderToggle:setOn(config.serverLeaderTarget) - comboWindow.server.targetServerLeaderToggle.onClick = function(widget) - config.serverLeaderTarget = not config.serverLeaderTarget - widget:setOn(config.serverLeaderTarget) - end - - -- buttons comboWindow.closeButton.onClick = function(widget) comboWindow:hide() end - -- combo boxes - comboWindow.actions.followLeader:setOption(config.follow) comboWindow.actions.followLeader.onOptionChange = function(widget) config.follow = widget:getCurrentOption().text @@ -118,9 +98,13 @@ if rootWidget then comboWindow.actions.attackLeaderTarget:setOption(config.attack) comboWindow.actions.attackLeaderTarget.onOptionChange = function(widget) config.attack = widget:getCurrentOption().text + -- Auto-enable attack when LEADER TARGET is selected + if config.attack == "LEADER TARGET" then + config.attackLeaderTargetEnabled = true + comboWindow.actions.attackLeaderTargetToggle:setChecked(true) + end end - -- checkboxes comboWindow.trigger.onSayToggle:setChecked(config.onSayEnabled) comboWindow.trigger.onSayToggle.onClick = function(widget) config.onSayEnabled = not config.onSayEnabled @@ -137,38 +121,37 @@ if rootWidget then comboWindow.trigger.onCastToggle.onClick = function(widget) config.onCastEnabled = not config.onCastEnabled widget:setChecked(config.onCastEnabled) - end + end comboWindow.actions.followLeaderToggle:setChecked(config.followLeaderEnabled) comboWindow.actions.followLeaderToggle.onClick = function(widget) config.followLeaderEnabled = not config.followLeaderEnabled widget:setChecked(config.followLeaderEnabled) end - + comboWindow.actions.attackLeaderTargetToggle:setChecked(config.attackLeaderTargetEnabled) comboWindow.actions.attackLeaderTargetToggle.onClick = function(widget) config.attackLeaderTargetEnabled = not config.attackLeaderTargetEnabled widget:setChecked(config.attackLeaderTargetEnabled) - end - + end + comboWindow.actions.attackSpellToggle:setChecked(config.attackSpellEnabled) comboWindow.actions.attackSpellToggle.onClick = function(widget) config.attackSpellEnabled = not config.attackSpellEnabled widget:setChecked(config.attackSpellEnabled) end - + comboWindow.actions.attackItemToggle:setChecked(config.attackItemEnabled) comboWindow.actions.attackItemToggle.onClick = function(widget) config.attackItemEnabled = not config.attackItemEnabled widget:setChecked(config.attackItemEnabled) end - - -- text edits + comboWindow.trigger.onSayLeader:setText(config.sayLeader) comboWindow.trigger.onSayLeader.onTextChange = function(widget, text) config.sayLeader = text end - + comboWindow.trigger.onShootLeader:setText(config.shootLeader) comboWindow.trigger.onShootLeader.onTextChange = function(widget, text) config.shootLeader = text @@ -183,268 +166,197 @@ if rootWidget then comboWindow.trigger.onSayPhrase.onTextChange = function(widget, text) config.sayPhrase = text end - + comboWindow.actions.attackSpell:setText(config.spell) comboWindow.actions.attackSpell.onTextChange = function(widget, text) config.spell = text end - - comboWindow.server.botServerLeader:setText(config.serverLeader) - comboWindow.server.botServerLeader.onTextChange = function(widget, text) - config.serverLeader = text - end -end - --- bot server --- [[ join party made by Frosty ]] -- - -local shouldCloseWindow = false -local firstInvitee = true -local isInComboTeam = false - --- Party window close handler (100ms) -local function partyWindowHandler() - if shouldCloseWindow and config.serverEnabled and config.enabled then - local channelsWindow = modules.game_console.channelsWindow - if channelsWindow then - local child = channelsWindow:getChildById("buttonCancel") - if child then - child:onClick() - shouldCloseWindow = false - isInComboTeam = true - end - end - end end --- Use UnifiedTick if available (reduces macro overhead) -if UnifiedTick and UnifiedTick.register then - UnifiedTick.register("combo_party_window", { - interval = 100, - priority = UnifiedTick.Priority and UnifiedTick.Priority.LOW or 25, - handler = partyWindowHandler, - group = "combo" - }) -else - -- Fallback to traditional macro - macro(100, partyWindowHandler) -end +onTalk(function(name, level, mode, text, channelId, pos) + if not config.enabled then return end -comboWindow.server.partyButton.onClick = function(widget) - if config.serverEnabled and config.enabled then - if config.serverLeader:len() > 0 and storage.BotServerChannel:len() > 0 then - talkPrivate(config.serverLeader, "request invite " .. storage.BotServerChannel) - else - error("Request failed. Lack of data.") - end + if name:lower() == config.sayLeader:lower() and config.sayPhrase and string.find(text, config.sayPhrase) and config.onSayEnabled then + startCombo = true end -end - -onTextMessage(function(mode, text) - if config.serverEnabled and config.enabled then - if mode == 20 then - if string.find(text, "invited you to") then - local regex = "[a-zA-Z]*" - local regexData = regexMatch(text, regex) - if regexData[1][1]:lower() == config.serverLeader:lower() then - local leader = SafeCall.getCreatureByName(regexData[1][1]) - if leader then - g_game.partyJoin(leader:getId()) - g_game.requestChannels() - g_game.joinChannel(1) - shouldCloseWindow = true - end - end - end - end + if config.castLeader and name:lower() == config.castLeader:lower() and isAttSpell and isAttSpell(text) and config.onCastEnabled then + startCombo = true end -end) -onTalk(function(name, level, mode, text, channelId, pos) - if config.serverEnabled and config.enabled then - if mode == 4 then - if string.find(text, "request invite") then - local access = string.match(text, "%d.*") - if access and access == storage.BotServerChannel then - local minion = SafeCall.getCreatureByName(name) - if minion then - g_game.partyInvite(minion:getId()) - if firstInvitee then - g_game.requestChannels() - g_game.joinChannel(1) - shouldCloseWindow = true - firstInvitee = false - end + if config.commandsEnabled then + local isLeader = (config.shootLeader and name:lower() == config.shootLeader:lower()) + or (config.sayLeader and name:lower() == config.sayLeader:lower()) + or (config.castLeader and name:lower() == config.castLeader:lower()) + if isLeader then + local textLower = text:lower() + if textLower == "ue" then + say(config.spell) + elseif textLower == "sd" then + local params = string.split(text, ",") + if #params == 2 then + local target = params[2]:trim() + local creature = SafeCall.getCreatureByName(target) + if creature and useWith then + useWith(config.item, creature) end - else - talkPrivate(name, "Incorrect access key!") end - end - end - end - -- [[ End of Frosty's Code ]] -- - if config.enabled and config.enabled then - if name:lower() == config.sayLeader:lower() and string.find(text, config.sayPhrase) and config.onSayEnabled then - startCombo = true - end - if (config.castLeader and name:lower() == config.castLeader:lower()) and isAttSpell and isAttSpell(text) and config.onCastEnabled then - startCombo = true - end - end - if config.enabled and config.commandsEnabled and (config.shootLeader and name:lower() == config.shootLeader:lower()) or (config.sayLeader and name:lower() == config.sayLeader:lower()) or (config.castLeader and name:lower() == config.castLeader:lower()) then - if string.find(text, "ue") then - say(config.spell) - elseif string.find(text, "sd") then - local params = string.split(text, ",") - if #params == 2 then - local target = params[2]:trim() - local creature = SafeCall.getCreatureByName(target) - if creature then - if useWith then useWith(3155, creature) end - end - end - elseif string.find(text, "att") then - local attParams = string.split(text, ",") - if #attParams == 2 then - local atTarget = attParams[2]:trim() - local creature = SafeCall.getCreatureByName(atTarget) - if creature and config.attack == "COMMAND TARGET" then - g_game.attack(creature) + elseif textLower == "att" then + local attParams = string.split(text, ",") + if #attParams == 2 then + local atTarget = attParams[2]:trim() + local creature = SafeCall.getCreatureByName(atTarget) + if creature and config.attack == "COMMAND TARGET" and AttackStateMachine and AttackStateMachine.requestAttack then + AttackStateMachine.requestAttack(creature, 1000) + end end end end end - if isAttSpell and isAttSpell(text) and config.enabled and config.serverEnabled then - BotServer.send("trigger", "start") + + if isAttSpell and isAttSpell(text) and config.enabled and isLeader and config.onCastEnabled then + EventBus.emit("combo:trigger") end end) onMissle(function(missle) if zChanging() then return end - if config.enabled and config.onShootEnabled then - if not config.shootLeader or config.shootLeader:len() == 0 then - return - end - local src = missle:getSource() - if src.z ~= posz() then - return - end - local from = g_map.getTile(src) - local to = g_map.getTile(missle:getDestination()) - if not from or not to then - return - end - local fromCreatures = from:getCreatures() - local toCreatures = to:getCreatures() - if #fromCreatures ~= 1 or #toCreatures ~= 1 then - return + if not config.enabled or not config.onShootEnabled then return end + if not config.shootLeader or config.shootLeader:len() == 0 then return end + + local src = missle:getSource() + if src.z ~= posz() then return end + + local from = g_map.getTile(src) + local to = g_map.getTile(missle:getDestination()) + if not from or not to then return end + + local fromCreatures = from:getCreatures() + local toCreatures = to:getCreatures() + if #fromCreatures == 0 or #toCreatures == 0 then return end + + -- Find the leader among creatures on the source tile + local leader = nil + for _, c in ipairs(fromCreatures) do + if c:getName():lower() == config.shootLeader:lower() then + leader = c + break end - local c1 = fromCreatures[1] - local t1 = toCreatures[1] - leaderTarget = t1 - if c1:getName():lower() == config.shootLeader:lower() then - if config.attackItemEnabled and config.item and config.item > 100 and findItem and findItem(config.item) then - if useWith then useWith(config.item, t1) end - end - if config.attackSpellEnabled and config.spell:len() > 1 then - say(config.spell) - end + end + if not leader then return end + + -- Pick the target: prefer the first non-leader, non-local creature on destination tile + local player = g_game.getLocalPlayer() + local t1 = nil + for _, c in ipairs(toCreatures) do + if c ~= leader and (not player or c ~= player) then + t1 = c + break end end + if not t1 then return end + + leaderTarget = t1 + if canUseAttackItem() and useWith then + useWith(config.item, t1) + end + if config.attackSpellEnabled and config.spell and config.spell:len() > 1 then + say(config.spell) + end + if config.attack == "LEADER TARGET" and AttackStateMachine and AttackStateMachine.requestAttack then + AttackStateMachine.requestAttack(leaderTarget, 1000) + end end) --- Leader target attack handler (100ms) local function leaderTargetHandler() - if not config.enabled or not config.attackLeaderTargetEnabled then return end - if leaderTarget and config.attack == "LEADER TARGET" then - local target = SafeCall.getTarget() - if not target or target:getName() ~= leaderTarget:getName() then - g_game.attack(leaderTarget) - end - end - if config.enabled and config.serverEnabled and config.attack == "SERVER LEADER TARGET" and serverTarget then - local target = SafeCall.getTarget() - if serverTarget and not target or (target and target:getName() ~= serverTarget) then - g_game.attack(serverTarget) + if not config.enabled then return end + if not leaderTarget or config.attack ~= "LEADER TARGET" then return end + + -- Clear stale target (creature left screen or died) + if not leaderTarget then return end + if not leaderTarget.getPosition then leaderTarget = nil; return end + local ltPos = leaderTarget:getPosition() + if not ltPos then leaderTarget = nil; return end + + local target = SafeCall.getTarget() + if not target or target:getName() ~= leaderTarget:getName() then + if AttackStateMachine and AttackStateMachine.requestAttack then + AttackStateMachine.requestAttack(leaderTarget, 1000) end end end - -local toFollow +local toFollow = nil local toFollowPos = {} +local lastFollowPos = nil local lastFollowWalk = 0 local FOLLOW_WALK_COOLDOWN = 100 --- Follow leader handler (100ms) local function followLeaderHandler() - toFollow = nil - if not config.enabled or not config.followLeaderEnabled then return end - if leaderTarget and config.follow == "LEADER TARGET" and leaderTarget:isPlayer() then + if not config.enabled or not config.followLeaderEnabled then + toFollow = nil + return + end + toFollow = nil -- Clear before evaluating rules + + if config.follow == "LEADER TARGET" and leaderTarget and leaderTarget:isPlayer() then toFollow = leaderTarget:getName() - elseif config.follow == "SERVER LEADER TARGET" and config.serverLeader:len() ~= 0 then - toFollow = serverTarget - elseif config.follow == "SERVER LEADER" and config.serverLeader:len() ~= 0 then - toFollow = config.serverLeader elseif config.follow == "LEADER" then - if config.onSayEnabled and config.sayLeader:len() ~= 0 then + if config.onSayEnabled and config.sayLeader and config.sayLeader:len() ~= 0 then toFollow = config.sayLeader - elseif config.onCastEnabled and config.castLeader:len() ~= 0 then + elseif config.onCastEnabled and config.castLeader and config.castLeader:len() ~= 0 then toFollow = config.castLeader - elseif config.onShootEnabled and config.shootLeader:len() ~= 0 then + elseif config.onShootEnabled and config.shootLeader and config.shootLeader:len() ~= 0 then toFollow = config.shootLeader end end + if not toFollow then return end + local target = SafeCall.getCreatureByName(toFollow) if target then local tpos = target:getPosition() toFollowPos[tpos.z] = tpos end + if player:isWalking() then return end local p = toFollowPos[posz()] if not p then return end - - -- Non-blocking cooldown check + + local posKey = p.x .. "," .. p.y .. "," .. p.z + if posKey == lastFollowPos then return end if (now - lastFollowWalk) < FOLLOW_WALK_COOLDOWN then return end - + if CaveBot.walkTo(p, 20, {ignoreNonPathable=true, precision=1, ignoreStairs=false}) then lastFollowWalk = now + lastFollowPos = posKey end end onCreaturePositionChange(function(creature, oldPos, newPos) if zChanging() then return end - if creature:getName() == toFollow and newPos then + if toFollow and creature:getName() == toFollow and newPos then toFollowPos[newPos.z] = newPos + lastFollowPos = nil end end) -local timeout = now - --- Combo trigger handler (100ms) local function comboTriggerHandler() if config.enabled and startCombo then - if config.attackItemEnabled and config.item and config.item > 100 and findItem and findItem(config.item) then + if canUseAttackItem() and useWith then local target = SafeCall.getTarget() - if useWith and target then useWith(config.item, target) end + if target then useWith(config.item, target) end end - if config.attackSpellEnabled and config.spell:len() > 1 then + if config.attackSpellEnabled and config.spell and config.spell:len() > 1 then say(config.spell) end startCombo = false end - -- attack part / server - if BotServer._websocket and config.enabled and config.serverEnabled then - if target and target() and now - timeout > 500 then - targetPos = target():getName() - BotServer.send("target", targetPos) - timeout = now - end - end end --- Use UnifiedTick if available (reduces macro overhead) +EventBus.on("combo:trigger", function() + startCombo = true +end) + if UnifiedTick and UnifiedTick.register then UnifiedTick.register("combo_leader_target", { interval = 100, @@ -452,14 +364,14 @@ if UnifiedTick and UnifiedTick.register then handler = leaderTargetHandler, group = "combo" }) - + UnifiedTick.register("combo_follow_leader", { interval = 100, priority = UnifiedTick.Priority and UnifiedTick.Priority.NORMAL or 50, handler = followLeaderHandler, group = "combo" }) - + UnifiedTick.register("combo_trigger", { interval = 100, priority = UnifiedTick.Priority and UnifiedTick.Priority.HIGH or 75, @@ -467,39 +379,7 @@ if UnifiedTick and UnifiedTick.register then group = "combo" }) else - -- Fallback to traditional macros macro(100, leaderTargetHandler) macro(100, followLeaderHandler) macro(100, comboTriggerHandler) end - -onUseWith(function(pos, itemId, target, subType) - if BotServer._websocket and itemId == 3155 then - BotServer.send("useWith", target:getPosition()) - end -end) - -if BotServer._websocket and config.enabled and config.serverEnabled then - BotServer.listen("trigger", function(name, message) - if message == "start" and name:lower() ~= player:getName():lower() and name:lower() == config.serverLeader:lower() and config.serverTriggers then - startCombo = true - end - end) - BotServer.listen("target", function(name, message) - if name:lower() ~= player:getName():lower() and name:lower() == config.serverLeader:lower() then - local msgCreature = SafeCall.getCreatureByName(message) - if not (target and target()) or (target and target():getName() == msgCreature) then - if config.serverLeaderTarget then - serverTarget = msgCreature - if g_game and g_game.attack then g_game.attack(msgCreature) end - end - end - end - end) - BotServer.listen("useWith", function(name, message) - local tile = g_map.getTile(message) - if config.serverTriggers and name:lower() ~= player:getName():lower() and name:lower() == config.serverLeader:lower() and config.attackItemEnabled and config.item and findItem and findItem(config.item) then - if useWith then useWith(config.item, tile:getTopUseThing()) end - end - end) -end \ No newline at end of file diff --git a/core/combo.otui b/core/combo.otui index 6c8c882..fc4d6ab 100644 --- a/core/combo.otui +++ b/core/combo.otui @@ -10,9 +10,7 @@ FollowComboBoxPopupMenuButton < ComboBoxPopupMenuButton FollowComboBox < ComboBox @onSetup: | self:addOption("LEADER TARGET") - self:addOption("SERVER LEADER TARGET") self:addOption("LEADER") - self:addOption("SERVER LEADER") ComboTrigger < Panel id: trigger @@ -246,77 +244,9 @@ ComboActions < Panel text-wrap: true multiline: true -BotServer < Panel - id: server - image-source: /images/ui/panel_flat - image-border: 6 - padding: 3 - size: 220 100 - - Label - id: labelX - anchors.left: parent.left - anchors.top: parent.top - text: Leader: - height: 15 - color: #ffaa00 - margin-left: 3 - margin-top: 5 - - TextEdit - id: botServerLeader - anchors.left: prev.right - anchors.top: prev.top - anchors.right: parent.right - margin-right: 3 - margin-left: 9 - height: 15 - font: cipsoftFont - - Button - id: partyButton - anchors.left: labelX.left - anchors.top: botServerLeader.bottom - margin-top: 5 - height: 30 - text: Join Party - text-wrap: true - multiline: true - - BotSwitch - id: botServerToggle - anchors.left: prev.right - anchors.top: botServerLeader.bottom - anchors.right: parent.right - height: 30 - margin-left: 3 - margin-right: 3 - margin-top: 5 - text: Server Enabled - - BotSwitch - id: targetServerLeaderToggle - anchors.left: partyButton.left - anchors.top: partyButton.bottom - anchors.right: partyButton.right - margin-top: 3 - height: 30 - text: Leader Targets - - BotSwitch - id: Triggers - anchors.left: prev.right - anchors.top: partyButton.bottom - anchors.right: parent.right - margin-top: 3 - height: 30 - margin-left: 3 - margin-right: 3 - text: Triggers - ComboWindow < MainWindow !text: tr('Combo Options') - size: 500 280 + size: 480 280 @onEscape: self:hide() ComboTrigger @@ -333,7 +263,7 @@ ComboWindow < MainWindow text: Combo Trigger color: #ff7700 - ComboActions + ComboActions id: actions anchors.top: trigger.bottom anchors.left: trigger.left @@ -346,21 +276,6 @@ ComboWindow < MainWindow margin-left: 10 margin-top: 85 text: Combo Actions - color: #ff7700 - - BotServer - id: server - anchors.top: actions.top - anchors.left: actions.right - margin-left: 10 - - Label - id: title - anchors.top: parent.top - anchors.left: server.left - margin-left: 3 - margin-top: 85 - text: BotServer color: #ff7700 HorizontalSeparator @@ -368,7 +283,7 @@ ComboWindow < MainWindow anchors.right: parent.right anchors.left: parent.left anchors.bottom: closeButton.top - margin-bottom: 8 + margin-bottom: 8 Button id: closeButton diff --git a/core/configs.lua b/core/configs.lua index 015e6e5..f836126 100644 --- a/core/configs.lua +++ b/core/configs.lua @@ -53,24 +53,16 @@ local function loadCharacterProfiles() end loadCharacterProfiles() --- Save character profiles mapping -function saveCharacterProfiles() +local function saveCharacterProfiles() local status, result = pcall(function() - return json.encode(CharacterProfiles, 2) + return json.encode(CharacterProfiles) end) if status then g_resources.writeFileContents(charProfileFile, result) end end --- Get current character name (with safety check) -function getCharacterName() - -- Try global player first (OTClient bot framework provides this) - if player and player.getName then - local status, name = pcall(function() return player:getName() end) - if status and name then return name end - end - -- Fallback to g_game.getLocalPlayer() +local function getCharacterName() local localPlayer = g_game.getLocalPlayer() return localPlayer and localPlayer:getName() or nil end diff --git a/core/container_opener.lua b/core/container_opener.lua deleted file mode 100644 index 33575b9..0000000 --- a/core/container_opener.lua +++ /dev/null @@ -1,260 +0,0 @@ ---[[ - ═══════════════════════════════════════════════════════════════════════════ - CONTAINER OPENER v11.0 - Passive Helper Module - - This is a PASSIVE helper that does NOT automatically open containers. - It only provides utility functions for the looting system to use ON-DEMAND. - - The Container Panel (Containers.lua) handles all automatic container opening. - This module ONLY provides helper functions - NO macros, NO event handlers. - - Functions: - - ContainerOpener.getAllOpenContainers() - Get all open containers - - ContainerOpener.getOpenContainersByItemId(itemId) - Get containers by type - - ContainerOpener.findNestedContainer(itemId) - Find closed nested container - - ContainerOpener.openItemAsNewWindow(item) - Open item in new window - - ContainerOpener.countOpenContainers() - Count open container windows - ═══════════════════════════════════════════════════════════════════════════ -]] - --- ═══════════════════════════════════════════════════════════════════════════ --- SAFE UTILITIES --- ═══════════════════════════════════════════════════════════════════════════ - -local function safeCall(fn) - local ok, result = pcall(fn) - return ok and result or nil -end - -local function getItemId(item) - return item and safeCall(function() return item:getId() end) -end - -local function isContainerItem(item) - return item and safeCall(function() return item:isContainer() end) == true -end - -local function getContainerWindowId(container) - return container and safeCall(function() return container:getId() end) -end - -local function getContainerItemId(container) - if not container then return nil end - local cItem = safeCall(function() return container:getContainerItem() end) - return cItem and getItemId(cItem) -end - -local function getContainerItems(container) - return container and safeCall(function() return container:getItems() end) or {} -end - --- ═══════════════════════════════════════════════════════════════════════════ --- PUBLIC API --- ═══════════════════════════════════════════════════════════════════════════ - -local ContainerOpener = {} - --- Get count of open containers -function ContainerOpener.countOpenContainers() - local containers = safeCall(function() return g_game.getContainers() end) or {} - local count = 0 - for _ in pairs(containers) do count = count + 1 end - return count -end - --- Get all open containers as array -function ContainerOpener.getAllOpenContainers() - local containers = safeCall(function() return g_game.getContainers() end) or {} - local result = {} - for _, container in pairs(containers) do - table.insert(result, container) - end - return result -end - --- Get open containers matching specific item ID(s) -function ContainerOpener.getOpenContainersByItemId(itemIds) - if type(itemIds) ~= "table" then - itemIds = { itemIds } - end - - local lookup = {} - for _, id in ipairs(itemIds) do lookup[id] = true end - - local containers = safeCall(function() return g_game.getContainers() end) or {} - local result = {} - - for _, container in pairs(containers) do - local itemId = getContainerItemId(container) - if itemId and lookup[itemId] then - table.insert(result, container) - end - end - - return result -end - --- Find a nested container of specified type that is NOT yet open --- Returns: item, parentContainer, slot (or nil if not found) -function ContainerOpener.findNestedContainer(itemId) - local containers = safeCall(function() return g_game.getContainers() end) or {} - - -- Build set of itemIds that are currently open as windows - local openItemIds = {} - for _, container in pairs(containers) do - local id = getContainerItemId(container) - if id then - openItemIds[id] = (openItemIds[id] or 0) + 1 - end - end - - -- Search for nested container - for _, container in pairs(containers) do - local items = getContainerItems(container) - for slot, item in ipairs(items) do - if isContainerItem(item) then - local id = getItemId(item) - if id == itemId or (not itemId and id) then - -- Found a container, return it - return item, container, slot - end - end - end - end - - return nil -end - --- Open an item in a NEW container window --- IMPORTANT: This will toggle if already open! Only call if you're sure it's closed -function ContainerOpener.openItemAsNewWindow(item) - if not item then return false end - if not isContainerItem(item) then return false end - - return safeCall(function() - g_game.open(item, nil) - return true - end) == true -end - --- Get all containers that have space for looting -function ContainerOpener.getContainersWithSpace(itemIds) - local containers = ContainerOpener.getOpenContainersByItemId(itemIds) - local result = {} - - for _, container in ipairs(containers) do - local capacity = safeCall(function() return container:getCapacity() end) or 0 - local count = safeCall(function() return container:getItemsCount() end) or 0 - local hasPages = safeCall(function() return container:hasPages() end) - - if hasPages or count < capacity then - table.insert(result, container) - end - end - - return result -end - --- Check if there's at least one open container with space -function ContainerOpener.hasLootSpace(itemIds) - local containers = ContainerOpener.getContainersWithSpace(itemIds) - return #containers > 0 -end - --- Get inventory container that isn't open yet -function ContainerOpener.getClosedInventoryContainer(slot) - slot = slot or InventorySlotBack - local item = safeCall(function() return getInventoryItem(slot) end) - if not item or not isContainerItem(item) then return nil end - - local itemId = getItemId(item) - if not itemId then return nil end - - -- Check if already open - local containers = safeCall(function() return g_game.getContainers() end) or {} - for _, container in pairs(containers) do - local openItemId = getContainerItemId(container) - if openItemId == itemId then - return nil -- Already open - end - end - - return item -end - --- ═══════════════════════════════════════════════════════════════════════════ --- OPENTIBIABR ENHANCED OPERATIONS --- Uses ClientService for cross-client compatibility --- ═══════════════════════════════════════════════════════════════════════════ - --- Refresh a container's contents (OpenTibiaBR feature) --- Useful when container state may be stale -function ContainerOpener.refreshContainer(container) - local Client = ClientService or getClient and getClient() - if Client and Client.refreshContainer then - return Client.refreshContainer(container) - end - -- Fallback: re-fetch from g_game - if container and container.getId then - local id = container:getId() - return safeCall(function() return g_game.getContainer(id) end) - end - return container -end - --- Request container queue sync (OpenTibiaBR feature) --- Forces server to re-sync container state -function ContainerOpener.requestContainerQueue() - local Client = ClientService or getClient and getClient() - if Client and Client.requestContainerQueue then - return Client.requestContainerQueue() - end -end - --- Open container at specific position (OpenTibiaBR feature) --- More precise than g_game.open for ground items -function ContainerOpener.openContainerAtPosition(item, pos) - local Client = ClientService or getClient and getClient() - if Client and Client.openContainerAt then - return Client.openContainerAt(item, pos) - end - -- Fallback to standard open - return safeCall(function() - g_game.open(item, nil) - return true - end) -end - --- Check if using OpenTibiaBR client (has enhanced container APIs) -function ContainerOpener.hasEnhancedAPIs() - local Client = ClientService or getClient and getClient() - if Client and Client.isOpenTibiaBR then - return Client.isOpenTibiaBR() - end - return false -end - --- Get all containers with forced refresh (for accuracy) -function ContainerOpener.getAllContainersRefreshed() - -- Request queue sync first if available - ContainerOpener.requestContainerQueue() - - local containers = safeCall(function() return g_game.getContainers() end) or {} - local result = {} - - for _, container in pairs(containers) do - -- Refresh each container for accurate state - local refreshed = ContainerOpener.refreshContainer(container) or container - table.insert(result, refreshed) - end - - return result -end - --- ═══════════════════════════════════════════════════════════════════════════ --- EXPORT --- ═══════════════════════════════════════════════════════════════════════════ - -nExBot = nExBot or {} -nExBot.ContainerOpener = ContainerOpener -ContainerOpener = ContainerOpener diff --git a/core/creature_cache.lua b/core/creature_cache.lua index bfa0e30..c6fe96f 100644 --- a/core/creature_cache.lua +++ b/core/creature_cache.lua @@ -1,724 +1,13 @@ --[[ - Unified Creature Cache - Single Source for All Creature Lookups - - Consolidates three separate caching systems: - - SpectatorCache (utils/spectator_cache.lua) - - CreatureCache (targetbot/target.lua) - - MonsterCache (targetbot/creature_position.lua) - - ARCHITECTURE: - - Single cache with category views (monsters, players, npcs) - - Event-driven invalidation (no polling) - - LRU eviction with configurable max size - - Object pooling integration for reduced GC pressure - - Weak table support for automatic memory cleanup (Phase 5) - - PERFORMANCE: - - O(1) lookups by creature ID - - Lazy category building (only when requested) - - Automatic cleanup of removed/dead creatures - - USAGE: - local CreatureCache = dofile("core/creature_cache.lua") - local monsters = CreatureCache.getMonsters() - local creature = CreatureCache.getById(creatureId) - local nearest = CreatureCache.getNearestMonster(pos, maxRange) + CreatureCache compatibility shim + Redirects to BotCore.Creatures (merged in Phase 3) ]] +CreatureCache = CreatureCache or {} -local CreatureCache = {} - --- ============================================================================ --- CONFIGURATION --- ============================================================================ - -CreatureCache.CONFIG = { - MAX_SIZE = 100, -- Maximum creatures to cache - SPECTATOR_RANGE_X = 14, -- Default spectator range X - SPECTATOR_RANGE_Y = 11, -- Default spectator range Y - CACHE_TTL = 200, -- Base cache TTL (ms) - CLEANUP_INTERVAL = 2000, -- Cleanup interval (ms) - ENABLE_POOLING = true, -- Use object pooling for entries - USE_WEAK_REFS = true -- Use weak references for creature objects -} - --- ============================================================================ --- INTERNAL STATE --- ============================================================================ - --- Use WeakCache if available for automatic GC cleanup -local WC = WeakCache - -local cache = { - -- Main creature storage: id -> entry (uses weak values for creature refs) - creatures = (WC and WC.createWeakValues) and WC.createWeakValues() or {}, - - -- Category caches (lazily built) - monsters = nil, - players = nil, - npcs = nil, - - -- Metadata - lastUpdate = 0, - lastCleanup = 0, - categoryDirty = true, - - -- LRU tracking (O(1) doubly-linked list) - lruHead = {}, -- sentinel: lruHead.next = LRU (oldest) - lruTail = {}, -- sentinel: lruTail.prev = MRU (newest) - lruNodes = {}, -- id -> DLL node {id, prev, next} - lruSize = 0, - - -- Stats - stats = { - hits = 0, - misses = 0, - evictions = 0, - cleanups = 0 - } -} - --- Initialize DLL sentinels -cache.lruHead.next = cache.lruTail -cache.lruTail.prev = cache.lruHead - --- Time helper (use ClientHelper for DRY) -local nowMs = ClientHelper and ClientHelper.nowMs or function() - if now then return now end - if g_clock and g_clock.millis then return g_clock.millis() end - return os.time() * 1000 -end - --- ============================================================================ --- CLIENT HELPERS --- ============================================================================ - -local getClient = nExBot.Shared.getClient - -local function getLocalPlayer() - local Client = getClient() - if Client and Client.getLocalPlayer then - return Client.getLocalPlayer() - elseif g_game and g_game.getLocalPlayer then - return g_game.getLocalPlayer() - end - return nil -end - -local function getPlayerPosition() - local player = getLocalPlayer() - if not player then return nil end - local ok, pos = pcall(function() return player:getPosition() end) - return ok and pos or nil -end - -local function getSpectatorsInRange(pos, rangeX, rangeY) - if not pos then return {} end - rangeX = rangeX or CreatureCache.CONFIG.SPECTATOR_RANGE_X - rangeY = rangeY or CreatureCache.CONFIG.SPECTATOR_RANGE_Y - - local Client = getClient() - if Client and Client.getSpectatorsInRange then - return Client.getSpectatorsInRange(pos, false, rangeX, rangeY) or {} - elseif g_map and g_map.getSpectatorsInRange then - return g_map.getSpectatorsInRange(pos, false, rangeX, rangeY) or {} - end - return {} -end - --- ============================================================================ --- CREATURE VALIDATION (Delegates to SafeCreature) --- ============================================================================ - -local SC = SafeCreature or {} - -local function safeGetId(creature) - if SC and SC.getId then - local ok, result = pcall(SC.getId, creature) - return ok and result or nil - end - return nil -end - -local function safeIsMonster(creature) - if SC and SC.isMonster then - local ok, result = pcall(SC.isMonster, creature) - return ok and result or false - end - return false -end - -local function safeIsPlayer(creature) - if SC and SC.isPlayer then - local ok, result = pcall(SC.isPlayer, creature) - return ok and result or false - end - return false -end - -local function safeIsNpc(creature) - if SC and SC.isNpc then - local ok, result = pcall(SC.isNpc, creature) - return ok and result or false - end - return false -end - -local function safeIsDead(creature) - if SC and SC.isDead then - local ok, result = pcall(SC.isDead, creature) - return ok and result or true - end - return true -end - -local function safeIsRemoved(creature) - if SC and SC.isRemoved then - local ok, result = pcall(SC.isRemoved, creature) - return ok and result or true - end - return true -end - -local function safeGetPosition(creature) - if SC and SC.getPosition then - local ok, result = pcall(SC.getPosition, creature) - return ok and result or nil - end - return nil -end - -local function safeGetName(creature) - if SC and SC.getName then - local ok, result = pcall(SC.getName, creature) - return ok and result or nil - end - return nil -end - -local function safeGetHealthPercent(creature) - if SC and SC.getHealthPercent then - local ok, result = pcall(SC.getHealthPercent, creature) - return ok and result or 100 - end - return 100 -end - --- Check if creature is valid and alive -local function isValidCreature(creature) - if not creature then return false end - local id = safeGetId(creature) - if not id then return false end - return not safeIsDead(creature) and not safeIsRemoved(creature) -end - --- ============================================================================ --- LRU MANAGEMENT --- ============================================================================ - -local function touchLRU(id) - local node = cache.lruNodes[id] - if node then - -- Detach from current position - node.prev.next = node.next - node.next.prev = node.prev - else - -- New node - node = { id = id } - cache.lruNodes[id] = node - cache.lruSize = cache.lruSize + 1 - end - -- Insert before tail (MRU position) - local prev = cache.lruTail.prev - prev.next = node - node.prev = prev - node.next = cache.lruTail - cache.lruTail.prev = node -end - -local function removeLRU(id) - local node = cache.lruNodes[id] - if node then - node.prev.next = node.next - node.next.prev = node.prev - cache.lruNodes[id] = nil - cache.lruSize = cache.lruSize - 1 - end -end - -local function evictLRU() - local node = cache.lruHead.next - if node == cache.lruTail then return end - - local evictId = node.id - -- Detach from DLL - node.prev.next = node.next - node.next.prev = node.prev - cache.lruNodes[evictId] = nil - cache.lruSize = cache.lruSize - 1 - - -- Release entry to pool if configured - local entry = cache.creatures[evictId] - if entry and CreatureCache.CONFIG.ENABLE_POOLING and nExBot and nExBot.releaseTable then - nExBot.releaseTable("creatureCacheEntry", entry) - end - - cache.creatures[evictId] = nil - cache.categoryDirty = true - cache.stats.evictions = cache.stats.evictions + 1 -end - --- ============================================================================ --- CACHE OPERATIONS --- ============================================================================ - ---[[ - Add or update a creature in cache - @param creature Creature object - @return entry table or nil -]] -function CreatureCache.set(creature) - if not isValidCreature(creature) then return nil end - - local id = safeGetId(creature) - if not id then return nil end - - local nowt = nowMs() - - -- Get or create entry - local entry = cache.creatures[id] - if not entry then - -- Check capacity - if cache.lruSize >= CreatureCache.CONFIG.MAX_SIZE then - evictLRU() - end - - -- Create new entry (use pool if available) - if CreatureCache.CONFIG.ENABLE_POOLING and nExBot and nExBot.acquireTable then - entry = nExBot.acquireTable("creatureCacheEntry") - else - entry = {} - end - - cache.creatures[id] = entry - cache.categoryDirty = true - end - - -- Update entry - entry.id = id - entry.creature = creature - entry.name = safeGetName(creature) - entry.position = safeGetPosition(creature) - entry.healthPercent = safeGetHealthPercent(creature) - entry.isMonster = safeIsMonster(creature) - entry.isPlayer = safeIsPlayer(creature) - entry.isNpc = safeIsNpc(creature) - entry.lastUpdate = nowt - - -- Touch LRU - touchLRU(id) - - return entry -end - ---[[ - Get creature by ID - @param id number Creature ID - @return entry table or nil -]] -function CreatureCache.getById(id) - local entry = cache.creatures[id] - if not entry then - cache.stats.misses = cache.stats.misses + 1 - return nil - end - - -- Validate creature is still valid - if not isValidCreature(entry.creature) then - CreatureCache.remove(id) - cache.stats.misses = cache.stats.misses + 1 - return nil - end - - cache.stats.hits = cache.stats.hits + 1 - touchLRU(id) - return entry -end - ---[[ - Get creature object by ID - @param id number Creature ID - @return Creature or nil -]] -function CreatureCache.getCreatureById(id) - local entry = CreatureCache.getById(id) - return entry and entry.creature or nil -end - ---[[ - Remove creature from cache - @param id number Creature ID -]] -function CreatureCache.remove(id) - local entry = cache.creatures[id] - if entry then - -- Release to pool - if CreatureCache.CONFIG.ENABLE_POOLING and nExBot and nExBot.releaseTable then - nExBot.releaseTable("creatureCacheEntry", entry) - end - - cache.creatures[id] = nil - - -- Remove from LRU - removeLRU(id) - - cache.categoryDirty = true - end -end - ---[[ - Clear entire cache -]] -function CreatureCache.clear() - -- Release all entries to pool - if CreatureCache.CONFIG.ENABLE_POOLING and nExBot and nExBot.releaseTable then - for id, entry in pairs(cache.creatures) do - nExBot.releaseTable("creatureCacheEntry", entry) - end - end - - cache.creatures = {} - cache.monsters = nil - cache.players = nil - cache.npcs = nil - cache.lruHead = {} - cache.lruTail = {} - cache.lruHead.next = cache.lruTail - cache.lruTail.prev = cache.lruHead - cache.lruNodes = {} - cache.lruSize = 0 - cache.categoryDirty = true -end - --- ============================================================================ --- CATEGORY VIEWS --- ============================================================================ - --- Rebuild category caches -local function rebuildCategories() - if not cache.categoryDirty then return end - - cache.monsters = {} - cache.players = {} - cache.npcs = {} - - for id, entry in pairs(cache.creatures) do - if entry.isMonster then - cache.monsters[#cache.monsters + 1] = entry - elseif entry.isPlayer then - cache.players[#cache.players + 1] = entry - elseif entry.isNpc then - cache.npcs[#cache.npcs + 1] = entry - end - end - - cache.categoryDirty = false -end - ---[[ - Get all cached monsters - @return array of entries -]] -function CreatureCache.getMonsters() - rebuildCategories() - return cache.monsters or {} -end - ---[[ - Get all cached players - @return array of entries -]] -function CreatureCache.getPlayers() - rebuildCategories() - return cache.players or {} -end - ---[[ - Get all cached NPCs - @return array of entries -]] -function CreatureCache.getNpcs() - rebuildCategories() - return cache.npcs or {} -end - ---[[ - Get monster count - @return number -]] -function CreatureCache.getMonsterCount() - rebuildCategories() - return cache.monsters and #cache.monsters or 0 -end - --- ============================================================================ --- SPECTATOR UPDATE --- ============================================================================ - ---[[ - Update cache with current spectators - @param rangeX number (optional) - @param rangeY number (optional) - @return number creatures updated -]] -function CreatureCache.updateFromSpectators(rangeX, rangeY) - local playerPos = getPlayerPosition() - if not playerPos then return 0 end - - local spectators = getSpectatorsInRange(playerPos, rangeX, rangeY) - if not spectators then return 0 end - - local updated = 0 - for i = 1, #spectators do - local creature = spectators[i] - if CreatureCache.set(creature) then - updated = updated + 1 - end - end - - cache.lastUpdate = nowMs() - return updated -end - ---[[ - Get spectators with caching (replaces SpectatorCache.getNearby) - @param rangeX number - @param rangeY number - @param ttl number Cache TTL (ms) - @return array of creatures -]] -function CreatureCache.getNearby(rangeX, rangeY, ttl) - rangeX = rangeX or CreatureCache.CONFIG.SPECTATOR_RANGE_X - rangeY = rangeY or CreatureCache.CONFIG.SPECTATOR_RANGE_Y - ttl = ttl or CreatureCache.CONFIG.CACHE_TTL - - local nowt = nowMs() - - -- Check if cache is fresh enough - if (nowt - cache.lastUpdate) < ttl then - -- Return cached creatures - local result = {} - for id, entry in pairs(cache.creatures) do - if entry.creature and isValidCreature(entry.creature) then - result[#result + 1] = entry.creature - end - end - cache.stats.hits = cache.stats.hits + 1 - return result - end - - -- Refresh from spectators - cache.stats.misses = cache.stats.misses + 1 - CreatureCache.updateFromSpectators(rangeX, rangeY) - - -- Return creatures - local result = {} - for id, entry in pairs(cache.creatures) do - if entry.creature then - result[#result + 1] = entry.creature - end - end - return result -end - --- ============================================================================ --- SPATIAL QUERIES --- ============================================================================ - ---[[ - Get nearest monster to a position - @param pos Position - @param maxRange number (optional) - @return entry, distance or nil -]] -function CreatureCache.getNearestMonster(pos, maxRange) - if not pos then return nil, nil end - maxRange = maxRange or 50 - - rebuildCategories() - - local nearest = nil - local nearestDist = maxRange + 1 - - for i = 1, #(cache.monsters or {}) do - local entry = cache.monsters[i] - if entry.position then - local dist = math.max( - math.abs(entry.position.x - pos.x), - math.abs(entry.position.y - pos.y) - ) - if dist < nearestDist then - nearestDist = dist - nearest = entry - end - end - end - - return nearest, nearestDist -end - ---[[ - Get monsters within range - @param pos Position - @param range number - @return array of entries -]] -function CreatureCache.getMonstersInRange(pos, range) - if not pos then return {} end - range = range or 10 - - rebuildCategories() - - local result = {} - for i = 1, #(cache.monsters or {}) do - local entry = cache.monsters[i] - if entry.position then - local dist = math.max( - math.abs(entry.position.x - pos.x), - math.abs(entry.position.y - pos.y) - ) - if dist <= range then - result[#result + 1] = entry - end - end - end - - return result -end - ---[[ - Get monsters on same floor - @param z number Floor level - @return array of entries -]] -function CreatureCache.getMonstersOnFloor(z) - rebuildCategories() - - local result = {} - for i = 1, #(cache.monsters or {}) do - local entry = cache.monsters[i] - if entry.position and entry.position.z == z then - result[#result + 1] = entry - end - end - - return result -end - --- ============================================================================ --- CLEANUP --- ============================================================================ - ---[[ - Remove dead and invalid creatures from cache -]] -function CreatureCache.cleanup() - local nowt = nowMs() - local removed = 0 - - for id, entry in pairs(cache.creatures) do - if not isValidCreature(entry.creature) then - CreatureCache.remove(id) - removed = removed + 1 - end - end - - cache.lastCleanup = nowt - cache.stats.cleanups = cache.stats.cleanups + 1 - - return removed -end - --- ============================================================================ --- STATISTICS --- ============================================================================ - ---[[ - Get cache statistics - @return table -]] -function CreatureCache.getStats() - local total = cache.stats.hits + cache.stats.misses - return { - hits = cache.stats.hits, - misses = cache.stats.misses, - evictions = cache.stats.evictions, - cleanups = cache.stats.cleanups, - hitRate = total > 0 and (cache.stats.hits / total) or 0, - size = cache.lruSize, - maxSize = CreatureCache.CONFIG.MAX_SIZE, - monstersCount = cache.monsters and #cache.monsters or 0, - playersCount = cache.players and #cache.players or 0 - } -end - ---[[ - Reset statistics -]] -function CreatureCache.resetStats() - cache.stats.hits = 0 - cache.stats.misses = 0 - cache.stats.evictions = 0 - cache.stats.cleanups = 0 -end - --- ============================================================================ --- EVENTBUS INTEGRATION --- Auto-update cache on creature events --- ============================================================================ - -if EventBus and EventBus.on then - -- Update cache when creature appears - EventBus.on("creature:appear", function(creature) - CreatureCache.set(creature) - end, 10) -- Lower priority - - -- Remove from cache when creature disappears - EventBus.on("creature:disappear", function(creature) - local id = safeGetId(creature) - if id then - CreatureCache.remove(id) - end - end, 10) - - -- Update health when it changes - EventBus.on("creature:health", function(creature, percent) - local id = safeGetId(creature) - if id and cache.creatures[id] then - cache.creatures[id].healthPercent = percent - end - end, 10) - - -- Clear cache on player position change (optional, for strict freshness) - -- EventBus.on("player:move", function() - -- cache.categoryDirty = true - -- end, 5) -end - --- ============================================================================ --- BACKWARDS COMPATIBILITY --- Provide same API as old SpectatorCache --- ============================================================================ - -CreatureCache.SpectatorCompat = { - getNearby = function(rx, ry, ttl) - return CreatureCache.getNearby(rx, ry, ttl) - end, - clear = function() - CreatureCache.clear() - end, - getStats = function() - return CreatureCache.getStats() +setmetatable(CreatureCache, { + __index = function(_, key) + return BotCore and BotCore.Creatures and BotCore.Creatures[key] end -} +}) return CreatureCache diff --git a/core/depot_withdraw.lua b/core/depot_withdraw.lua index 856b901..b0d92ca 100644 --- a/core/depot_withdraw.lua +++ b/core/depot_withdraw.lua @@ -48,7 +48,6 @@ local function depotWithdrawHandler() end end - if playerContainer and freecap() >= 200 then local time = 500 if depotContainer then diff --git a/core/eat_food.lua b/core/eat_food.lua index 0472134..04fde74 100644 --- a/core/eat_food.lua +++ b/core/eat_food.lua @@ -129,7 +129,7 @@ end -- Find food item in all open containers local function findFoodInContainers() - local containers = getContainers and getContainers() or (g_game and g_game.getContainers and g_game.getContainers()) + local containers = nExBot.Shared.getContainers() if not containers then return nil end for _, container in pairs(containers) do diff --git a/core/equip.lua b/core/equip.lua index 098c0ba..20b73e4 100644 --- a/core/equip.lua +++ b/core/equip.lua @@ -6,21 +6,13 @@ local scripts = 2 -- if you want more auto equip panels you can change 2 to high local lastEquipTime = 0 local EQUIP_COOLDOWN = 1000 --- Profile storage helpers -local function getProfileSetting(key) - if ProfileStorage then - return ProfileStorage.get(key) - end - return storage[key] -end - -local function setProfileSetting(key, value) - if ProfileStorage then - ProfileStorage.set(key, value) - else - storage[key] = value - end +local SharedHelpers = nExBot.SharedHelpers +if not SharedHelpers then + warn("[equip] SharedHelpers not loaded") + return end +local getProfileSetting = SharedHelpers.getProfileSetting +local setProfileSetting = SharedHelpers.setProfileSetting -- script by kondrah, don't edit below unless you know what you are doing UI.Label("Auto equip") diff --git a/core/event_bus.lua b/core/event_bus.lua index 2329f84..bae6a6f 100644 --- a/core/event_bus.lua +++ b/core/event_bus.lua @@ -46,6 +46,7 @@ local _zGen = 0 -- generation token: prevents stale safety-valve from clearing function zChanging() return _zBlocked end +nExBot.zChanging = zChanging -- Activate z-change block (idempotent) local function _zActivate() @@ -176,18 +177,6 @@ function EventBus.on(event, callback, priority) end end --- Subscribe to an event (one-time only) --- @param event string: Event name --- @param callback function: Handler function -function EventBus.once(event, callback) - local unsubscribe - unsubscribe = EventBus.on(event, function(...) - unsubscribe() - callback(...) - end) - return unsubscribe -end - -- Emit an event to all subscribers -- @param event string: Event name -- @param ... any: Arguments to pass to handlers @@ -250,47 +239,26 @@ function EventBus.flush() processing = false end --- Remove all listeners for an event --- @param event string: Event name (optional, clears all if nil) -function EventBus.clear(event) - if event then - listeners[event] = nil - else - listeners = {} - end -end - --- Get listener count for debugging --- @param event string: Event name (optional) --- @return number: Listener count -function EventBus.listenerCount(event) - if event then - return listeners[event] and #listeners[event] or 0 - end - - local total = 0 - for _, handlers in pairs(listeners) do - total = total + #handlers - end - return total -end - -- Get number of queued events currently waiting to be processed -function EventBus.queueSize() - return math.max(0, eventQueue.tail - eventQueue.head + 1) -end - --------------------------------------------------------------------------------- -- OTClient Native Event Registration -- Register once, dispatch through EventBus --------------------------------------------------------------------------------- -- Creature events +-- Throttle monster:appear events — 10 subscribers, expensive per-monster allocation +local _monsterAppearThrottle = {} +local MONSTER_APPEAR_THROTTLE_MS = 300 + if onCreatureAppear then onCreatureAppear(function(creature) if _zBurst() then return end if creature:isMonster() then - EventBus.emit("monster:appear", creature) + local cId = nil + pcall(function() cId = creature:getId() end) + local nowMs3 = now or (g_clock and g_clock.millis and g_clock.millis()) or 0 + if not cId or not _monsterAppearThrottle[cId] or (nowMs3 - _monsterAppearThrottle[cId]) >= MONSTER_APPEAR_THROTTLE_MS then + if cId then _monsterAppearThrottle[cId] = nowMs3 end + EventBus.emit("monster:appear", creature) + end elseif creature:isPlayer() then EventBus.emit("player:appear", creature) elseif creature:isNpc() then @@ -316,16 +284,18 @@ end local creatureHealthCache = {} setmetatable(creatureHealthCache, { __mode = "k" }) -- Weak keys for auto-cleanup +-- Throttle monster:health events — too many subscribers, too frequent +local _monsterHealthThrottle = {} -- { [creatureId] = lastEmitTime } +local MONSTER_HEALTH_THROTTLE_MS = 150 -- 150ms between emits per creature + -- Track killed monsters with their positions for corpse access local killedMonsters = {} -- { [creatureId] = { pos, name, timestamp } } local KILLED_MONSTER_EXPIRY_MS = 15000 -- 15 seconds -- Public accessor for killed monsters list -function EventBus.getKilledMonsters() - return killedMonsters -end - --- Clean up old killed monster entries +-- Clean up old killed monster entries + throttle tables +local _cleanupCounter = 0 +local _creatureMoveLastEmit = {} local function cleanupKilledMonsters() local nowMs = now or (g_clock and g_clock.millis and g_clock.millis()) or 0 for id, data in pairs(killedMonsters) do @@ -333,6 +303,20 @@ local function cleanupKilledMonsters() killedMonsters[id] = nil end end + -- Prune throttle tables every ~10 calls (every ~5s at 500ms interval) + _cleanupCounter = _cleanupCounter + 1 + if _cleanupCounter >= 10 then + _cleanupCounter = 0 + for id, t in pairs(_monsterHealthThrottle) do + if (nowMs - t) > 5000 then _monsterHealthThrottle[id] = nil end + end + for id, t in pairs(_monsterAppearThrottle) do + if (nowMs - t) > 5000 then _monsterAppearThrottle[id] = nil end + end + for id, t in pairs(_creatureMoveLastEmit) do + if (nowMs - t) > 5000 then _creatureMoveLastEmit[id] = nil end + end + end end if onCreatureHealthPercentChange then @@ -341,15 +325,24 @@ if onCreatureHealthPercentChange then -- Get cached old HP (default to 100 if not tracked) local oldPercent = creatureHealthCache[creature] or 100 creatureHealthCache[creature] = percent - - -- Emit with both old and new values for proper change detection + + local nowMs2 = now or (g_clock and g_clock.millis and g_clock.millis()) or 0 + + -- Always emit creature:health (used by creature_cache, exeta, friend_healer — lightweight) EventBus.emit("creature:health", creature, percent, oldPercent) - + if creature:isMonster() then - EventBus.emit("monster:health", creature, percent, oldPercent) - + -- Throttle monster:health — 9 subscribers, expensive work + local cId = nil + pcall(function() cId = creature:getId() end) + local isKill = percent <= 0 and oldPercent > 0 + if isKill or not cId or not _monsterHealthThrottle[cId] or (nowMs2 - _monsterHealthThrottle[cId]) >= MONSTER_HEALTH_THROTTLE_MS then + if cId then _monsterHealthThrottle[cId] = nowMs2 end + EventBus.emit("monster:health", creature, percent, oldPercent) + end + -- MONSTER KILLED: Detect when health drops to 0 - if percent <= 0 and oldPercent > 0 then + if isKill then local creatureId = nil local creatureName = nil local creaturePos = nil @@ -387,6 +380,8 @@ if onCreatureHealthPercentChange then end -- Player events +local _playerMoveLastEmit = 0 +local PLAYER_MOVE_THROTTLE_MS = 80 -- Don't emit more than 12x/sec if onPlayerPositionChange then onPlayerPositionChange(function(newPos, oldPos) if newPos and oldPos and newPos.z ~= oldPos.z then @@ -396,7 +391,11 @@ if onPlayerPositionChange then EventBus.emit("player:z_change_settled", newPos, oldPos) end) end - EventBus.emit("player:move", newPos, oldPos) + local nowMs4 = now or (g_clock and g_clock.millis and g_clock.millis()) or 0 + if (nowMs4 - _playerMoveLastEmit) >= PLAYER_MOVE_THROTTLE_MS then + _playerMoveLastEmit = nowMs4 + EventBus.emit("player:move", newPos, oldPos) + end end) end @@ -431,9 +430,11 @@ local function attributeDamageSource(damage) -- Cache spectator list for 200ms to avoid repeated API calls local nowt = now or (g_clock and g_clock.millis and g_clock.millis()) or (os.time() * 1000) if not _damageAttrCachedCreatures or (nowt - _damageAttrCacheTime) > _damageAttrCacheTTL then - _damageAttrCachedCreatures = (MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby) - and MovementCoordinator.MonsterCache.getNearby(radius) - or g_map.getSpectatorsInRange(playerPos, false, radius, radius) + if BotCore and BotCore.Creatures and BotCore.Creatures.getNearby then + _damageAttrCachedCreatures = BotCore.Creatures.getNearby(radius) or {} + else + _damageAttrCachedCreatures = {} + end _damageAttrCacheTime = nowt end @@ -603,10 +604,8 @@ local function checkEquipmentChanges() end end --- ============================================================================ -- UNIFIED TICK INTEGRATION -- Migrate polling macros to UnifiedTick for consolidated tick management --- ============================================================================ if UnifiedTick and UnifiedTick.register then -- Equipment check handler (200ms, LOW priority - UI updates) @@ -673,10 +672,30 @@ if onGroupSpellCooldown then end -- Creature walk events +local CREATURE_MOVE_THROTTLE_MS = 100 if onWalk then onWalk(function(creature, oldPos, newPos) if _zBlocked then return end EventBus.emit("creature:walk", creature, oldPos, newPos) + -- Throttle creature:move — 14 subscribers, fires every walk step + local cId = nil + pcall(function() cId = creature:getId() end) + local nowMs5 = now or (g_clock and g_clock.millis and g_clock.millis()) or 0 + if not cId or not _creatureMoveLastEmit[cId] or (nowMs5 - _creatureMoveLastEmit[cId]) >= CREATURE_MOVE_THROTTLE_MS then + if cId then _creatureMoveLastEmit[cId] = nowMs5 end + EventBus.emit("creature:move", creature, oldPos) + elseif cId then + _creatureMovePending = _creatureMovePending or {} + _creatureMovePending[cId] = {creature = creature, oldPos = oldPos} + schedule(CREATURE_MOVE_THROTTLE_MS, function() + local pending = _creatureMovePending and _creatureMovePending[cId] + if pending then + _creatureMovePending[cId] = nil + _creatureMoveLastEmit[cId] = now or (os.time() * 1000) + EventBus.emit("creature:move", pending.creature, pending.oldPos) + end + end) + end if creature:isMonster() then EventBus.emit("monster:walk", creature, oldPos, newPos) elseif creature:isPlayer() then diff --git a/core/exeta.lua b/core/exeta.lua index 3353f5e..cdce6da 100644 --- a/core/exeta.lua +++ b/core/exeta.lua @@ -73,21 +73,8 @@ if voc == 1 or voc == 11 then end -- Safe debounce factory - local function makeDebounce(ms, fn) - if nExBot and nExBot.EventUtil and nExBot.EventUtil.debounce then - return nExBot.EventUtil.debounce(ms, fn) - end - local scheduled = false - return function(...) - if scheduled then return end - scheduled = true - local args = {...} - schedule(ms, function() - scheduled = false - pcall(fn, safe_unpack(args)) - end) - end - end + local SharedHelpers = nExBot.SharedHelpers or {} + local makeDebounce = SharedHelpers.makeDebounce local function checkAndCastExeta() if not exetaIfPlayerMacro:isOn() then return end @@ -98,16 +85,11 @@ if voc == 1 or voc == 11 then -- Check for nearby monsters (radius 1) local monstersNearby = 0 - if MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby then - monstersNearby = #MovementCoordinator.MonsterCache.getNearby(1) - else - local p = pos() - local creatures = g_map.getSpectatorsInRange(p, false, 1, 1) - for i = 1, #creatures do - local c = creatures[i] - if c and c:isMonster() and not c:isDead() then - monstersNearby = monstersNearby + 1 - end + local creatures = BotCore.Creatures.getNearby(1, 1) or {} + for i = 1, #creatures do + local c = creatures[i] + if c and c:isMonster() and not c:isDead() then + monstersNearby = monstersNearby + 1 end end @@ -115,8 +97,7 @@ if voc == 1 or voc == 11 then -- Check for nearby players (radius 6) local playersNearby = 0 - local ppos = pos() - local spects = g_map.getSpectatorsInRange(ppos, false, 6, 6) + local spects = BotCore.Creatures.getNearby(6, 6) or {} for i = 1, #spects do local c = spects[i] if c and c:isPlayer() and not c:isLocalPlayer() then @@ -163,16 +144,7 @@ if voc == 1 or voc == 11 then -- ═══════════════════════════════════════════════════════════════════════ -- Direction vectors for facing check - local DIR_VECTORS = { - [0] = {x = 0, y = -1}, -- North - [1] = {x = 1, y = 0}, -- East - [2] = {x = 0, y = 1}, -- South - [3] = {x = -1, y = 0}, -- West - [4] = {x = 1, y = -1}, -- NE - [5] = {x = 1, y = 1}, -- SE - [6] = {x = -1, y = 1}, -- SW - [7] = {x = -1, y = -1}, -- NW - } + local DIR_VECTORS = Directions.DIR_TO_OFFSET -- Helper: Check if a monster is facing the local player (i.e., attacking us) local function isMonsterFacingPlayer(creature) @@ -240,15 +212,7 @@ if voc == 1 or voc == 11 then if (now - lastExetaAmp) < 6000 then return end -- Get nearby monsters from cache or fallback to map scan - local monsters = nil - if MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby then - monsters = MovementCoordinator.MonsterCache.getNearby(7) - else - local ppos = pos() - if ppos then - monsters = g_map.getSpectatorsInRange(ppos, false, 7, 7) - end - end + local monsters = BotCore.Creatures.getNearby(7) or {} if not monsters then return end @@ -323,16 +287,7 @@ if voc == 1 or voc == 11 then -- Fallback: polling-based check for environments without EventBus if not EventBus then -- Direction vectors for facing check (fallback) - local FB_DIR_VECTORS = { - [0] = {x = 0, y = -1}, -- North - [1] = {x = 1, y = 0}, -- East - [2] = {x = 0, y = 1}, -- South - [3] = {x = -1, y = 0}, -- West - [4] = {x = 1, y = -1}, -- NE - [5] = {x = 1, y = 1}, -- SE - [6] = {x = -1, y = 1}, -- SW - [7] = {x = -1, y = -1}, -- NW - } + local DIR_VECTORS = Directions.DIR_TO_OFFSET -- Helper: Check if monster is facing local player (fallback version) local function fbIsMonsterFacingPlayer(creature, playerPos) @@ -343,7 +298,7 @@ if voc == 1 or voc == 11 then local dx = playerPos.x - cpos.x local dy = playerPos.y - cpos.y - local vec = FB_DIR_VECTORS[direction] + local vec = DIR_VECTORS[direction] if not vec then return false end if vec.x == 0 then @@ -369,9 +324,7 @@ if voc == 1 or voc == 11 then local playerPos = player and player:getPosition() if not playerPos then return end - local creatures = (MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby) - and MovementCoordinator.MonsterCache.getNearby(7) - or g_map.getSpectatorsInRange(playerPos, false, 7, 7) + local creatures = BotCore.Creatures.getNearby(7) or {} if not creatures then return end diff --git a/core/extras.lua b/core/extras.lua index 017d967..8587907 100644 --- a/core/extras.lua +++ b/core/extras.lua @@ -1,6 +1,7 @@ setDefaultTab("Main") -- securing storage namespace +local zChanging = nExBot.zChanging or function() return false end local panelName = "extras" if not storage[panelName] then storage[panelName] = {} @@ -177,14 +178,22 @@ if true then -- Window title handler function local function windowTitleHandler() + if settings._titleDisabled then return end + local ok, err if settings.title then if hppercent() > 0 then - g_window.setTitle("Tibia - " .. name() .. " - " .. lvl() .. "lvl " .. vocText) + ok, err = pcall(g_window.setTitle, "Tibia - " .. name() .. " - " .. lvl() .. "lvl " .. vocText) else - g_window.setTitle("Tibia - " .. name() .. " - DEAD") + ok, err = pcall(g_window.setTitle, "Tibia - " .. name() .. " - DEAD") end else - g_window.setTitle("Tibia - " .. name()) + ok, err = pcall(g_window.setTitle, "Tibia - " .. name()) + end + if not ok and err then + warn("[Extras] setTitle failed: " .. tostring(err)) + settings.title = false + + settings._titleDisabled = true end end @@ -264,7 +273,6 @@ if true then end end - addCheckBox("timers", "MW & WG Timers", true, rightPanel, "Show times for Magic Walls and Wild Growths.") if true then local activeTimers = {} @@ -300,7 +308,6 @@ if true then end, 30) end - addCheckBox("antiKick", "Anti - Kick", true, rightPanel, "Turn every 10 minutes to prevent kick.") if true then -- Anti-kick handler function @@ -324,7 +331,6 @@ if true then end end - addCheckBox("stake", "Skin Monsters", false, leftPanel, "Automatically skin & stake corpses when cavebot is enabled") if true then -- Pre-built lookup sets for O(1) body type check @@ -460,7 +466,6 @@ if true then end end - addCheckBox("oberon", "Auto Reply Oberon", true, rightPanel, "Auto reply to Grand Master Oberon talk minigame.") if true then onTalk(function(name, level, mode, text, channelId, pos) @@ -489,7 +494,6 @@ if true then end) end - addCheckBox("autoOpenDoors", "Auto Open Doors", true, rightPanel, "Open doors when trying to step on them.") if true then local doorsIds = { 5007, 8265, 1629, 1632, 5129, 6252, 6249, 7715, 7712, 7714, @@ -537,7 +541,6 @@ if true then end) end - addCheckBox("bless", "Buy bless at login", true, rightPanel, "Say !bless at login.") if true then local blessed = false @@ -564,7 +567,6 @@ if true then end end - addCheckBox("reUse", "Keep Crosshair", false, rightPanel, "Keep crosshair after using with item") if true then local excluded = {268, 237, 238, 23373, 266, 236, 239, 7643, 23375, 7642, 23374, 5908, 5942} @@ -581,7 +583,6 @@ if true then end) end - addCheckBox("suppliesControl", "TargetBot off if low supply", false, leftPanel, "Turn off TargetBot if either one of supply amount is below 50% of minimum.") if true then -- Supplies control handler function diff --git a/core/follow.lua b/core/follow.lua new file mode 100644 index 0000000..c62b504 --- /dev/null +++ b/core/follow.lua @@ -0,0 +1,363 @@ +-- Follow Player — party hunt companion +-- Single responsibility: keep bot near leader. MovementCoordinator handles priority. + +local Follow = {} + +local getClient = nExBot.Shared.getClient +local SC = SafeCreature + +-- Config +local config = { + enabled = false, + playerName = "", + followWhileAttacking = true, + maxDistance = 3, +} + +-- State +local state = { + leaderId = nil, + leaderCreature = nil, + leaderPos = nil, + lastDistance = 0, + lastKnownPos = nil, + lastLostTime = 0, + lastFollowAttempt = 0, +} + +local COOLDOWN = 75 +local LOST_TIMEOUT = 10000 +local PATH_MAX = 25 +local PATH_FLAGS = 1 + 16 + +-- ── Helpers ────────────────────────────────────────────────────────── + +local function nowMs() + return now or (os.time() * 1000) +end + +local targetPathfinding = nExBot.target_pathfinding or dofile("/targetbot/target_pathfinding.lua") + +local function getGameAttackTarget() + local Client = getClient() + if Client and Client.getAttackingCreature then return Client.getAttackingCreature() end + if g_game and g_game.getAttackingCreature then return g_game.getAttackingCreature() end + return nil +end + +local function isAttacking() + local Client = getClient() + if Client and Client.isAttacking then return Client.isAttacking() end + if g_game and g_game.isAttacking then return g_game.isAttacking() end + return false +end + +local function cancelAttack() + local Client = getClient() + if Client and Client.cancelAttack then pcall(Client.cancelAttack) end +end + +local function getFollowingCreature() + local Client = getClient() + if Client and Client.getFollowingCreature then return Client.getFollowingCreature() end + if g_game and g_game.getFollowingCreature then return g_game.getFollowingCreature() end + return nil +end + +local function cancelFollow() + local Client = getClient() + if Client and Client.cancelFollow then pcall(Client.cancelFollow) end +end + +local function startFollow(creature) + if not creature then return false end + local Client = getClient() + local ok = pcall(function() + if Client and Client.follow then Client.follow(creature) + elseif g_game and g_game.follow then g_game.follow(creature) + end + end) + return ok +end + +-- ── Leader lookup (O(1) cached by ID) ──────────────────────────────── + +local function findLeader(name) + if not name or name == "" then return nil end + + -- Re-validate cached creature + if state.leaderId and state.leaderCreature then + local ok, alive = pcall(function() + return not state.leaderCreature:isDead() + end) + if ok and alive then + local pos = SC.getPosition(state.leaderCreature) + if pos then + state.leaderPos = pos + return state.leaderCreature + end + end + state.leaderId = nil + state.leaderCreature = nil + end + + -- Lookup by name + local c = SafeCall.getCreatureByName(name, true) + if c then + local okP, isP = pcall(function() return c:isPlayer() end) + local okL, isL = pcall(function() return c:isLocalPlayer() end) + if okP and isP and (not okL or not isL) then + state.leaderId = SC.getId(c) + state.leaderCreature = c + state.leaderPos = SC.getPosition(c) + return c + end + end + + return nil +end + +-- ── Pathfinding (single path, no fallback chain) ───────────────────── + +local function pathTo(leaderPos) + local lp = ClientService.getLocalPlayer() + if not lp or not leaderPos then return nil end + local pp = SC.getPosition(lp) + if not pp or pp.z ~= leaderPos.z then return nil end + if targetPathfinding.chebyshev(pp, leaderPos) <= 1 then return {} end + + if g_map and g_map.findPath then + local ok, result = pcall(function() + return g_map.findPath(pp, leaderPos, 50, PATH_FLAGS) + end) + if ok and result and #result > 0 and #result < PATH_MAX then + return result + end + end + + if findPath then + local ok, result = pcall(function() + return findPath(pp, leaderPos, 20, { ignoreCreatures = true, ignoreCost = true }) + end) + if ok and result then return result end + end + + return nil +end + +-- ── Movement ───────────────────────────────────────────────────────── + +local function walkStep(dir) + local lp = ClientService.getLocalPlayer() + if not lp or not dir then return false end + if lp.isWalking and lp:isWalking() then return false end + + if g_game and g_game.forceWalk then + local ok = pcall(function() g_game.forceWalk(dir) end) + if ok then return true end + end + if lp.walk then + local ok = pcall(function() lp:walk(dir) end) + if ok then return true end + end + if g_game and g_game.walk then + local ok = pcall(function() g_game.walk(dir) end) + if ok then return true end + end + return false +end + +local function registerFollowIntent(targetPos, confidence) + if not MovementCoordinator or not MovementCoordinator.Intent then return false end + local intentType = MovementCoordinator.CONSTANTS + and MovementCoordinator.CONSTANTS.INTENT + and MovementCoordinator.CONSTANTS.INTENT.FOLLOW + if not intentType then return false end + + MovementCoordinator.Intent.register( + intentType, targetPos, confidence, "party_follow", + { leader = config.playerName } + ) + return true +end + +-- ── Public API ─────────────────────────────────────────────────────── + +function Follow.isEnabled() + return config.enabled +end + +function Follow.getConfig() + return config +end + +function Follow.getState() + return state +end + +function Follow.getLeaderPos() + return state.leaderPos +end + +function Follow.getDistance() + return state.lastDistance +end + +function Follow.isNearLeader() + return state.lastDistance <= config.maxDistance +end + +function Follow.setEnabled(enabled) + config.enabled = enabled + if not enabled then + cancelFollow() + state.leaderId = nil + state.leaderCreature = nil + state.leaderPos = nil + state.lastKnownPos = nil + state.lastLostTime = 0 + end +end + +function Follow.setPlayerName(name) + config.playerName = name + state.leaderId = nil + state.leaderCreature = nil + state.leaderPos = nil +end + +-- ── Main tick (called by macro) ────────────────────────────────────── + +function Follow.tick() + if not config.enabled then return end + + -- Yield to CaveBot when it's walking + if CaveBot and CaveBot.isOn and CaveBot.isOn() and WaypointEngine and WaypointEngine.isWalking and WaypointEngine.isWalking() then + return + end + + local t = nowMs() + if (t - state.lastFollowAttempt) < COOLDOWN then return end + state.lastFollowAttempt = t + + local name = config.playerName and config.playerName:trim() or "" + if name == "" then return end + + local lp = ClientService.getLocalPlayer() + if not lp then return end + local pp = SC.getPosition(lp) + if not pp then return end + + local leader = findLeader(name) + + if leader then + local lpos = SC.getPosition(leader) + if not lpos then return end + state.leaderPos = lpos + state.lastKnownPos = lpos + state.lastLostTime = 0 + state.lastDistance = targetPathfinding.chebyshev(pp, lpos) + + -- Close enough + if state.lastDistance <= 1 then return end + + -- Beyond max distance — must catch up + if state.lastDistance > config.maxDistance then + if isAttacking() and config.followWhileAttacking then + -- Parallel: walk toward leader while attack persists via ASM + local path = pathTo(lpos) + if path and #path > 0 then + registerFollowIntent(lpos, 0.95) + end + else + -- Not attacking or followWhileAttacking off: catch up + -- Don't cancel attack — g_game.attack() persists through movement server-side + registerFollowIntent(lpos, 0.95) + end + return + end + + -- Within range but not following — try native follow + local cur = getFollowingCreature() + local following = cur and SC.getId(cur) == state.leaderId + if not following and not (lp.isWalking and lp:isWalking()) then + startFollow(leader) + end + + else + -- Leader not visible — walk to last known position + if state.lastLostTime == 0 then + state.lastLostTime = t + end + + local cur = getFollowingCreature() + if cur and state.leaderId and SC.getId(cur) == state.leaderId then + state.leaderPos = SC.getPosition(cur) + return + end + + if state.lastKnownPos and (t - state.lastLostTime) < LOST_TIMEOUT then + registerFollowIntent(state.lastKnownPos, 0.80) + end + end +end + +-- ── EventBus listeners ─────────────────────────────────────────────── + +if EventBus then + EventBus.on("creature:move", function(creature, oldPos) + if not config.enabled then return end + if not state.leaderId then return end + local cid = SC.getId(creature) + if cid ~= state.leaderId then return end + + local newPos = SC.getPosition(creature) + if not newPos then return end + state.leaderPos = newPos + state.leaderCreature = creature + + local lp = ClientService.getLocalPlayer() + if not lp then return end + local pp = SC.getPosition(lp) + if not pp then return end + + state.lastDistance = targetPathfinding.chebyshev(pp, newPos) + if state.lastDistance > 2 then + Follow.tick() + end + end, 30) + + EventBus.on("combat:end", function() + if not config.enabled then return end + if not state.leaderId then return end + schedule(50, function() Follow.tick() end) + end, 20) +end + +-- ── Config persistence ─────────────────────────────────────────────── + +function Follow.loadConfig() + if CharacterDB and CharacterDB.isReady and CharacterDB.isReady() then + local saved = CharacterDB.get("tools.followPlayer") + if saved then + config.enabled = saved.enabled or false + config.playerName = saved.playerName or "" + config.followWhileAttacking = saved.followWhileAttacking ~= false + config.maxDistance = saved.maxDistance or 3 + end + end +end + +function Follow.saveConfig() + if CharacterDB and CharacterDB.isReady and CharacterDB.isReady() then + CharacterDB.set("tools.followPlayer", { + enabled = config.enabled, + playerName = config.playerName, + followWhileAttacking = config.followWhileAttacking, + maxDistance = config.maxDistance, + }) + end +end + +nExBot.Follow = Follow +return Follow diff --git a/core/global_config.lua b/core/global_config.lua index 026049a..bf85212 100644 --- a/core/global_config.lua +++ b/core/global_config.lua @@ -47,9 +47,7 @@ local TILE_ACTIONS = { }, } --------------------------------------------------------------------------------- -- Public API --------------------------------------------------------------------------------- -- Get a tool item ID (reads from storage.extras) function GlobalConfig.getTool(toolName) @@ -74,9 +72,7 @@ function GlobalConfig.getToolDelay() return 500 end --------------------------------------------------------------------------------- -- Tile Detection Helpers --------------------------------------------------------------------------------- -- Check if a tile requires a specific tool -- @param tileId number: The tile/item ID to check @@ -130,9 +126,7 @@ function GlobalConfig.isDoor(itemId) return false end --------------------------------------------------------------------------------- -- Tool Usage Functions --------------------------------------------------------------------------------- local lastToolUse = 0 local TOOL_DELAY = 500 diff --git a/core/heal_engine.lua b/core/heal_engine.lua index de7c2ad..94d37c2 100644 --- a/core/heal_engine.lua +++ b/core/heal_engine.lua @@ -25,17 +25,13 @@ - Added detailed logging for debugging ]] --- ============================================================================ -- MODULE INITIALIZATION --- ============================================================================ HealEngine = HealEngine or {} local VERSION = "2.0.0" --- ============================================================================ -- PRIVATE STATE (Encapsulated) --- ============================================================================ -- Local cooldown tracking (shared across sessions) local cooldowns = {} @@ -62,9 +58,7 @@ local friendSpells = { local lastEventHeal = 0 local EVENT_DEBOUNCE_MS = 25 --- ============================================================================ -- LOGGING --- ============================================================================ local VERBOSE = (type(nExBotVerbose) == "boolean" and nExBotVerbose) or false @@ -83,19 +77,11 @@ end -- Optional potion debug mode (opt-in): when enabled, HealEngine will emit -- short diagnostics about why potions were/weren't selected or used. local _potionDebug = false -function HealEngine.setPotionDebug(flag) - _potionDebug = not not flag -end - --- ============================================================================ -- TIME UTILITIES (DRY via Shared) --- ============================================================================ local nowMs = nExBot.Shared.nowMs --- ============================================================================ -- COOLDOWN MANAGEMENT (Unified with BotCore.Cooldown) --- ============================================================================ -- Check if healing group cooldown is active local function isHealingGroupOnCooldown() @@ -158,9 +144,7 @@ local function markPotionUsed() end end --- ============================================================================ -- STAT ACCESSORS (Safe fallbacks) --- ============================================================================ local function getHpPercent() if hppercent then return hppercent() or 0 end @@ -189,9 +173,7 @@ local function canUseItem() return potionReady() end --- ============================================================================ -- POTION USAGE (Safe wrapper - now prioritizes hotkey-style usage) --- ============================================================================ local function useItemSafe(itemId) if not itemId or itemId <= 0 then return false end @@ -237,9 +219,7 @@ local function useItemSafe(itemId) return false end --- ============================================================================ -- LIST MANAGEMENT --- ============================================================================ local function sortByPrio(list) if not list or #list <= 1 then return end @@ -252,9 +232,7 @@ local function sortByPrio(list) end sortByPrio(friendSpells) --- ============================================================================ -- PUBLIC API: Configuration --- ============================================================================ -- Configure feature usage; accepts partial table {selfSpells?, potions?, friendHeals?} function HealEngine.configure(opts) @@ -323,51 +301,13 @@ function HealEngine.setCustomPotions(potionList) end -- Debug helper: attempt to use a potion by id (returns true on success) -function HealEngine.tryUsePotionById(itemId) - if not itemId or itemId <= 0 then return false end - local action = { kind = "potion", id = itemId, key = "potion_test_" .. tostring(itemId), cd = 1000, name = "potion_test", potionType = "mana" } - if _potionDebug then warn(string.format("[HealEngine][POTION_DEBUG] tryUsePotionById: attempting to use id=%d", itemId)) end - local ok = execute(action) - if _potionDebug then warn(string.format("[HealEngine][POTION_DEBUG] tryUsePotionById: result=%s", tostring(ok))) end - return ok -end - -function HealEngine.setSelfSpellsEnabled(flag) - options.selfSpells = not not flag -end - -function HealEngine.setPotionsEnabled(flag) - options.potions = not not flag -end - function HealEngine.setFriendHealingEnabled(flag) options.friendHeals = not not flag end -- Public status for debugging: returns current toggles and counts -function HealEngine.getStatus() - return { - version = VERSION, - selfSpells = options.selfSpells, - potions = options.potions, - friendHeals = options.friendHeals, - spellsLoaded = #selfSpells, - potionsLoaded = #selfPotions, - healingGroupOnCooldown = isHealingGroupOnCooldown(), - potionOnCooldown = isPotionOnCooldown() - } -end - -- Get loaded spells for debugging -function HealEngine.getLoadedSpells() - return selfSpells -end - -- Get loaded potions for debugging -function HealEngine.getLoadedPotions() - return selfPotions -end - -- Select best self action based on snapshot -- CRITICAL: This is the main healing decision function! function HealEngine.planSelf(snap) @@ -478,7 +418,6 @@ function HealEngine.planSelf(snap) return true, nil end - if options.selfSpells and #selfSpells > 0 then local rejectReasons = {} for _, spell in ipairs(selfSpells) do @@ -495,7 +434,6 @@ function HealEngine.planSelf(snap) end end - if options.potions and #selfPotions > 0 then for _, pot in ipairs(selfPotions) do -- Get the actual potion name - prefer pot.name, then try to look up by ID @@ -521,7 +459,6 @@ function HealEngine.planSelf(snap) -- Evaluate reasons for not selecting this pot - if pot.hp and hp <= pot.hp and allowPotion and ready(pot.key, pot.cd) and canUseItem() then if VERBOSE then print("[HealBot] Executing potion: " .. tostring(potionName) .. " (id=" .. tostring(pot.id) .. ") for HP " .. tostring(hp) .. "% <= " .. tostring(pot.hp) .. "%") end return {kind = "potion", id = pot.id, key = pot.key, cd = pot.cd, name = potionName, potionType = "heal"} @@ -537,108 +474,43 @@ function HealEngine.planSelf(snap) logDebug("planSelf: no action selected") return nil end +-- Batch evaluate all spells for an ally and return the best action +function HealEngine.evaluateAlly(ally, allyHp, spellList) + if not ally or not allyHp then return nil end + local list = spellList or friendSpells + if #list == 0 then return nil end --- Debug helper: simulate a self snapshot and print planned action -function HealEngine.debugPlan(hp, mp, inPz) - local snap = { hp = hp or getHpPercent(), mp = mp or getMpPercent(), inPz = inPz } - local action = HealEngine.planSelf(snap) - if not action then - print(string.format("HealEngine.debugPlan: no action for hp=%.1f mp=%.1f inPz=%s", snap.hp, snap.mp, tostring(snap.inPz))) - return nil - end - if action.kind == "potion" then - print(string.format("HealEngine.debugPlan: selected potion id=%d name=%s type=%s", action.id or 0, action.name or "-", action.potionType or "-")) - elseif action.kind == "spell" then - print(string.format("HealEngine.debugPlan: selected spell %s", action.name or "-")) - else - print("HealEngine.debugPlan: selected action of kind=" .. tostring(action.kind)) - end - return action -end - --- Select best friend action; target must include name and hp --- IMPORTANT: Shares cooldowns with self-healing via BotCore.Cooldown --- v2.1: Added custom HP threshold support and improved spell selection -function HealEngine.planFriend(snap, target) - if not options.friendHeals then - logDebug("planFriend: friendHeals disabled") - return nil - end - if not target or not target.name then - logDebug("planFriend: no target or no target name") - return nil - end - - local hp = target.hp or 100 - local currentMana = snap.currentMana or getCurrentMana() - local inPz = snap.inPz - if inPz == nil then inPz = getInPz() end - - -- Don't heal friends in protection zone - if inPz then - logDebug("planFriend: in protection zone, skipping") - return nil - end - - -- Get custom HP threshold for this specific player (from UI config) - local customThreshold = target.customHp - - logDebug(string.format("planFriend: evaluating '%s' hp=%d%% customThreshold=%s mana=%d", - target.name, hp, tostring(customThreshold), currentMana)) - - -- Check if friend actually needs healing - -- Use custom threshold if set, otherwise use spell's built-in threshold - local needsHealing = false - if customThreshold then - needsHealing = hp <= customThreshold - end - - -- Select best spell (sorted by priority - strongest first) - for _, spell in ipairs(friendSpells) do - local spellThreshold = spell.hp or 0 - local mpCost = spell.mpCost or spell.mana or spell.mp or 0 - - -- Determine the effective threshold for this spell - -- Custom threshold overrides spell threshold, but only if friend HP is below it - local effectiveThreshold = spellThreshold - if customThreshold and customThreshold > spellThreshold then - -- For strong heals (gran sio), still require lower HP even with custom threshold - -- For normal heals (sio), use the custom threshold - if spell.prio == 1 then - -- Strong heal: use lower of custom/2 or spell threshold - effectiveThreshold = math.min(customThreshold / 2, spellThreshold) - else - effectiveThreshold = customThreshold - end - end - - -- Check if friend HP is at or below threshold - if hp <= effectiveThreshold or needsHealing then - -- CRITICAL: Check if we have enough mana to cast! - if currentMana >= mpCost then - -- Check cooldowns (shared with self-healing) - if healingGroupReady() and ready(spell.key, spell.cd or 1100) then - logDebug(string.format("planFriend: healing '%s' (hp=%d%%, threshold=%d) with %s", - target.name, hp, effectiveThreshold, spell.name)) - return { - kind = "spell", - name = string.format('%s "%s"', spell.name, target.name), - key = spell.key, - cd = spell.cd or 1100, - targetName = target.name, - targetHp = hp - } - else - logDebug(string.format("planFriend: cooldown not ready for %s", spell.name)) + local currentMana = getCurrentMana() + local bestAction = nil + local bestPrio = 999 + + for _, spell in ipairs(list) do + local hpThreshold = spell.hp or 0 + if allyHp <= hpThreshold then + local mpCost = spell.mpCost or spell.mana or spell.mp or 0 + if currentMana >= mpCost and healingGroupReady() and ready(spell.key, spell.cd or 1100) then + local prio = spell.prio or 999 + if prio < bestPrio then + bestPrio = prio + bestAction = spell end - else - logDebug(string.format("planFriend: insufficient mana for %s (need %d, have %d)", - spell.name, mpCost, currentMana)) end end end - - logDebug(string.format("planFriend: no suitable spell for '%s' at %d%% HP", target.name, hp)) + + if bestAction then + local targetName = ally.getName and ally:getName() or ally.name or "target" + return { + kind = "spell", + name = string.format('%s "%s"', bestAction.name, targetName), + key = bestAction.key, + cd = bestAction.cd or 1100, + mana = bestAction.mpCost or bestAction.mana or 0, + targetName = targetName, + targetHp = allyHp + } + end + return nil end @@ -795,4 +667,3 @@ logDebug("HealEngine v2.0 loaded - Safety-critical healing system") return HealEngine - diff --git a/core/lib.lua b/core/lib.lua index b8f005d..b539016 100644 --- a/core/lib.lua +++ b/core/lib.lua @@ -6,6 +6,7 @@ nExBot = nExBot or {} -- global namespace for bot variables -- Get ClientService reference (may not be loaded yet, lazy load in functions) +local zChanging = nExBot.zChanging or function() return false end local function getClient() return ClientService end @@ -107,33 +108,6 @@ function containerIsFull(c) return c:getCapacity() <= #c:getItems() end -function dropItem(idOrObject) - if type(idOrObject) == "number" then - idOrObject = findItem(idOrObject) - end - if not idOrObject then return end - - local Client = getClient() - if Client and Client.move then - Client.move(idOrObject, pos(), idOrObject:getCount()) - elseif g_game and g_game.move then - g_game.move(idOrObject, pos(), idOrObject:getCount()) - end -end - --- if using index as table element, this can be used to properly assign new idex to all values --- table needs to contain "index" as value --- if no index in tables, it will create one -function reindexTable(t) - if not t or type(t) ~= "table" then return end - - local i = 0 - for _, e in pairs(t) do - i = i + 1 - e.index = i - end -end - -- supports only new tibia, ver 10+ -- returns how many kills left to get next skull - can be red skull, can be black skull! -- reutrns number @@ -249,7 +223,6 @@ end -- exctracts data about spell from gamelib SpellInfo table -- returns table -- ie:['Spell Name'] = {id, words, exhaustion, premium, type, icon, mana, level, soul, group, vocations} --- cooldown detection module function getSpellData(spell) if not spell then return false end spell = spell:lower() @@ -589,12 +562,10 @@ local function getClientVersion() end local isOldTibia = getClientVersion() < 960 --------------------------------------------------------------------------------- -- SHAPE-BASED CREATURE COUNTING -- Delegates to BotCore.Creatures when available; provides standalone fallback. -- Shape constants and isInShape are the single source of truth here. -- BotCore.Creatures reuses these via nExBot.SHAPE / nExBot.isInShape. --------------------------------------------------------------------------------- local SHAPE = { SQUARE = 1, CIRCLE = 2, DIAMOND = 3, CROSS = 4, CONE = 5 @@ -777,13 +748,6 @@ end -- self explanatory -- a is item to use on -- b is item to use a on -function useOnInvertoryItem(a, b) - local item = findItem(b) - if not item then return end - - return SafeCall.useWith(a, item) -end - -- Pre-computed direction offsets (static, never changes) local NEAR_TILE_DIRS = { {-1, 1}, {0, 1}, {1, 1}, {-1, 0}, {1, 0}, {-1, -1}, {0, -1}, {1, -1} @@ -818,98 +782,6 @@ function getNearTiles(pos) return tiles end --- self explanatory --- use along with delay, it will only call action -function useGroundItem(id) - if not id then return false end - - local dest = nil - local Client = getClient() - local tiles = (Client and Client.getTiles) and Client.getTiles(posz()) or (g_map and g_map.getTiles(posz())) or {} - for i, tile in ipairs(tiles) do - for j, item in ipairs(tile:getItems()) do - if item:getId() == id then - dest = item - break - end - end - end - - if dest then - return use(dest) - else - return false - end -end - --- self explanatory --- use along with delay, it will only call action -function reachGroundItem(id) - if not id then return false end - - local dest = nil - local iPos = nil - local Client = getClient() - local tiles = (Client and Client.getTiles) and Client.getTiles(posz()) or (g_map and g_map.getTiles(posz())) or {} - for i, tile in ipairs(tiles) do - for j, item in ipairs(tile:getItems()) do - iPos = item:getPosition() - local iId = item:getId() - if iId == id then - if findPath(pos(), iPos, 20, - {ignoreNonPathable = true, precision = 1}) then - dest = item - break - end - end - end - end - - if dest and iPos then - return autoWalk(iPos, 20, {ignoreNonPathable = true, precision = 1}) - else - return false - end -end - --- self explanatory --- returns object -function findItemOnGround(id) - local Client = getClient() - local tiles = (Client and Client.getTiles) and Client.getTiles(posz()) or (g_map and g_map.getTiles(posz())) or {} - for i, tile in ipairs(tiles) do - for j, item in ipairs(tile:getItems()) do - if item:getId() == id then return item end - end - end -end - --- self explanatory --- use along with delay, it will only call action -function useOnGroundItem(a, b) - if not b then return false end - local item = findItem(a) - if not item then return false end - - local dest = nil - local Client = getClient() - local tiles = (Client and Client.getTiles) and Client.getTiles(posz()) or (g_map and g_map.getTiles(posz())) or {} - for i, tile in ipairs(tiles) do - for j, tileItem in ipairs(tile:getItems()) do - if tileItem:getId() == b then - dest = tileItem - break - end - end - end - - if dest then - return SafeCall.useWith(item, dest) - else - return false - end -end - -- returns target creature function target() local Client = getClient() @@ -920,9 +792,6 @@ function target() return (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) end --- returns target creature -function getTarget() return target() end - -- dist is boolean -- returns target position/distance from player function targetPos(dist) @@ -938,55 +807,6 @@ function targetPos(dist) end end --- for gunzodus/ezodus only --- it will reopen loot bag, necessary for depositer -function reopenPurse() - local Client = getClient() - local containers = (Client and Client.getContainers) and Client.getContainers() or getContainers() - for i, c in pairs(containers) do - if c:getName():lower() == "loot bag" or c:getName():lower() == - "store inbox" then - if Client and Client.close then - Client.close(c) - elseif g_game and g_game.close then - g_game.close(c) - end - end - end - schedule(100, function() - local Client = getClient() - local player = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer()) - if player then - local purseItem = player:getInventoryItem(InventorySlotPurse) - if purseItem then - if Client and Client.use then - Client.use(purseItem) - elseif g_game and g_game.use then - g_game.use(purseItem) - end - end - end - end) - schedule(1400, function() - local Client = getClient() - local containers = (Client and Client.getContainers) and Client.getContainers() or getContainers() - for i, c in pairs(containers) do - if c:getName():lower() == "store inbox" then - for _, item in pairs(c:getItems()) do - if item:getId() == 23721 then - if Client and Client.open then - Client.open(item, c) - elseif g_game and g_game.open then - g_game.open(item, c) - end - end - end - end - end - end) - return CaveBot.delay(1500) -end - -- getSpectator patterns -- param1 - pos/creature -- param2 - pattern diff --git a/core/new_cavebot_lib.lua b/core/new_cavebot_lib.lua index d52f7ce..116fcbd 100644 --- a/core/new_cavebot_lib.lua +++ b/core/new_cavebot_lib.lua @@ -1,6 +1,5 @@ CaveBot = {} -- global namespace -------------------------------------------------------------------- -- CaveBot lib 1.0 - Optimized version -- Contains a universal set of functions to be used in CaveBot @@ -11,7 +10,6 @@ CaveBot = {} -- global namespace -- overall tips to creating extension: -- - functions return action(nil) or true(done) -- - extensions are controlled by retries var -------------------------------------------------------------------- -- Pre-built lookup tables for O(1) access local LOCKERS_LIST = {3497, 3498, 3499, 3500} @@ -152,7 +150,9 @@ end -- @return boolean function CaveBot.HasLootItems() local lootSet = getLootItemsSet() - if not next(lootSet) then return false end + local hasItems = false + for _ in pairs(lootSet) do hasItems = true; break end + if not hasItems then return false end for _, container in pairs(getContainers()) do local name = container:getName():lower() diff --git a/core/new_healer.lua b/core/new_healer.lua deleted file mode 100644 index 79c2a62..0000000 --- a/core/new_healer.lua +++ /dev/null @@ -1,817 +0,0 @@ ---[[ - Friend Healer UI & Integration - - Uses BotCore.FriendHealer for high-performance healing logic. - This file handles UI and configuration only. - - Features: - - Shares exhaustion with HealBot via BotCore.Cooldown - - Priority-based: Self-healing ALWAYS takes precedence - - Event-driven for instant response to health changes - - Custom player list persisted per-character via CharacterDB - - Vocation filtering integrated with VocationUtils -]] - -setDefaultTab("Main") -local panelName = "newHealer" -local ui = setupUI([[ -Panel - height: 19 - - BotSwitch - id: title - anchors.top: parent.top - anchors.left: parent.left - text-align: center - width: 130 - !text: tr('Friend Healer') - - Button - id: edit - anchors.top: prev.top - anchors.left: prev.right - anchors.right: parent.right - margin-left: 3 - height: 17 - text: Setup - -]]) -ui:setId(panelName) - --- ============================================================================ --- CONFIGURATION (persisted in storage) --- ============================================================================ - --- Validate and migrate old config -if not storage[panelName] or not storage[panelName].priorities then - storage[panelName] = nil -end - -if not storage[panelName] then - storage[panelName] = { - enabled = false, - customPlayers = {}, - vocations = {}, - groups = {}, - priorities = { - {name="Custom Spell", enabled=false, custom=true}, - {name="Exura Gran Sio", enabled=true, strong = true}, - {name="Exura Tio Sio", enabled=true, medium = true}, - {name="Exura Sio", enabled=true, normal = true}, - {name="Exura Gran Mas Res", enabled=true, area = true}, - {name="Health Item", enabled=true, health=true}, - {name="Mana Item", enabled=true, mana=true} - }, - settings = { - {type="HealItem", text="Mana Item ", value=268}, - {type="HealScroll", text="Item Range: ", value=6}, - {type="HealItem", text="Health Item ", value=3160}, - {type="HealScroll", text="Mas Res Players: ", value=2}, - {type="HealScroll", text="Heal Friend at: ", value=80}, - {type="HealScroll", text="Use Gran Sio at: ", value=40}, - {type="HealScroll", text="Use Tio Sio at: ", value=65}, - {type="HealScroll", text="Min Player HP%: ", value=80}, - {type="HealScroll", text="Min Player MP%: ", value=50}, - }, - conditions = { - knights = true, - paladins = true, - druids = false, - sorcerers = false, - monks = false, - party = true, - guild = false, - friends = false - } - } -end - -local config = storage[panelName] - -local function normalizeSettings(settings) - if type(settings) ~= "table" then - settings = {} - end - - local hasTio = false - for i = 1, #settings do - local text = settings[i] and settings[i].text - if text and text:find("Tio Sio") then - hasTio = true - break - end - end - - if not hasTio then - table.insert(settings, 7, {type="HealScroll", text="Use Tio Sio at: ", value=65}) - end - - return settings -end - -local function getSettingValue(idx, default) - local entry = config.settings and config.settings[idx] - if entry and entry.value ~= nil then - return entry.value - end - return default -end - -config.settings = normalizeSettings(config.settings) - --- ============================================================================ --- CHARACTERDB INTEGRATION: Per-character custom players list --- Migrates from shared storage to per-character storage on first load --- ============================================================================ - -local function loadCharacterCustomPlayers() - if not CharacterDB or not CharacterDB.isReady or not CharacterDB.isReady() then - return - end - -- Try to load per-character custom players - local charPlayers = CharacterDB.get("friendHealer.customPlayers") - if charPlayers and type(charPlayers) == "table" and #charPlayers > 0 then - config.customPlayers = charPlayers - elseif config.customPlayers and #config.customPlayers > 0 then - -- Migrate existing shared custom players to CharacterDB - CharacterDB.set("friendHealer.customPlayers", config.customPlayers) - end - -- Also load per-character conditions - local charConditions = CharacterDB.get("friendHealer.conditions") - if charConditions and type(charConditions) == "table" then - for k, v in pairs(charConditions) do - config.conditions[k] = v - end - end -end - --- Defer loading until player is available -schedule(500, loadCharacterCustomPlayers) - --- Save custom players to both storage and CharacterDB -local function saveCustomPlayers() - if CharacterDB and CharacterDB.isReady and CharacterDB.isReady() then - CharacterDB.set("friendHealer.customPlayers", config.customPlayers) - end -end - --- ============================================================================ --- BOTCORE INTEGRATION: Build config for FriendHealer module --- ============================================================================ - --- Convert UI config to BotCore format (pure function) -local function buildBotCoreConfig() - local bcConfig = { - enabled = config.enabled, - customPlayers = config.customPlayers or {}, - conditions = config.conditions or {}, - settings = { - manaItem = getSettingValue(1, 268), - itemRange = getSettingValue(2, 6), - healthItem = getSettingValue(3, 3160), - masResPlayers = getSettingValue(4, 2), - healAt = getSettingValue(5, 80), - granSioAt = getSettingValue(6, 40), - tioSioAt = getSettingValue(7, 65), - minPlayerHp = getSettingValue(8, 80), - minPlayerMp = getSettingValue(9, 50), - }, - -- Priority actions (in order) - useSio = false, - useGranSio = false, - useTioSio = false, - useMasRes = false, - useHealthItem = false, - useManaItem = false, - customSpell = false, - customSpellName = nil - } - - -- Map priorities - for _, p in ipairs(config.priorities or {}) do - if p.enabled then - if p.strong then bcConfig.useGranSio = true end - if p.medium then bcConfig.useTioSio = true end - if p.normal then bcConfig.useSio = true end - if p.area then bcConfig.useMasRes = true end - if p.health then bcConfig.useHealthItem = true end - if p.mana then bcConfig.useManaItem = true end - if p.custom then - bcConfig.customSpell = true - bcConfig.customSpellName = p.name - end - end - end - - return bcConfig -end - --- Initialize BotCore FriendHealer if available -local function initBotCoreHealer() - if BotCore and BotCore.FriendHealer and BotCore.FriendHealer.init then - local bcConfig = buildBotCoreConfig() - BotCore.FriendHealer.init(bcConfig) - if BotCore.FriendHealer.setEnabled then - BotCore.FriendHealer.setEnabled(config.enabled) - end - return true - end - return false -end - --- Update BotCore when config changes (also syncs HealEngine spells) -local function updateBotCoreConfig() - if BotCore and BotCore.FriendHealer and BotCore.FriendHealer.init then - local bcConfig = buildBotCoreConfig() - BotCore.FriendHealer.init(bcConfig) - -- Sync spells to HealEngine - if BotCore.FriendHealer.syncHealEngineSpells then - BotCore.FriendHealer.syncHealEngineSpells() - end - end -end - --- Macro handle so we can fully stop the engine when unused -local friendHealerMacro = nil - -local function syncFriendHealerState() - -- Update BotCore FriendHealer - if BotCore and BotCore.FriendHealer and BotCore.FriendHealer.setEnabled then - BotCore.FriendHealer.setEnabled(config.enabled) - -- Also sync spell configuration - if BotCore.FriendHealer.syncHealEngineSpells then - BotCore.FriendHealer.syncHealEngineSpells() - end - end - -- Update HealEngine directly too - if HealEngine and HealEngine.setFriendHealingEnabled then - HealEngine.setFriendHealingEnabled(config.enabled) - end - -- Update macro state - if friendHealerMacro and friendHealerMacro.setOn then - friendHealerMacro:setOn(config.enabled) - end -end -local healerWindow = UI.createWindow('FriendHealer') -healerWindow:hide() -healerWindow:setId(panelName) - -ui.title:setOn(config.enabled) -ui.title.onClick = function(widget) - config.enabled = not config.enabled - widget:setOn(config.enabled) - syncFriendHealerState() -end - --- Initialize integration on load so unused friend heal stays disabled by default -initBotCoreHealer() -syncFriendHealerState() - -ui.edit.onClick = function() - healerWindow:show() - healerWindow:raise() - healerWindow:focus() -end - -local conditions = healerWindow.conditions -local targetSettings = healerWindow.targetSettings -local customList = healerWindow.customList -local priority = healerWindow.priority - --- customList --- DRY: Helper to create a player entry widget -local function createPlayerEntry(name, health) - local widget = UI.createWidget("HealerPlayerEntry", customList.playerList.list) - widget.remove.onClick = function() - config.customPlayers[name] = nil - widget:destroy() - saveCustomPlayers() - updateBotCoreConfig() - end - widget:setText("["..health.."%] "..name) - return widget -end - -for name, health in pairs(config.customPlayers) do - createPlayerEntry(name, health) -end - -customList.playerList.onDoubleClick = function() - customList.playerList:hide() -end - -local function clearFields() - customList.addPanel.name:setText("friend name") - customList.addPanel.health:setText("1") - customList.playerList:show() -end - --- Use Shared.properCase for name formatting (fixes leading space bug) -local properCase = nExBot and nExBot.Shared and nExBot.Shared.properCase or function(str) - local words = {} - for word in str:gmatch("%S+") do - words[#words + 1] = word:sub(1,1):upper() .. word:sub(2) - end - return table.concat(words, " ") -end - -customList.addPanel.add.onClick = function() - local rawName = customList.addPanel.name:getText() - local name = properCase(rawName) - local health = tonumber(customList.addPanel.health:getText()) - - if not health then - clearFields() - return warn("[Friend Healer] Please enter health percent value!") - end - - if name:len() == 0 or name:lower() == "friend name" then - clearFields() - return warn("[Friend Healer] Please enter friend name to be added!") - end - - if config.customPlayers[name] or config.customPlayers[name:lower()] then - clearFields() - return warn("[Friend Healer] Player already added to custom list.") - else - config.customPlayers[name] = health - createPlayerEntry(name, health) - saveCustomPlayers() - updateBotCoreConfig() - end - - clearFields() -end - -local function validate(widget, category) - local list = widget:getParent() - local label = list:getParent().title - -- 1 - priorities | 2 - vocation - category = category or 0 - - if category == 2 and not storage.extras.checkPlayer then - label:setColor("#d9321f") - label:setTooltip("! WARNING ! \nTurn on check players in extras to use this feature!") - return - else - label:setColor("#dfdfdf") - label:setTooltip("") - end - - local checked = false - for i, child in ipairs(list:getChildren()) do - if category == 1 and child.enabled:isChecked() or child:isChecked() then - checked = true - end - end - - if not checked then - label:setColor("#d9321f") - label:setTooltip("! WARNING ! \nNo category selected!") - else - label:setColor("#dfdfdf") - label:setTooltip("") - end -end --- DRY: Generic checkbox binder for condition toggles -local function bindConditionCheckbox(widget, conditionKey, category) - widget:setChecked(config.conditions[conditionKey]) - widget.onClick = function(w) - config.conditions[conditionKey] = not config.conditions[conditionKey] - w:setChecked(config.conditions[conditionKey]) - validate(w, category or 0) - updateBotCoreConfig() - -- Persist to CharacterDB if available - if CharacterDB and CharacterDB.isReady and CharacterDB.isReady() then - CharacterDB.set("friendHealer.conditions", config.conditions) - end - end -end - --- Vocation checkboxes (category 2 = requires checkPlayer) -bindConditionCheckbox(targetSettings.vocations.box.knights, "knights", 2) -bindConditionCheckbox(targetSettings.vocations.box.paladins, "paladins", 2) -bindConditionCheckbox(targetSettings.vocations.box.druids, "druids", 2) -bindConditionCheckbox(targetSettings.vocations.box.sorcerers, "sorcerers", 2) -bindConditionCheckbox(targetSettings.vocations.box.monks, "monks", 2) - --- Group checkboxes (category 0 = no special requirement) -bindConditionCheckbox(targetSettings.groups.box.friends, "friends") -bindConditionCheckbox(targetSettings.groups.box.party, "party") -bindConditionCheckbox(targetSettings.groups.box.guild, "guild") - -validate(targetSettings.vocations.box.knights) -validate(targetSettings.groups.box.friends) -validate(targetSettings.vocations.box.sorcerers, 2) - --- conditions -for i, setting in ipairs(config.settings) do - local widget = UI.createWidget(setting.type, conditions.box) - local text = setting.text - local val = setting.value - widget.text:setText(text) - - if setting.type == "HealScroll" then - widget.text:setText(widget.text:getText()..val) - if not (text:find("Range") or text:find("Mas Res")) then - widget.text:setText(widget.text:getText().."%") - end - widget.scroll:setValue(val) - widget.scroll.onValueChange = function(scroll, value) - setting.value = value - widget.text:setText(text..value) - if not (text:find("Range") or text:find("Mas Res")) then - widget.text:setText(widget.text:getText().."%") - end - -- Sync config changes to BotCore when settings change - updateBotCoreConfig() - end - if text:find("Range") or text:find("Mas Res") then - widget.scroll:setMaximum(10) - end - else - widget.item:setItemId(val) - widget.item:setShowCount(false) - widget.item.onItemChange = function(widget) - setting.value = widget:getItemId() - -- Sync config changes to BotCore when item changes - updateBotCoreConfig() - end - end -end - - - --- priority and toggles -local function setCrementalButtons() - local children = priority.list:getChildren() - local count = #children - for i, child in ipairs(children) do - if i == 1 then - child.increment:disable() - elseif i == count then - child.decrement:disable() - else - child.increment:enable() - child.decrement:enable() - end - end -end - --- Helper to create a priority entry widget -local function createPriorityWidget(action, index) - local widget = UI.createWidget("PriorityEntry", priority.list) - - widget:setText(action.name) - widget.increment.onClick = function() - local idx = priority.list:getChildIndex(widget) - local tbl = config.priorities - - priority.list:moveChildToIndex(widget, idx-1) - tbl[idx], tbl[idx-1] = tbl[idx-1], tbl[idx] - setCrementalButtons() - updateBotCoreConfig() - end - widget.decrement.onClick = function() - local idx = priority.list:getChildIndex(widget) - local tbl = config.priorities - - priority.list:moveChildToIndex(widget, idx+1) - tbl[idx], tbl[idx+1] = tbl[idx+1], tbl[idx] - setCrementalButtons() - updateBotCoreConfig() - end - widget.enabled:setChecked(action.enabled) - widget:setColor(action.enabled and "#98BF64" or "#dfdfdf") - widget.enabled.onClick = function() - action.enabled = not action.enabled - widget:setColor(action.enabled and "#98BF64" or "#dfdfdf") - widget.enabled:setChecked(action.enabled) - validate(widget, 1) - updateBotCoreConfig() - end - - -- Show remove button for custom spells - if action.custom then - widget.remove:show() - widget.remove.onClick = function() - -- Remove from config - local idx = priority.list:getChildIndex(widget) - table.remove(config.priorities, idx) - widget:destroy() - setCrementalButtons() - validate(priority.list:getFirstChild(), 1) - updateBotCoreConfig() - end - widget.onDoubleClick = function() - local window = modules.client_textedit.show(widget, {title = "Custom Spell", description = "Enter below formula for a custom healing spell"}) - schedule(50, function() - window:raise() - window:focus() - end) - end - widget.onTextChange = function(w, text) - action.name = text - updateBotCoreConfig() - end - widget:setTooltip("Double click to edit. X to remove.") - end - - return widget -end - --- Build initial priority list -for i, action in ipairs(config.priorities) do - createPriorityWidget(action, i) - - if i == #config.priorities then - validate(priority.list:getFirstChild(), 1) - setCrementalButtons() - end -end - --- Add Custom Spell button handler -priority.addSpellButton.onClick = function() - -- Create new custom spell entry - local newSpell = { - name = "Custom Spell " .. (#config.priorities + 1), - enabled = true, - custom = true - } - table.insert(config.priorities, newSpell) - local widget = createPriorityWidget(newSpell, #config.priorities) - setCrementalButtons() - updateBotCoreConfig() - - -- Open text edit for the new spell - schedule(100, function() - local window = modules.client_textedit.show(widget, {title = "Custom Spell", description = "Enter below formula for a custom healing spell"}) - schedule(50, function() - window:raise() - window:focus() - end) - end) -end - --- ============================================================================ --- BOTCORE-POWERED FRIEND HEALER --- Uses BotCore.FriendHealer for high-performance healing with shared exhaustion --- ============================================================================ - --- Track if BotCore is available -local useBotCore = false - --- Initialize on load -schedule(100, function() - useBotCore = initBotCoreHealer() - syncFriendHealerState() - if useBotCore then - -- BotCore high-performance mode enabled - -- Configure HealEngine friend spells based on UI priorities - if HealEngine and HealEngine.setFriendSpells then - local friendSpells = {} - local healAt = getSettingValue(5, 80) - local granSioAt = getSettingValue(6, 40) - - for i, action in ipairs(config.priorities or {}) do - if action.enabled then - if action.strong then - table.insert(friendSpells, { - name = "exura gran sio", - hp = granSioAt, - mpCost = 140, - cd = 1100, - prio = 1 - }) - end - if action.medium then - local tioSioAt = getSettingValue(7, 65) - table.insert(friendSpells, { - name = "exura tio sio", - hp = tioSioAt, - mpCost = 120, - cd = 1100, - prio = 2 - }) - end - if action.normal then - table.insert(friendSpells, { - name = "exura sio", - hp = healAt, - mpCost = 100, - cd = 1100, - prio = 3 - }) - end - if action.custom and action.name and action.name ~= "Custom Spell" then - table.insert(friendSpells, { - name = action.name, - hp = healAt, - mpCost = 50, - cd = 1100, - prio = 3 - }) - end - end - end - - if #friendSpells > 0 then - HealEngine.setFriendSpells(friendSpells) - end - end - end -end) - --- Legacy fallback functions (used when BotCore is not available) -local lastItemUse = now - -local function legacyHealAction(spec, targetsInRange) - local name = spec:getName() - local health = spec:getHealthPercent() - local mana = spec:getManaPercent() - local dist = distanceFromPlayer(spec:getPosition()) - targetsInRange = targetsInRange or 0 - - local masResAmount = getSettingValue(4, 2) - local itemRange = getSettingValue(2, 6) - local healItem = getSettingValue(3, 3160) - local manaItem = getSettingValue(1, 268) - local normalHeal = config.customPlayers[name] or getSettingValue(5, 80) - local strongHeal = config.customPlayers[name] and normalHeal/2 or getSettingValue(6, 40) - local mediumHeal = getSettingValue(7, 65) - - -- Check healing cooldown (shared with HealBot via BotCore) - local canHeal = true - if BotCore and BotCore.Cooldown and BotCore.Cooldown.isHealingOnCooldown then - canHeal = not BotCore.Cooldown.isHealingOnCooldown() - elseif modules and modules.game_cooldown then - canHeal = not modules.game_cooldown.isGroupCooldownIconActive(2) - end - if not canHeal then return end - - for i, action in ipairs(config.priorities) do - if action.enabled then - if action.area and masResAmount <= targetsInRange and canCast("exura gran mas res") then - return say("exura gran mas res") - end - if action.mana and findItem(manaItem) and mana <= normalHeal and dist <= itemRange and now - lastItemUse > 1000 then - lastItemUse = now - if BotCore and BotCore.Cooldown then BotCore.Cooldown.markPotionUsed() end - return SafeCall.useWith(manaItem, spec) - end - if action.health and findItem(healItem) and health <= normalHeal and dist <= itemRange and now - lastItemUse > 1000 then - lastItemUse = now - if BotCore and BotCore.Cooldown then BotCore.Cooldown.markPotionUsed() end - return SafeCall.useWith(healItem, spec) - end - if action.strong and health <= strongHeal then - local canCastGranSio = true - if modules and modules.game_cooldown then - canCastGranSio = not modules.game_cooldown.isCooldownIconActive(131) - end - if canCastGranSio then - return say('exura gran sio "'..name) - end - end - if action.medium and health <= mediumHeal and canCast('exura tio sio "'..name) then - return say('exura tio sio "'..name) - end - if (action.normal or action.custom) and health <= normalHeal and canCast('exura sio "'..name) then - return say('exura sio "'..name) - end - end - end -end - -local function legacyIsCandidate(spec) - if spec:isLocalPlayer() or not spec:isPlayer() then - return nil - end - if not spec:canShoot() then - return false - end - - local name = spec:getName() - local curHp = spec:getHealthPercent() - if curHp == 100 or (config.customPlayers[name] and curHp > config.customPlayers[name]) then - return false - end - - -- Vocation filter (map-based for DRY/KISS) - local vocConditionMap = { - EK = "knights", RP = "paladins", ED = "druids", - MS = "sorcerers", MN = "monks" - } - if storage.extras and storage.extras.checkPlayer and VocationUtils and VocationUtils.getCreatureVocationShort then - local short = VocationUtils.getCreatureVocationShort(spec) - local condKey = short and vocConditionMap[short] - if condKey and not config.conditions[condKey] and not config.customPlayers[name] then - return nil - end - end - - local okParty = config.conditions.party and spec:isPartyMember() - local okFriend = config.conditions.friends and isFriend and isFriend(spec) - local okGuild = config.conditions.guild and spec:getEmblem() == 1 - - if not (okParty or okFriend or okGuild) and not config.customPlayers[name] then - return nil - end - - local health = config.customPlayers[name] and curHp/2 or curHp - local dist = distanceFromPlayer(spec:getPosition()) - - return health, dist -end - --- ============================================================================ --- MAIN MACRO: Uses BotCore when available, falls back to legacy --- ============================================================================ - -friendHealerMacro = macro(100, function() - if not config.enabled then return end - - -- Update BotCore config on each tick (in case settings changed) - if useBotCore and BotCore and BotCore.FriendHealer then - -- Let BotCore handle everything - local actionTaken = BotCore.FriendHealer.tick() - -- If BotCore handled the heal, skip legacy fallback - if actionTaken then return end - end - - -- Only use legacy fallback if BotCore is not available - -- This prevents double-healing attempts - if useBotCore then return end - - -- Legacy fallback (only when BotCore is unavailable) - - -- Check healing cooldown - if modules and modules.game_cooldown and modules.game_cooldown.isGroupCooldownIconActive(2) then - return - end - - local minHp = getSettingValue(8, 80) - local minMp = getSettingValue(9, 50) - - -- Safety: Don't heal friends if self needs healing - if hppercent() <= minHp or manapercent() <= minMp then return end - - local healTarget = {creature=nil, hp=100} - local inMasResRange = 0 - - -- Scan spectators - local spectators = {} - if getSpectators then - local ok, specs = pcall(getSpectators) - if ok and specs then spectators = specs end - elseif SafeCall and SafeCall.global then - spectators = SafeCall.global("getSpectators") or {} - end - - for i, spec in ipairs(spectators) do - local health, dist = legacyIsCandidate(spec) - if dist then - inMasResRange = dist <= 3 and inMasResRange+1 or inMasResRange - if health < healTarget.hp then - healTarget = {creature = spec, hp = health} - end - end - end - - -- Execute heal - if healTarget.creature then - return legacyHealAction(healTarget.creature, inMasResRange) - end -end) - -syncFriendHealerState() - --- ============================================================================ --- EVENT-DRIVEN HEALING --- Note: EventBus handlers are registered by BotCore.FriendHealer module --- This section only provides legacy fallback when EventBus is unavailable --- ============================================================================ - --- Fallback: Hook into creature health changes for instant response (legacy only) --- Only register if EventBus is NOT available (FriendHealer handles EventBus) -if onCreatureHealthPercentChange and not EventBus then - onCreatureHealthPercentChange(function(creature, newHp, oldHp) - if not config.enabled then return end - if not creature then return end - - -- Skip non-players and local player - local ok, isPlayer = pcall(function() return creature:isPlayer() end) - if not ok or not isPlayer then return end - local ok2, isLocal = pcall(function() return creature:isLocalPlayer() end) - if ok2 and isLocal then return end - - -- Only react to significant health drops - local drop = (oldHp or 100) - (newHp or 100) - if drop < 10 then return end - - -- Use BotCore event handler if available - if useBotCore and BotCore and BotCore.FriendHealer then - BotCore.FriendHealer.onFriendHealthChange(creature, newHp, oldHp) - end - end) -end \ No newline at end of file diff --git a/core/outfit_cloner.lua b/core/outfit_cloner.lua index c5f8e67..6660417 100644 --- a/core/outfit_cloner.lua +++ b/core/outfit_cloner.lua @@ -10,9 +10,7 @@ Architecture: DRY, KISS, SOLID, SRP - minimal, focused, efficient ]] --- ============================================================================ -- PURE OUTFIT FUNCTIONS --- ============================================================================ -- Get the current player's outfit local function getPlayerOutfit() @@ -120,9 +118,7 @@ local function applyOutfit(outfit, targetName, isColorsOnly) return true end --- ============================================================================ -- MENU ACTION HANDLERS --- ============================================================================ -- Clone full outfit from target creature local function cloneOutfitAction(menuPosition, lookThing, useThing, creatureThing) @@ -148,9 +144,7 @@ local function copyColorsAction(menuPosition, lookThing, useThing, creatureThing applyOutfit(newOutfit, name, true) end --- ============================================================================ -- MENU CONDITION (when to show the options) --- ============================================================================ -- Only show for other players (not self, not NPCs, not monsters) local function isValidPlayerTarget(menuPosition, lookThing, useThing, creatureThing) @@ -160,9 +154,7 @@ local function isValidPlayerTarget(menuPosition, lookThing, useThing, creatureTh return true end --- ============================================================================ -- HOOK INTO GAME INTERFACE MENU --- ============================================================================ local function registerMenuOptions() local gameInterface = modules.game_interface @@ -199,9 +191,7 @@ local function registerMenuOptions() return true end --- ============================================================================ -- INITIALIZATION --- ============================================================================ -- Register menu options on load registerMenuOptions() diff --git a/core/pushmax.lua b/core/pushmax.lua index ecf85c5..a0300e6 100644 --- a/core/pushmax.lua +++ b/core/pushmax.lua @@ -1,6 +1,7 @@ ---@diagnostic disable: undefined-global setDefaultTab("Main") +local zChanging = nExBot.zChanging or function() return false end local panelName = "pushmax" local ui = setupUI([[ Panel @@ -84,7 +85,6 @@ if rootWidget then pushWindow.hotkey:setText(config.pushMaxKey) end - -- variables for config local fieldTable = {2118, 105, 2122} local cleanTile = nil diff --git a/core/quiver_label.lua b/core/quiver_label.lua index 6d6b49e..5cf9fff 100644 --- a/core/quiver_label.lua +++ b/core/quiver_label.lua @@ -29,12 +29,10 @@ Label text: ]], quiverSlot) - function getQuiverAmount() -- old tibia if g_game.getClientVersion() < 1000 then return end - local isQuiverEquipped = getRight() and getRight():isContainer() or false local quiver = isQuiverEquipped and getContainerByItem(getRight():getId()) local count = 0 diff --git a/core/quiver_manager.lua b/core/quiver_manager.lua index d7e3045..e328934 100644 --- a/core/quiver_manager.lua +++ b/core/quiver_manager.lua @@ -38,8 +38,8 @@ if voc() == 2 or voc() == 12 then -- Find ammo item in OPEN containers only (simple and reliable) local function findAmmoItem(ammoIds) - local Client = getClient() - local containers = (Client and Client.getContainers) and Client.getContainers() or (g_game and g_game.getContainers and g_game.getContainers()) or {} + local containers = nExBot.Shared and nExBot.Shared.getContainers and nExBot.Shared.getContainers() + if not containers then return nil end for _, container in pairs(containers) do local cname = container:getName():lower() if not cname:find("quiver") then @@ -102,8 +102,7 @@ if voc() == 2 or voc() == 12 then -- Find a valid destination container for wrong ammo local function findDestContainer(quiverContainer) - local Client = getClient() - local containers = (Client and Client.getContainers) and Client.getContainers() or (g_game and g_game.getContainers and g_game.getContainers()) or {} + local containers = nExBot.Shared.getContainers() for _, container in pairs(containers) do if container ~= quiverContainer and not containerIsFull(container) then local cname = container:getName():lower() diff --git a/core/smart_hunt.lua b/core/smart_hunt.lua index 915c8b4..2b43878 100644 --- a/core/smart_hunt.lua +++ b/core/smart_hunt.lua @@ -25,10 +25,9 @@ setDefaultTab("Main") --- ============================================================================ -- CONSTANTS & CONFIGURATION --- ============================================================================ +local zChanging = nExBot.zChanging or function() return false end local SEVERITY = { INFO = "INFO", TIP = "TIP", WARNING = "WARN", CRITICAL = "CRIT" } local SKILL_NAMES = { @@ -45,9 +44,7 @@ local CONDITION_MAP = { { check = "isInFight", key = "timeInCombat", name = "inCombat" } } --- ============================================================================ -- PURE UTILITY FUNCTIONS --- ============================================================================ -- Safe get with default value local function safeGet(fn, default) @@ -92,19 +89,12 @@ local function expForLevel(lvl) end -- Check if table has data (pure function) -local function hasData(tbl) - if not tbl then return false end - return next(tbl) ~= nil -end - -- Clamp value between min and max (pure function) local function clamp(value, min, max) return math.max(min, math.min(max, value)) end --- ============================================================================ -- PLAYER DATA ACCESSORS (Single Responsibility) --- ============================================================================ local Player = {} @@ -181,9 +171,7 @@ function Player.levelProgress() return { level = lvl, percent = pct, xpNeeded = xpNeeded, xpRemaining = xpNext - currentXp } end --- ============================================================================ -- STORAGE & SESSION (Single Responsibility) --- ============================================================================ local DEFAULT_METRICS = { tilesWalked = 0, kills = 0, spellsCast = 0, potionsUsed = 0, runesUsed = 0, @@ -269,9 +257,7 @@ local function startSession() if EventBus then EventBus.emit("analytics:session:start") end end --- ============================================================================ -- LOOT PARSING (Server message listener) --- ============================================================================ local COIN_VALUES = { ["gold coin"] = 1, @@ -283,24 +269,6 @@ local function trim(s) return (s or ""):gsub("^%s+", ""):gsub("%s+$", "") end --- Parse values like "1.2k" -> 1200, "2.5m" -> 2500000 -local function parseValue(str) - if not str then return 0 end - str = trim(str:lower()) - local num, suffix = str:match("^([%d%.]+)([km]?)$") - if not num then - -- Try plain number - return tonumber(str) or 0 - end - local value = tonumber(num) or 0 - if suffix == "k" then - value = value * 1000 - elseif suffix == "m" then - value = value * 1000000 - end - return math.floor(value) -end - local function normalizeItemName(name) if not name then return nil end local lowered = trim(name:lower()) @@ -425,7 +393,8 @@ local function recordLoot(entry) if nameKey then local bucket = analytics.lootItems[nameKey] or {count = 0, value = 0} bucket.count = bucket.count + (itm.count or 0) - local itemValue = (itm.price or 0) * (itm.count or 0) + local unitPrice = COIN_VALUES[nameKey] or (itm.price or 0) + local itemValue = unitPrice * (itm.count or 0) bucket.value = bucket.value + itemValue analytics.lootItems[nameKey] = bucket -- Track gold from coins @@ -450,9 +419,7 @@ local function endSession() if EventBus then EventBus.emit("analytics:session:end") end end --- ============================================================================ -- EVENT HANDLERS (Metrics Collection) --- ============================================================================ onWalk(function(creature) if zChanging() then return end @@ -516,10 +483,8 @@ onPlayerHealthChange(function(healthPercent) lastHP, lastHpPercent = currentHP, healthPercent end) --- ============================================================================ -- CONSUMPTION TRACKING API -- Provides functions for HealBot and TargetBot to report spell/potion/rune usage --- ============================================================================ local Analytics = {} @@ -653,9 +618,7 @@ end -- Expose Analytics API globally for HealBot/TargetBot integration HuntAnalytics = Analytics --- ============================================================================ -- GLOBAL RUNE TRACKING HOOK --- ============================================================================ -- This hooks into ALL useWith calls to track rune usage automatically, -- regardless of whether runes are used via TargetBot, combo, hotkey, etc. @@ -689,10 +652,6 @@ local RUNE_ITEM_IDS = { -- Check if an item is a rune based on ID or name pattern local function isRune(itemId) - if RUNE_ITEM_IDS[itemId] then - return true, RUNE_ITEM_IDS[itemId], "attack" - end - -- Try to get item info from g_things if g_things and g_things.getThingType then local ok, thing = pcall(function() return g_things.getThingType(itemId, ThingCategoryItem) end) @@ -742,9 +701,7 @@ onUseWith(function(pos, itemId, target, subType) end end) --- ============================================================================ -- PERIODIC UPDATES --- ============================================================================ local lastConditionCheck = 0 @@ -787,10 +744,8 @@ local function updateTracking() end end --- ============================================================================ -- INSIGHTS ENGINE (Analysis) -- Uses: Weighted scoring, trend analysis, statistical methods, correlation --- ============================================================================ local Insights = {} @@ -804,9 +759,7 @@ local function addInsight(results, severity, category, message, confidence) }) end --- ============================================================================ -- STATISTICAL HELPERS (Pure Functions) --- ============================================================================ -- Calculate weighted average with time decay (recent data matters more) local function weightedAverage(values, decayFactor) @@ -821,7 +774,6 @@ local function weightedAverage(values, decayFactor) return weightSum > 0 and (sum / weightSum) or 0 end --- Calculate standard deviation (consistency measure) local function standardDeviation(values, mean) if not values or #values < 2 then return 0 end mean = mean or 0 @@ -838,24 +790,7 @@ local function normalize(value, min, max) return clamp((value - min) / (max - min), 0, 1) end --- Calculate percentile rank -local function percentileRank(value, thresholds) - for i, threshold in ipairs(thresholds) do - if value <= threshold then return (i - 1) / #thresholds end - end - return 1.0 -end - --- Sigmoid function for smooth scoring transitions -local function sigmoid(x, midpoint, steepness) - midpoint = midpoint or 0 - steepness = steepness or 1 - return 1 / (1 + math.exp(-steepness * (x - midpoint))) -end - --- ============================================================================ -- TREND TRACKING (Rolling Window Analysis) --- ============================================================================ local trendData = { xpPerHour = {}, @@ -904,9 +839,7 @@ local function calculateTrend(values) return direction, changePercent end --- ============================================================================ -- ADVANCED METRICS CALCULATION --- ============================================================================ local function calculateMetrics() local m = analytics.metrics @@ -987,9 +920,7 @@ local function calculateMetrics() return metrics end --- ============================================================================ -- INSIGHTS ANALYSIS --- ============================================================================ function Insights.analyze() local results = {} @@ -1432,9 +1363,7 @@ function Insights.scoreBar(score) return string.format("[%s%s] %d/100 (%s)", string.rep("#", filled), string.rep("-", 10 - filled), score, rating) end --- ============================================================================ -- SUMMARY BUILDER (Template Pattern) --- ============================================================================ local function addSection(lines, title, content) table.insert(lines, "[" .. title .. "]") @@ -1674,9 +1603,7 @@ local function buildSummary() return table.concat(lines, "\n") end --- ============================================================================ -- UI --- ============================================================================ local analyticsWindow = nil @@ -1773,9 +1700,7 @@ local function showAnalytics() startLiveUpdates() end --- ============================================================================ -- MACROS (Hidden - runs automatically in background) --- ============================================================================ -- Background tracking (no visible button) macro(5000, function() @@ -1790,9 +1715,7 @@ end) macro(1000, function() updateTracking() end) --- ============================================================================ -- UI BUTTON --- ============================================================================ UI.Separator(); @@ -1830,9 +1753,7 @@ local monsterBtn = UI.Button("Monster Insights", function() end) if monsterBtn then monsterBtn:setTooltip("View learned monster patterns and samples") end --- ============================================================================ -- PUBLIC API --- ============================================================================ nExBot.Analytics = { start = startSession, diff --git a/core/spy_level.lua b/core/spy_level.lua index a6b8222..557d2c6 100644 --- a/core/spy_level.lua +++ b/core/spy_level.lua @@ -1,5 +1,6 @@ -- config +local zChanging = nExBot.zChanging or function() return false end local keyUp = "=" local keyDown = "-" local keyToggle = "Ctrl+Shift+L" @@ -9,8 +10,6 @@ local spyLevelEnabled = false if storage then if storage.tools and storage.tools.spyLevelEnabled ~= nil then spyLevelEnabled = storage.tools.spyLevelEnabled - elseif storage.spyLevelEnabled ~= nil then - spyLevelEnabled = storage.spyLevelEnabled end end diff --git a/core/supplies.lua b/core/supplies.lua index 3985647..1fab239 100644 --- a/core/supplies.lua +++ b/core/supplies.lua @@ -83,35 +83,16 @@ if not config then end end end +SuppliesWindow = UI.createWindow("SuppliesWindow") +SuppliesWindow:hide() -function getEmptyItemPanels() - local panel = SuppliesWindow.items - local count = 0 - - for i, child in ipairs(panel:getChildren()) do - count = child:getId() == "blank" and count + 1 or count - end - - return count -end - -function deleteFirstEmptyPanel() - local panel = SuppliesWindow.items - - for i, child in ipairs(panel:getChildren()) do - if child:getId() == "blank" then - child:destroy() - break - end - end -end - -function clearEmptyPanels() - local panel = SuppliesWindow.items - - if panel:getChildCount() > 1 then - if getEmptyItemPanels() > 1 then - deleteFirstEmptyPanel() +local function clearEmptyPanels() + local parent = SuppliesWindow.items + if not parent then return end + for i = parent:getChildCount(), 1, -1 do + local child = parent:getChildByIndex(i) + if child and child:getId() == "blank" then + parent:removeChild(child) end end end @@ -132,38 +113,31 @@ function addItemPanel() local id = widget:getItemId() local panelId = panel:getId() - -- empty, verify if id < 100 then config.items[panelId] = nil panel:setId("blank") - clearEmptyPanels() -- clear empty panels if any + clearEmptyPanels() return end - -- itemId was not changed, ignore if tonumber(panelId) == id then return end - -- check if isnt already added - if config[tostring(id)] then + if config.items[tostring(id)] then warn("nExBot[Drop Tracker]: Item already added!") widget:setItemId(0) return end - -- new item id - config.items[tostring(id)] = config.items[tostring(id)] or {} -- min, max, avg + config.items[tostring(id)] = config.items[tostring(id)] or {} panel:setId(id) - addItemPanel() -- add new panel + addItemPanel() end return panel end -SuppliesWindow = UI.createWindow("SuppliesWindow") -SuppliesWindow:hide() - UI.Button( "Supply Settings", function() @@ -272,7 +246,6 @@ local function refreshProfileList() end end end -refreshProfileList() local function setProfileFocus() for i, v in ipairs(SuppliesWindow.profiles:getChildren()) do @@ -417,8 +390,8 @@ end Supplies.hasEnough = function() local data = Supplies.getItemsData() - for id, values in pairs(data) do - id = tonumber(id) + for key, values in pairs(data) do + local id = tonumber(key) local minimum = values.min local current = player:getItemsCount(id) or 0 diff --git a/core/tools.lua b/core/tools.lua index 0062dce..73e5340 100644 --- a/core/tools.lua +++ b/core/tools.lua @@ -9,27 +9,16 @@ local getClient = nExBot.Shared.getClient -- Version check helper local getClientVersion = nExBot.Shared.getClientVersion +local SC = SafeCreature -- ═══════════════════════════════════════════════════════════════════════════ -- PROFILE STORAGE INTEGRATION -- All settings are stored per-profile using ProfileStorage from configs.lua -- ═══════════════════════════════════════════════════════════════════════════ --- Helper to get/set profile storage with fallback to old storage for compatibility -local function getProfileSetting(key) - if ProfileStorage then - return ProfileStorage.get(key) - end - return storage[key] -end - -local function setProfileSetting(key, value) - if ProfileStorage then - ProfileStorage.set(key, value) - else - storage[key] = value - end -end +local SharedHelpers = nExBot.SharedHelpers or {} +local getProfileSetting = SharedHelpers.getProfileSetting +local setProfileSetting = SharedHelpers.setProfileSetting -- ═══════════════════════════════════════════════════════════════════════════ -- MONEY EXCHANGER - Auto-exchange 100 gold → platinum, 100 platinum → crystal @@ -52,8 +41,7 @@ end -- Pure function: Find first exchangeable item in all containers local function findExchangeableItem() - local Client = getClient() - local containers = (Client and Client.getContainers) and Client.getContainers() or (g_game and g_game.getContainers and g_game.getContainers()) or {} + local containers = nExBot.Shared.getContainers() if not containers then return nil end for _, container in pairs(containers) do @@ -125,7 +113,6 @@ UI.Separator() UI.Label("Tools:") - -- ═══════════════════════════════════════════════════════════════════════════ -- AUTO LEVITATE v2.0 — Event-Driven with Look-Ahead & CaveBot Integration -- Analyzes Z±1 fields in movement direction to detect levitate opportunities @@ -835,1053 +822,55 @@ end UI.Separator() -- ═══════════════════════════════════════════════════════════════════════════ --- FOLLOW PLAYER v4.0 - OpenTibiaBR Parallel Attack+Follow System --- Enhanced for party hunting with TRUE parallel attack and follow --- Features: --- - PARALLEL ATTACK+FOLLOW: Walk toward leader while maintaining attack --- - Manual path-based movement (doesn't cancel attack like native follow) --- - Attack re-issue between walk steps to maintain target lock --- - Smart distance tracking with path-based following --- - TargetBot & MovementCoordinator integration --- - EventBus for instant reaction to leader movements --- - OpenTibiaBR forceWalk for reliable movement --- - Combat window system with parallel execution +-- FOLLOW PLAYER — Party hunt companion -- ═══════════════════════════════════════════════════════════════════════════ --- Load follow player settings from CharacterDB (per-character) -local function loadFollowPlayerConfig() - local config = { - enabled = false, - playerName = "", - followWhileAttacking = true, - maxDistance = 4, -- Max distance before prioritizing follow over combat - combatWindowMs = 1500, -- Max time to finish a monster before forced follow - smartFollow = true, -- Use pathfinding when native follow fails - aggressiveFollow = true -- OpenTibiaBR: More aggressive re-follow - } - - if CharacterDB and CharacterDB.isReady and CharacterDB.isReady() then - local charConfig = CharacterDB.get("tools.followPlayer") - if charConfig then - config.enabled = charConfig.enabled or false - config.playerName = charConfig.playerName or "" - config.followWhileAttacking = charConfig.followWhileAttacking ~= false -- default true - config.maxDistance = charConfig.maxDistance or 4 - config.combatWindowMs = charConfig.combatWindowMs or 1500 - config.smartFollow = charConfig.smartFollow ~= false - config.aggressiveFollow = charConfig.aggressiveFollow ~= false - end - - -- Migration from ProfileStorage - local profileConfig = getProfileSetting("followPlayer") - if profileConfig and profileConfig.playerName and config.playerName == "" then - config.playerName = profileConfig.playerName - CharacterDB.set("tools.followPlayer", config) - end - else - -- Fallback to ProfileStorage - local profileConfig = getProfileSetting("followPlayer") - if profileConfig then - config.enabled = profileConfig.enabled or false - config.playerName = profileConfig.playerName or "" - config.followWhileAttacking = profileConfig.followWhileAttacking ~= false - config.maxDistance = profileConfig.maxDistance or 4 - end - end - - return config -end - -local followPlayerConfig = loadFollowPlayerConfig() - --- Forward decl for UI switch so helper can sync it -local followPlayerToggle = nil - --- State tracking (v4.0: Enhanced for parallel attack+follow) -local followState = { - targetPlayerId = nil, - targetPlayerCreature = nil, - targetPlayerPosition = nil, - lastFollowAttempt = 0, - lastPathFollow = 0, - lastNativeFollow = 0, -- Track native follow attempts - lastWalkStep = 0, -- Track manual walk steps - lastAttackReissue = 0, -- NEW v4.0: Track attack re-issues - combatStartTime = 0, - pathToLeader = nil, - pathTime = 0, - lastLeaderDistance = 0, - lostLeaderTime = 0, - forceFollowMode = false, -- When true, prioritize following over combat - consecutiveFollowFails = 0, -- Track native follow failures - lastSuccessfulFollow = 0, -- When native follow last worked - walkingToLeader = false, -- Currently path-walking to leader - parallelMode = false, -- NEW v4.0: Using parallel attack+follow - currentAttackTarget = nil, -- NEW v4.0: Current attack target creature - currentAttackTargetId = nil, -- NEW v4.0: Current attack target ID - pathIndex = 1, -- NEW v4.0: Current position in path -} - --- v4.0: Tuned timings for parallel attack+follow -local FOLLOW_ATTEMPT_COOLDOWN = 50 -- Very fast checks -local PATH_RECALC_INTERVAL = 200 -- Path recalc interval -local NATIVE_FOLLOW_COOLDOWN = 200 -- Cooldown between native follow attempts -local WALK_STEP_COOLDOWN = 150 -- Cooldown between manual walk steps (slightly slower for stability) -local ATTACK_REISSUE_INTERVAL = 800 -- NEW v4.0: Re-issue attack every 800ms to maintain lock -local FORCE_FOLLOW_DISTANCE = 5 -- Distance at which we force follow over combat -local MAX_FOLLOW_FAILS = 3 -- After this many fails, switch to path-walking -local PATH_PARAMS = { ignoreCreatures = true, ignoreCost = true, allowNotSeenTiles = true } - --- Helper: save follow player settings -local function saveFollowPlayerConfig() - if CharacterDB and CharacterDB.isReady and CharacterDB.isReady() then - CharacterDB.set("tools.followPlayer", { - enabled = followPlayerConfig.enabled, - playerName = followPlayerConfig.playerName, - followWhileAttacking = followPlayerConfig.followWhileAttacking, - maxDistance = followPlayerConfig.maxDistance, - combatWindowMs = followPlayerConfig.combatWindowMs, - smartFollow = followPlayerConfig.smartFollow, - aggressiveFollow = followPlayerConfig.aggressiveFollow - }) - else - setProfileSetting("followPlayer", followPlayerConfig) - end -end - --- Safe creature getters -local function safeGetId(creature) - if not creature then return nil end - local ok, id = pcall(function() return creature:getId() end) - return ok and id or nil -end - -local function safeGetPosition(creature) - if not creature then return nil end - local ok, pos = pcall(function() return creature:getPosition() end) - return ok and pos or nil -end - -local function safeGetName(creature) - if not creature then return nil end - local ok, name = pcall(function() return creature:getName() end) - return ok and name or nil -end - -local function safeIsDead(creature) - if not creature then return true end - local ok, dead = pcall(function() return creature:isDead() end) - return ok and dead or true -end - --- Calculate distance between positions (Chebyshev distance) -local function calcDistance(pos1, pos2) - if not pos1 or not pos2 then return 999 end - if pos1.z ~= pos2.z then return 999 end - return math.max(math.abs(pos1.x - pos2.x), math.abs(pos1.y - pos2.y)) -end - --- Helper: Find player by name using OTClient's native functions for better performance -local function findPlayerByName(name) - if not name or name == "" then return nil end - - -- First try exact match using OTClient helper (works for off-screen known creatures) - local exact = SafeCall.getCreatureByName(name, true) - if exact then - local okPlayer, isPlayer = pcall(function() return exact:isPlayer() end) - local okLocal, isLocal = pcall(function() return exact:isLocalPlayer() end) - if okPlayer and isPlayer and (not okLocal or not isLocal) then - return exact - end - end - - -- Fallback: Search through visible spectators for partial matches - local lname = name:lower() - local spectators = SafeCall.global("getSpectators") or {} - - for i, c in ipairs(spectators) do - local okPlayer, isPlayer = pcall(function() return c and c:isPlayer() end) - local okLocal, isLocal = pcall(function() return c and c:isLocalPlayer() end) - if okPlayer and isPlayer and (not okLocal or not isLocal) then - local cname = safeGetName(c) - if cname and (cname:lower() == lname or cname:lower():find(lname, 1, true)) then - return c - end - end - end - - return nil -end - --- v3.0: Enhanced follow with OpenTibiaBR fallbacks -local function followStartCreature(creature) - if not creature then return false end - - local currentTime = now or (os.time() * 1000) - - -- Store reference - followState.targetPlayerId = safeGetId(creature) - followState.targetPlayerCreature = creature - followState.targetPlayerPosition = safeGetPosition(creature) - followState.lastNativeFollow = currentTime - - -- Use native follow API with ClientService fallback - local ok = pcall(function() - local Client = getClient() - -- Try multiple follow methods for OpenTibiaBR compatibility - if Client and Client.follow then - Client.follow(creature) - elseif g_game and g_game.follow then - g_game.follow(creature) - elseif follow then - follow(creature) - else - SafeCall.global("follow", creature) - end - end) - +local Follow = nil +do + local ok, result = pcall(dofile, "/core/follow.lua") if ok then - followState.lastSuccessfulFollow = currentTime - followState.consecutiveFollowFails = 0 - else - followState.consecutiveFollowFails = followState.consecutiveFollowFails + 1 + Follow = result end - - return ok + if not Follow then Follow = nExBot.Follow end + if not Follow then print("[Follow] Failed to load: dofile returned nil and nExBot.Follow not set") end end - --- Follow manager: stop following -local function followStop() - local Client = getClient() - if Client and Client.cancelFollow then - pcall(Client.cancelFollow) - elseif g_game and g_game.cancelFollow then - pcall(g_game.cancelFollow) - end - followState.targetPlayerId = nil - followState.targetPlayerCreature = nil - followState.targetPlayerPosition = nil - followState.pathToLeader = nil - followState.forceFollowMode = false - followState.consecutiveFollowFails = 0 - followState.walkingToLeader = false +if Follow and Follow.loadConfig then + Follow.loadConfig() end --- Check if we're currently following our target player -local function isFollowingTarget() - if not followState.targetPlayerId then return false end - local Client = getClient() - local currentFollow = nil - - -- Try multiple methods for OpenTibiaBR - if Client and Client.getFollowingCreature then - currentFollow = Client.getFollowingCreature() - elseif g_game and g_game.getFollowingCreature then - currentFollow = g_game.getFollowingCreature() +local followPlayerMacro = macro(75, "Follow Player", function() + if Follow and Follow.tick then + Follow.tick() end - - if not currentFollow then return false end - return safeGetId(currentFollow) == followState.targetPlayerId -end - --- v3.0: Enhanced path calculation with OpenTibiaBR g_map.findPath -local function getPathToLeader(leaderPos) - local Client = getClient() - local localPlayer = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) - if not localPlayer or not leaderPos then return nil, 999 end - - local playerPos = safeGetPosition(localPlayer) - if not playerPos then return nil, 999 end - - -- Different floor = can't path - if playerPos.z ~= leaderPos.z then return nil, 999 end - - -- Already adjacent = no path needed - local dist = calcDistance(playerPos, leaderPos) - if dist <= 1 then return {}, 0 end - - -- Try OpenTibiaBR native g_map.findPath first (most accurate) - local path = nil - local flags = 1 + 16 -- ALLOW_NOT_SEEN + IGNORE_CREATURES - - if g_map and g_map.findPath then - local ok, result, _ = pcall(function() - return g_map.findPath(playerPos, leaderPos, 50, flags) - end) - if ok and result and #result > 0 then - path = result - end - end - - -- Fallback to bot's findPath - if not path then - if findPath then - local ok, result = pcall(function() return findPath(playerPos, leaderPos, 20, PATH_PARAMS) end) - if ok then path = result end - elseif getPath then - local ok, result = pcall(function() return getPath(playerPos, leaderPos, 20, PATH_PARAMS) end) - if ok then path = result end - end - end - - local pathLen = path and #path or 999 - return path, pathLen -end - --- v3.0: Aggressive walking to leader (for when native follow fails) -local function walkStepToLeader(leaderPos) - if not leaderPos then return false end - - local currentTime = now or (os.time() * 1000) - if (currentTime - followState.lastWalkStep) < WALK_STEP_COOLDOWN then - return false - end - - local Client = getClient() - local localPlayer = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) - if not localPlayer then return false end - - -- Don't walk while already walking - local isWalking = localPlayer.isWalking and localPlayer:isWalking() - if isWalking then return false end - - local playerPos = safeGetPosition(localPlayer) - if not playerPos then return false end - - local path, pathLen = getPathToLeader(leaderPos) - - if path and pathLen > 0 then - followState.pathToLeader = path - followState.pathTime = currentTime - followState.walkingToLeader = true - - -- Take the first step - local nextDir = path[1] - if nextDir then - followState.lastWalkStep = currentTime - - -- Use native walk - local walkOk = false - if localPlayer.walk then - walkOk = pcall(function() localPlayer:walk(nextDir) end) - end - if not walkOk and g_game and g_game.walk then - walkOk = pcall(function() g_game.walk(nextDir) end) - end - if not walkOk and walk then - walkOk = pcall(function() walk(nextDir) end) - end - - return walkOk - end - end - - return false -end - --- v3.0: Smart walk with MovementCoordinator integration -local function smartWalkToLeader(leaderPos) - if not leaderPos then return false end - if not followPlayerConfig.smartFollow then return false end - - local currentTime = now or (os.time() * 1000) - if (currentTime - followState.lastPathFollow) < PATH_RECALC_INTERVAL then - return false - end - followState.lastPathFollow = currentTime - - local Client = getClient() - local localPlayer = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) - if not localPlayer then return false end - - local playerPos = safeGetPosition(localPlayer) - if not playerPos then return false end - - local path, pathLen = getPathToLeader(leaderPos) - - if path and pathLen > 0 and pathLen < 25 then - followState.pathToLeader = path - followState.pathTime = currentTime - - -- Use MovementCoordinator if available (integrates with TargetBot) - if MovementCoordinator and MovementCoordinator.Intent then - local confidence = 0.88 -- High priority for following leader - if followState.forceFollowMode then - confidence = 0.96 -- Very high when in force follow mode - end - - MovementCoordinator.Intent.register( - MovementCoordinator.CONSTANTS and MovementCoordinator.CONSTANTS.INTENT and - MovementCoordinator.CONSTANTS.INTENT.FOLLOW or "follow", - leaderPos, - confidence, - "party_follow", - { leader = followPlayerConfig.playerName, distance = pathLen } - ) - return true - end - - -- Fallback: Use autoWalk for multi-step paths - if pathLen > 1 then - local ok = false - if localPlayer.autoWalk then - ok = pcall(function() localPlayer:autoWalk(path) end) - end - if ok then - followState.walkingToLeader = true - return true - end - end - - -- Final fallback: Manual single step - return walkStepToLeader(leaderPos) - end - - return false -end - --- ═══════════════════════════════════════════════════════════════════════════ --- PARALLEL ATTACK+FOLLOW SYSTEM v4.0 --- Walk toward leader while maintaining attack on current target --- Key insight: Native follow() cancels attack, but walk() does not! --- ═══════════════════════════════════════════════════════════════════════════ - --- Re-issue attack command to maintain target lock -local function reissueAttack() - if not followState.currentAttackTarget then return false end - - -- Defer to AttackStateMachine when active (prevents competing attack commands) - if AttackStateMachine and AttackStateMachine.isActive and AttackStateMachine.isActive() then - return true -- ASM handles attack persistence - end - - local currentTime = now or (os.time() * 1000) - if (currentTime - followState.lastAttackReissue) < ATTACK_REISSUE_INTERVAL then - return true -- Still within interval, attack should be active - end - - -- Verify target is still valid - local target = followState.currentAttackTarget - if safeIsDead(target) then - followState.currentAttackTarget = nil - followState.currentAttackTargetId = nil - followState.parallelMode = false - return false - end - - -- Re-issue attack command - local Client = getClient() - local attackOk = false - - if Client and Client.attack then - attackOk = pcall(function() Client.attack(target) end) - elseif g_game and g_game.attack then - attackOk = pcall(function() g_game.attack(target) end) - elseif attack then - attackOk = pcall(function() attack(target) end) - end - - if attackOk then - followState.lastAttackReissue = currentTime - end - - return attackOk -end - --- Get current attack target from game state -local function getCurrentAttackTarget() - local Client = getClient() - local target = nil - - if Client and Client.getAttackingCreature then - target = Client.getAttackingCreature() - elseif g_game and g_game.getAttackingCreature then - target = g_game.getAttackingCreature() - end - - return target -end - --- Walk one step toward leader using forceWalk (OpenTibiaBR) or regular walk --- This does NOT cancel the attack! -local function parallelWalkStep(leaderPos) - if not leaderPos then return false end - - local currentTime = now or (os.time() * 1000) - if (currentTime - followState.lastWalkStep) < WALK_STEP_COOLDOWN then - return false - end - - local Client = getClient() - local localPlayer = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) - if not localPlayer then return false end - - -- Check if we're currently walking - local isWalking = localPlayer.isWalking and localPlayer:isWalking() - if isWalking then return false end - - local playerPos = safeGetPosition(localPlayer) - if not playerPos then return false end - - -- Calculate path to leader - local path, pathLen = getPathToLeader(leaderPos) - - if not path or pathLen == 0 then - -- Already adjacent or can't path - return false - end - - -- Get next direction to walk - local nextDir = path[1] - if not nextDir then return false end - - followState.lastWalkStep = currentTime - followState.pathToLeader = path - followState.pathIndex = 1 - - -- Use OpenTibiaBR forceWalk if available (most reliable) - local walkOk = false - if g_game and g_game.forceWalk then - walkOk = pcall(function() g_game.forceWalk(nextDir) end) - end - - -- Fallback to regular walk - if not walkOk then - if localPlayer.walk then - walkOk = pcall(function() localPlayer:walk(nextDir) end) - end - if not walkOk and g_game and g_game.walk then - walkOk = pcall(function() g_game.walk(nextDir) end) - end - if not walkOk and walk then - walkOk = pcall(function() walk(nextDir) end) - end - end - - return walkOk -end - --- Main parallel attack+follow execution --- This runs when we're attacking AND need to follow the leader -local function executeParallelAttackFollow(leaderPos) - if not leaderPos then return false end - - local Client = getClient() - local localPlayer = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) - if not localPlayer then return false end - - local playerPos = safeGetPosition(localPlayer) - if not playerPos then return false end - - local distance = calcDistance(playerPos, leaderPos) - - -- If we're close enough, no need to walk - if distance <= 1 then - followState.parallelMode = false - return true - end - - -- Track current attack target for re-issuing - local currentTarget = getCurrentAttackTarget() - if currentTarget then - followState.currentAttackTarget = currentTarget - followState.currentAttackTargetId = safeGetId(currentTarget) - end - - -- Step 1: Re-issue attack to maintain target lock - reissueAttack() - - -- Step 2: Take a walk step toward leader - local walked = parallelWalkStep(leaderPos) - - -- Step 3: If we walked, re-issue attack again after a short delay - -- This ensures we don't lose the attack when walking - if walked and followState.currentAttackTarget then - -- Schedule attack re-issue after walk step completes - schedule(100, function() - reissueAttack() - end) - end - - return walked -end - --- Check combat window - should we stop fighting to follow? -local function shouldForceFollow() - if not followPlayerConfig.enabled then return false end - if not followState.targetPlayerPosition then return false end - - local Client = getClient() - local localPlayer = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) - if not localPlayer then return false end - - local playerPos = safeGetPosition(localPlayer) - if not playerPos then return false end - - local leaderPos = followState.targetPlayerPosition - local distance = calcDistance(playerPos, leaderPos) - followState.lastLeaderDistance = distance - - -- Check if we're too far from leader - if distance > FORCE_FOLLOW_DISTANCE then - -- Check if we've been in combat too long - local currentTime = now or (os.time() * 1000) - if followState.combatStartTime > 0 then - local combatDuration = currentTime - followState.combatStartTime - if combatDuration > (followPlayerConfig.combatWindowMs or 1500) then - return true - end - end - - -- Or if we're very far, force follow immediately - if distance > (followPlayerConfig.maxDistance or 4) + 2 then - return true - end - end - - return false -end - --- v4.0: Main follow logic - PARALLEL attack+follow for OpenTibiaBR -local function ensureFollowing() - if not followPlayerConfig.enabled then return end - - local currentTime = now or (os.time() * 1000) - if (currentTime - followState.lastFollowAttempt) < FOLLOW_ATTEMPT_COOLDOWN then return end - followState.lastFollowAttempt = currentTime - - local name = followPlayerConfig.playerName and followPlayerConfig.playerName:trim() or "" - if name == "" then return end - - local Client = getClient() - local localPlayer = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) - if not localPlayer then return end - - local playerPos = safeGetPosition(localPlayer) - if not playerPos then return end - - -- Try to find the target player first - local target = findPlayerByName(name) - - if target then - -- Update our cached reference - followState.targetPlayerId = safeGetId(target) - followState.targetPlayerCreature = target - followState.targetPlayerPosition = safeGetPosition(target) - followState.lostLeaderTime = 0 - - local leaderPos = followState.targetPlayerPosition - local distance = calcDistance(playerPos, leaderPos) - followState.lastLeaderDistance = distance - - -- Check if we're attacking - local isAttacking = (Client and Client.isAttacking) and Client.isAttacking() or (g_game and g_game.isAttacking and g_game.isAttacking()) - - if isAttacking then - if followState.combatStartTime == 0 then - followState.combatStartTime = currentTime - end - - -- ═══════════════════════════════════════════════════════════════════════ - -- v4.0 PARALLEL MODE: Walk toward leader while maintaining attack - -- This is the key change - we DON'T cancel attack, we walk AND attack! - -- ═══════════════════════════════════════════════════════════════════════ - - -- Check if followWhileAttacking is enabled - if followPlayerConfig.followWhileAttacking and distance > 1 then - followState.parallelMode = true - - -- Use parallel attack+follow system - executeParallelAttackFollow(leaderPos) - - -- Emit event for other modules to know we're in parallel mode - if EventBus then - pcall(function() - EventBus.emit("followplayer/parallel_mode", leaderPos, distance, followState.currentAttackTargetId) - end) - end - - return -- Don't use native follow, we're walking manually - end - - -- Check if we should force follow (only when followWhileAttacking is OFF) - if not followPlayerConfig.followWhileAttacking and shouldForceFollow() then - followState.forceFollowMode = true - followState.parallelMode = false - - -- Cancel attack to follow leader (party survival priority) - if Client and Client.cancelAttack then - pcall(Client.cancelAttack) - elseif g_game and g_game.cancelAttack then - pcall(g_game.cancelAttack) - end - - -- Emit event for TargetBot to know we're prioritizing follow - if EventBus then - pcall(function() - EventBus.emit("followplayer/force_follow", leaderPos, distance) - end) - end - elseif not followPlayerConfig.followWhileAttacking then - return -- Don't follow while attacking if option is disabled - end - else - -- Reset combat tracking when not attacking - followState.combatStartTime = 0 - followState.forceFollowMode = false - followState.parallelMode = false - followState.currentAttackTarget = nil - followState.currentAttackTargetId = nil - end - - -- ═══════════════════════════════════════════════════════════════════════ - -- NOT ATTACKING: Use native follow or smart walk - -- ═══════════════════════════════════════════════════════════════════════ - if not isAttacking then - -- Check if we're already following them - local currentFollow = (Client and Client.getFollowingCreature) and Client.getFollowingCreature() or (g_game and g_game.getFollowingCreature and g_game.getFollowingCreature()) - local isFollowing = currentFollow and safeGetId(currentFollow) == followState.targetPlayerId - - if not isFollowing then - -- Not following our target - try native follow first - local isWalking = localPlayer:isWalking() - - if not isWalking then - local followOk = followStartCreature(target) - - -- If native follow failed or we're far, use smart pathfinding - if (not followOk or distance > 3) and followPlayerConfig.smartFollow then - smartWalkToLeader(leaderPos) - end - end - elseif distance > 5 and followPlayerConfig.smartFollow then - -- We're "following" but still far - use smart walk to catch up - smartWalkToLeader(leaderPos) - end - end - else - -- Player not visible - if followState.lostLeaderTime == 0 then - followState.lostLeaderTime = currentTime - end - - -- Check if native follow is still tracking them - local currentFollow = (Client and Client.getFollowingCreature) and Client.getFollowingCreature() or (g_game and g_game.getFollowingCreature and g_game.getFollowingCreature()) - if currentFollow and followState.targetPlayerId and safeGetId(currentFollow) == followState.targetPlayerId then - -- Native follow is still tracking, update position - followState.targetPlayerPosition = safeGetPosition(currentFollow) - return - end - - -- If we have last known position and haven't been lost long, walk there - if followState.targetPlayerPosition and followPlayerConfig.smartFollow then - local timeLost = currentTime - followState.lostLeaderTime - if timeLost < 5000 then -- Try for 5 seconds - -- If attacking, use parallel mode - local isAttacking = (Client and Client.isAttacking) and Client.isAttacking() or (g_game and g_game.isAttacking and g_game.isAttacking()) - if isAttacking and followPlayerConfig.followWhileAttacking then - executeParallelAttackFollow(followState.targetPlayerPosition) - else - smartWalkToLeader(followState.targetPlayerPosition) - end - end - end - end -end - --- ═══════════════════════════════════════════════════════════════════════════ --- EVENT-DRIVEN FOLLOW (High performance using EventBus) --- Reacts instantly to player movements and combat state changes --- ═══════════════════════════════════════════════════════════════════════════ - -if EventBus then - -- v3.0: Aggressive creature:move handler for OpenTibiaBR - -- When followed player moves, ensure we're still following them - EventBus.on("creature:move", function(creature, oldPos) - if not followPlayerConfig.enabled then return end - - -- Safe player check - local okPlayer, isPlayer = pcall(function() return creature and creature:isPlayer() end) - if not okPlayer or not isPlayer then return end - if not followState.targetPlayerId then return end - - -- Check if this is our followed player - local creatureId = safeGetId(creature) - if creatureId ~= followState.targetPlayerId then return end - - -- Update last known position - local newPos = safeGetPosition(creature) - followState.targetPlayerPosition = newPos - followState.targetPlayerCreature = creature - - -- Check distance - local Client = getClient() - local localPlayer = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) - if localPlayer then - local playerPos = safeGetPosition(localPlayer) - if playerPos and newPos then - local distance = calcDistance(playerPos, newPos) - followState.lastLeaderDistance = distance - - -- v3.0: More aggressive - if distance > 2, ensure we're moving - if distance > 2 then - -- Check if we're actually following - local currentFollow = nil - if Client and Client.getFollowingCreature then - currentFollow = Client.getFollowingCreature() - elseif g_game and g_game.getFollowingCreature then - currentFollow = g_game.getFollowingCreature() - end - - local isFollowing = currentFollow and safeGetId(currentFollow) == followState.targetPlayerId - local isWalking = localPlayer.isWalking and localPlayer:isWalking() - - -- If not following and not walking, we're frozen - fix it! - if not isFollowing and not isWalking then - -- Immediate re-follow attempt - followStartCreature(creature) - - -- Also try smart walk as backup - if distance > 3 and followPlayerConfig.smartFollow then - schedule(50, function() - if followPlayerConfig.enabled then - smartWalkToLeader(newPos) - end - end) - end - elseif distance > 4 then - -- Even if following, verify we're making progress - schedule(25, ensureFollowing) - end - end - end - end - end, 30) -- Higher priority for party following - - -- When we stop attacking, immediately resume following - EventBus.on("combat:end", function() - if not followPlayerConfig.enabled then return end - if not followState.targetPlayerId then return end - - followState.combatStartTime = 0 - followState.forceFollowMode = false - - -- Immediate follow resume - schedule(50, ensureFollowing) - end, 20) - - -- When we start attacking, track combat start time - EventBus.on("combat:start", function(creature) - if not followPlayerConfig.enabled then return end - - local currentTime = now or (os.time() * 1000) - if followState.combatStartTime == 0 then - followState.combatStartTime = currentTime - end - end, 15) - - -- When target changes (new attack target), manage combat window - EventBus.on("combat:target", function(creature, oldCreature) - if not followPlayerConfig.enabled then return end - if not followPlayerConfig.followWhileAttacking then return end - if not followState.targetPlayerId then return end - - local currentTime = now or (os.time() * 1000) - - if creature then - -- Started attacking new target - if oldCreature == nil then - -- Fresh combat start - followState.combatStartTime = currentTime - end - -- Continue following even while attacking - schedule(100, ensureFollowing) - else - -- Stopped attacking - followState.combatStartTime = 0 - followState.forceFollowMode = false - schedule(50, ensureFollowing) - end - end, 15) - - -- When player moves (self), check if we need to catch up - EventBus.on("player:move", function(newPos, oldPos) - if not followPlayerConfig.enabled then return end - if not followState.targetPlayerPosition then return end - - local distance = calcDistance(newPos, followState.targetPlayerPosition) - followState.lastLeaderDistance = distance - - -- If we're getting close, native follow should work - -- If we're still far, keep smart walking - if distance > 5 then - schedule(100, ensureFollowing) - end - end, 10) - - -- When followed player appears (comes into view), start following - EventBus.on("creature:appear", function(creature) - if not followPlayerConfig.enabled then return end - - -- Safe player check - local okPlayer, isPlayer = pcall(function() return creature and creature:isPlayer() end) - if not okPlayer or not isPlayer then return end - - local name = followPlayerConfig.playerName and followPlayerConfig.playerName:trim():lower() or "" - if name == "" then return end - - local creatureName = safeGetName(creature) - if creatureName then - creatureName = creatureName:lower() - if creatureName == name or creatureName:find(name, 1, true) then - -- Our target player appeared! Start following - followState.lostLeaderTime = 0 - followStartCreature(creature) - end - end - end, 30) - - -- When followed player disappears, track lost time but don't clear state - EventBus.on("creature:disappear", function(creature) - if not creature then return end - local creatureId = safeGetId(creature) - if followState.targetPlayerId and creatureId == followState.targetPlayerId then - -- Player went out of view - followState.targetPlayerCreature = nil - followState.lostLeaderTime = now or (os.time() * 1000) - -- Keep targetPlayerId and targetPlayerPosition for recovery - end - end, 15) - - -- Integration with TargetBot: When TargetBot wants to move, coordinate - EventBus.on("targetbot/movement_intent", function(intentType, targetPos, confidence) - if not followPlayerConfig.enabled then return end - if not followState.forceFollowMode then return end - - -- In force follow mode, reject low-confidence movement intents - -- This helps prevent getting pulled away by TargetBot when we need to catch up - if confidence and confidence < 0.80 then - -- Emit that we're overriding - pcall(function() - EventBus.emit("followplayer/override_movement", intentType, "force_follow_active") - end) - end - end, 5) -- Very high priority to intercept - - -- Listen for TargetBot allowing CaveBot (we can piggyback on this) - EventBus.on("targetbot/cavebot_allowed", function() - if not followPlayerConfig.enabled then return end - -- Good time to ensure following since TargetBot isn't busy - schedule(25, ensureFollowing) - end, 10) -end - --- v3.0: Faster backup macro for OpenTibiaBR responsiveness --- Runs every 75ms instead of 200ms to prevent "freeze" issues -local followPlayerMacro = macro(75, function() - ensureFollowing() end) --- Small status indicator (non-intrusive) -local followStatusLabel = UI.Label((followPlayerConfig.playerName and followPlayerConfig.playerName ~= "") and ("Target: "..followPlayerConfig.playerName) or "Target: -") -followStatusLabel:setId("followStatusLabel") -followStatusLabel:setTooltip("Shows current follow target, distance, and status") - --- Helper to update status label -local function updateFollowStatusLabel() - local Client = getClient() - local current = (Client and Client.getFollowingCreature) and Client.getFollowingCreature() or (g_game and g_game.getFollowingCreature and g_game.getFollowingCreature()) - local isAttacking = (Client and Client.isAttacking) and Client.isAttacking() or (g_game and g_game.isAttacking and g_game.isAttacking()) - local distance = followState.lastLeaderDistance or 0 - - if current and followState.targetPlayerId and safeGetId(current) == followState.targetPlayerId then - local suffix = "" - if followState.forceFollowMode then - suffix = " [CATCH UP!]" - elseif isAttacking then - suffix = " (attacking)" +if Follow then + local lastMacroState = nil + schedule(500, function() + local macroOn = followPlayerMacro:isOn() + if macroOn ~= lastMacroState then + lastMacroState = macroOn + Follow.setEnabled(macroOn) + Follow.saveConfig() end - followStatusLabel:setText("Following: " .. safeGetName(current) .. " [" .. distance .. "m]" .. suffix) - elseif followState.targetPlayerId and followState.targetPlayerCreature then - local name = safeGetName(followState.targetPlayerCreature) or "..." - followStatusLabel:setText("Tracking: " .. name .. " [" .. distance .. "m]") + end) + if Follow.getConfig and Follow.getConfig().enabled then + followPlayerMacro:setOn() else - followStatusLabel:setText("Target: " .. (followPlayerConfig.playerName ~= "" and followPlayerConfig.playerName or "-")) - end -end - --- Update label periodically -schedule(500, function() - if followStatusLabel and followStatusLabel:isVisible() then - updateFollowStatusLabel() + followPlayerMacro:setOff() end -end) - --- Initialize macro state based on config -if followPlayerConfig.enabled then - followPlayerMacro:setOn() -else - followPlayerMacro:setOff() end --- Helper: sync state, UI, and side effects -local function setFollowEnabled(state) - followPlayerConfig.enabled = state - saveFollowPlayerConfig() - if followPlayerToggle then - followPlayerToggle:setOn(state) - end +if Follow then + UI.Label("Auto Follow") - if followPlayerMacro then - if state then - pcall(function() followPlayerMacro:setOn() end) - - -- If we have a name set, attempt immediate follow - if followPlayerConfig.playerName and followPlayerConfig.playerName ~= "" then - local tgt = findPlayerByName(followPlayerConfig.playerName) - if tgt then - followStartCreature(tgt) - end - end - - -- Emit event for other modules - if EventBus then - pcall(function() - EventBus.emit("followplayer/enabled", followPlayerConfig.playerName) - end) - end - else - pcall(function() followPlayerMacro:setOff() end) - followStop() - - if EventBus then - pcall(function() - EventBus.emit("followplayer/disabled") - end) - end - end - end -end - --- Target input -UI.Label("Target:") - -local followPlayerNameEdit = UI.TextEdit(followPlayerConfig.playerName, function(widget, text) - followPlayerConfig.playerName = text:trim() - saveFollowPlayerConfig() - -- If enabled, attempt to follow immediately - if followPlayerConfig.enabled and followPlayerConfig.playerName ~= "" then - local tgt = findPlayerByName(followPlayerConfig.playerName) - if tgt then - followStartCreature(tgt) - else - followStop() - end - end -end) + UI.Label("Target:") + local followPlayerNameEdit = UI.TextEdit(Follow.getConfig().playerName, function(widget, text) + Follow.setPlayerName(text:trim()) + Follow.saveConfig() + end) --- Follow while attacking toggle -local followWhileAttackingUI = setupUI([[ + local followWhileAttackingUI = setupUI([[ Panel height: 19 @@ -1895,30 +884,13 @@ Panel tooltip: Keep following player even when attacking monsters with TargetBot ]]) -followWhileAttackingUI.followWhileAttackingToggle:setOn(followPlayerConfig.followWhileAttacking) -followWhileAttackingUI.followWhileAttackingToggle.onClick = function(widget) - followPlayerConfig.followWhileAttacking = not followPlayerConfig.followWhileAttacking - widget:setOn(followPlayerConfig.followWhileAttacking) - saveFollowPlayerConfig() -end - -local followToggleUI = setupUI([[ -Panel - height: 19 - - BotSwitch - id: followPlayerToggle - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - text-align: center - !text: tr('Follow') -]]) - -followPlayerToggle = followToggleUI.followPlayerToggle -followPlayerToggle:setOn(followPlayerConfig.enabled) -followPlayerToggle.onClick = function(widget) - setFollowEnabled(not followPlayerConfig.enabled) + followWhileAttackingUI.followWhileAttackingToggle:setOn(Follow.getConfig().followWhileAttacking) + followWhileAttackingUI.followWhileAttackingToggle.onClick = function(widget) + local cfg = Follow.getConfig() + cfg.followWhileAttacking = not cfg.followWhileAttacking + widget:setOn(cfg.followWhileAttacking) + Follow.saveConfig() + end end UI.Separator() diff --git a/core/unified_storage.lua b/core/unified_storage.lua index 3012d65..902a81b 100644 --- a/core/unified_storage.lua +++ b/core/unified_storage.lua @@ -1,753 +1,180 @@ ---[[ - ═══════════════════════════════════════════════════════════════════════════ - UNIFIED STORAGE - Per-Character State Management with EventBus Persistence - ═══════════════════════════════════════════════════════════════════════════ - - Version: 1.0.1 (ClientService refactor for cross-client compatibility) - - Purpose: - - Provides COMPLETE per-CHARACTER storage isolation for multi-client setups - - Each character gets their own JSON file for ALL configurations - - Prevents configuration overlap between characters running simultaneously - - Real-time persistence via EventBus (no data loss on crash/disconnect) - - Architecture (SOLID Principles): - - SRP: Single Responsibility - One module for unified character storage - - OCP: Open/Closed - Extensible via modules without modifying core - - LSP: Modules use consistent interface - - ISP: Clean, minimal public API - - DIP: Depends on abstractions (EventBus), not concretions - - Key Features: - - Character-based isolation (not profile-based) - - Debounced writes for performance - - EventBus integration for real-time persistence - - Automatic migration from legacy profile storage - - Schema validation with defaults - - Dirty tracking for efficient saves - - File Structure: - /bot/{config}/nExBot_configs/characters/{charName}/ - ├── UnifiedStorage.json (all settings) - ├── CharacterDB.json (legacy, for backward compat) - └── backups/ - └── UnifiedStorage_{timestamp}.json - - Usage: - UnifiedStorage.get("targetbot.priority") -- Read nested value - UnifiedStorage.set("targetbot.priority", value) -- Write with auto-save - UnifiedStorage.getModule("targetbot") -- Get entire module config - UnifiedStorage.setModule("targetbot", config) -- Set entire module config - UnifiedStorage.batch({...}) -- Batch updates (single save) - UnifiedStorage.onReady(function() ... end) -- Wait for player to be available -]]-- - --- ═══════════════════════════════════════════════════════════════════════════ --- CONFIGURATION --- ═══════════════════════════════════════════════════════════════════════════ - -local CONFIG = { - SAVE_DEBOUNCE_MS = 300, -- Batch saves within this window (fast response) - BACKUP_INTERVAL_MS = 300000, -- Backup every 5 minutes - MAX_FILE_SIZE = 10 * 1024 * 1024, -- 10MB max - SCHEMA_VERSION = 1, - MAX_BACKUPS = 5, -- Keep last 5 backups -} - --- ═══════════════════════════════════════════════════════════════════════════ --- CLIENT SERVICE HELPERS (Cross-client compatibility: OTCv8 / OpenTibiaBR) --- ═══════════════════════════════════════════════════════════════════════════ - +local StorageEngine = nExBot.StorageEngine +if not StorageEngine then + warn("[UnifiedStorage] StorageEngine not loaded") + return +end local getClient = nExBot.Shared.getClient +local deepClone = nExBot.Shared.deepClone --- ═══════════════════════════════════════════════════════════════════════════ --- SCHEMA DEFINITION (Single Source of Truth for All Defaults) --- ═══════════════════════════════════════════════════════════════════════════ - -local SCHEMA = { - version = CONFIG.SCHEMA_VERSION, - characterName = "", - createdAt = 0, - lastModified = 0, - - -- Targetbot settings - targetbot = { - enabled = false, - selectedConfig = "", - priority = { - enabled = true, - emergencyHP = 25, - combatTimeout = 12, - scanRadius = 2, +local engine = StorageEngine.new({ + filename = "UnifiedStorage.json", + pathStrategy = "character", + debounceMs = 300, + maxFileSize = 10 * 1024 * 1024, + defaults = { + version = 1, characterName = "", createdAt = 0, lastModified = 0, + targetbot = { + enabled = false, selectedConfig = "", + priority = { enabled = true, emergencyHP = 25, combatTimeout = 12, scanRadius = 2 }, + monsterPatterns = {}, combatActive = false, emergency = false, }, - monsterPatterns = {}, - combatActive = false, - emergency = false, - }, - - -- Cavebot settings - cavebot = { - enabled = false, - selectedConfig = "", - walking = { - pathSmoothingEnabled = true, - floorChangeDelay = 200, - stuckTimeout = 5000, + cavebot = { + enabled = false, selectedConfig = "", + walking = { pathSmoothingEnabled = true, floorChangeDelay = 200, stuckTimeout = 5000 }, }, - }, - - -- Healbot settings - healbot = { - enabled = false, - rules = {}, - }, - - -- Attackbot settings - attackbot = { - enabled = false, - rules = {}, - }, - - -- New healer (friend healer) - newHealer = { - enabled = false, - priorities = {}, - settings = {}, - conditions = {}, - customPlayers = {}, - }, - - -- Macro toggle states - macros = { - exchangeMoney = false, - autoTradeMsg = false, - autoHaste = false, - autoMount = false, - manaTraining = false, - eatFood = false, - antiRs = false, - holdTarget = false, - exetaLowHp = false, - exetaIfPlayer = false, - depotWithdraw = false, - quiverManager = false, - fishing = false, - }, - - -- Tool settings - tools = { - manaTraining = { - spell = "exura", - minManaPercent = 80, + healbot = { enabled = false, rules = {} }, + attackbot = { enabled = false, rules = {} }, + newHealer = { enabled = false, priorities = {}, settings = {}, conditions = {}, customPlayers = {} }, + macros = { + exchangeMoney = false, autoTradeMsg = false, autoHaste = false, + autoMount = false, manaTraining = false, eatFood = false, + antiRs = false, holdTarget = false, exetaLowHp = false, + exetaIfPlayer = false, depotWithdraw = false, quiverManager = false, + fishing = false, }, - autoTradeMessage = "nExBot is online!", - fishing = { - dropFish = true, + tools = { + manaTraining = { spell = "exura", minManaPercent = 80 }, + autoTradeMessage = "nExBot is online!", + fishing = { dropFish = true }, }, + dropper = { enabled = false, trashItems = {}, useItems = {}, capItems = {} }, + equipper = { enabled = false, rules = {}, activeRule = nil }, + containers = { purse = true, autoMinimize = true, autoOpenOnLogin = false, containerList = {} }, + supplies = { eatFromCorpses = false, sellItems = {} }, + combobot = { enabled = false, spell = "", attack = "", follow = "" }, + analytics = { showOnStartup = false }, + extras = { looting = 40, lootLast = false }, }, - - -- Dropper settings - dropper = { - enabled = false, - trashItems = {}, - useItems = {}, - capItems = {}, - }, - - -- Equipment settings - equipper = { - enabled = false, - rules = {}, - activeRule = nil, - }, - - -- Container settings - containers = { - purse = true, - autoMinimize = true, - autoOpenOnLogin = false, - containerList = {}, - }, - - -- Supplies settings - supplies = { - eatFromCorpses = false, - sellItems = {}, - }, - - -- Combobot settings - combobot = { - enabled = false, - spell = "", - attack = "", - follow = "", - }, - - -- Analytics - analytics = { - showOnStartup = false, - }, - - -- Extras - extras = { - looting = 40, - lootLast = false, - }, -} - --- ═══════════════════════════════════════════════════════════════════════════ --- PURE UTILITY FUNCTIONS --- ═══════════════════════════════════════════════════════════════════════════ - --- Alias shared deepClone (DRY) -local deepClone = nExBot.Shared.deepClone - --- Deep merge two tables (source into target, returns new table) -local function deepMerge(target, source) - if type(target) ~= "table" then return deepClone(source) end - if type(source) ~= "table" then return target end - - local result = deepClone(target) - for k, v in pairs(source) do - if type(v) == "table" and type(result[k]) == "table" then - result[k] = deepMerge(result[k], v) - else - result[k] = deepClone(v) - end - end - return result -end +}) --- Get nested value by dot-separated path (pure function) -local function getPath(obj, path) - if type(obj) ~= "table" or type(path) ~= "string" then - return nil - end - - local current = obj - for key in path:gmatch("[^%.]+") do - if type(current) ~= "table" then return nil end - current = current[key] - end - return current -end - --- Set nested value by dot-separated path (returns new table, no mutation) -local function setPath(obj, path, value) - if type(path) ~= "string" then return obj end - - local result = deepClone(obj) or {} - local current = result - local keys = {} - - for key in path:gmatch("[^%.]+") do - keys[#keys + 1] = key - end - - for i = 1, #keys - 1 do - local key = keys[i] - if type(current[key]) ~= "table" then - current[key] = {} - end - current = current[key] - end - - if #keys > 0 then - current[keys[#keys]] = value - end - - return result -end +UnifiedStorage = {} +for k, v in pairs(engine) do UnifiedStorage[k] = v end --- Sanitize character name for filesystem -local function sanitizeCharName(name) - if not name then return nil end - return name:gsub("[/\\:*?\"<>|]", "_"):lower() -end +local _readyCallbacks = {} +local _backupScheduled = false +local _lastBackup = 0 +local BACKUP_INTERVAL = 300 +local MAX_BACKUPS = 5 --- Get timestamp -local function getTimestamp() - return os.time() +function UnifiedStorage.onReady(cb) + if UnifiedStorage.isReady() then pcall(cb) + else table.insert(_readyCallbacks, cb) end end --- Sanitize data against schema (ensures proper defaults) -local function sanitizeData(data, schema) - if type(schema) ~= "table" then - return data ~= nil and data or schema - end - - local result = {} - - -- Copy schema defaults first - for k, v in pairs(schema) do - if type(v) == "table" and not (v[1] ~= nil) then -- Not an array - local dataValue = (type(data) == "table") and data[k] or nil - result[k] = sanitizeData(dataValue, v) - else - if type(data) == "table" and data[k] ~= nil then - result[k] = data[k] - else - result[k] = deepClone(v) - end - end - end - - -- Preserve extra keys from data not in schema - if type(data) == "table" then - for k, v in pairs(data) do - if result[k] == nil then - result[k] = deepClone(v) - end - end +local _engineLoad = engine.load +function UnifiedStorage.load() + local result = _engineLoad() + if not result then return result end + if not UnifiedStorage.isReady() then return result end + local rawName = nil + if player and player.getName then pcall(function() rawName = player:getName() end) end + if not rawName then + local C = getClient() + local lp = (C and C.getLocalPlayer) and C.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) + if lp then rawName = lp:getName() end end - + result.characterName = rawName or UnifiedStorage.getCharName() + if not result.createdAt or result.createdAt == 0 then result.createdAt = os.time() end + for _, cb in ipairs(_readyCallbacks) do pcall(cb) end + _readyCallbacks = {} + if EventBus then EventBus.emit("storage:initialized", UnifiedStorage.getCharName()) end return result end --- ═══════════════════════════════════════════════════════════════════════════ --- PRIVATE STATE --- ═══════════════════════════════════════════════════════════════════════════ - -local configName = modules.game_bot.contentsPanel.config:getCurrentOption().text - -local _state = { - cache = nil, -- In-memory cache - dirty = false, -- Has unsaved changes - saveScheduled = false, -- Is a save pending - initialized = false, -- Has been loaded - charName = nil, -- Character name (sanitized) - basePath = nil, -- Base path for this character - dbFile = nil, -- Full path to UnifiedStorage.json - readyCallbacks = {}, -- Callbacks waiting for initialization - backupScheduled = false, - lastBackup = 0, -} - --- ═══════════════════════════════════════════════════════════════════════════ --- FILE I/O (Isolated side effects) --- ═══════════════════════════════════════════════════════════════════════════ - --- Get character name safely -local function getCharName() - if _state.charName then return _state.charName end - - -- Try global player first - if player and player.getName then - local ok, name = pcall(function() return player:getName() end) - if ok and name then - _state.charName = sanitizeCharName(name) - return _state.charName - end - end - - -- Fallback to g_game.getLocalPlayer() with ClientService - local Client = getClient() - local localPlayer = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) - if localPlayer and localPlayer:getName() then - _state.charName = sanitizeCharName(localPlayer:getName()) - return _state.charName - end - - return nil -end - --- Build paths for current character -local function buildPaths() - local charName = getCharName() - if not charName then return false end - - _state.basePath = "/bot/" .. configName .. "/nExBot_configs/characters/" .. charName .. "/" - _state.dbFile = _state.basePath .. "UnifiedStorage.json" - return true -end - --- Ensure directory exists -local function ensureDir() - if not _state.basePath then return end - - local parentPath = "/bot/" .. configName .. "/nExBot_configs/characters/" - if not g_resources.directoryExists(parentPath) then - g_resources.makeDir(parentPath) - end - - if not g_resources.directoryExists(_state.basePath) then - g_resources.makeDir(_state.basePath) - end - - local backupPath = _state.basePath .. "backups/" - if not g_resources.directoryExists(backupPath) then - g_resources.makeDir(backupPath) - end -end - --- Read file from disk -local function readFile() - if not _state.dbFile then return nil end - if not g_resources.fileExists(_state.dbFile) then return nil end - - local status, content = pcall(function() - return g_resources.readFileContents(_state.dbFile) - end) - - if not status or not content then return nil end - - local parseStatus, data = pcall(function() - return json.decode(content) - end) - - if not parseStatus or type(data) ~= "table" then return nil end - return data +local _engineSet = engine.set +function UnifiedStorage.set(path, value) + local r = _engineSet(path, value) + if EventBus then EventBus.emit("storage:changed", path, value, UnifiedStorage.getCharName()) end + return r end --- Write file to disk -local function writeFile(data) - if not _state.dbFile or not data then return false end - - -- Update lastModified - data.lastModified = getTimestamp() - - local encodeStatus, content = pcall(function() - return json.encode(data, 2) - end) - - if not encodeStatus or not content then return false end - if #content > CONFIG.MAX_FILE_SIZE then - warn("[UnifiedStorage] File too large, not saving") - return false - end - - ensureDir() - - local writeStatus = pcall(function() - g_resources.writeFileContents(_state.dbFile, content) - end) - - if writeStatus then - -- Emit save event for debugging/telemetry - if EventBus then - EventBus.emit("storage:saved", _state.charName, #content) - end - end - - return writeStatus +local _engineBatch = engine.batch +function UnifiedStorage.batch(updates) + _engineBatch(updates) + if EventBus then EventBus.emit("storage:batchChanged", updates, UnifiedStorage.getCharName()) end end --- Create backup local function createBackup() - if not _state.cache or not _state.dbFile then return end - - local backupPath = _state.basePath .. "backups/" - local timestamp = os.date("%Y%m%d_%H%M%S") - local backupFile = backupPath .. "UnifiedStorage_" .. timestamp .. ".json" - - local content = json.encode(_state.cache, 2) - if content then - pcall(function() - g_resources.writeFileContents(backupFile, content) - end) - end - - -- Cleanup old backups + local data = UnifiedStorage.getData() + if not data then return end + local stats = engine.getStats() + if not stats.basePath then return end + local backupDir = stats.basePath .. "backups/" + if not g_resources.directoryExists(backupDir) then g_resources.makeDir(backupDir) end + local ts = os.date("%Y%m%d_%H%M%S") + local backupFile = backupDir .. "UnifiedStorage_" .. ts .. ".json" + local content = json.encode(data, 2) + if content then pcall(function() g_resources.writeFileContents(backupFile, content) end) end pcall(function() - local files = g_resources.listDirectoryFiles(backupPath, false, false) - if files and #files > CONFIG.MAX_BACKUPS then + local files = g_resources.listDirectoryFiles(backupDir, false, false) + if files and #files > MAX_BACKUPS then table.sort(files) - for i = 1, #files - CONFIG.MAX_BACKUPS do - g_resources.deleteFile(backupPath .. files[i]) - end + for i = 1, #files - MAX_BACKUPS do g_resources.deleteFile(backupDir .. files[i]) end end end) - - _state.lastBackup = getTimestamp() -end - --- ═══════════════════════════════════════════════════════════════════════════ --- CORE FUNCTIONS --- ═══════════════════════════════════════════════════════════════════════════ - --- Load data from file -local function load() - if _state.initialized and _state.cache then return _state.cache end - - if not buildPaths() then - -- Player not available yet, return empty schema but don't cache - return deepClone(SCHEMA) - end - - local fileData = readFile() - _state.cache = sanitizeData(fileData, SCHEMA) - - -- Set character name in data - local rawName = nil - if player and player.getName then - pcall(function() rawName = player:getName() end) - end - if not rawName then - local Client = getClient() - local lp = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) - if lp then rawName = lp:getName() end - end - _state.cache.characterName = rawName or _state.charName - - -- Set createdAt if new - if not _state.cache.createdAt or _state.cache.createdAt == 0 then - _state.cache.createdAt = getTimestamp() - end - - _state.initialized = true - - -- Fire ready callbacks - for _, callback in ipairs(_state.readyCallbacks) do - pcall(callback) - end - _state.readyCallbacks = {} - - -- Emit initialization event - if EventBus then - EventBus.emit("storage:initialized", _state.charName) - end - - return _state.cache -end - --- Schedule debounced save -local function scheduleSave() - if _state.saveScheduled then return end - _state.saveScheduled = true - - schedule(CONFIG.SAVE_DEBOUNCE_MS, function() - _state.saveScheduled = false - if _state.dirty and _state.cache then - writeFile(_state.cache) - _state.dirty = false - - -- Check if backup is needed - local now = getTimestamp() - if now - _state.lastBackup > (CONFIG.BACKUP_INTERVAL_MS / 1000) then - createBackup() - end - end - end) -end - --- ═══════════════════════════════════════════════════════════════════════════ --- PUBLIC API: UnifiedStorage --- ═══════════════════════════════════════════════════════════════════════════ - -UnifiedStorage = {} - --- Check if UnifiedStorage is ready (player is logged in) -function UnifiedStorage.isReady() - return getCharName() ~= nil and _state.initialized -end - --- Wait for UnifiedStorage to be ready -function UnifiedStorage.onReady(callback) - if UnifiedStorage.isReady() then - pcall(callback) - else - table.insert(_state.readyCallbacks, callback) - end + _lastBackup = os.time() end --- Get current character name -function UnifiedStorage.getCharacterName() - return _state.charName or getCharName() -end - --- Get a value by path (pure read, no side effects) --- @param path: dot-separated path like "targetbot.priority.enabled" --- @return: the value or nil -function UnifiedStorage.get(path) - local data = load() - if not path then return data end - return getPath(data, path) -end - --- Set a value by path (triggers debounced save) --- @param path: dot-separated path like "targetbot.priority.enabled" --- @param value: the value to set --- @return: success boolean -function UnifiedStorage.set(path, value) - if not buildPaths() then - return false - end - - local data = load() - _state.cache = setPath(data, path, value) - _state.dirty = true - scheduleSave() - - -- Emit change event for real-time sync - if EventBus then - EventBus.emit("storage:changed", path, value, _state.charName) - end - - return true -end - --- Get with default fallback --- @param path: dot-separated path --- @param default: value to return if path is nil -function UnifiedStorage.getOr(path, default) - local value = UnifiedStorage.get(path) - if value == nil then return default end - return value -end - --- Toggle a boolean value --- @param path: dot-separated path --- @return: the new value -function UnifiedStorage.toggle(path) - local current = UnifiedStorage.get(path) - local newValue = not current - UnifiedStorage.set(path, newValue) - return newValue -end +function UnifiedStorage.backup() createBackup() end --- Get entire module configuration -function UnifiedStorage.getModule(moduleName) - return UnifiedStorage.get(moduleName) or deepClone(SCHEMA[moduleName] or {}) -end - --- Set entire module configuration -function UnifiedStorage.setModule(moduleName, config) - return UnifiedStorage.set(moduleName, config) -end - --- Batch update multiple values (single save) --- @param updates: table of {path = value} pairs -function UnifiedStorage.batch(updates) - if type(updates) ~= "table" then return end - - local data = load() - for path, value in pairs(updates) do - data = setPath(data, path, value) - end - _state.cache = data - _state.dirty = true - scheduleSave() - - -- Emit batch change event - if EventBus then - EventBus.emit("storage:batchChanged", updates, _state.charName) - end -end - --- Force save immediately (bypasses debounce) +local _engineSave = engine.save function UnifiedStorage.save() - if _state.cache then - writeFile(_state.cache) - _state.dirty = false - end + local data = UnifiedStorage.getData() + if data then data.lastModified = os.time() end + _engineSave() + if EventBus then EventBus.emit("storage:saved", UnifiedStorage.getCharName(), 0) end end --- Force reload from disk -function UnifiedStorage.reload() - _state.initialized = false - _state.cache = nil - _state.charName = nil - load() -end - --- Create manual backup -function UnifiedStorage.backup() - createBackup() -end - --- Get the schema (for debugging) -function UnifiedStorage.getSchema() - return deepClone(SCHEMA) -end - --- Get storage stats function UnifiedStorage.getStats() - return { - characterName = _state.charName, - initialized = _state.initialized, - dirty = _state.dirty, - lastBackup = _state.lastBackup, - basePath = _state.basePath, - } + local s = engine.getStats() + s.lastBackup = _lastBackup + return s end --- ═══════════════════════════════════════════════════════════════════════════ --- EVENTBUS INTEGRATION --- ═══════════════════════════════════════════════════════════════════════════ - --- Listen to config changes and persist immediately -local function setupEventBusListeners() - if not EventBus then return end - - -- Listen to targetbot config changes - EventBus.on("targetbot:configChanged", function(configName) - UnifiedStorage.set("targetbot.selectedConfig", configName) - end) - - -- Listen to cavebot config changes - EventBus.on("cavebot:configChanged", function(configName) - UnifiedStorage.set("cavebot.selectedConfig", configName) - end) - - -- Listen to macro toggles - EventBus.on("macro:toggled", function(macroName, enabled) - UnifiedStorage.set("macros." .. macroName, enabled) - end) - - -- Listen to module enable/disable - EventBus.on("module:toggled", function(moduleName, enabled) - UnifiedStorage.set(moduleName .. ".enabled", enabled) - end) - - -- Listen to monster pattern updates - EventBus.on("monsterAI:patternUpdated", function(monsterName, pattern) - local patterns = UnifiedStorage.get("targetbot.monsterPatterns") or {} - patterns[monsterName] = pattern - UnifiedStorage.set("targetbot.monsterPatterns", patterns) - end) - - -- Save on logout/disconnect - EventBus.on("player:logout", function() - UnifiedStorage.save() - end) - - -- Periodic backup via EventBus tick - EventBus.on("tick:slow", function() - local now = getTimestamp() - if now - _state.lastBackup > (CONFIG.BACKUP_INTERVAL_MS / 1000) then - if _state.cache and _state.initialized then - createBackup() - end - end - end) -end - --- ═══════════════════════════════════════════════════════════════════════════ --- INITIALIZATION --- ═══════════════════════════════════════════════════════════════════════════ - --- Helper to check if local player is available (cross-client compatible) local function hasLocalPlayer() - local Client = getClient() - local localPlayer = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) - return localPlayer ~= nil + local C = getClient() + local lp = (C and C.getLocalPlayer) and C.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) + return lp ~= nil end --- Try to initialize immediately if player is available -if hasLocalPlayer() then - load() -end +if hasLocalPlayer() then UnifiedStorage.load() end --- Setup EventBus listeners after a short delay (allow EventBus to be loaded first) schedule(100, function() - setupEventBusListeners() - - -- If not initialized yet, try again - if not _state.initialized and hasLocalPlayer() then - load() - end + if not EventBus then + schedule(500, function() + if EventBus then + EventBus.on("targetbot:configChanged", function(cn) UnifiedStorage.set("targetbot.selectedConfig", cn) end) + EventBus.on("cavebot:configChanged", function(cn) UnifiedStorage.set("cavebot.selectedConfig", cn) end) + EventBus.on("macro:toggled", function(mn, en) UnifiedStorage.set("macros." .. mn, en) end) + EventBus.on("module:toggled", function(mn, en) UnifiedStorage.set(mn .. ".enabled", en) end) + EventBus.on("monsterAI:patternUpdated", function(monster, pattern) + local p = UnifiedStorage.get("targetbot.monsterPatterns") or {} + p[monster] = pattern + UnifiedStorage.set("targetbot.monsterPatterns", p) + end) + EventBus.on("player:logout", function() UnifiedStorage.save() end) + EventBus.on("tick:slow", function() + if os.time() - _lastBackup > BACKUP_INTERVAL and UnifiedStorage.getData() then createBackup() end + end) + end + end) + return + end + EventBus.on("targetbot:configChanged", function(cn) UnifiedStorage.set("targetbot.selectedConfig", cn) end) + EventBus.on("cavebot:configChanged", function(cn) UnifiedStorage.set("cavebot.selectedConfig", cn) end) + EventBus.on("macro:toggled", function(mn, en) UnifiedStorage.set("macros." .. mn, en) end) + EventBus.on("module:toggled", function(mn, en) UnifiedStorage.set(mn .. ".enabled", en) end) + EventBus.on("monsterAI:patternUpdated", function(monster, pattern) + local p = UnifiedStorage.get("targetbot.monsterPatterns") or {} + p[monster] = pattern + UnifiedStorage.set("targetbot.monsterPatterns", p) + end) + EventBus.on("player:logout", function() UnifiedStorage.save() end) + EventBus.on("tick:slow", function() + if os.time() - _lastBackup > BACKUP_INTERVAL and UnifiedStorage.getData() then createBackup() end + end) + if not engine.getStats().initialized and hasLocalPlayer() then UnifiedStorage.load() end end) --- Export to global namespace nExBot = nExBot or {} nExBot.UnifiedStorage = UnifiedStorage diff --git a/core/unified_tick.lua b/core/unified_tick.lua index ebb0f84..46305a4 100644 --- a/core/unified_tick.lua +++ b/core/unified_tick.lua @@ -26,19 +26,16 @@ }) ]] +local zChanging = nExBot.zChanging or function() return false end local UnifiedTick = {} --- ============================================================================ -- CONFIGURATION --- ============================================================================ UnifiedTick.MASTER_INTERVAL = 50 -- Master tick interval (ms) UnifiedTick.DEBUG = false -- Enable debug logging UnifiedTick.ENABLED = true -- Global enable flag --- ============================================================================ -- PRIORITY LEVELS (Higher = runs first) --- ============================================================================ UnifiedTick.Priority = { CRITICAL = 100, -- Safety-critical (healing, emergency) @@ -48,9 +45,7 @@ UnifiedTick.Priority = { IDLE = 10 -- Non-essential (cosmetics, logging) } --- ============================================================================ -- INTERNAL STATE --- ============================================================================ local handlers = {} -- Registered tick handlers local handlerOrder = {} -- Sorted handler keys by priority @@ -70,9 +65,7 @@ local stats = { -- Time helper local nowMs = nExBot.Shared.nowMs --- ============================================================================ -- HANDLER REGISTRATION --- ============================================================================ --[[ Register a tick handler @@ -134,23 +127,6 @@ function UnifiedTick.register(name, config) end --[[ - Unregister a tick handler - @param name string Handler name - @return boolean success -]] -function UnifiedTick.unregister(name) - if not handlers[name] then - return false - end - - handlers[name] = nil - stats.handlerStats[name] = nil - UnifiedTick._rebuildOrder() - - if UnifiedTick.DEBUG then - print("[UnifiedTick] Unregistered: " .. name) - end - return true end @@ -165,45 +141,17 @@ function UnifiedTick.setEnabled(name, enabled) end end ---[[ - Enable/disable all handlers in a group - @param group string Group name - @param enabled boolean -]] -function UnifiedTick.setGroupEnabled(group, enabled) - for name, handler in pairs(handlers) do - if handler.group == group then - handler.enabled = enabled - end - end -end - ---[[ - Update handler interval at runtime - @param name string Handler name - @param interval number New interval (ms) -]] -function UnifiedTick.setInterval(name, interval) - if handlers[name] and interval > 0 then - handlers[name].interval = interval - end -end - --- Rebuild sorted handler order by priority function UnifiedTick._rebuildOrder() handlerOrder = {} for name, _ in pairs(handlers) do handlerOrder[#handlerOrder + 1] = name end - table.sort(handlerOrder, function(a, b) return (handlers[a].priority or 0) > (handlers[b].priority or 0) end) end --- ============================================================================ -- MASTER TICK EXECUTION --- ============================================================================ --[[ Main tick function - called by master macro @@ -279,9 +227,7 @@ function UnifiedTick._tick() end end --- ============================================================================ -- LIFECYCLE MANAGEMENT --- ============================================================================ --[[ Start the unified tick system @@ -302,110 +248,10 @@ function UnifiedTick.start() print("[UnifiedTick] Started (interval=" .. UnifiedTick.MASTER_INTERVAL .. "ms)") end ---[[ - Stop the unified tick system -]] -function UnifiedTick.stop() - if masterMacro then - -- In OTClient, macros can be disabled by setting enabled to false - -- or calling removeEvent if available - if type(masterMacro) == "table" and masterMacro.setEnabled then - masterMacro:setEnabled(false) - end - masterMacro = nil - end - - print("[UnifiedTick] Stopped") -end - ---[[ - Pause the unified tick system temporarily -]] -function UnifiedTick.pause() - UnifiedTick.ENABLED = false -end - ---[[ - Resume the unified tick system -]] -function UnifiedTick.resume() - UnifiedTick.ENABLED = true - lastTick = nowMs() -end - --- ============================================================================ -- STATISTICS AND DEBUGGING --- ============================================================================ - ---[[ - Get tick system statistics - @return table stats -]] -function UnifiedTick.getStats() - return { - enabled = UnifiedTick.ENABLED, - totalTicks = stats.totalTicks, - totalHandlerCalls = stats.totalHandlerCalls, - avgTickTime = stats.avgTickTime, - peakTickTime = stats.peakTickTime, - handlerCount = #handlerOrder, - handlers = stats.handlerStats - } -end - ---[[ - Get list of registered handlers with their stats - @return array of handler info -]] -function UnifiedTick.getHandlers() - local result = {} - for i = 1, #handlerOrder do - local name = handlerOrder[i] - local handler = handlers[name] - if handler then - result[#result + 1] = { - name = name, - interval = handler.interval, - priority = handler.priority, - enabled = handler.enabled, - group = handler.group, - runCount = handler.runCount, - avgTime = handler.avgTime, - errors = handler.errors - } - end - end - return result -end ---[[ - Reset statistics -]] -function UnifiedTick.resetStats() - stats.totalTicks = 0 - stats.totalHandlerCalls = 0 - stats.avgTickTime = 0 - stats.peakTickTime = 0 - - for name, hs in pairs(stats.handlerStats) do - hs.calls = 0 - hs.totalTime = 0 - hs.avgTime = 0 - hs.errors = 0 - end - - for name, handler in pairs(handlers) do - handler.runCount = 0 - handler.totalTime = 0 - handler.avgTime = 0 - handler.errors = 0 - end -end - --- ============================================================================ -- PRE-DEFINED HANDLER TEMPLATES -- Common handler patterns for easy migration --- ============================================================================ --[[ Create a condition check handler @@ -413,79 +259,32 @@ end @param checkFn function Condition check function @param interval number Check interval (default 500ms) ]] -function UnifiedTick.registerConditionCheck(name, checkFn, interval) - return UnifiedTick.register(name, { - interval = interval or 500, - priority = UnifiedTick.Priority.NORMAL, - group = "conditions", - handler = checkFn - }) -end - --[[ Create a healing handler (high priority) @param name string Handler name @param healFn function Healing check function @param interval number Check interval (default 100ms) ]] -function UnifiedTick.registerHealingHandler(name, healFn, interval) - return UnifiedTick.register(name, { - interval = interval or 100, - priority = UnifiedTick.Priority.CRITICAL, - group = "healing", - handler = healFn - }) -end - --[[ Create a targeting handler (high priority) @param name string Handler name @param targetFn function Targeting logic function @param interval number Check interval (default 200ms) ]] -function UnifiedTick.registerTargetingHandler(name, targetFn, interval) - return UnifiedTick.register(name, { - interval = interval or 200, - priority = UnifiedTick.Priority.HIGH, - group = "targeting", - handler = targetFn - }) -end - --[[ Create a UI update handler (low priority) @param name string Handler name @param updateFn function UI update function @param interval number Update interval (default 300ms) ]] -function UnifiedTick.registerUIHandler(name, updateFn, interval) - return UnifiedTick.register(name, { - interval = interval or 300, - priority = UnifiedTick.Priority.LOW, - group = "ui", - handler = updateFn - }) -end - --[[ Create an analytics handler (idle priority) @param name string Handler name @param analyticsFn function Analytics function @param interval number Update interval (default 1000ms) ]] -function UnifiedTick.registerAnalyticsHandler(name, analyticsFn, interval) - return UnifiedTick.register(name, { - interval = interval or 1000, - priority = UnifiedTick.Priority.IDLE, - group = "analytics", - handler = analyticsFn - }) -end - --- ============================================================================ -- AUTO-START (Optional) -- Uncomment to auto-start when module is loaded --- ============================================================================ -- UnifiedTick.start() diff --git a/core/updater.lua b/core/updater.lua index 0fb10e8..449dc01 100644 --- a/core/updater.lua +++ b/core/updater.lua @@ -34,9 +34,7 @@ end local Shared = nExBot.Shared local P = nExBot.paths --- ============================================================================ -- CONSTANTS --- ============================================================================ local GITHUB_OWNER = "mCodex" local GITHUB_REPO = "nExBot" @@ -63,9 +61,7 @@ local EXCLUDE_PATTERNS = { -- Extensions we DO update. local INCLUDE_EXT = { lua = true, otui = true, ui = true, cfg = true } --- ============================================================================ -- FILE SYSTEM (SRP: only file I/O) --- ============================================================================ local function readLocalVersion() local ok, content = pcall(g_resources.readFileContents, P.base .. "/" .. VERSION_FILE) @@ -133,9 +129,7 @@ local function ensureDir(relativePath) return ensureDirRecursive(P.base, relativePath) end --- ============================================================================ -- HTTP LAYER — auto-detect available API --- ============================================================================ local _httpBackend = nil -- resolved once, cached for session @@ -203,9 +197,7 @@ local function openInBrowser(url) end end --- ============================================================================ -- GITHUB API --- ============================================================================ local function fetchRemoteVersion(callback) httpGet(GITHUB_RAW_BASE .. "/" .. VERSION_FILE, function(content, err) @@ -253,9 +245,7 @@ local function downloadFile(relativePath, callback, attempt) end) end --- ============================================================================ -- UPDATE ENGINE --- ============================================================================ local _state = { isChecking = false, @@ -279,260 +269,3 @@ local function isUpdatable(path) return true end ---- Check for update. callback(available, localStr, remoteStr, err) -local function checkForUpdate(callback) - if _state.isChecking then callback(false, nil, nil, "Already checking") return end - if not detectHttpBackend() then - _state.status = "no_http" - callback(false, nil, nil, "No HTTP module - cannot check") - return - end - - _state.isChecking = true - _state.status = "checking" - - local localStr = effectiveLocalVersion() - _state.localVer = localStr - if not localStr then - _state.isChecking = false; _state.status = "error" - callback(false, nil, nil, "Cannot read local version"); return - end - local localSem = Shared.parseSemver(localStr) - if not localSem then - _state.isChecking = false; _state.status = "error" - callback(false, nil, nil, "Invalid local version: " .. localStr); return - end - - fetchRemoteVersion(function(remoteStr, err) - _state.isChecking = false - if err then _state.status = "error"; callback(false, localStr, nil, err); return end - - _state.remoteVer = remoteStr - local remoteSem = Shared.parseSemver(remoteStr) - if Shared.compareSemver(localSem, remoteSem) < 0 then - _state.status = "update_available" - callback(true, localStr, remoteStr, nil) - else - _state.status = "idle" - callback(false, localStr, remoteStr, nil) - end - end) -end - ---- Apply the update. callback(success, errMsg) -local function applyUpdate(callback, onProgress) - if _state.isUpdating then callback(false, "Already updating") return end - _state.isUpdating = true - _state.status = "updating" - _state.progress = 0 - _state.errors = {} - - info("[Updater] Updating to v" .. tostring(_state.remoteVer) .. " ...") - - fetchFileTree(function(fileList, err) - if err or not fileList or #fileList == 0 then - _state.isUpdating = false; _state.status = "error" - callback(false, err or "No files found"); return - end - - local updateFiles = {} - for _, path in ipairs(fileList) do - if isUpdatable(path) then updateFiles[#updateFiles + 1] = path end - end - _state.totalFiles = #updateFiles - - if #updateFiles == 0 then - _state.isUpdating = false; _state.status = "error" - callback(false, "No updatable files"); return - end - - info("[Updater] Downloading " .. #updateFiles .. " files ...") - - local idx = 0 - local function downloadNext() - idx = idx + 1 - if idx > #updateFiles then - if _state.remoteVer then writeLocalVersion(_state.remoteVer) end - _state.isUpdating = false - _state.progress = 100 - if onProgress then onProgress(100, #updateFiles, #updateFiles) end - - if #_state.errors > 0 then - _state.status = "done" - local msg = #_state.errors .. " failed: " .. table.concat(_state.errors, ", ") - warn("[Updater] Completed with errors: " .. msg) - callback(true, msg) - else - _state.status = "done" - info("[Updater] v" .. tostring(_state.remoteVer) .. " installed (" - .. #updateFiles .. " files). Restart bot to apply.") - callback(true, nil) - end - return - end - - local filePath = updateFiles[idx] - _state.progress = math.floor((idx / #updateFiles) * 100) - if onProgress then onProgress(_state.progress, idx, #updateFiles) end - - local dir = filePath:match("(.+)/[^/]+$") - if dir then ensureDir(dir) end - - downloadFile(filePath, function(content, dlErr) - if dlErr or not content then - _state.errors[#_state.errors + 1] = filePath - warn("[Updater] Failed: " .. filePath .. " - " .. tostring(dlErr)) - else - writeFile(filePath, content) - end - schedule(DOWNLOAD_DELAY_MS, downloadNext) - end) - end - - downloadNext() - end) -end - --- ============================================================================ --- UI --- ============================================================================ - -local _ui - -local function resetCheckButton(text) - if not _ui then return end - _ui.checkNow:enable() - _ui.checkNow:setText(text or "Check") -end - -local function bindCheckClick() - if not _ui then return end - _ui.checkNow.onClick = function() - _ui.checkNow:setText("..."); _ui.checkNow:disable() - - if not detectHttpBackend() then - info("[Updater] No HTTP module - opening releases page.") - openInBrowser(GITHUB_RELEASES) - schedule(2000, function() resetCheckButton() end) - return - end - - checkForUpdate(function(available, localVer, remoteVer, err) - _ui.checkNow:enable() - if err then - _ui.checkNow:setText("Error"); warn("[Updater] " .. err) - schedule(3000, function() resetCheckButton() end) - return - end - - if available then - _ui.checkNow:setText("Update!") - info("[Updater] v" .. tostring(localVer) .. " -> v" .. tostring(remoteVer)) - -- re-bind button to trigger download - _ui.checkNow.onClick = function() - _ui.checkNow:setText("0%"); _ui.checkNow:disable() - applyUpdate(function(success, msg) - if success then - _ui.checkNow:setText("Done!") - info("[Updater] Restart bot to apply update.") - else - _ui.checkNow:setText("Failed"); warn("[Updater] " .. tostring(msg)) - end - schedule(5000, function() resetCheckButton(); bindCheckClick() end) - end, function(pct) - if _ui then _ui.checkNow:setText(pct .. "%") end - end) - end - else - _ui.checkNow:setText("Up to date") - info("[Updater] v" .. tostring(localVer) .. " is current.") - schedule(3000, function() resetCheckButton() end) - end - end) - end -end - -local function createUpdaterUI() - setDefaultTab("Main") - - _ui = setupUI([[ -Panel - height: 19 - - BotSwitch - id: autoCheck - anchors.top: parent.top - anchors.left: parent.left - text-align: center - width: 130 - !text: tr('Auto-Updater') - - Button - id: checkNow - anchors.top: prev.top - anchors.left: prev.right - anchors.right: parent.right - margin-left: 3 - height: 17 - text: Check -]]) - _ui:setId("nExBotUpdater") - - storage.updaterAutoCheck = storage.updaterAutoCheck ~= false - _ui.autoCheck:setOn(storage.updaterAutoCheck) - _ui.autoCheck.onClick = function(w) - storage.updaterAutoCheck = not storage.updaterAutoCheck - w:setOn(storage.updaterAutoCheck) - end - - bindCheckClick() - return _ui -end - --- ============================================================================ --- AUTO-CHECK SCHEDULER --- ============================================================================ - -local function startAutoCheck() - schedule(10000, function() - if not storage.updaterAutoCheck then return end - if not detectHttpBackend() then return end - - checkForUpdate(function(available, localVer, remoteVer, err) - if err then warn("[Updater] Auto-check: " .. err) return end - if available then - info("[Updater] v" .. tostring(remoteVer) .. " available! Click 'Update!' to install.") - end - end) - - -- periodic re-checks - local function loop() - schedule(CHECK_INTERVAL_MS, function() - if storage.updaterAutoCheck and detectHttpBackend() then - checkForUpdate(function(available, _, remoteVer) - if available then info("[Updater] v" .. tostring(remoteVer) .. " available.") end - end) - end - loop() - end) - end - loop() - end) -end - --- ============================================================================ --- PUBLIC API --- ============================================================================ - -function Updater.checkForUpdate(cb) return checkForUpdate(cb) end -function Updater.applyUpdate(cb, onProgress) return applyUpdate(cb, onProgress) end - --- ============================================================================ --- INITIALIZE --- ============================================================================ - -createUpdaterUI() -startAutoCheck() - -nExBot.Updater = Updater -return Updater diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 88a07a8..03c97ed 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -136,14 +136,12 @@ The `_Loader.lua` organizes initialization into phases: |-------|---------| | 1 | ACL + Client abstraction | | 2 | Constants (floor items, food, directions) | -| 3 | Utils (shared, ring buffer, path utils, path strategy) | -| 4 | Core libraries (lib, items, configs, database) | -| 5 | Architecture (EventBus, UnifiedStorage, UnifiedTick) | -| 6 | Feature modules (HealBot, AttackBot, CaveBot, TargetBot, etc.) | -| 7 | Tools (Containers, Dropper, antiRs, etc.) | -| 8 | Analytics (Analyzer, Hunt Analyzer, Spy Level) | -| 9 | Private scripts (user custom scripts) | -| 10 | Activate UnifiedTick | +| 3 | Utils (shared, shared_helpers, storage_engine, safe_creature, path_utils, path_strategy) | +| 4 | Core libraries (lib, items, configs, database, updater) | +| 6 | Architecture (EventBus, UnifiedStorage, UnifiedTick, CreatureCache, GlobalConfig) | +| 8 | Legacy features (CaveBot, TargetBot, HealBot, AttackBot, Combo, Extras, etc.) | +| 9 | Legacy tools (Containers, Dropper, antiRs, Tools, Equip, EatFood, etc.) | +| 11 | Analytics (Analyzer, HuntAnalyzer, SpyLevel, Supplies, NPC Talk, HoldTarget) | Each module is error-isolated — a failure in one module doesn't prevent others from loading. @@ -169,6 +167,14 @@ Each module is error-isolated — a failure in one module doesn't prevent others | **Lazy Evaluation** | Skip unnecessary work | Safety checks, pathfinding | | **Burst Detection** | Z-change protection | EventBus | | **SSoT Constants** | DRY direction/floor data | Directions, FloorItems modules | +| **UnifiedTick** | Single 50ms master tick replaces 30+ individual timers | All modules via `UnifiedTick.register()` | +| **Spectator Cache** | 200ms TTL cache for `getSpectators()` calls | TargetBot, Looting | +| **Single Attack Authority** | `AttackStateMachine` is sole issuer of `g_game.attack()` | TargetBot | +| **SafeCreature** | Single pcall wrapper — all creature access via `SC.*` | TargetBot, CaveBot, Core | +| **DRY Deduplication** | Chebyshev, PathCache, isTargetable, getLocalPlayer — one canonical copy each | target_pathfinding, ClientService | +| **Table-Driven Delegation** | 209 ACL methods via mapping table + generic dispatch | ClientService | +| **Unified Storage Engine** | Single JSON backend for BotDatabase, CharacterDB, UnifiedStorage | utils/storage_engine | +| **Shared Helpers** | Profile settings, debounce, path utils — one copy each | utils/shared_helpers | --- diff --git a/docs/CAVEBOT.md b/docs/CAVEBOT.md index 72fe6d0..f8aeea4 100644 --- a/docs/CAVEBOT.md +++ b/docs/CAVEBOT.md @@ -202,11 +202,19 @@ Enable **"Ignore fields"** in the CaveBot config panel to allow field crossing. ### Chunked Walking -Paths are split into segments of **max 25 tiles** per autoWalk call. autoWalk kicks in for paths with **5+ tiles** where direction changes make up ≤55% of total steps. This covers most cave corridors while keeping pathfinding fresh. +Paths are split into segments of **max 25 tiles** per autoWalk call. autoWalk kicks in for paths with **3+ tiles** (above the keyboard threshold of 2) where direction changes make up ≤55% of total steps. This covers most cave corridors while keeping pathfinding fresh. + +### Step Pacing + +Keyboard steps are paced to match the server's step duration — the bot enforces a minimum interval between steps so movement feels smooth and human-like rather than pushing every 75ms tick. + +### Keyboard Threshold + +The `KEYBOARD_THRESHOLD` is set to **2 tiles** — paths with 2 or fewer tiles use keyboard stepping, while longer paths use native autowalk. This gives the best balance: autowalk handles multi-tile paths with proper server-paced timing, while keyboard stepping gives precise control for short nudges. ### Step Pipelining -When using keyboard stepping (short paths or high-zigzag routes), the engine dispatches **2 steps ahead** to create smooth animation without pauses between steps. Pipelining is disabled when: +When using keyboard stepping, the engine dispatches **2 steps ahead** to create smooth animation without pauses between steps. Pipelining is disabled when: - The next step's direction changes by more than 90° - A floor-change tile is within 2 steps - `canWalkDirection` fails for the lookahead step @@ -359,6 +367,16 @@ The Depositor module handles loot depositing at the depot. Configure it through When TargetBot's Lure/Pull system is active, CaveBot **pauses** waypoint execution so the player stays in place and fights. Navigation only resumes after the lure target count is satisfied or all nearby monsters are dead. +### Combat Blocking + +CaveBot checks three conditions before walking: + +1. **TargetBot.shouldWaitForMonsters()** — pauses when targetable monsters are on screen (respects lure/pull allowCaveBot override) +2. **AttackStateMachine.isActive()** — blocks while ASM is ENGAGING or LOCKED (attack in progress) +3. **EventTargeting.isCombatActive()** — blocks during active combat events + +All three respect the `TargetBot.isCaveBotActionAllowed()` override, which lure/pull systems set via `TargetBot.allowCaveBot()`. The `closeLure` path now checks ASM state before allowing CaveBot — preventing walk-during-combat when monsters are nearby. + --- ## 🎥 Recorder @@ -463,9 +481,10 @@ end 1. Is CaveBot **enabled** and **started** (`Ctrl+Z`)? 2. Is TargetBot's Pull System pausing navigation? -3. Are the waypoint coordinates reachable from your current position? -4. Is there a door or obstacle blocking the path? -5. Check for field tiles — enable "Ignore fields" if needed. +3. Is the AttackStateMachine active (ENGAGING/LOCKED)? CaveBot blocks during attacks. +4. Are the waypoint coordinates reachable from your current position? +5. Is there a door or obstacle blocking the path? +6. Check for field tiles — enable "Ignore fields" if needed. ### Stuck at a door diff --git a/docs/CONTAINERS.md b/docs/CONTAINERS.md index 1de9112..e01a5a6 100644 --- a/docs/CONTAINERS.md +++ b/docs/CONTAINERS.md @@ -53,27 +53,38 @@ Holds your attack and utility runes. AttackBot pulls runes from here during comb ## 🔓 Auto-Open System -The Container Opener (v12) uses a sophisticated BFS queue system: +The Container Panel uses a BFS queue system with re-scan on timeout: 1. On login, it waits for containers to load (500 ms delay) 2. Opens assigned containers 3. Scans for nested containers and queues them for opening 4. Handles paginated containers automatically -5. Emits `containers:open_all_complete` via EventBus when done +5. Re-scans parent containers on safety timeout (fixes late-opening backpacks) +6. Skips monster corpses during sorting (dead, remains, body of) +7. Enforces server-side container limit (max 19 open) to prevent server rejection +8. Emits `containers:open_all_complete` via EventBus when done ### Architecture | Component | Responsibility | |-----------|----------------| -| **ContainerQueue** | Manages the BFS queue of containers to open | +| **ContainerBFS** | BFS queue with O(1) pop (index cursor instead of table.remove) | | **ContainerTracker** | Prevents duplicate opens (4-second grace period) | | **ContainerScanner** | Scans containers for nested containers | -| **ContainerOpener** | Orchestrates the entire opening process | +| **getCachedContainers()** | Per-tick cache — eliminates redundant g_game.getContainers() calls | ### Deduplication The queue uses slot-based keys (`containerId:absoluteSlotIndex`) for robust deduplication. The `ContainerTracker` prevents re-opening the same slot within a 4-second grace period, even if events fire multiple times. +### Safety Timeout + +When a container entry is pending for too long, the safety timeout rescans the parent container before dropping the entry. This fixes backpacks that stay unopened when the child container opens before the parent. + +### Corpse Filtering + +The sorting system automatically skips monster corpses (identified by names containing "dead", "remains", or "body of") — these are looted by TargetBot, not sorted by the Container Panel. + --- ## 🏹 Quiver Management @@ -177,4 +188,8 @@ The `onAddItem` handler queues new container items for opening, and `onContainer ### "Schedule execution error" or nil function errors -This usually means a partial or outdated `Containers.lua` file. Replace it with the latest version and restart the client. +This usually means a partial or outdated `Containers.lua` file. Replace it with the latest version and restart the client. If the error persists after restart, the server may have reached its open container limit (~20) — close some backpacks manually and re-open them via the "Reopen All" button. + +### Containers closing immediately after opening + +The server has a limit of ~20 open containers. The bot enforces a safety margin at 19 — when the limit is reached, BFS pauses and resumes when a container closes. Keep your assigned containers under 15-18 to leave room for loot bags and other dynamically opened containers. diff --git a/docs/EXTRAS.md b/docs/EXTRAS.md index d75aa78..fbb6b4b 100644 --- a/docs/EXTRAS.md +++ b/docs/EXTRAS.md @@ -211,3 +211,9 @@ When you switch characters, their last-used profiles are automatically restored. ## 🖼️ Multi-Client Support nExBot supports running multiple OTClient instances simultaneously. Each character's configurations are independent — there is no conflict between running bots. + +--- + +## 🍎 macOS Compatibility + +On macOS, `g_window.setTitle()` can throw a C++ exception. All `setTitle` calls in nExBot are wrapped in `pcall()` to prevent crashes. This is a known OTClient issue on macOS and does not affect functionality. diff --git a/docs/FOLLOW.md b/docs/FOLLOW.md new file mode 100644 index 0000000..e886b84 --- /dev/null +++ b/docs/FOLLOW.md @@ -0,0 +1,103 @@ +# Follow Player + +Party hunt companion — keeps your bot near the party leader while attacking monsters. + +--- + +## Overview + +Follow Player is a single-purpose module: stay close to your party leader. When monsters appear, the bot attacks them but never walks past the leader to chase. If the leader moves beyond max distance, the bot catches up immediately. + +Key behaviors: + +- Attacks monsters on screen but stays within max distance of leader +- Follows while attacking (parallel mode) — attack persists via ASM, movement uses forceWalk +- When leader moves beyond threshold, cancels attack and catches up +- Recovers to last known position if leader goes off-screen (10s window) +- MovementCoordinator integration — FOLLOW intent (priority 95) beats CHASE (priority 35) + +--- + +## Quick Start + +1. Open the **Tools** tab. +2. Find the **Auto Follow** section. +3. Enter the party leader's **name** in the Target field. +4. Toggle **Follow Player** ON (this is the macro toggle). +5. Toggle **Follow While Attacking** ON (recommended). + +--- + +## Configuration + +| Setting | Default | Description | +|---------|---------|-------------| +| **Title** | "Auto Follow" | Section header in Tools tab | +| **Target** | "" | Player name to follow (shown as "Target:" label) | +| **Follow Player** | OFF | Macro toggle — click to enable/disable following | +| **Follow While Attacking** | ON | Walk toward leader while fighting monsters | +| **Max Distance** | 3 | Tiles before bot catches up to leader | + +--- + +## How It Works + +### Priority System + +The bot uses MovementCoordinator's FOLLOW intent (priority 95) which beats: + +| Intent | Priority | Result | +|--------|----------|--------| +| FOLLOW | 95 | Bot catches up to leader | +| WAVE_AVOIDANCE | 90 | Dodge wave attacks | +| FINISH_KILL | 80 | Chase wounded target | +| CHASE | 35 | Close gap to monster | + +If the leader is beyond max distance, the bot stops chasing monsters and catches up. Monsters within max distance are attacked normally. + +### Parallel Mode + +When `followWhileAttacking` is ON and the bot is attacking: + +1. Attack continues via AttackStateMachine (server-maintained) +2. MovementCoordinator registers FOLLOW intent +3. Bot walks toward leader using `forceWalk()` (does not cancel attack) +4. Attack re-sends automatically if dropped + +### Lost Leader Recovery + +If the leader goes off-screen: + +1. Bot walks to last known position for up to 10 seconds +2. If leader reappears, resumes following immediately +3. If 10s passes, stops and waits + +--- + +## Troubleshooting + +### Bot walks away from leader to chase monsters + +- Max distance is too high — lower it to 2-3 +- `followWhileAttacking` is OFF — enable it so bot walks while fighting +- Check ASM state — attack must be active for parallel mode + +### Bot stutters (follow, stop, follow) + +- Leader distance fluctuates around maxDistance — lower maxDistance by 1 +- Native follow is conflicting with forceWalk — check `isFollowing()` state + +### Bot doesn't follow after login + +- Re-enter the leader name in the Target field +- Toggle the Follow Player macro OFF then ON + +--- + +## Technical Details + +- Module: `core/follow.lua` +- Macro interval: 75ms +- Pathfinding: `g_map.findPath` with fallback to `findPath` +- EventBus listeners: `creature:move`, `combat:end` +- MovementCoordinator intent: `FOLLOW` (priority 95) diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 07fcbd2..8e8fc20 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -102,8 +102,8 @@ CaveBot uses a combined approach for movement: 3. findPath ignore creatures (if step 2 fails) 4. findPath allow unseen tiles (if distance ≤ 30) 5. findPath ignore fields (if distance ≤ 30) -6. Short paths (≤5 tiles) → keyboard step-by-step with 2-step pipelining -7. Longer paths (>5 tiles, ≤55% dir changes) → autoWalk with chunking (max 25 tiles) +6. Short paths (≤2 tiles) → keyboard step-by-step with step pacing + 2-step pipelining +7. Longer paths (>2 tiles, ≤55% dir changes) → autoWalk with chunking (max 25 tiles) ``` Most walks complete with a single findPath + autoWalk dispatch. The PathCursor is preserved across ticks for the same destination, eliminating redundant A* recomputation. @@ -194,12 +194,13 @@ Affected parameters: | `NEG_CACHE_MAX` | 32 | Max negative cache entries | | `MAX_WALK_CHUNK` | 25 | Max tiles per autoWalk dispatch | | `AUTOWALK_THRESHOLD` | 5 tiles | Min path length to use autoWalk | +| `KEYBOARD_THRESHOLD` | 2 | Max path length (tiles) for keyboard stepping | | `DIR_CHANGE_TOLERANCE` | 55% | Max direction changes for autoWalk eligibility | | `VERIFY_INTERVAL` | 150 ms | Mid-walk verification interval | | `PIPELINING_DEPTH` | 2 | Steps dispatched ahead during keyboard walking | | `BLACKLIST_BASE_TTL` | 15000 ms | Base waypoint blacklist duration | | `BLACKLIST_MAX_TTL` | 120000 ms | Max waypoint blacklist duration | -| `FINDPATH_LRU_SIZE` | 4 | Number of cached pathfinding results | +| `FINDPATH_LRU_SIZE` | 8 | Number of cached pathfinding results | > [!WARNING] > Only adjust these if you understand the performance trade-offs. Lower values = faster response but more CPU. Higher values = less CPU but slower response. diff --git a/docs/TARGETBOT.md b/docs/TARGETBOT.md index 630bbf4..c7f779c 100644 --- a/docs/TARGETBOT.md +++ b/docs/TARGETBOT.md @@ -70,27 +70,38 @@ The creature with the highest score becomes the active target. ## ⚙️ Attack State Machine -All attacks in nExBot go through a single **AttackStateMachine** (ASM). No other module is allowed to call `g_game.attack()` directly — this eliminates the classic "attack once then stop" bug caused by competing attack issuers. +All attacks in nExBot go through a single **AttackStateMachine** (ASM). No other module is allowed to call `g_game.attack()` directly — this eliminates the classic "attack once then stop" bug caused by competing attack issuers. This includes combo.lua spell combos, which route through the ASM instead of attacking directly. ### State Flow ```text -IDLE → ACQUIRING → CONFIRMING → ATTACKING → RECOVERING → IDLE +IDLE → ENGAGING → LOCKED → IDLE ``` | State | Description | |-------|-------------| -| **IDLE** | No target. Waiting for `requestSwitch()`. | -| **ACQUIRING** | Target selected, `g_game.attack()` sent. | -| **CONFIRMING** | Waiting for server confirmation (up to 1000 ms). | -| **ATTACKING** | Server confirmed. Actively fighting. | -| **RECOVERING** | Target died or disappeared. Brief grace period before IDLE. | +| **IDLE** | No target. Waiting for `requestAttack()`. | +| **ENGAGING** | `g_game.attack()` sent. Waiting for server confirmation. Retries with exponential backoff (1.5s base, 1.5x growth, max 5 retries). | +| **LOCKED** | Server confirmed attack. Actively fighting. Re-sends attack immediately if lost. | +| **IDLE** | Target killed or unreachable. Brief grace period before next engagement. | + +### SafeCreature + +All creature access goes through `SafeCreature` (`utils/safe_creature.lua`). This eliminates 40+ raw `pcall()` wrappers across the codebase. Use `SC.getId(creature)`, `SC.getPosition(creature)`, `SC.isMonster(creature)`, etc. instead of inline pcall patterns. + +### Attack Persistence + +The bot sends `g_game.attack()` once per target. The Tibia server maintains the attack state automatically — the bot does not need to re-send the same attack command. The ASM only re-sends when it detects the attack has dropped (nil game target). + +When the ASM is ENGAGING or LOCKED, CaveBot is blocked from walking to prevent interrupting the attack. The `TargetBot.isActive()` window (1000ms) covers the macro interval plus ASM retry timing. ### Key Parameters | Parameter | Default | Description | |-----------|---------|-------------| -| Reissue Interval | 1400 ms | Re-send attack if server didn't confirm | +| Engage Backoff Base | 1500 ms | Initial retry delay | +| Engage Backoff Growth | 1.5x | Multiplier per retry | +| Engage Max Retries | 5 | Give up after this many | | Confirm Timeout | 1000 ms | Wait time for server confirmation | | Attack Cooldown | 300 ms | Minimum time between attack commands | | Switch Cooldown | 5000 ms | Minimum time between target switches | @@ -160,13 +171,14 @@ TargetBot uses an **intent-based voting system** for movement. Multiple subsyste | Priority | Intent | Description | |----------|--------|-------------| -| 1 | **Wave Avoidance** | Dodge predicted wave attacks | -| 2 | **Finish Kill** | Move into range of low-HP target | -| 3 | **Spell Position** | Optimal position for AoE spells | -| 4 | **Keep Distance** | Maintain range for ranged vocations | -| 5 | **Reposition** | Multi-factor tile scoring for safety | -| 6 | **Chase** | Close distance to target | -| 7 | **Face Monster** | Turn toward target | +| 95 | **Follow** | Catch up to party leader | +| 90 | **Wave Avoidance** | Dodge predicted wave attacks | +| 80 | **Finish Kill** | Move into range of low-HP target | +| 70 | **Spell Position** | Optimal position for AoE spells | +| 60 | **Keep Distance** | Maintain range for ranged vocations | +| 50 | **Reposition** | Multi-factor tile scoring for safety | +| 35 | **Chase** | Close distance to target | +| 30 | **Face Monster** | Turn toward target | Each intent carries a **confidence score**. Only the highest-confidence intent executes per tick. This prevents erratic movement from conflicting systems. @@ -348,7 +360,14 @@ MonsterAI.DEBUG = true 2. Are there creatures configured in the creature list? 3. Are matching monsters on screen? 4. Do you have mana for attack spells? -5. Check ASM state — it should be ATTACKING when a target is present. +5. Check ASM state — it should be LOCKED when a target is present. + +### Attack stops after one hit + +The bot sends attack once and lets the server maintain it. If the attack drops: +- The ASM re-sends immediately when it detects a nil game target +- CaveBot stays blocked while the ASM is ENGAGING or LOCKED +- Check that no other module is calling `g_game.attack()` or `cancelAttackAndFollow()` ### Target keeps switching (zigzag) @@ -359,7 +378,7 @@ MonsterAI.DEBUG = true ### Not attacking after target dies -- The ASM enters RECOVERING state for 350–600 ms after a kill +- The ASM enters IDLE state after the grace period expires - This is normal — it prevents attacking the wrong creature during the transition - If it seems stuck, check for `STOP_START_DEBOUNCE` timing diff --git a/targetbot/attack_coordinator.lua b/targetbot/attack_coordinator.lua new file mode 100644 index 0000000..d84068b --- /dev/null +++ b/targetbot/attack_coordinator.lua @@ -0,0 +1,668 @@ +-- TargetBot Attack Coordinator Module +-- Main attack loop, walk/chase/reposition, lure/pull system + +local zChanging = nExBot.zChanging or function() return false end +local getClient = nExBot.Shared.getClient +local SC = SafeCreature or {} +local Dirs = Directions +local DIRECTIONS = (Dirs and Dirs.ADJACENT_OFFSETS) or { + {x = 0, y = -1}, {x = 1, y = 0}, {x = 0, y = 1}, {x = -1, y = 0}, + {x = 1, y = -1}, {x = 1, y = 1}, {x = -1, y = 1}, {x = -1, y = -1} +} +local DIR_VECTORS = Directions.DIR_TO_OFFSET + + +local function isTileSafe(pos) + if TargetCore and TargetCore.PathSafety and TargetCore.PathSafety.isTileSafe then + return TargetCore.PathSafety.isTileSafe(pos) + end + return nExBot.Shared.isTileSafe(pos) +end + +local targetBotLure = false +local targetCount = 0 +local delayValue = 0 +local lureMax = 0 +local anchorPosition = nil +local delayFrom = nil +local dynamicLureDelay = false +local smartPullState = { lastEval = 0, lowStreak = 0, highStreak = 0, active = false, lastChange = 0 } +local dynamicLureState = { lastTrigger = 0 } + +local function countMonstersByRange(range) + local specs = BotCore.Creatures.getNearby(range, range) + if not specs then return 0 end + local count = 0 + for i = 1, #specs do + local creature = specs[i] + if creature and SC.isMonster(creature) and not SC.isDead(creature) then + count = count + 1 + end + end + return count +end + +local function safeGetMonsters(range) + if SafeCall and SafeCall.getMonsters then + return SafeCall.getMonsters(range) or 0 + end + if getMonsters then + return getMonsters(range) or 0 + end + return countMonstersByRange(range) +end + +local zigzagState = { blockUntil = 0, cooldown = 250 } + +local function movementAllowed() + local nowt = now or (os.time() * 1000) + if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.isZigzagging then + if MonsterAI.Scenario.isZigzagging() then + if nowt < zigzagState.blockUntil then return false end + zigzagState.blockUntil = nowt + zigzagState.cooldown + return false + end + end + if nExBot and nExBot.MovementCoordinator and nExBot.MovementCoordinator.canMove then + return nExBot.MovementCoordinator.canMove() + end + return true +end + +local function evaluateLureAndPull(creature, config, targets) + if not creature or not config then return false end + local cpos = creature:getPosition() + local pos = player:getPosition() + if not cpos or not pos then return false end + local creatureHealth = creature:getHealthPercent() + local killUnder = storage.extras.killUnder or 30 + local targetIsLowHealth = creatureHealth < killUnder + local isTrapped = nExBot.isPlayerTrapped(pos) + if config.anchor then + if not anchorPosition or nExBot.distanceFromPlayer(anchorPosition) > (config.anchorRange or 5) * 2 then + anchorPosition = pos + end + else + anchorPosition = nil + end + if config.lureMin and config.lureMax and config.dynamicLure then + targetBotLure = config.lureMin >= targets + if targets >= config.lureMax then targetBotLure = false end + end + targetCount = targets + delayValue = config.lureDelay + lureMax = config.lureMax or 0 + dynamicLureDelay = config.dynamicLureDelay + delayFrom = config.delayFrom + if not targetIsLowHealth and not isTrapped then + if config.smartPull then + local nowt = now or (os.time() * 1000) + if (nowt - smartPullState.lastEval) >= 300 then + smartPullState.lastEval = nowt + local screenMonsters = 0 + if EventTargeting and EventTargeting.getLiveMonsterCount then + screenMonsters = EventTargeting.getLiveMonsterCount() or 0 + else + screenMonsters = countMonstersByRange(7) + end + if screenMonsters == 0 then + smartPullState.active = false + smartPullState.lowStreak = 0 + smartPullState.highStreak = 0 + else + local pullRange = config.smartPullRange or 2 + local pullMin = config.smartPullMin or 3 + local pullShape = config.smartPullShape or (nExBot.SHAPE and nExBot.SHAPE.CIRCLE) or 2 + local pullOff = pullMin + 1 + local nearbyMonsters = 0 + if getMonstersAdvanced then + nearbyMonsters = SafeCall.global("getMonstersAdvanced", pullRange, pullShape) or 0 + elseif getMonsters then + nearbyMonsters = getMonsters(pullRange) or 0 + else + nearbyMonsters = countMonstersByRange(pullRange) + end + local underImmediateThreat = false + if MonsterAI and MonsterAI.getImmediateThreat then + local threatData = MonsterAI.getImmediateThreat() + underImmediateThreat = threatData.immediateThreat and threatData.highestConfidence >= 0.7 + end + if underImmediateThreat then + smartPullState.active = false + smartPullState.lowStreak = 0 + smartPullState.highStreak = 0 + else + if nearbyMonsters < pullMin then + smartPullState.lowStreak = smartPullState.lowStreak + 1 + smartPullState.highStreak = 0 + elseif nearbyMonsters >= pullOff then + smartPullState.highStreak = smartPullState.highStreak + 1 + smartPullState.lowStreak = 0 + else + smartPullState.lowStreak = 0 + smartPullState.highStreak = 0 + end + if smartPullState.lowStreak >= 2 then + smartPullState.active = true + smartPullState.lastChange = nowt + elseif smartPullState.highStreak >= 2 then + smartPullState.active = false + smartPullState.lastChange = nowt + end + end + end + end + TargetBot.smartPullActive = smartPullState.active + else + TargetBot.smartPullActive = false + smartPullState.active = false + smartPullState.lowStreak = 0 + smartPullState.highStreak = 0 + end + if not TargetBot.smartPullActive and TargetBot.canLure() and config.dynamicLure then + local nowt = now or (os.time() * 1000) + if targetBotLure and (nowt - (dynamicLureState.lastTrigger or 0)) > 700 then + dynamicLureState.lastTrigger = nowt + TargetBot.allowCaveBot(250) + return true + end + end + if config.closeLure and config.closeLureAmount then + if safeGetMonsters(1) >= config.closeLureAmount then + local asmActive = AttackStateMachine and AttackStateMachine.isActive and AttackStateMachine.isActive() + if not asmActive then + TargetBot.allowCaveBot(250) + end + return true + end + end + if not config.dynamicLure then + safeGetMonsters(7) + end + else + TargetBot.smartPullActive = false + end + return false +end + +local function calculateLureEligibility(config, targets) + if not config then + return { shouldLure = false, confidence = 0, reason = "no_config" } + end + if not config.dynamicLure then + return { shouldLure = false, confidence = 0, reason = "disabled" } + end + local lureMin = config.lureMin or 3 + local lurMax = config.lureMax or 6 + if targets < lureMin then + local deficit = lureMin - targets + local confidence = 0.5 + (deficit / lureMin) * 0.3 + return { shouldLure = true, confidence = math.min(0.85, confidence), reason = "below_min", deficit = deficit } + end + if targets >= lurMax then + return { shouldLure = false, confidence = 0.9, reason = "at_max" } + end + return { shouldLure = false, confidence = 0.6, reason = "sufficient" } +end + +TargetBot.Creature.attack = function(params, targets, isLooting) + if TargetBot then + if TargetBot.canAttack and not TargetBot.canAttack() then return + elseif TargetBot.explicitlyDisabled then return + elseif TargetBot.isOn and not TargetBot.isOn() then return end + end + if player:isWalking() then lastWalk = now end + local config = params.config + local creature = params.creature + local creaturePos = creature:getPosition() + local playerPos = player:getPosition() + if TargetBot.ActiveMovementConfig then + TargetBot.ActiveMovementConfig.chase = config.chase or false + TargetBot.ActiveMovementConfig.keepDistance = config.keepDistance or false + TargetBot.ActiveMovementConfig.keepDistanceRange = config.keepDistanceRange or 4 + TargetBot.ActiveMovementConfig.finishKillThreshold = storage.extras and storage.extras.killUnder or 30 + TargetBot.ActiveMovementConfig.anchor = config.anchor and playerPos or nil + TargetBot.ActiveMovementConfig.anchorRange = config.anchorRange or 5 + end + local useNativeChase = config.chase and not config.keepDistance + local Client = getClient() + if ChaseController then + ChaseController.setDesiredChase(useNativeChase) + ChaseController.syncMode() + elseif (Client and Client.setChaseMode) or (g_game and g_game.setChaseMode) then + local desiredMode = useNativeChase and 1 or 0 + local currentMode = ClientService.getChaseMode() or -1 + if currentMode ~= desiredMode then + if Client and Client.setChaseMode then Client.setChaseMode(desiredMode) + elseif g_game and g_game.setChaseMode then g_game.setChaseMode(desiredMode) end + if TargetCore and TargetCore.Native then TargetCore.Native.lastChaseMode = desiredMode end + end + end + TargetBot.usingNativeChase = useNativeChase + -- Skip reachability check if ASM is already locked on this target — the attack is working + local creatureId = nil + pcall(function() creatureId = creature:getId() end) + local asmAlreadyAttacking = AttackStateMachine and AttackStateMachine.isActive and AttackStateMachine.isActive() + local asmTargetId = nil + if asmAlreadyAttacking then + pcall(function() asmTargetId = AttackStateMachine.getTargetId and AttackStateMachine.getTargetId() end) + end + local sameTarget = asmAlreadyAttacking and creatureId == asmTargetId + if not sameTarget and MonsterAI and MonsterAI.Reachability and MonsterAI.Reachability.validateTarget then + if TargetBot then + TargetBot.UnreachableTracker = TargetBot.UnreachableTracker or { + entries = {}, ttl = 800, lastCleanup = 0, cleanupInterval = 2000 + } + end + local tracker = TargetBot and TargetBot.UnreachableTracker or nil + local timeNow = now or (os.time() * 1000) + local isValid, reason, path = MonsterAI.Reachability.validateTarget(creature) + if isValid and tracker and creatureId then tracker.entries[creatureId] = nil end + if not isValid then + if reason == "no_path" or reason == "blocked_tile" then + if tracker and creatureId then + local entry = tracker.entries[creatureId] + if not entry then + entry = { firstSeen = timeNow, lastSeen = timeNow } + tracker.entries[creatureId] = entry + else + entry.lastSeen = timeNow + end + if (timeNow - (entry.firstSeen or timeNow)) < tracker.ttl then return end + if (timeNow - (tracker.lastCleanup or 0)) > tracker.cleanupInterval then + for id, data in pairs(tracker.entries) do + if (timeNow - (data.lastSeen or timeNow)) > tracker.cleanupInterval then tracker.entries[id] = nil end + end + tracker.lastCleanup = timeNow + end + end + if AttackStateMachine and AttackStateMachine.isActive and AttackStateMachine.isActive() then + pcall(AttackStateMachine.stop) + else + local Client2 = getClient() + if Client2 and Client2.cancelAttackAndFollow then pcall(Client2.cancelAttackAndFollow) + elseif g_game and g_game.cancelAttackAndFollow then pcall(g_game.cancelAttackAndFollow) end + end + if TargetBot.allowCaveBot then TargetBot.allowCaveBot(300) end + return + end + end + end + local currentTarget = ClientService.getAttackingCreature() + local currentTargetId = nil + local wantedTargetId = nil + pcall(function() currentTargetId = currentTarget and currentTarget:getId() end) + pcall(function() wantedTargetId = creature and creature:getId() end) + local needsAttack = (currentTargetId ~= wantedTargetId) or (not currentTarget) + if needsAttack and wantedTargetId then + local attackIssued = false + if AttackStateMachine and AttackStateMachine.requestSwitch then + local priority = params.priority or (params.config and params.config.priority) or 100 + attackIssued = AttackStateMachine.requestSwitch(creature, priority * 100) + else + log("[TargetBot] AttackStateMachine unavailable — skipping attack (no fallback)") + end + if EventTargeting and EventTargeting.CombatCoordinator then + local dist = math.max(math.abs(playerPos.x - creaturePos.x), math.abs(playerPos.y - creaturePos.y)) + if dist > 1 then pcall(function() EventTargeting.CombatCoordinator.registerChaseIntent(creature, creaturePos, dist) end) end + pcall(function() EventTargeting.CombatCoordinator.pauseCaveBot() end) + end + if EventBus then pcall(function() EventBus.emit("targetbot/target_acquired", creature, creaturePos) end) end + schedule(200, function() + local atk = ClientService.getAttackingCreature() + end) + end + local lureTriggered = evaluateLureAndPull(creature, config, targets) + if not isLooting then + if lureTriggered then + elseif not useNativeChase then + TargetBot.Creature.walk(creature, config, targets) + elseif config.avoidAttacks or config.rePosition then + TargetBot.Creature.walk(creature, config, targets) + end + end + local mana = player:getMana() +end + +TargetBot.Creature.walk = function(creature, config, targets) + local cpos = creature:getPosition() + local pos = player:getPosition() + if TargetBot.isForceFollowActive and TargetBot.isForceFollowActive() then return end + if config.anchor and not anchorPosition then anchorPosition = pos end + local useCoordinator = MovementCoordinator and MovementCoordinator.Intent + local creatures = BotCore.Creatures.getNearby(7) or {} + local monsters = {} + for i = 1, #creatures do + local c = creatures[i] + if c and c:isMonster() and not c:isDead() then monsters[#monsters + 1] = c end + end + if MonsterAI and MonsterAI.updateAll then MonsterAI.updateAll() end + local needsPrecisionControl = config.avoidAttacks or config.keepDistance + local creatureHealth = creature and creature:getHealthPercent() or 100 + local killUnder = storage.extras.killUnder or 30 + local targetIsLowHealth = creatureHealth < killUnder + local isTrapped = nExBot.isPlayerTrapped and nExBot.isPlayerTrapped(pos) or false + local pathLen = 0 + local path = findPath(pos, cpos, 10, {ignoreNonPathable = true, ignoreCreatures = true}) + if path then pathLen = #path end + local Client = getClient() + if needsPrecisionControl then + local hasSetChaseMode = (Client and Client.setChaseMode) or (g_game and g_game.setChaseMode) + local hasGetChaseMode = (Client and Client.getChaseMode) or (g_game and g_game.getChaseMode) + if hasSetChaseMode and hasGetChaseMode then + local currentMode = ClientService.getChaseMode() + if currentMode == 1 then + if Client and Client.setChaseMode then Client.setChaseMode(0) + elseif g_game and g_game.setChaseMode then g_game.setChaseMode(0) end + TargetBot.usingNativeChase = false + end + end + local hasCancelFollow = (Client and Client.cancelFollow) or (g_game and g_game.cancelFollow) + local hasGetFollowingCreature = (Client and Client.getFollowingCreature) or (g_game and g_game.getFollowingCreature) + if hasCancelFollow and hasGetFollowingCreature then + local currentFollow = ClientService.getFollowingCreature() + if currentFollow then + ClientService.cancelFollow() + end + end + elseif config.chase then + local hasSetChaseMode = (Client and Client.setChaseMode) or (g_game and g_game.setChaseMode) + local hasGetChaseMode = (Client and Client.getChaseMode) or (g_game and g_game.getChaseMode) + if hasSetChaseMode and hasGetChaseMode then + local currentMode = ClientService.getChaseMode() + if currentMode ~= 1 then + if Client and Client.setChaseMode then Client.setChaseMode(1) + elseif g_game and g_game.setChaseMode then g_game.setChaseMode(1) end + TargetBot.usingNativeChase = true + end + end + end + if config.avoidAttacks then + local safePos, safeScore = nExBot.findSafeAdjacentTile(pos, monsters, creature) + if safePos then + local confidence = 0.5 + local currentDanger = nExBot.analyzePositionDanger(pos, monsters) + if currentDanger.waveThreats >= 2 then confidence = 0.85 + elseif currentDanger.waveThreats == 1 and currentDanger.meleeThreats >= 2 then confidence = 0.80 + elseif currentDanger.totalDanger >= 4 then confidence = 0.75 + elseif currentDanger.totalDanger >= 2 then confidence = 0.70 end + if useCoordinator then + MovementCoordinator.avoidWave(safePos, confidence) + else + if confidence >= 0.70 then + nExBot.avoidWaveAttacks() + return true + end + end + end + end + if targetIsLowHealth and pathLen > 1 then + local confidence = 0.55 + if creatureHealth < 10 then confidence = 0.85 + elseif creatureHealth < 15 then confidence = 0.75 + elseif creatureHealth < 20 then confidence = 0.70 end + if useCoordinator then + MovementCoordinator.finishKill(cpos, confidence) + else + if confidence >= 0.70 then + if movementAllowed() then return TargetBot.walkTo(cpos, 10, {ignoreNonPathable = true, precision = 1}) end + end + end + end + if SpellOptimizer and config.optimizeSpellPosition and #monsters >= 2 then + local spellShape = config.spellShape or SpellOptimizer.CONSTANTS.SHAPE.ADJACENT + local optPos, score, confidence, details = SpellOptimizer.findOptimalPosition( + spellShape, monsters, { minMonsters = 2, avoidDanger = config.avoidAttacks } + ) + if optPos and details and details.monstersHit >= 2 then + if details.distance > 0 and confidence >= 0.6 then + if useCoordinator then MovementCoordinator.positionForSpell(optPos, confidence, "AoE") end + end + end + end + if config.keepDistance then + local keepRange = config.keepDistanceRange or 4 + local currentDist = pathLen + if currentDist ~= keepRange and currentDist ~= keepRange + 1 then + local dx = cpos.x - pos.x + local dy = cpos.y - pos.y + local dist = math.sqrt(dx * dx + dy * dy) + if dist > 0 then + local targetDist = keepRange + local ratio = targetDist / dist + local keepPos = { + x = math.floor(cpos.x - dx * ratio + 0.5), y = math.floor(cpos.y - dy * ratio + 0.5), z = pos.z + } + local anchorValid = true + if config.anchor and anchorPosition then + local anchorDist = math.max(math.abs(keepPos.x - anchorPosition.x), math.abs(keepPos.y - anchorPosition.y)) + anchorValid = anchorDist <= (config.anchorRange or 5) + end + if anchorValid then + local confidence = 0.55 + if currentDist < keepRange then confidence = 0.7 end + if useCoordinator then + MovementCoordinator.keepDistance(keepPos, confidence) + else + local walkParams = { ignoreNonPathable = true, marginMin = keepRange, marginMax = keepRange + 1 } + if config.anchor and anchorPosition then walkParams.maxDistanceFrom = {anchorPosition, config.anchorRange or 5} end + if movementAllowed() then return TargetBot.walkTo(cpos, 10, walkParams) end + end + end + end + end + end + if config.rePosition and not isTrapped then + local currentWalkable = nExBot.countWalkableTiles(pos) + local threshold = config.rePositionAmount or 5 + if currentWalkable < threshold then + local betterPos = nil + local bestScore = currentWalkable * 12 + for dx = -2, 2 do + for dy = -2, 2 do + if dx ~= 0 or dy ~= 0 then + local checkPos = {x = pos.x + dx, y = pos.y + dy, z = pos.z} + local tileSafe = isTileSafe(checkPos) + if tileSafe then + local anchorValid = true + if config.anchor and anchorPosition then + local anchorDist = math.max(math.abs(checkPos.x - anchorPosition.x), math.abs(checkPos.y - anchorPosition.y)) + anchorValid = anchorDist <= (config.anchorRange or 5) + end + if anchorValid then + local walkable = nExBot.countWalkableTiles(checkPos) + local score = walkable * 12 + local analysis = nExBot.analyzePositionDanger(checkPos, monsters) + score = score - analysis.totalDanger * 15 + if score > bestScore + 10 then bestScore = score; betterPos = checkPos end + end + end + end + end + end + if betterPos then + local confidence = math.min(0.4 + (bestScore - currentWalkable * 12) / 100, 0.75) + if useCoordinator then + MovementCoordinator.reposition(betterPos, confidence) + else + if confidence >= 0.5 then return CaveBot.GoTo(betterPos, 0) end + end + end + end + end + local chaseDistanceThreshold = config.chaseDistanceThreshold or 2 + local directDist = math.max(math.abs(pos.x - cpos.x), math.abs(pos.y - cpos.y)) + local chaseExecuted = false + if config.chase and not config.keepDistance and pathLen > 1 and directDist > chaseDistanceThreshold then + local nativeChaseMayWork = false + local Client2 = getClient() + local hasGetChaseMode = (Client2 and Client2.getChaseMode) or (g_game and g_game.getChaseMode) + local hasIsAttacking = (Client2 and Client2.isAttacking) or (g_game and g_game.isAttacking) + if hasGetChaseMode and hasIsAttacking then + local isAttacking = ClientService.isAttacking() + local chaseMode = ClientService.getChaseMode() + nativeChaseMayWork = isAttacking and chaseMode == 1 + end + local anchorValid = true + local hasAnchorConstraint = false + if config.anchor and anchorPosition then + hasAnchorConstraint = true + local anchorDist = math.max(math.abs(cpos.x - anchorPosition.x), math.abs(cpos.y - anchorPosition.y)) + anchorValid = anchorDist <= (config.anchorRange or 5) + end + local needsCustomChase = not nativeChaseMayWork or hasAnchorConstraint + if needsCustomChase and anchorValid then + if player and player.autoWalk and not player:isWalking() then + pcall(function() player:autoWalk(cpos) end) + chaseExecuted = true + return true + end + elseif nativeChaseMayWork and anchorValid then + chaseExecuted = true + return true + end + end + if config.faceMonster then + local dx = cpos.x - pos.x + local dy = cpos.y - pos.y + local dist = math.max(math.abs(dx), math.abs(dy)) + if dist == 1 and math.abs(dx) == 1 and math.abs(dy) == 1 then + local candidates = { + {x = pos.x + dx, y = pos.y, z = pos.z}, {x = pos.x, y = pos.y + dy, z = pos.z} + } + for i = 1, 2 do + local tileSafe = isTileSafe(candidates[i]) + if tileSafe then + local anchorValid = true + if config.anchor and anchorPosition then + local anchorDist = math.max(math.abs(candidates[i].x - anchorPosition.x), math.abs(candidates[i].y - anchorPosition.y)) + anchorValid = anchorDist <= (config.anchorRange or 5) + end + if anchorValid then + if useCoordinator then MovementCoordinator.faceMonster(candidates[i], 0.45) + else if movementAllowed() then return TargetBot.walkTo(candidates[i], 2, {ignoreNonPathable = true}) end end + break + end + end + end + elseif dist <= 1 then + local dir = player:getDirection() + if dx == 1 and dir ~= 1 then turn(1) + elseif dx == -1 and dir ~= 3 then turn(3) + elseif dy == 1 and dir ~= 2 then turn(2) + elseif dy == -1 and dir ~= 0 then turn(0) end + end + end + if useCoordinator then + local success, reason = MovementCoordinator.tick() + if success then return true end + local fallbackDirectDist = math.max(math.abs(pos.x - cpos.x), math.abs(pos.y - cpos.y)) + local fallbackChaseThreshold = config.chaseDistanceThreshold or 2 + if config.chase and not config.keepDistance and pathLen > 1 and fallbackDirectDist > fallbackChaseThreshold then + local nativeChaseMayWork = false + local Client = getClient() + local hasGetChaseMode = (Client and Client.getChaseMode) or (g_game and g_game.getChaseMode) + local hasIsAttacking = (Client and Client.isAttacking) or (g_game and g_game.isAttacking) + if hasGetChaseMode and hasIsAttacking then + local isAttacking = ClientService.isAttacking() + local chaseMode = ClientService.getChaseMode() + nativeChaseMayWork = isAttacking and chaseMode == 1 + end + if nativeChaseMayWork then return true end + if not player:isWalking() then + local anchorValid = true + if config.anchor and anchorPosition then + local anchorDist = math.max(math.abs(cpos.x - anchorPosition.x), math.abs(cpos.y - anchorPosition.y)) + anchorValid = anchorDist <= (config.anchorRange or 5) + end + if anchorValid then + if player and player.autoWalk then pcall(function() player:autoWalk(cpos) end); return true end + end + end + end + end +end + +onPlayerPositionChange(function(newPos, oldPos) + if zChanging() then return end + if not CaveBot or not CaveBot.isOff or CaveBot.isOff() then return end + if not TargetBot or not TargetBot.isOff or TargetBot.isOff() then return end + if not lureMax then return end + if not dynamicLureDelay then return end + local targetThreshold = delayFrom or lureMax * 0.5 + if targetCount < targetThreshold or not (target and target()) then return end + CaveBot.delay(delayValue or 0) +end) + +if EventBus then + local lastLureState = { active = false, time = 0 } + EventBus.on("targetbot/target_count_change", function(newCount, oldCount) + if not TargetBot or not TargetBot.isOn or not TargetBot.isOn() then return end + local activeConfig = TargetBot.ActiveMovementConfig + if not activeConfig then return end + local eligibility = calculateLureEligibility(activeConfig, newCount) + if eligibility.shouldLure ~= lastLureState.active then + lastLureState.active = eligibility.shouldLure + lastLureState.time = now + if eligibility.shouldLure then + pcall(function() EventBus.emit("targetbot/lure_start", { reason = eligibility.reason, confidence = eligibility.confidence, deficit = eligibility.deficit }) end) + if MovementCoordinator and MovementCoordinator.Intent then + local playerPos = player and player:getPosition() + if playerPos then + MovementCoordinator.Intent.register(MovementCoordinator.CONSTANTS.INTENT.LURE, playerPos, eligibility.confidence, "lure_event", { triggered = "target_count", targets = newCount, deficit = eligibility.deficit }) + + -- Call allowCaveBot directly so CaveBot stays blocked during lure. + if TargetBot.allowCaveBot then TargetBot.allowCaveBot(150) end + end + end + else + pcall(function() EventBus.emit("targetbot/lure_stop", { reason = eligibility.reason, targets = newCount }) end) + end + end + end, 15) + EventBus.on("monster:disappear", function(creature) + if TargetBot.isOff() then return end + if not creature then return end + local monsterCount = 0 + if MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby then + local nearby = MovementCoordinator.MonsterCache.getNearby(7) + monsterCount = #nearby + end + pcall(function() EventBus.emit("targetbot/target_count_change", monsterCount, monsterCount + 1) end) + end, 18) + EventBus.on("monster:appear", function(creature) + if TargetBot.isOff() then return end + if not creature then return end + local playerPos = player and player:getPosition() + local creaturePos = creature:getPosition() + if not playerPos or not creaturePos then return end + local dist = math.max(math.abs(playerPos.x - creaturePos.x), math.abs(playerPos.y - creaturePos.y)) + if dist <= 7 then + local monsterCount = 0 + if MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby then + local nearby = MovementCoordinator.MonsterCache.getNearby(7) + monsterCount = #nearby + end + pcall(function() EventBus.emit("targetbot/target_count_change", monsterCount, monsterCount - 1) end) + end + end, 18) + local lastPullState = false + EventBus.on("targetbot/combat_start", function(creature, data) + if TargetBot.isOff() then return end + schedule(100, function() + if TargetBot and TargetBot.smartPullActive ~= lastPullState then + lastPullState = TargetBot.smartPullActive + if TargetBot.smartPullActive then pcall(function() EventBus.emit("targetbot/pull_active", { creature = creature, time = now }) end) end + end + end) + end, 12) + EventBus.on("targetbot/combat_end", function() + if TargetBot.isOff() then return end + if lastPullState then + lastPullState = false + pcall(function() EventBus.emit("targetbot/pull_inactive") end) + end + end, 12) +end + +nExBot.calculateLureEligibility = calculateLureEligibility diff --git a/targetbot/attack_spells.lua b/targetbot/attack_spells.lua new file mode 100644 index 0000000..f9689b9 --- /dev/null +++ b/targetbot/attack_spells.lua @@ -0,0 +1,100 @@ +-- TargetBot Attack Spells Module +-- Spell/rune usage and targeting + +local lastSpell = 0 +local lastAttackSpell = 0 +local lastItemUse = 0 +local lastRuneAttack = 0 + +local getClient = nExBot.Shared.getClient + +local function doSay(text) + if type(text) ~= 'string' or text:len() < 1 then return false end + local SafeCall = SafeCall or require("core.safe_call") + if type(say) == 'function' then + local ok, res = SafeCall.call(say, text) + if ok then return true end + warn("[TargetBot] doSay: say(...) failed") + return false + end + if g_game and type(g_game.say) == 'function' then + local ok, res = SafeCall.call(g_game.say, text) + if ok then return true end + warn("[TargetBot] doSay: g_game.say(...) failed") + return false + end + if g_game and type(g_game.talk) == 'function' then + local ok, res = SafeCall.call(g_game.talk, text) + if ok then return true end + warn("[TargetBot] doSay: g_game.talk(...) failed") + return false + end + if g_game and type(g_game.talkLocal) == 'function' then + local ok, res = SafeCall.call(g_game.talkLocal, text) + if ok then return true end + warn("[TargetBot] doSay: g_game.talkLocal(...) failed") + return false + end + return false +end + +TargetBot.saySpell = function(text, delay) + if type(text) ~= 'string' or text:len() < 1 then return false end + if not delay then delay = 500 end + if lastSpell + delay < now then + if not doSay(text) then + warn("[TargetBot] no suitable say/talk method; cannot cast: " .. tostring(text)) + return false + end + lastSpell = now + return true + end + return false +end + +TargetBot.sayAttackSpell = function(text, delay) + if type(text) ~= 'string' or text:len() < 1 then return end + if not delay then delay = 2000 end + if BotCore and BotCore.AttackSystem and BotCore.AttackSystem.isEnabled and BotCore.AttackSystem.isEnabled() then + return BotCore.AttackSystem.executeSingleSpell(text, delay) + end + if lastAttackSpell + delay < now then + if doSay(text) then + lastAttackSpell = now + if HuntAnalytics and HuntAnalytics.trackAttackSpell then + HuntAnalytics.trackAttackSpell(text, 0) + end + return true + end + end + return false +end + +TargetBot.useItem = function(item, subType, target, delay) + if AttackBot and type(AttackBot.useItem) == 'function' then + return AttackBot.useItem(item, subType, target, delay) + end + if not delay then delay = 200 end + if lastItemUse + delay < now then + warn("[TargetBot] useItem called but AttackBot.useItem not available; item=" .. tostring(item)) + lastItemUse = now + end + return false +end + +TargetBot.useAttackItem = function(item, subType, target, delay) + if AttackBot and type(AttackBot.useAttackItem) == 'function' then + return AttackBot.useAttackItem(item, subType, target, delay) + end + if not delay then delay = 2000 end + if BotCore and BotCore.AttackSystem and BotCore.AttackSystem.isEnabled and BotCore.AttackSystem.isEnabled() then + return BotCore.AttackSystem.executeSingleRune(item, target, delay) + end + if lastRuneAttack + delay < now then + warn("[TargetBot] useAttackItem called but AttackBot.useAttackItem not available; item=" .. tostring(item)) + lastRuneAttack = now + else + warn("[TargetBot] Rune on cooldown: last=" .. tostring(lastRuneAttack) .. ", now=" .. tostring(now) .. ", delay=" .. tostring(delay)) + end + return false +end diff --git a/targetbot/attack_state_machine.lua b/targetbot/attack_state_machine.lua index 59ce567..4f393ae 100644 --- a/targetbot/attack_state_machine.lua +++ b/targetbot/attack_state_machine.lua @@ -44,17 +44,13 @@ DRY — Uses SafeCreature + CombatConstants. No local wrappers. ]] --- ============================================================================ -- MODULE --- ============================================================================ AttackStateMachine = AttackStateMachine or {} AttackStateMachine.VERSION = "3.1" AttackStateMachine.DEBUG = false --- ============================================================================ -- STATES --- ============================================================================ local STATE = { IDLE = "IDLE", @@ -63,9 +59,7 @@ local STATE = { } AttackStateMachine.STATE = STATE --- ============================================================================ -- DEPENDENCIES (resolved lazily — may not exist at load time) --- ============================================================================ local SC -- SafeCreature (utils/safe_creature.lua) local CC -- CombatConstants (targetbot/combat_constants.lua) @@ -79,7 +73,7 @@ local function ensureDeps() -- Inline minimal defaults so ASM works standalone during boot CC = { TICK_INTERVAL = 100, COMMAND_COOLDOWN = 350, CONFIRM_TIMEOUT = 1200, - GRACE_PERIOD = 1500, KEEPALIVE_INTERVAL = 2000, STOP_DEBOUNCE = 150, + GRACE_PERIOD = 1500, STOP_DEBOUNCE = 150, REAFFIRM_RETRY_MAX = 5, ENGAGE_BACKOFF_BASE = 1500, ENGAGE_BACKOFF_GROWTH = 1.5, SWITCH_COOLDOWN = 2500, CONFIG_SWITCH_COOLDOWN = 400, CRITICAL_HP = 25, @@ -89,9 +83,7 @@ local function ensureDeps() end end --- ============================================================================ -- CLIENT + CREATURE HELPERS (single source: SafeCreature / ClientService) --- ============================================================================ local nowMs = nExBot.Shared.nowMs @@ -129,9 +121,7 @@ local function cName(c) return ok and v or "?" end --- ============================================================================ -- INTERNAL STATE --- ============================================================================ local state = { current = STATE.IDLE, @@ -169,16 +159,13 @@ local state = { kills = 0, switches = 0, skips = 0, - reaffirms = 0, }, } local player = nil local lastTick = 0 --- ============================================================================ -- LOGGING --- ============================================================================ local function log(msg) if AttackStateMachine.DEBUG then print("[ASM] " .. msg) end @@ -190,9 +177,7 @@ local function logTransition(to, reason) end end --- ============================================================================ -- STATE TRANSITION --- ============================================================================ local function transition(to, reason) if state.current == to then return end @@ -213,12 +198,10 @@ local function transition(to, reason) end end --- ============================================================================ -- GAME INTERACTION --- ============================================================================ local function updatePlayer() - if not player or not pcall(function() return player:getPosition() end) then + if not player or not (SC and SC.getPosition and SC.getPosition(player)) then local C = getClient() player = (C and C.getLocalPlayer and C.getLocalPlayer()) or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) @@ -307,9 +290,7 @@ local function cancelAttack() end end --- ============================================================================ -- TARGET MANAGEMENT --- ============================================================================ local function clearTarget() -- Save hold-target memory before clearing @@ -345,9 +326,7 @@ local function setTarget(creature, priority, reason) sendAttack(creature, "engage_immediate") end --- ============================================================================ -- SWITCH EVALUATION (simplified — no MonsterAI coupling) --- ============================================================================ local function getConfigPriority(creature) if not (TargetBot and TargetBot.Creature and TargetBot.Creature.getConfigs) then return 0 end @@ -411,9 +390,7 @@ local function shouldSwitch(newCreature, newPriority) return false, "insufficient" end --- ============================================================================ -- PATH-BLOCKED SKIP LIST (external filter — SRP) --- ============================================================================ function AttackStateMachine.isSkipped(creatureId) if not creatureId then return false end @@ -438,9 +415,7 @@ function AttackStateMachine.getSkippedCount() return n end --- ============================================================================ -- STATE HANDLERS --- ============================================================================ --- IDLE: No target. Hold-target re-scan + passive game sync. local function handleIdle() @@ -449,19 +424,10 @@ local function handleIdle() -- Hold-target: re-scan for previously attacked creature if state.holdTargetId then - local C = getClient() - local pPos = player and pcall(function() return player:getPosition() end) and player:getPosition() + local pPos = player and SC.getPosition(player) if pPos then - local specs - if C and C.getSpectatorsInRange then - local ok, s = pcall(C.getSpectatorsInRange, pPos, 7, 5, false) - specs = ok and s or {} - elseif g_map and g_map.getSpectatorsInRange then - local ok, s = pcall(g_map.getSpectatorsInRange, pPos, 7, 5, false) - specs = ok and s or {} - else - specs = {} - end + local ok, specs = pcall(BotCore.Creatures.getNearby, 7, 5) + specs = ok and specs or {} for _, spec in ipairs(specs) do if cId(spec) == state.holdTargetId and not cDead(spec) then log("Hold-target re-acquired: " .. cName(spec)) @@ -571,13 +537,15 @@ local function handleLocked() -- Confirmation check with grace window if isConfirmed() then state.lastConfirmedAt = nowMs() - elseif (nowMs() - state.lastConfirmedAt) > CC.GRACE_PERIOD then - -- Attack genuinely lost → re-engage with backoff - log("Attack lost after " .. CC.GRACE_PERIOD .. "ms grace") - state.retries = 0 - state.currentTimeout = 0 -- reset backoff for fresh ENGAGING - transition(STATE.ENGAGING, "grace_expired") - return + else + sendAttack(state.creature, "lock_recover") + if (nowMs() - state.lastConfirmedAt) > CC.GRACE_PERIOD then + log("Attack lost after " .. CC.GRACE_PERIOD .. "ms grace") + state.retries = 0 + state.currentTimeout = 0 + transition(STATE.ENGAGING, "grace_expired") + return + end end -- Process pending switch @@ -593,21 +561,10 @@ local function handleLocked() return end - -- Periodic keepalive: only re-send if attack appears lost. - -- sendAttack's toggle guard provides secondary protection, but - -- skipping the call entirely when confirmed saves the overhead. - if (nowMs() - state.lastCommandAt) > CC.KEEPALIVE_INTERVAL then - if not isConfirmed() then - sendAttack(state.creature, "keepalive") - else - state.lastCommandAt = nowMs() -- confirmed & alive, just reset timer - end - end + -- No keepalive: g_game.attack() is persistent — server handles it. end --- ============================================================================ -- UPDATE LOOP --- ============================================================================ local function update() ensureDeps() @@ -647,9 +604,7 @@ local function update() end end --- ============================================================================ -- PUBLIC API --- ============================================================================ function AttackStateMachine.getState() return state.current end function AttackStateMachine.getTarget() return state.creature end @@ -681,31 +636,13 @@ function AttackStateMachine.requestAttack(creature, priority) local id = cId(creature) - -- REAFFIRM path: same target in ENGAGING → reset retries, keep going + -- Same target → just update priority if higher, no-op otherwise. + -- g_game.attack() is persistent — no reaffirm needed. if id == state.targetId then - if state.current == STATE.ENGAGING then - -- Refresh creature ref (may be newer object) - state.creature = creature - state.stats.reaffirms = state.stats.reaffirms + 1 - -- Only actively re-send if game doesn't show us attacking yet. - -- sendAttack's toggle guard also prevents this, but being - -- explicit avoids resetting backoff counters unnecessarily. - if not isConfirmed() then - state.retries = 0 - state.currentTimeout = 0 - state.enteredAt = nowMs() - log("Reaffirm in ENGAGING: " .. cName(creature)) - sendAttack(creature, "reaffirm") - else - log("Reaffirm skip (confirmed): " .. cName(creature)) - end - return true - end - -- LOCKED or same target → update priority, no-op otherwise if priority and priority > state.priority then state.priority = priority end - return true -- "accepted" — we're already on it + return true end -- New target @@ -768,7 +705,7 @@ function AttackStateMachine.reset() state.holdTargetId = nil state.holdTargetName = nil state.skipList = {} - state.stats = { commands = 0, confirms = 0, kills = 0, switches = 0, skips = 0, reaffirms = 0 } + state.stats = { commands = 0, confirms = 0, kills = 0, switches = 0, skips = 0 } log("Reset") end @@ -805,9 +742,7 @@ AttackStateMachine.forceSwitch = AttackStateMachine.forceAttack function AttackStateMachine.isPathBlocked() return false end function AttackStateMachine.findBestTarget() return nil, 0 end --- ============================================================================ -- EVENTBUS INTEGRATION --- ============================================================================ if EventBus then -- Game combat target changed @@ -860,9 +795,7 @@ if EventBus then end, 100) end --- ============================================================================ -- TICK & INIT --- ============================================================================ AttackStateMachine.update = update diff --git a/targetbot/attack_waves.lua b/targetbot/attack_waves.lua new file mode 100644 index 0000000..1df3d41 --- /dev/null +++ b/targetbot/attack_waves.lua @@ -0,0 +1,624 @@ +-- TargetBot Attack Waves Module +-- Wave/damage zone detection and avoidance + +local getClient = nExBot.Shared.getClient +local SC = SafeCreature or {} +local Dirs = Directions +local DIRECTIONS = (Dirs and Dirs.ADJACENT_OFFSETS) or { + {x = 0, y = -1}, {x = 1, y = 0}, {x = 0, y = 1}, {x = -1, y = 0}, + {x = 1, y = -1}, {x = 1, y = 1}, {x = -1, y = 1}, {x = -1, y = -1} +} +local DIR_VECTORS = Directions.DIR_TO_OFFSET + +local avoidanceState = { + lastMove = 0, baseCooldown = 350, lastSafePos = nil, + baseStickiness = 600, consecutiveMoves = 0, maxConsecutive = 3, + baseDangerThreshold = 1.5, lastMonsterCount = 0 +} + +local AVOID_PREDICT_CONF = (TargetCore and TargetCore.CONSTANTS and TargetCore.CONSTANTS.AVOID_PREDICT_CONF) or 0.5 +local AVOID_PREDICT_DANGER = (TargetCore and TargetCore.CONSTANTS and TargetCore.CONSTANTS.AVOID_PREDICT_DANGER) or 3.0 + +local function calculateScaling(monsterCount) + local reactivityScale = 1.0 + if monsterCount >= 7 then reactivityScale = 0.4 + elseif monsterCount >= 5 then reactivityScale = 0.55 + elseif monsterCount >= 3 then reactivityScale = 0.75 + end + return { + cooldownMultiplier = reactivityScale, + stickinessMultiplier = reactivityScale, + dangerThresholdMultiplier = reactivityScale, + scoreThresholdMultiplier = reactivityScale, + monsterCount = monsterCount + } +end + +local function isInFrontArc(pos, monsterPos, monsterDir, range, arcWidth) + range = range or 5 + arcWidth = arcWidth or 1 + local dirVec = DIR_VECTORS[monsterDir] + if not dirVec then return false, 99 end + local dx = pos.x - monsterPos.x + local dy = pos.y - monsterPos.y + local dist = math.max(math.abs(dx), math.abs(dy)) + if dist == 0 or dist > range then return false, dist end + local distFromCenter + if dirVec.x == 0 then + local inDirection = (dy * dirVec.y) > 0 + distFromCenter = math.abs(dx) + return inDirection and distFromCenter <= arcWidth, distFromCenter + elseif dirVec.y == 0 then + local inDirection = (dx * dirVec.x) > 0 + distFromCenter = math.abs(dy) + return inDirection and distFromCenter <= arcWidth, distFromCenter + else + local inX = (dirVec.x > 0 and dx > 0) or (dirVec.x < 0 and dx < 0) + local inY = (dirVec.y > 0 and dy > 0) or (dirVec.y < 0 and dy < 0) + distFromCenter = math.abs(dx - dy) / 2 + return inX and inY, distFromCenter + end +end + +local function analyzePositionDanger(pos, monsters, usePrediction) + local result = { + totalDanger = 0, waveThreats = 0, meleeThreats = 0, details = {}, + realTimeMetrics = { + monstersFacingPos = 0, highTurnRateMonsters = 0, + imminentAttacks = 0, avgPredictionConfidence = 0 + } + } + local predCache = {} + local totalConfidence = 0 + local confCount = 0 + local rtThreatCache = nil + if MonsterAI and MonsterAI.RealTime and MonsterAI.RealTime.threatCache then + rtThreatCache = MonsterAI.RealTime.threatCache + end + for i = 1, #monsters do + local monster = monsters[i] + if monster and not monster:isDead() then + local mpos = monster:getPosition() + if mpos then + local mdir = monster:getDirection() + local dist = math.max(math.abs(pos.x - mpos.x), math.abs(pos.y - mpos.y)) + local threat = { monster = monster, distance = dist, inWaveArc = false, arcDistance = 99 } + local monsterId = monster:getId() + local rtData = nil + if MonsterAI and MonsterAI.RealTime and MonsterAI.RealTime.directions then + rtData = MonsterAI.RealTime.directions[monsterId] + end + if rtData then + local isFacing = false + if MonsterAI and MonsterAI.Predictor and MonsterAI.Predictor.isFacingPosition then + isFacing = MonsterAI.Predictor.isFacingPosition(mpos, mdir, pos) + end + if isFacing then + result.realTimeMetrics.monstersFacingPos = result.realTimeMetrics.monstersFacingPos + 1 + local turnRate = rtData.turnRate or 0 + if turnRate > 0.5 then + result.realTimeMetrics.highTurnRateMonsters = result.realTimeMetrics.highTurnRateMonsters + 1 + result.totalDanger = result.totalDanger + turnRate * 1.5 + end + local facingDuration = 0 + if rtData.facingPlayerSince then + facingDuration = (now or 0) - rtData.facingPlayerSince + end + if facingDuration > 300 then + result.totalDanger = result.totalDanger + math.min(2, facingDuration / 500) + end + end + if rtData.consecutiveChanges and rtData.consecutiveChanges >= 2 then + result.totalDanger = result.totalDanger + rtData.consecutiveChanges * 0.5 + end + end + if usePrediction and MonsterAI and MonsterAI.Predictor then + local pid = monsterId + local pattern = MonsterAI.Patterns.get(monster:getName()) + local isPred, confidence, timeToAttack = nil, nil, nil + if predCache[pid] then + isPred, confidence, timeToAttack = predCache[pid].isPred, predCache[pid].conf, predCache[pid].tta + else + local ok, p, c, tta = pcall(function() return MonsterAI.Predictor.predictWaveAttack(monster) end) + if ok then isPred, confidence, timeToAttack = p, c, tta else isPred, confidence, timeToAttack = false, 0, 999999 end + predCache[pid] = { isPred = isPred, conf = confidence, tta = timeToAttack } + end + if confidence then + totalConfidence = totalConfidence + confidence + confCount = confCount + 1 + end + local trackerData = nil + if MonsterAI and MonsterAI.Tracker and MonsterAI.Tracker.monsters then + trackerData = MonsterAI.Tracker.monsters[pid] + end + if trackerData and trackerData.ewmaCooldown and trackerData.ewmaCooldown > 0 then + local lastAttack = trackerData.lastWaveTime or trackerData.lastAttackTime or 0 + local elapsed = (now or 0) - lastAttack + local cooldown = trackerData.ewmaCooldown + timeToAttack = math.max(0, cooldown - elapsed) + confidence = math.max(confidence or 0, trackerData.confidence or 0.5) + end + local inPredPath = false + if confidence and confidence >= AVOID_PREDICT_CONF then + local ok, pathResult = pcall(MonsterAI.Predictor.isPositionInWavePath, pos, mpos, mdir, pattern.waveRange, pattern.waveWidth) + inPredPath = ok and pathResult + end + if inPredPath then + local maxWindow = AVOID_PREDICT_TTA_WINDOW or 3000 + local urgency = 1 - math.max(0, math.min(timeToAttack, maxWindow)) / maxWindow + local pdanger = (pattern.dangerLevel or 1) * urgency * (confidence or 1) + pdanger = pdanger + AVOID_PREDICT_DANGER * (confidence or 1) + if timeToAttack < 800 then + result.realTimeMetrics.imminentAttacks = result.realTimeMetrics.imminentAttacks + 1 + pdanger = pdanger * 1.3 + end + threat.inWaveArc = true; threat.arcDistance = 0; threat.predicted = true + threat.predConf = confidence; threat.predTTA = timeToAttack + result.waveThreats = result.waveThreats + 1 + result.totalDanger = result.totalDanger + pdanger + end + else + local inArc, arcDist = isInFrontArc(pos, mpos, mdir, 5, 1) + if inArc then + threat.inWaveArc = true; threat.arcDistance = arcDist + result.waveThreats = result.waveThreats + 1 + result.totalDanger = result.totalDanger + (3 - arcDist) + end + end + if dist == 1 then + result.meleeThreats = result.meleeThreats + 1 + result.totalDanger = result.totalDanger + 2 + elseif dist == 2 then + result.totalDanger = result.totalDanger + 0.5 + end + result.details[#result.details + 1] = threat + end -- if mpos + end + end + if confCount > 0 then + result.realTimeMetrics.avgPredictionConfidence = totalConfidence / confCount + end + if usePrediction and MonsterAI and MonsterAI.isPositionDangerous then + local isDangerous, dangerLevel = MonsterAI.isPositionDangerous(pos) + if isDangerous then + result.totalDanger = result.totalDanger + dangerLevel * 2 + end + end + return result +end + +local function isDangerousPosition(pos, monsters) + local analysis = analyzePositionDanger(pos, monsters) + return analysis.totalDanger > 0, (analysis.waveThreats or 0) + (analysis.meleeThreats or 0) +end + +local function countWalkableTiles(position) + if PathUtils and PathUtils.findEveryPath then + local reachable = PathUtils.findEveryPath(position, 1, { ignoreCreatures = false }) + if reachable then return #reachable end + end + local count = 0 + for i = 1, 8 do + local dir = DIRECTIONS[i] + local checkPos = { x = position.x + dir.x, y = position.y + dir.y, z = position.z } + local safe = (PathUtils and PathUtils.isTileSafe and PathUtils.isTileSafe(checkPos)) + or (TargetCore and TargetCore.PathSafety and TargetCore.PathSafety.isTileSafe and TargetCore.PathSafety.isTileSafe(checkPos)) + or (function() + local Client = getClient() + local tile = (Client and Client.getTile) and Client.getTile(checkPos) or (g_map and g_map.getTile and g_map.getTile(checkPos)) + return tile and tile:isWalkable() + end)() + if safe then count = count + 1 end + end + return count +end + +local function isPlayerTrapped(playerPos) + return countWalkableTiles(playerPos) == 0 +end + +local function findSafeAdjacentTile(playerPos, monsters, currentTarget, scaling) + local candidates = {} + local currentAnalysis = analyzePositionDanger(playerPos, monsters, true) + scaling = scaling or calculateScaling(#monsters) + local dynamicDangerThreshold = avoidanceState.baseDangerThreshold * scaling.dangerThresholdMultiplier + local immediateThreat = false + if MonsterAI and MonsterAI.getImmediateThreat then + local threatData = MonsterAI.getImmediateThreat() + immediateThreat = threatData.immediateThreat or false + if immediateThreat then dynamicDangerThreshold = dynamicDangerThreshold * 0.5 end + end + if not immediateThreat and currentAnalysis.totalDanger < dynamicDangerThreshold then + return nil, 0 + end + local threatDirections = {} + if MonsterAI and MonsterAI.RealTime and MonsterAI.RealTime.directions then + for id, rtData in pairs(MonsterAI.RealTime.directions) do + if rtData.facingPlayerSince then + local dir = rtData.dir + if DIR_VECTORS[dir] then + threatDirections[#threatDirections + 1] = { + vec = DIR_VECTORS[dir], turnRate = rtData.turnRate or 0, + consecutiveChanges = rtData.consecutiveChanges or 0 + } + end + end + end + end + local WEIGHTS = { + DANGER = -25, TARGET_ADJACENT = 20, TARGET_CLOSE = 10, TARGET_FAR = -5, + ESCAPE_ROUTES = 4, STABILITY = 8, PREVIOUS_SAFE = 15, STAY_BONUS = 10, + PERPENDICULAR = 10, NOT_FACING = 8, LOW_TURN_RATE = 5, IMMINENT_SAFE = 12 + } + for i = 1, 8 do + local dir = DIRECTIONS[i] + local checkPos = { x = playerPos.x + dir.x, y = playerPos.y + dir.y, z = playerPos.z } + local tileSafe = (TargetCore and TargetCore.PathSafety and TargetCore.PathSafety.isTileSafe) + and TargetCore.PathSafety.isTileSafe(checkPos) + or (function() + local Client = getClient() + local tile = (Client and Client.getTile) and Client.getTile(checkPos) or (g_map and g_map.getTile and g_map.getTile(checkPos)) + local hasCreature = tile and tile.hasCreature and tile:hasCreature() + return tile and tile:isWalkable() and not hasCreature + end)() + if tileSafe then + local analysis = analyzePositionDanger(checkPos, monsters, true) + local score = 0 + score = score + analysis.totalDanger * WEIGHTS.DANGER + if analysis.waveThreats == 0 then score = score + WEIGHTS.STABILITY end + if analysis.realTimeMetrics then + local rtm = analysis.realTimeMetrics + if rtm.monstersFacingPos == 0 then score = score + WEIGHTS.NOT_FACING end + if rtm.imminentAttacks == 0 then score = score + WEIGHTS.IMMINENT_SAFE end + if rtm.highTurnRateMonsters == 0 then score = score + WEIGHTS.LOW_TURN_RATE end + end + if #threatDirections > 0 then + local moveVec = { x = dir.x, y = dir.y } + local perpBonus = 0 + for _, threat in ipairs(threatDirections) do + local threatVec = threat.vec + local dot = moveVec.x * threatVec.x + moveVec.y * threatVec.y + local moveMag = math.sqrt(moveVec.x^2 + moveVec.y^2) + local threatMag = math.sqrt(threatVec.x^2 + threatVec.y^2) + if moveMag > 0 and threatMag > 0 then + local normalizedDot = math.abs(dot) / (moveMag * threatMag) + perpBonus = perpBonus + (1 - normalizedDot) * WEIGHTS.PERPENDICULAR + if threat.turnRate > 0.5 then + perpBonus = perpBonus + (1 - normalizedDot) * 3 + end + end + end + score = score + perpBonus / math.max(1, #threatDirections) + end + if currentTarget then + local tpos = currentTarget:getPosition() + local targetDist = math.max(math.abs(checkPos.x - tpos.x), math.abs(checkPos.y - tpos.y)) + if targetDist <= 1 then score = score + WEIGHTS.TARGET_ADJACENT + elseif targetDist <= 3 then score = score + WEIGHTS.TARGET_CLOSE + else score = score + (targetDist - 3) * WEIGHTS.TARGET_FAR end + end + local escapeRoutes = 0 + for j = 1, 8 do + local escapeDir = DIRECTIONS[j] + local escapePos = { x = checkPos.x + escapeDir.x, y = checkPos.y + escapeDir.y, z = checkPos.z } + local escapeSafe = (TargetCore and TargetCore.PathSafety and TargetCore.PathSafety.isTileSafe) + and TargetCore.PathSafety.isTileSafe(escapePos) + or (function() + local Client = getClient() + local et = (Client and Client.getTile) and Client.getTile(escapePos) or (g_map and g_map.getTile and g_map.getTile(escapePos)) + return et and et:isWalkable() + end)() + if escapeSafe then escapeRoutes = escapeRoutes + 1 end + end + score = score + escapeRoutes * WEIGHTS.ESCAPE_ROUTES + if avoidanceState.lastSafePos then + local isPreviousSafe = checkPos.x == avoidanceState.lastSafePos.x and checkPos.y == avoidanceState.lastSafePos.y + if isPreviousSafe then score = score + WEIGHTS.PREVIOUS_SAFE end + end + candidates[#candidates + 1] = { pos = checkPos, score = score, danger = analysis.totalDanger, waveThreats = analysis.waveThreats } + end + end + if #candidates == 0 then return nil, 0 end + table.sort(candidates, function(a, b) return a.score > b.score end) + local best = candidates[1] + local currentScore = currentAnalysis.totalDanger * WEIGHTS.DANGER + WEIGHTS.STAY_BONUS + local baseScoreThreshold = 12 + local dynamicScoreThreshold = baseScoreThreshold * scaling.scoreThresholdMultiplier + if best.score > currentScore + dynamicScoreThreshold then return best.pos, best.score end + return nil, 0 +end + +local function avoidWaveAttacks() + local currentTime = now + local playerPos = player:getPosition() + local creatures = BotCore.Creatures.getNearby(7) or {} + local monsters = {} + for i = 1, #creatures do + local c = creatures[i] + if c and c:isMonster() and not c:isDead() then monsters[#monsters + 1] = c end + end + local monsterCount = #monsters + if monsterCount == 0 then + avoidanceState.consecutiveMoves = 0; avoidanceState.lastSafePos = nil; avoidanceState.lastMonsterCount = 0 + return false + end + local scaling = calculateScaling(monsterCount) + avoidanceState.lastMonsterCount = monsterCount + local dynamicCooldown = avoidanceState.baseCooldown * scaling.cooldownMultiplier + local maxConsecutive = avoidanceState.maxConsecutive + if monsterCount >= 5 then maxConsecutive = maxConsecutive + 1 end + if avoidanceState.consecutiveMoves >= maxConsecutive then + local pauseDuration = 1200 * scaling.cooldownMultiplier + if currentTime - avoidanceState.lastMove < pauseDuration then return false end + avoidanceState.consecutiveMoves = 0 + end + if currentTime - avoidanceState.lastMove < dynamicCooldown then return false end + local dynamicStickiness = avoidanceState.baseStickiness * scaling.stickinessMultiplier + if avoidanceState.lastSafePos then + local atSafePos = playerPos.x == avoidanceState.lastSafePos.x and playerPos.y == avoidanceState.lastSafePos.y + if atSafePos and currentTime - avoidanceState.lastMove < dynamicStickiness then + local analysis = analyzePositionDanger(playerPos, monsters, true) + local leaveThreshold = avoidanceState.baseDangerThreshold * scaling.dangerThresholdMultiplier + 0.5 + if analysis.totalDanger < leaveThreshold then return false end + end + end + local currentTarget = target and target() + local safePos, score = findSafeAdjacentTile(playerPos, monsters, currentTarget, scaling) + if safePos then + if MovementCoordinator and MovementCoordinator.canMove and MovementCoordinator.canMove() then + avoidanceState.lastMove = currentTime; avoidanceState.lastSafePos = safePos + avoidanceState.consecutiveMoves = avoidanceState.consecutiveMoves + 1 + TargetBot.walkTo(safePos, 2, {ignoreNonPathable = true, precision = 0}) + return true + end + return false + end + avoidanceState.consecutiveMoves = 0 + return false +end + +local function rePosition(minTiles, config) + minTiles = minTiles or 6 + if now - (lastCall or 0) < 500 then return end + lastCall = now + local playerPos = player:getPosition() + local currentWalkable = countWalkableTiles(playerPos) + local immediateThreat = false; local threatBoost = 0 + if MonsterAI and MonsterAI.getImmediateThreat then + local threatData = MonsterAI.getImmediateThreat() + immediateThreat = threatData.immediateThreat or false + if immediateThreat then + minTiles = math.max(3, minTiles - 2); threatBoost = threatData.totalThreat * 5 + end + end + if currentWalkable >= minTiles and not immediateThreat then return end + local creatures = BotCore.Creatures.getNearby(5) or {} + local monsters = {} + for i = 1, #creatures do + local c = creatures[i] + if c and c:isMonster() and not c:isDead() then monsters[#monsters + 1] = c end + end + local currentTarget = target and target() + local bestPos = nil; local bestScore = -9999 + local anchorPos = config and config.anchor and anchorPosition + local anchorRange = config and config.anchorRange or 5 + local threatDirections = {} + if MonsterAI and MonsterAI.RealTime and MonsterAI.RealTime.directions then + for id, rtData in pairs(MonsterAI.RealTime.directions) do + if rtData.facingPlayerSince then + local dir = rtData.dir + if DIR_VECTORS[dir] then threatDirections[#threatDirections + 1] = DIR_VECTORS[dir] end + end + end + end + local WEIGHTS = { + WALKABLE = 15, DANGER = -22, TARGET_ADJ = 20, TARGET_CLOSE = 10, TARGET_FAR = -4, + MOVE_COST = -4, CARDINAL = 3, STAY_BONUS = 15, PERPENDICULAR_ESCAPE = 12, + AWAY_FROM_FACING = 8, LOW_TURN_RATE_ZONE = 6, PREDICTION_SAFE = 10 + } + for dx = -2, 2 do + for dy = -2, 2 do + if dx ~= 0 or dy ~= 0 then + local checkPos = { x = playerPos.x + dx, y = playerPos.y + dy, z = playerPos.z } + local shouldSkip = false + if anchorPos then + local anchorDist = math.max(math.abs(checkPos.x - anchorPos.x), math.abs(checkPos.y - anchorPos.y)) + if anchorDist > anchorRange then shouldSkip = true end + end + if not shouldSkip then + local tileSafe = (TargetCore and TargetCore.PathSafety and TargetCore.PathSafety.isTileSafe) + and TargetCore.PathSafety.isTileSafe(checkPos) + or (function() + local Client = getClient() + local t = (Client and Client.getTile) and Client.getTile(checkPos) or (g_map and g_map.getTile and g_map.getTile(checkPos)) + local hasCreature = t and t.hasCreature and t:hasCreature() + return t and t:isWalkable() and not hasCreature + end)() + if tileSafe then + local score = 0 + local walkable = countWalkableTiles(checkPos) + score = score + walkable * WEIGHTS.WALKABLE + local analysis = analyzePositionDanger(checkPos, monsters, true) + score = score + analysis.totalDanger * WEIGHTS.DANGER + if analysis.realTimeMetrics then + if analysis.realTimeMetrics.monstersFacingPos == 0 then score = score + WEIGHTS.AWAY_FROM_FACING end + if analysis.realTimeMetrics.imminentAttacks == 0 then score = score + WEIGHTS.PREDICTION_SAFE end + if analysis.realTimeMetrics.highTurnRateMonsters == 0 then score = score + WEIGHTS.LOW_TURN_RATE_ZONE end + end + if #threatDirections > 0 then + local moveVec = { x = dx, y = dy } + local perpBonus = 0 + for _, threatVec in ipairs(threatDirections) do + local dot = moveVec.x * threatVec.x + moveVec.y * threatVec.y + local moveMag = math.sqrt(moveVec.x^2 + moveVec.y^2) + local threatMag = math.sqrt(threatVec.x^2 + threatVec.y^2) + if moveMag > 0 and threatMag > 0 then + local normalizedDot = math.abs(dot) / (moveMag * threatMag) + perpBonus = perpBonus + (1 - normalizedDot) * WEIGHTS.PERPENDICULAR_ESCAPE + end + end + score = score + perpBonus / math.max(1, #threatDirections) + end + if currentTarget then + local tpos = currentTarget:getPosition() + local targetDist = math.max(math.abs(checkPos.x - tpos.x), math.abs(checkPos.y - tpos.y)) + if targetDist <= 1 then score = score + WEIGHTS.TARGET_ADJ + elseif targetDist <= 3 then score = score + WEIGHTS.TARGET_CLOSE + else score = score + (targetDist - 3) * WEIGHTS.TARGET_FAR end + end + local moveDist = math.abs(dx) + math.abs(dy) + score = score + moveDist * WEIGHTS.MOVE_COST + if dx == 0 or dy == 0 then score = score + WEIGHTS.CARDINAL end + if immediateThreat then + local safetyBonus = (8 - analysis.totalDanger) * 3 + score = score + safetyBonus + end + if score > bestScore then bestScore = score; bestPos = checkPos end + end + end + end + end + end + local currentScore = currentWalkable * WEIGHTS.WALKABLE + WEIGHTS.STAY_BONUS + if bestPos and bestScore > currentScore + 20 then + return CaveBot.GoTo(bestPos, 0) + end +end + +if EventBus then + EventBus.on("monster:disappear", function(creature) + if TargetBot.isOff() then return end + avoidanceState.lastSafePos = nil + avoidanceState.consecutiveMoves = 0 + end, 20) + EventBus.on("player:move", function(newPos, oldPos) + if TargetBot.isOff() then return end + if avoidanceState.lastSafePos then + local atSafe = newPos.x == avoidanceState.lastSafePos.x and newPos.y == avoidanceState.lastSafePos.y + if not atSafe then avoidanceState.lastSafePos = nil end + end + end, 20) + local monsterDirections = {} + EventBus.on("creature:move", function(creature, oldPos) + if TargetBot.isOff() then return end + if not SC.isMonster(creature) then return end + if SC.isDead(creature) then return end + local id = SC.getId(creature) + local newDir = nil + newDir = SC.getDirection(creature) + if not id or not newDir then return end + local oldDir = monsterDirections[id] + monsterDirections[id] = newDir + if oldDir and oldDir ~= newDir then + local playerPos = player and SC.getPosition(player) or nil + local monsterPos = SC.getPosition(creature) + if not playerPos or not monsterPos then return end + local dist = math.max(math.abs(playerPos.x - monsterPos.x), math.abs(playerPos.y - monsterPos.y)) + if dist <= 5 then + local inArc, arcDist = isInFrontArc(playerPos, monsterPos, newDir, 5, 1) + if inArc then + local monsters = {} + local creatures = BotCore.Creatures.getNearby(7) or {} + for _, c in ipairs(creatures) do + if SC.isMonster(c) and not SC.isDead(c) then monsters[#monsters + 1] = c end + end + if #monsters > 0 then + local Client = getClient() + local currentTarget = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) + local safePos, score = findSafeAdjacentTile(playerPos, monsters, currentTarget) + if safePos and MovementCoordinator and MovementCoordinator.Intent then + local confidence = 0.75 + (5 - dist) * 0.03 + local mName = SC.getName(creature) or "unknown" + MovementCoordinator.Intent.register( + MovementCoordinator.CONSTANTS.INTENT.WAVE_AVOIDANCE, safePos, confidence, "wave_direction_change", + {triggered = "direction_change", monster = mName} + ) + end + end + end + end + end + end, 8) + EventBus.on("monster:appear", function(creature) + if TargetBot.isOff() then return end + if not SC.isMonster(creature) then return end + local okPpos, playerPos = pcall(function() return player and player:getPosition() end) + local monsterPos = SC.getPosition(creature) + if not okPpos or not playerPos or not monsterPos then return end + local dist = math.max(math.abs(playerPos.x - monsterPos.x), math.abs(playerPos.y - monsterPos.y)) + if dist <= 2 then + local walkable = countWalkableTiles(playerPos) + if walkable < 5 then + local monsters = {} + local creatures = BotCore.Creatures.getNearby(5) or {} + for _, c in ipairs(creatures) do + if SC.isMonster(c) and not SC.isDead(c) then monsters[#monsters + 1] = c end + end + local bestPos, bestScore = nil, walkable * 12 + local Client = getClient() + for dx = -1, 1 do + for dy = -1, 1 do + if dx ~= 0 or dy ~= 0 then + local checkPos = {x = playerPos.x + dx, y = playerPos.y + dy, z = playerPos.z} + local tile = (Client and Client.getTile) and Client.getTile(checkPos) or (g_map and g_map.getTile and g_map.getTile(checkPos)) + local hasCreature = tile and tile.hasCreature and tile:hasCreature() + if tile and tile:isWalkable() and not hasCreature then + local newWalkable = countWalkableTiles(checkPos) + local score = newWalkable * 12 + if score > bestScore + 15 then bestScore = score; bestPos = checkPos end + end + end + end + end + if bestPos and MovementCoordinator and MovementCoordinator.Intent then + MovementCoordinator.Intent.register( + MovementCoordinator.CONSTANTS.INTENT.REPOSITION, bestPos, 0.65, "reposition_monster_appear", + {triggered = "monster_appear", walkable = walkable} + ) + end + end + end + end, 12) + EventBus.on("monster:disappear", function(creature) + if TargetBot.isOff() then return end + if creature then + local id = creature:getId() + if id then monsterDirections[id] = nil end + end + end, 25) + local debounceAvoid = (nExBot and nExBot.EventUtil and nExBot.EventUtil.debounce) and nExBot.EventUtil.debounce(200, function() + schedule(60, function() pcall(avoidWaveAttacks) end) + end) + if debounceAvoid then + EventBus.on("creature:appear", function(creature) + if TargetBot.isOff() then return end + if creature and creature:isMonster() then + local p = player and player:getPosition() + local cpos = creature and creature:getPosition() + if p and cpos and math.max(math.abs(p.x-cpos.x), math.abs(p.y-cpos.y)) <= 7 then debounceAvoid() end + end + end, 10) + EventBus.on("creature:move", function(creature, oldPos) + if TargetBot.isOff() then return end + if creature and creature:isMonster() then + local p = player and player:getPosition() + local cpos = creature and creature:getPosition() + if p and cpos and math.max(math.abs(p.x-cpos.x), math.abs(p.y-cpos.y)) <= 7 then debounceAvoid() end + end + end, 10) + EventBus.on("monster:disappear", function(creature) + if TargetBot.isOff() then return end + debounceAvoid() + end, 10) + end +end + +nExBot.avoidWaveAttacks = avoidWaveAttacks +nExBot.isInFrontArc = isInFrontArc +nExBot.isDangerousPosition = isDangerousPosition +nExBot.analyzePositionDanger = analyzePositionDanger +nExBot.findSafeAdjacentTile = findSafeAdjacentTile +nExBot.rePosition = rePosition +nExBot.countWalkableTiles = countWalkableTiles +nExBot.isPlayerTrapped = isPlayerTrapped +nExBot.avoidanceState = avoidanceState diff --git a/targetbot/auto_tuner.lua b/targetbot/auto_tuner.lua index 544057b..0ae52b6 100644 --- a/targetbot/auto_tuner.lua +++ b/targetbot/auto_tuner.lua @@ -15,9 +15,7 @@ local nowMs = H.nowMs local CONST = MonsterAI.CONSTANTS --- ============================================================================ -- CLASSIFIER --- ============================================================================ MonsterAI.Classifier = MonsterAI.Classifier or { THRESHOLDS = { @@ -113,9 +111,7 @@ function MonsterAI.Classifier.get(name) return MonsterAI.Classifier.cache[name:lower()] end --- ============================================================================ -- AUTO-TUNER --- ============================================================================ MonsterAI.AutoTuner = MonsterAI.AutoTuner or { enabled = true, diff --git a/targetbot/chase_controller.lua b/targetbot/chase_controller.lua index f5777f2..2cbf493 100644 --- a/targetbot/chase_controller.lua +++ b/targetbot/chase_controller.lua @@ -19,9 +19,7 @@ local ChaseController = {} --- ============================================================================ -- CLIENT SERVICE ABSTRACTION (shared alias) --- ============================================================================ local getClient = nExBot.Shared.getClient @@ -29,9 +27,7 @@ local function getGame() return ClientHelper and ClientHelper.getGame() or ((getClient() and getClient().g_game) or g_game) end --- ============================================================================ -- STATE --- ============================================================================ local state = { -- Desired chase mode (what TargetBot config wants) @@ -51,9 +47,7 @@ local state = { usingNativeChase = false, } --- ============================================================================ -- CORE API --- ============================================================================ -- Set the desired chase mode (from TargetBot config) function ChaseController.setDesiredChase(enabled) @@ -166,9 +160,7 @@ function ChaseController.getCurrentMode() return state.currentMode end --- ============================================================================ -- CONVENIENCE METHODS --- ============================================================================ -- Enable chase mode function ChaseController.enableChase() @@ -204,9 +196,7 @@ function ChaseController.clearPrecisionHolds() ChaseController.syncMode() end --- ============================================================================ -- AUTO-WALK STATE MANAGEMENT --- ============================================================================ -- Stop any active auto-walk (uses native API) function ChaseController.stopAutoWalk() @@ -246,9 +236,7 @@ function ChaseController.isAutoWalking() return false end --- ============================================================================ -- ATTACK INTEGRATION --- ============================================================================ -- Called before attacking a new target function ChaseController.onTargetChange(creature, config) @@ -277,9 +265,7 @@ function ChaseController.onAttackCancelled() ChaseController.stopAutoWalk() end --- ============================================================================ -- INITIALIZATION --- ============================================================================ -- Hook into EventBus if available if EventBus then @@ -320,9 +306,7 @@ if EventBus then end, 100) end --- ============================================================================ -- MODULE EXPORT --- ============================================================================ -- Make ChaseController globally available (OTClient doesn't have _G) ChaseController = ChaseController -- This makes it globally accessible diff --git a/targetbot/combat_constants.lua b/targetbot/combat_constants.lua index a1dab15..501390d 100644 --- a/targetbot/combat_constants.lua +++ b/targetbot/combat_constants.lua @@ -15,16 +15,14 @@ local CC = {} CC.VERSION = "1.0" --- ========================================================================== -- CLIENT-AGNOSTIC DEFAULTS --- ========================================================================== -- ASM: Attack State Machine timing CC.TICK_INTERVAL = 100 -- ASM tick rate (ms) CC.COMMAND_COOLDOWN = 350 -- Min between g_game.attack() calls (ms) CC.CONFIRM_TIMEOUT = 1200 -- Max wait for server confirmation (ms) CC.GRACE_PERIOD = 1500 -- Stay LOCKED despite transient nil (ms) -CC.KEEPALIVE_INTERVAL = 2000 -- Re-send attack while LOCKED (ms) + CC.STOP_DEBOUNCE = 150 -- After stop, block requestAttack (ms) — was 800 CC.REAFFIRM_RETRY_MAX = 5 -- Max retries before forfeit — was 3 CC.ENGAGE_BACKOFF_BASE = 1500 -- First retry timeout (ms) @@ -51,8 +49,7 @@ CC.SCENARIO_DETECT_INTERVAL = 200 -- Scenario re-detect throttle (ms) -- PriorityEngine CC.PRIORITY_SCALE = 1000 -- config.priority * this = base score -CC.STICKINESS_BASE = 100 -- Base bonus for current target -CC.STICKINESS_FINISH_KILL = 300 -- Bonus when current target < FINISH_KILL_HP + CC.SWITCH_GATE_PENALTY = 0 -- Hard-blocked targets get score = 0 (absolute gate) CC.RECALC_IDLE_INTERVAL = 2000 -- Full recalc when stable + no new creatures (ms) CC.RECALC_ACTIVE_INTERVAL = 150 -- Full recalc during active combat (ms) @@ -63,24 +60,20 @@ CC.ZIGZAG_RATE_LIMIT = 5000 -- Seconds between allowed switches (ms) CC.ZIGZAG_MAX_SWITCHES = 3 -- Max switches before hard block CC.ZIGZAG_DECAY_TIME = 10000 -- Time before switch counter decays (ms) --- ========================================================================== -- CLIENT-SPECIFIC OVERRIDES -- Called once at boot by ASM after ACL detection completes. --- ========================================================================== function CC.applyClientTuning(isOTBR) if isOTBR then CC.COMMAND_COOLDOWN = 450 CC.CONFIRM_TIMEOUT = 1500 - CC.KEEPALIVE_INTERVAL = 2500 + CC.ENGAGE_BACKOFF_BASE = 1800 end -- OTCv8 uses the defaults above (faster client, less latency) end --- ========================================================================== -- FREEZE (prevent accidental mutation after init) --- ========================================================================== local _frozen = false diff --git a/targetbot/core.lua b/targetbot/core.lua index 1f4cf49..80ec04a 100644 --- a/targetbot/core.lua +++ b/targetbot/core.lua @@ -14,9 +14,7 @@ - TargetMetrics: Performance tracking and analysis ]] --- ============================================================================ -- MODULE NAMESPACE --- ============================================================================ TargetCore = TargetCore or {} @@ -24,9 +22,7 @@ TargetCore = TargetCore or {} local getClient = nExBot.Shared.getClient local getClientVersion = nExBot.Shared.getClientVersion --- ============================================================================ -- CONSTANTS (Centralized, immutable) --- ============================================================================ TargetCore.CONSTANTS = { -- Creature types @@ -50,16 +46,7 @@ TargetCore.CONSTANTS = { }, -- Direction index to vector (O(1) lookup) - DIR_VECTORS = { - [0] = {x = 0, y = -1}, -- North - [1] = {x = 1, y = 0}, -- East - [2] = {x = 0, y = 1}, -- South - [3] = {x = -1, y = 0}, -- West - [4] = {x = 1, y = -1}, -- NorthEast - [5] = {x = 1, y = 1}, -- SouthEast - [6] = {x = -1, y = 1}, -- SouthWest - [7] = {x = -1, y = -1} -- NorthWest - }, + DIR_VECTORS = Directions.DIR_TO_OFFSET, -- Adjacent offsets (pre-computed for iteration) ADJACENT_OFFSETS = { @@ -125,6 +112,27 @@ local TIMING = CONST.TIMING -- Geometry export for reuse by other modules TargetCore.Geometry = TargetCore.Geometry or {} + +function TargetCore.isAdjacent(pos1, pos2) + local dx = math.abs(pos1.x - pos2.x) + local dy = math.abs(pos1.y - pos2.y) + return dx <= 1 and dy <= 1 and (dx + dy) > 0 +end + +function TargetCore.getDirection(pos1, pos2) + local dx = pos2.x - pos1.x + local dy = pos2.y - pos1.y + if dx == 0 and dy < 0 then return 0 end + if dx > 0 and dy == 0 then return 1 end + if dx == 0 and dy > 0 then return 2 end + if dx < 0 and dy == 0 then return 3 end + if dx > 0 and dy < 0 then return 4 end + if dx > 0 and dy > 0 then return 5 end + if dx < 0 and dy > 0 then return 6 end + if dx < 0 and dy < 0 then return 7 end + return nil +end + TargetCore.Geometry.DIRECTIONS = CONST.DIRECTIONS TargetCore.Geometry.DIR_VECTORS = CONST.DIR_VECTORS TargetCore.Geometry.ADJACENT_OFFSETS = CONST.ADJACENT_OFFSETS @@ -133,11 +141,8 @@ TargetCore.Geometry.chebyshevDistance = TargetCore.chebyshevDistance TargetCore.Geometry.manhattanDistance = TargetCore.manhattanDistance TargetCore.Geometry.isAdjacent = TargetCore.isAdjacent - --- ============================================================================ -- PATH SAFETY HELPERS (Pure-ish functions operating on map API) -- Exported so cavebot and other modules can share the same logic --- ============================================================================ TargetCore.PathSafety = TargetCore.PathSafety or {} @@ -298,7 +303,7 @@ function TargetCore.PathSafety.recursiveReachable(startPos, destPos, depth, maxN maxNodes = maxNodes or 500 local visited = {} local nodes = 0 - local function key(p) return p.x..","..p.y..","..p.z end + local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end local function dfs(p, d) if nodes > maxNodes then return false @@ -338,7 +343,6 @@ function TargetCore.PathSafety.findSafeAlternate(playerPos, destPos, maxDist, op end -- small additional diagnostic: if no candidate found, optionally try to widen search if debug enabled - -- BFS fallback (small radius) local radius = opts.radius or 3 local queue = {{x = destPos.x, y = destPos.y, z = destPos.z}} @@ -370,9 +374,7 @@ function TargetCore.PathSafety.findSafeAlternate(playerPos, destPos, maxDist, op return nil, nil end --- ============================================================================ -- PURE UTILITY FUNCTIONS --- ============================================================================ -- Calculate Manhattan distance (pure) function TargetCore.manhattanDistance(pos1, pos2) @@ -384,356 +386,7 @@ function TargetCore.chebyshevDistance(pos1, pos2) return math.max(math.abs(pos1.x - pos2.x), math.abs(pos1.y - pos2.y)) end --- Check if position is adjacent (distance 1) (pure) -function TargetCore.isAdjacent(pos1, pos2) - local dx = math.abs(pos1.x - pos2.x) - local dy = math.abs(pos1.y - pos2.y) - return dx <= 1 and dy <= 1 and (dx + dy) > 0 -end - --- Check if position is diagonal from another (pure) -function TargetCore.isDiagonal(pos1, pos2) - local dx = math.abs(pos1.x - pos2.x) - local dy = math.abs(pos1.y - pos2.y) - return dx == 1 and dy == 1 -end - --- Get direction from pos1 to pos2 (pure) -function TargetCore.getDirection(pos1, pos2) - local dx = pos2.x - pos1.x - local dy = pos2.y - pos1.y - - if dx == 0 and dy < 0 then return 0 end -- North - if dx > 0 and dy == 0 then return 1 end -- East - if dx == 0 and dy > 0 then return 2 end -- South - if dx < 0 and dy == 0 then return 3 end -- West - if dx > 0 and dy < 0 then return 4 end -- NE - if dx > 0 and dy > 0 then return 5 end -- SE - if dx < 0 and dy > 0 then return 6 end -- SW - if dx < 0 and dy < 0 then return 7 end -- NW - - return nil -end - --- Clamp value between min and max (pure) -function TargetCore.clamp(value, min, max) - if value < min then return min end - if value > max then return max end - return value -end - --- Linear interpolation (pure) -function TargetCore.lerp(a, b, t) - return a + (b - a) * TargetCore.clamp(t, 0, 1) -end - --- ============================================================================ --- WAVE AVOIDANCE SYSTEM (Pure Functions) --- ============================================================================ - ---[[ - Wave Attack Detection Algorithm: - - Monsters face the player when attacking. A beam/wave attack hits tiles - in a cone AHEAD of the monster. We check if player is in this danger zone. - - For each monster: - 1. Get monster direction - 2. Calculate if player is in the "front arc" - 3. Front arc = player is in the direction monster faces, within beam width -]] - --- Check if position is in monster's front attack arc (pure) --- @param targetPos: position to check --- @param monsterPos: monster position --- @param monsterDir: monster direction (0-7) --- @param range: attack range (default 5) --- @param width: beam width (default 1) --- @return boolean -function TargetCore.isInFrontArc(targetPos, monsterPos, monsterDir, range, width) - range = range or 5 - width = width or 1 - - local dirVec = DIR_VEC[monsterDir] - if not dirVec then return false end - - local dx = targetPos.x - monsterPos.x - local dy = targetPos.y - monsterPos.y - local dist = math.max(math.abs(dx), math.abs(dy)) - - -- Must be within range and not at same position - if dist == 0 or dist > range then - return false - end - - -- Cardinal directions (N/E/S/W) - if dirVec.x == 0 then - -- North/South: player must be in that direction, within width sideways - local inDirection = (dy * dirVec.y) > 0 - local withinWidth = math.abs(dx) <= width - return inDirection and withinWidth - - elseif dirVec.y == 0 then - -- East/West: player must be in that direction, within width vertically - local inDirection = (dx * dirVec.x) > 0 - local withinWidth = math.abs(dy) <= width - return inDirection and withinWidth - - else - -- Diagonal directions: check if in the quadrant - local inX = (dirVec.x > 0 and dx > 0) or (dirVec.x < 0 and dx < 0) - local inY = (dirVec.y > 0 and dy > 0) or (dirVec.y < 0 and dy < 0) - -- For diagonals, also check proximity to the diagonal line - local onDiagonal = math.abs(math.abs(dx) - math.abs(dy)) <= width - return inX and inY and onDiagonal - end -end - --- Calculate danger score for a position (pure) --- @param pos: position to evaluate --- @param monsters: array of {creature, pos, dir} objects --- @return dangerScore (0 = safe, higher = more dangerous) -function TargetCore.calculatePositionDanger(pos, monsters) - local danger = 0 - - for i = 1, #monsters do - local m = monsters[i] - if m.creature and not m.creature:isDead() then - local mpos = m.pos or m.creature:getPosition() - local mdir = m.dir or m.creature:getDirection() - local dist = TargetCore.chebyshevDistance(pos, mpos) - - -- In front arc = high danger (wave attack) - if TargetCore.isInFrontArc(pos, mpos, mdir, 6, 1) then - danger = danger + 30 - end - - -- Adjacent = melee danger - if dist == 1 then - danger = danger + 15 - elseif dist == 2 then - danger = danger + 5 - end - end - end - - return danger -end - --- Find safest adjacent tile (pure) --- @param playerPos: current position --- @param monsters: array of monster data --- @param currentTarget: target creature (optional, to maintain attack range) --- @param getTileFunc: function(pos) -> tile (dependency injection for testing) --- @return {pos, danger, score} or nil -function TargetCore.findSafestTile(playerPos, monsters, currentTarget, getTileFunc) - getTileFunc = getTileFunc or function(p) - local Client = getClient() - return (Client and Client.getTile) and Client.getTile(p) or (g_map and g_map.getTile and g_map.getTile(p)) - end - - local currentDanger = TargetCore.calculatePositionDanger(playerPos, monsters) - - -- If current position is safe, don't move - if currentDanger == 0 then - return nil - end - - local candidates = {} - local targetPos = currentTarget and currentTarget:getPosition() - - -- Check all 8 adjacent tiles - for i = 1, 8 do - local dir = DIRS[i] - local checkPos = { - x = playerPos.x + dir.x, - y = playerPos.y + dir.y, - z = playerPos.z - } - - local tile = getTileFunc(checkPos) - local hasCreature = tile and tile.hasCreature and tile:hasCreature() - if tile and tile:isWalkable() and not hasCreature then - local danger = TargetCore.calculatePositionDanger(checkPos, monsters) - - -- Calculate composite score (lower = better) - local score = danger * 10 -- Primary: minimize danger - - -- Secondary: maintain distance to target - if targetPos then - local targetDist = TargetCore.chebyshevDistance(checkPos, targetPos) - -- Prefer staying within attack range (1-4 tiles) - if targetDist > 4 then - score = score + (targetDist - 4) * 5 - elseif targetDist == 0 then - score = score + 10 -- Don't walk onto target - end - end - - -- Prefer cardinal directions (easier movement) - if dir.x == 0 or dir.y == 0 then - score = score - 2 - end - - candidates[#candidates + 1] = { - pos = checkPos, - danger = danger, - score = score - } - end - end - - if #candidates == 0 then - return nil - end - - -- Sort by score (lowest first) - table.sort(candidates, function(a, b) return a.score < b.score end) - - -- Return best if it's safer than current - local best = candidates[1] - if best.danger < currentDanger then - return best - end - - return nil -end - --- ============================================================================ --- PRIORITY CALCULATION (Pure Functions) --- ============================================================================ - ---[[ - Priority Algorithm: - - Uses weighted scoring with exponential scaling for critical factors. - Designed to: - 1. FINISH kills (high priority for low HP monsters) - 2. MAINTAIN focus (bonus for current target) - 3. OPTIMIZE efficiency (consider distance and AOE potential) - 4. RESPECT configuration (user-defined base priority) -]] - --- ============================================================================ --- POSITIONING ALGORITHMS (Pure Functions) --- ============================================================================ - --- Count walkable adjacent tiles (escape routes) (pure) -function TargetCore.countEscapeRoutes(pos, getTileFunc) - getTileFunc = getTileFunc or function(p) return g_map.getTile(p) end - local count = 0 - - for i = 1, 8 do - local dir = DIRS[i] - local checkPos = { - x = pos.x + dir.x, - y = pos.y + dir.y, - z = pos.z - } - local tile = getTileFunc(checkPos) - if tile and tile:isWalkable() then - count = count + 1 - end - end - - return count -end - --- Check if position is trapped (no escape routes) (pure) -function TargetCore.isTrapped(pos, getTileFunc) - return TargetCore.countEscapeRoutes(pos, getTileFunc) == 0 -end - --- Score a position for repositioning (pure) --- @param pos: position to evaluate --- @param context: {playerPos, targetPos, monsters, anchorPos, anchorRange} --- @return score (higher = better position) -function TargetCore.scorePosition(pos, context, getTileFunc) - getTileFunc = getTileFunc or function(p) return g_map.getTile(p) end - - local score = 0 - - -- Factor 1: Escape routes (most important) - local escapeRoutes = TargetCore.countEscapeRoutes(pos, getTileFunc) - score = score + escapeRoutes * 15 - - -- Factor 2: Danger from monsters - if context.monsters then - local danger = TargetCore.calculatePositionDanger(pos, context.monsters) - score = score - danger * 2 - end - - -- Factor 3: Distance to target (maintain attack range) - if context.targetPos then - local targetDist = TargetCore.chebyshevDistance(pos, context.targetPos) - if targetDist <= 1 then - score = score + 25 -- Adjacent is ideal for melee - elseif targetDist <= 3 then - score = score + 15 -- Good range - elseif targetDist <= 5 then - score = score + 5 -- Acceptable - else - score = score - (targetDist - 5) * 3 -- Penalize too far - end - end - - -- Factor 4: Anchor constraint - if context.anchorPos and context.anchorRange then - local anchorDist = TargetCore.chebyshevDistance(pos, context.anchorPos) - if anchorDist > context.anchorRange then - return -9999 -- Invalid position - violates anchor - end - end - - -- Factor 5: Movement cost - if context.playerPos then - local moveDist = TargetCore.manhattanDistance(pos, context.playerPos) - score = score - moveDist * 2 - end - - return score -end - --- Find best position in radius (pure) --- @param centerPos: center of search --- @param radius: search radius --- @param context: scoring context --- @return {pos, score} or nil -function TargetCore.findBestPosition(centerPos, radius, context, getTileFunc) - getTileFunc = getTileFunc or function(p) return g_map.getTile(p) end - - local best = nil - local bestScore = -9999 - - for dx = -radius, radius do - for dy = -radius, radius do - if dx ~= 0 or dy ~= 0 then - local checkPos = { - x = centerPos.x + dx, - y = centerPos.y + dy, - z = centerPos.z - } - - local tile = getTileFunc(checkPos) - local hasCreature = tile and tile.hasCreature and tile:hasCreature() - if tile and tile:isWalkable() and not hasCreature then - local score = TargetCore.scorePosition(checkPos, context, getTileFunc) - - if score > bestScore then - bestScore = score - best = {pos = checkPos, score = score} - end - end - end - end - end - - return best -end - --- ============================================================================ -- METRICS & ANALYTICS --- ============================================================================ TargetCore.Metrics = { targetsKilled = 0, @@ -746,29 +399,10 @@ TargetCore.Metrics = { lastReset = 0 } -function TargetCore.Metrics.reset() - TargetCore.Metrics.targetsKilled = 0 - TargetCore.Metrics.targetsSwitched = 0 - TargetCore.Metrics.avoidancesMoved = 0 - TargetCore.Metrics.pathsCalculated = 0 - TargetCore.Metrics.cacheHits = 0 - TargetCore.Metrics.cacheMisses = 0 - TargetCore.Metrics.avgPriorityCalcTime = 0 - TargetCore.Metrics.lastReset = now -end - -function TargetCore.Metrics.getCacheHitRate() - local total = TargetCore.Metrics.cacheHits + TargetCore.Metrics.cacheMisses - if total == 0 then return 0 end - return TargetCore.Metrics.cacheHits / total * 100 -end - --- ============================================================================ -- OTCLIENT NATIVE API HELPERS -- -- Wrappers for OTClient's game API to handle version differences and -- provide caching to reduce unnecessary API calls --- ============================================================================ TargetCore.Native = { -- Cached chase mode to avoid redundant setChaseMode calls @@ -889,9 +523,7 @@ function TargetCore.Native.isFollowing(creature) return following and following:getId() == creature:getId() end --- ============================================================================ -- INITIALIZATION --- ============================================================================ -- Toggle to enable debug prints TargetCore.DEBUG = TargetCore.DEBUG or false diff --git a/targetbot/creature_attack.lua b/targetbot/creature_attack.lua index ddd53e9..67bdd59 100644 --- a/targetbot/creature_attack.lua +++ b/targetbot/creature_attack.lua @@ -1,2443 +1,6 @@ --------------------------------------------------------------------------------- --- TARGETBOT CREATURE ATTACK v1.2 --- Uses TargetBotCore for shared pure functions (DRY, SRP) --- Dynamic scaling based on monster count for better reactivity --- v1.1: Integrated PathUtils for shared floor-change detection and tile utilities --- v1.2: Integrated SafeCreature for safe creature access (DRY) --------------------------------------------------------------------------------- - --- Safe function calls to prevent "attempt to call global function (a nil value)" errors -local SafeCall = SafeCall or require("core.safe_call") - --- SafeCreature module for safe creature access (prevents pcall boilerplate) --- Defensive wrapper to handle cases where SafeCreature isn't fully loaded -local SC = SafeCreature or {} - --- Guard: returns true when TargetBot is disabled (used by EventBus handlers) -local function tbOff() return not TargetBot or not TargetBot.isOn or not TargetBot.isOn() end - --- Defensive helper functions (fallback if SafeCreature methods missing) -local function safeIsMonster(creature) - if SC.isMonster then return SC.isMonster(creature) end - if not creature then return false end - local ok, result = pcall(function() return creature:isMonster() end) - return ok and result == true -end - -local function safeIsDead(creature) - if SC.isDead then return SC.isDead(creature) end - if not creature then return true end - local ok, result = pcall(function() return creature:isDead() end) - return ok and result == true -end - -local function safeGetHealthPercent(creature) - if SC.getHealthPercent then return SC.getHealthPercent(creature) end - if not creature then return 0 end - local ok, hp = pcall(function() return creature:getHealthPercent() end) - return ok and hp or 0 -end - -local function safeGetPosition(creature) - if SC.getPosition then return SC.getPosition(creature) end - if not creature then return nil end - local ok, pos = pcall(function() return creature:getPosition() end) - return ok and pos or nil -end - -local function safeGetId(creature) - if SC.getId then return SC.getId(creature) end - if not creature then return nil end - local ok, id = pcall(function() return creature:getId() end) - return ok and id or nil -end - -local function safeGetName(creature) - if SC.getName then return SC.getName(creature) end - if not creature then return nil end - local ok, name = pcall(function() return creature:getName() end) - return ok and name or nil -end - --- Load PathUtils if available (shared module for DRY) -local PathUtils = nil -local function ensurePathUtils() - if PathUtils then return PathUtils end - -- OTClient compatible - just try dofile - local success = pcall(function() - dofile("nExBot/utils/path_utils.lua") - end) - -- After dofile, PathUtils should be global - if success then - PathUtils = PathUtils -- Re-check global - end - return PathUtils -end -ensurePathUtils() - --- Load ChaseController if available (OTClient compatible) -local ChaseController = ChaseController -- Try existing global -local function ensureChaseController() - if ChaseController then return ChaseController end - -- Try to load ChaseController from targetbot folder - local success = pcall(function() - dofile("nExBot/targetbot/chase_controller.lua") - end) - -- After dofile, ChaseController should be global - if success then - ChaseController = ChaseController -- Re-check global - end - return ChaseController -end -ensureChaseController() - -local targetBotLure = false -local targetCount = 0 -local delayValue = 0 -local lureMax = 0 -local anchorPosition = nil -local lastCall = now -local delayFrom = nil -local dynamicLureDelay = false -local smartPullState = { lastEval = 0, lowStreak = 0, highStreak = 0, active = false, lastChange = 0 } -local dynamicLureState = { lastTrigger = 0 } - -local function countMonstersByRange(range) - local playerPos = player and player:getPosition() - if not playerPos then return 0 end - - if SpectatorCache and SpectatorCache.getNearby then - local specs = SpectatorCache.getNearby(range, range) or {} - local count = 0 - for i = 1, #specs do - local creature = specs[i] - if creature and safeIsMonster(creature) and not safeIsDead(creature) then - count = count + 1 - end - end - return count - end - - local Client = getClient() - local specs = (Client and Client.getSpectatorsInRange) and Client.getSpectatorsInRange(playerPos, false, range, range) - or (g_map and g_map.getSpectatorsInRange and g_map.getSpectatorsInRange(playerPos, false, range, range)) - or {} - - local count = 0 - for i = 1, #specs do - local creature = specs[i] - if creature and safeIsMonster(creature) and not safeIsDead(creature) then - count = count + 1 - end - end - - return count -end - -local function safeGetMonsters(range) - if SafeCall and SafeCall.getMonsters then - return SafeCall.getMonsters(range) or 0 - end - if getMonsters then - return getMonsters(range) or 0 - end - return countMonstersByRange(range) -end - -local function evaluateLureAndPull(creature, config, targets) - if not creature or not config then return false end - - local cpos = creature:getPosition() - local pos = player:getPosition() - if not cpos or not pos then return false end - - local creatureHealth = creature:getHealthPercent() - local killUnder = storage.extras.killUnder or 30 - local targetIsLowHealth = creatureHealth < killUnder - local isTrapped = isPlayerTrapped(pos) - - -- Anchor position management (used by keepDistance and rePosition) - if config.anchor then - if not anchorPosition or distanceFromPlayer(anchorPosition) > (config.anchorRange or 5) * 2 then - anchorPosition = pos - end - else - anchorPosition = nil - end - - -- External data for dynamic lure delay system - if config.lureMin and config.lureMax and config.dynamicLure then - targetBotLure = config.lureMin >= targets - if targets >= config.lureMax then - targetBotLure = false - end - end - targetCount = targets - delayValue = config.lureDelay - lureMax = config.lureMax or 0 - dynamicLureDelay = config.dynamicLureDelay - delayFrom = config.delayFrom - - if not targetIsLowHealth and not isTrapped then - if config.smartPull then - local nowt = now or (os.time() * 1000) - if (nowt - smartPullState.lastEval) >= 300 then - smartPullState.lastEval = nowt - - local screenMonsters = 0 - if EventTargeting and EventTargeting.getLiveMonsterCount then - screenMonsters = EventTargeting.getLiveMonsterCount() or 0 - else - screenMonsters = countMonstersByRange(7) - end - - if screenMonsters == 0 then - smartPullState.active = false - smartPullState.lowStreak = 0 - smartPullState.highStreak = 0 - else - local pullRange = config.smartPullRange or 2 - local pullMin = config.smartPullMin or 3 - local pullShape = config.smartPullShape or (nExBot.SHAPE and nExBot.SHAPE.CIRCLE) or 2 - local pullOff = pullMin + 1 - - local nearbyMonsters = 0 - if getMonstersAdvanced then - nearbyMonsters = SafeCall.global("getMonstersAdvanced", pullRange, pullShape) or 0 - elseif getMonsters then - nearbyMonsters = getMonsters(pullRange) or 0 - else - nearbyMonsters = countMonstersByRange(pullRange) - end - - local underImmediateThreat = false - if MonsterAI and MonsterAI.getImmediateThreat then - local threatData = MonsterAI.getImmediateThreat() - underImmediateThreat = threatData.immediateThreat and threatData.highestConfidence >= 0.7 - end - - if underImmediateThreat then - smartPullState.active = false - smartPullState.lowStreak = 0 - smartPullState.highStreak = 0 - else - if nearbyMonsters < pullMin then - smartPullState.lowStreak = smartPullState.lowStreak + 1 - smartPullState.highStreak = 0 - elseif nearbyMonsters >= pullOff then - smartPullState.highStreak = smartPullState.highStreak + 1 - smartPullState.lowStreak = 0 - else - smartPullState.lowStreak = 0 - smartPullState.highStreak = 0 - end - - if smartPullState.lowStreak >= 2 then - smartPullState.active = true - smartPullState.lastChange = nowt - elseif smartPullState.highStreak >= 2 then - smartPullState.active = false - smartPullState.lastChange = nowt - end - end - end - end - TargetBot.smartPullActive = smartPullState.active - else - TargetBot.smartPullActive = false - smartPullState.active = false - smartPullState.lowStreak = 0 - smartPullState.highStreak = 0 - end - - if not TargetBot.smartPullActive and TargetBot.canLure() and config.dynamicLure then - local nowt = now or (os.time() * 1000) - if targetBotLure and (nowt - (dynamicLureState.lastTrigger or 0)) > 700 then - dynamicLureState.lastTrigger = nowt - TargetBot.allowCaveBot(250) - return true - end - end - - if config.closeLure and config.closeLureAmount then - if safeGetMonsters(1) >= config.closeLureAmount then - TargetBot.allowCaveBot(250) - return true - end - end - - if not config.dynamicLure then - local screenMonsters = safeGetMonsters(7) - if screenMonsters > 0 then - -- Keep CaveBot paused until screen is clear - end - end - else - TargetBot.smartPullActive = false - end - - return false -end - --- Use TargetCore if available (DRY - avoid duplicate implementations) -local Core = TargetCore or {} -local Geometry = Core.Geometry or {} - --- Helper: check MovementCoordinator for movement allowance -local zigzagState = { blockUntil = 0, cooldown = 250 } - -local function movementAllowed() - local nowt = now or (os.time() * 1000) - if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.isZigzagging then - if MonsterAI.Scenario.isZigzagging() then - if nowt < zigzagState.blockUntil then - return false - end - zigzagState.blockUntil = nowt + zigzagState.cooldown - return false - end - end - if nExBot and nExBot.MovementCoordinator and nExBot.MovementCoordinator.canMove then - return nExBot.MovementCoordinator.canMove() - end - return true -end - --- Use Directions constant module if available (DRY - Phase 3) -local Dirs = Directions - --- Pre-computed direction offsets (use Directions module, fallback to Geometry) --- Adjacent offsets array (use Directions.ADJACENT_OFFSETS if provided) -local DIRECTIONS = (Dirs and Dirs.ADJACENT_OFFSETS) or Geometry.ADJACENT_OFFSETS or Geometry.DIRECTIONS or { - {x = 0, y = -1}, -- North - {x = 1, y = 0}, -- East - {x = 0, y = 1}, -- South - {x = -1, y = 0}, -- West - {x = 1, y = -1}, -- NorthEast - {x = 1, y = 1}, -- SouthEast - {x = -1, y = 1}, -- SouthWest - {x = -1, y = -1} -- NorthWest -} - --- Direction index to vector (monster facing) -local DIR_VECTORS = (Dirs and Dirs.DIR_TO_OFFSET) or Geometry.DIR_VECTORS or { - [0] = {x = 0, y = -1}, -- North - [1] = {x = 1, y = 0}, -- East - [2] = {x = 0, y = 1}, -- South - [3] = {x = -1, y = 0}, -- West - [4] = {x = 1, y = -1}, -- NorthEast - [5] = {x = 1, y = 1}, -- SouthEast - [6] = {x = -1, y = 1}, -- SouthWest - [7] = {x = -1, y = -1} -- NorthWest -} - --------------------------------------------------------------------------------- --- CLIENTSERVICE HELPERS (shared aliases) --------------------------------------------------------------------------------- -local getClient = nExBot.Shared.getClient -local getClientVersion = nExBot.Shared.getClientVersion - --------------------------------------------------------------------------------- --- IMPROVED WAVE AVOIDANCE SYSTEM --- --- Uses TargetBotCore pure functions and improved scoring algorithm. --- Key improvements: --- 1. Dynamic scaling based on monster count (more reactive when surrounded) --- 2. Better front arc detection with configurable width --- 3. Multi-factor safe tile scoring --- 4. Balanced anti-oscillation (not too sticky when danger is high) --- 5. Adaptive thresholds based on threat level --------------------------------------------------------------------------------- - --- Avoidance state (prevents oscillation) -local avoidanceState = { - lastMove = 0, - baseCooldown = 350, -- Base cooldown (scales down with more monsters) - lastSafePos = nil, - baseStickiness = 600, -- Base stickiness (scales down with danger) - consecutiveMoves = 0, -- Track consecutive avoidance moves - maxConsecutive = 3, -- Increased back (was 2, too restrictive) - baseDangerThreshold = 1.5, -- Base danger threshold (scales with monster count) - lastMonsterCount = 0 -- Track monster count for scaling -} - --- Avoidance tuning knobs (can be tuned via TargetCore.CONSTANTS) -local AVOID_PREDICT_CONF = (TargetCore and TargetCore.CONSTANTS and TargetCore.CONSTANTS.AVOID_PREDICT_CONF) or 0.5 -- min confidence to treat predicted wave as threat -local AVOID_PREDICT_DANGER = (TargetCore and TargetCore.CONSTANTS and TargetCore.CONSTANTS.AVOID_PREDICT_DANGER) or 3.0 -- added danger weight for predicted wave - - --- Pure function: Calculate dynamic scaling factor based on monster count --- More monsters = more reactive (lower thresholds, shorter cooldowns) --- @param monsterCount: number of nearby monsters --- @return table with scaling factors -local function calculateScaling(monsterCount) - -- Scale from 1.0 (few monsters) to 0.4 (many monsters) - -- 1-2 monsters: full conservative behavior - -- 3-4 monsters: moderate reactivity - -- 5-6 monsters: high reactivity - -- 7+ monsters: maximum reactivity - local reactivityScale = 1.0 - if monsterCount >= 7 then - reactivityScale = 0.4 - elseif monsterCount >= 5 then - reactivityScale = 0.55 - elseif monsterCount >= 3 then - reactivityScale = 0.75 - end - - return { - -- Cooldown and stickiness scale DOWN (faster reactions when surrounded) - cooldownMultiplier = reactivityScale, - stickinessMultiplier = reactivityScale, - -- Danger threshold scales DOWN (more willing to move when surrounded) - dangerThresholdMultiplier = reactivityScale, - -- Score threshold scales DOWN (accept smaller improvements when surrounded) - scoreThresholdMultiplier = reactivityScale, - -- Monster count for reference - monsterCount = monsterCount - } -end - --- Pure function: Check if position is in front of a monster (in its attack arc) --- Improved with configurable arc width and better edge detection --- @param pos: position to check {x, y, z} --- @param monsterPos: monster position {x, y, z} --- @param monsterDir: monster direction (0-7) --- @param range: how far the attack reaches (default 5) --- @param arcWidth: how wide the arc is (default 1 tile on each side) --- @return boolean, number (isInArc, distanceToCenter) -local function isInFrontArc(pos, monsterPos, monsterDir, range, arcWidth) - -- Prefer TargetCore's implementation when available - if Core and Core.isInFrontArc then - return Core.isInFrontArc(pos, monsterPos, monsterDir, range, arcWidth) - end - range = range or 5 - arcWidth = arcWidth or 1 - - local dirVec = DIR_VECTORS[monsterDir] - if not dirVec then return false, 99 end - - local dx = pos.x - monsterPos.x - local dy = pos.y - monsterPos.y - - -- Use Chebyshev distance for game tiles - local dist = math.max(math.abs(dx), math.abs(dy)) - if dist == 0 or dist > range then - return false, dist - end - - local distFromCenter - if dirVec.x == 0 then - local inDirection = (dy * dirVec.y) > 0 - distFromCenter = math.abs(dx) - return inDirection and distFromCenter <= arcWidth, distFromCenter - elseif dirVec.y == 0 then - local inDirection = (dx * dirVec.x) > 0 - distFromCenter = math.abs(dy) - return inDirection and distFromCenter <= arcWidth, distFromCenter - else - local inX = (dirVec.x > 0 and dx > 0) or (dirVec.x < 0 and dx < 0) - local inY = (dirVec.y > 0 and dy > 0) or (dirVec.y < 0 and dy < 0) - distFromCenter = math.abs(dx - dy) / 2 - return inX and inY, distFromCenter - end -end - --- Pure function: Score a position's danger level --- Returns detailed danger analysis for better decision making --- Enhanced with MonsterAI.RealTime metrics for high-accuracy threat assessment --- @param pos: position to check --- @param monsters: array of monster creatures --- @param usePrediction: when true, consult MonsterAI predictions (only enabled for rePosition) --- @return table {totalDanger, waveThreats, meleeThreats, details, realTimeMetrics} -local function analyzePositionDanger(pos, monsters, usePrediction) - -- Prefer TargetCore if available - if Core and Core.calculatePositionDanger then - local danger = Core.calculatePositionDanger(pos, monsters) - if type(danger) == 'table' then - return danger - end - -- Wrap scalar danger value into expected table shape - return { totalDanger = danger or 0, waveThreats = 0, meleeThreats = 0, details = {} } - end - - local result = { - totalDanger = 0, - waveThreats = 0, - meleeThreats = 0, - details = {}, - -- New: RealTime metrics summary - realTimeMetrics = { - monstersFacingPos = 0, - highTurnRateMonsters = 0, - imminentAttacks = 0, - avgPredictionConfidence = 0 - } - } - - local predCache = {} - local totalConfidence = 0 - local confCount = 0 - - -- Query MonsterAI.RealTime threat cache for fast O(1) threat assessment - local rtThreatCache = nil - if MonsterAI and MonsterAI.RealTime and MonsterAI.RealTime.threatCache then - rtThreatCache = MonsterAI.RealTime.threatCache - end - - for i = 1, #monsters do - local monster = monsters[i] - if monster and not monster:isDead() then - local mpos = monster:getPosition() - local mdir = monster:getDirection() - local dist = math.max(math.abs(pos.x - mpos.x), math.abs(pos.y - mpos.y)) - local threat = { monster = monster, distance = dist, inWaveArc = false, arcDistance = 99 } - - local monsterId = monster:getId() - - -- ═══════════════════════════════════════════════════════════════════════ - -- NEW: MonsterAI.RealTime Integration - -- Use direction tracking, turn rate, and facing detection for better accuracy - -- ═══════════════════════════════════════════════════════════════════════ - local rtData = nil - if MonsterAI and MonsterAI.RealTime and MonsterAI.RealTime.directions then - rtData = MonsterAI.RealTime.directions[monsterId] - end - - if rtData then - -- Check if monster is facing this position - local isFacing = false - if MonsterAI and MonsterAI.Predictor and MonsterAI.Predictor.isFacingPosition then - isFacing = MonsterAI.Predictor.isFacingPosition(mpos, mdir, pos) - end - - if isFacing then - result.realTimeMetrics.monstersFacingPos = result.realTimeMetrics.monstersFacingPos + 1 - - -- High turn rate = monster actively tracking player = higher threat - local turnRate = rtData.turnRate or 0 - if turnRate > 0.5 then - result.realTimeMetrics.highTurnRateMonsters = result.realTimeMetrics.highTurnRateMonsters + 1 - -- Add extra danger for high turn rate (actively tracking) - result.totalDanger = result.totalDanger + turnRate * 1.5 - end - - -- Check how long monster has been facing this direction - local facingDuration = 0 - if rtData.facingPlayerSince then - facingDuration = (now or 0) - rtData.facingPlayerSince - end - - -- Long facing duration + in position = imminent attack - if facingDuration > 300 then - result.totalDanger = result.totalDanger + math.min(2, facingDuration / 500) - end - end - - -- Check consecutive direction changes (erratic = about to attack) - if rtData.consecutiveChanges and rtData.consecutiveChanges >= 2 then - result.totalDanger = result.totalDanger + rtData.consecutiveChanges * 0.5 - end - end - - -- If prediction mode is active (only used by rePosition and our avoidance integration), prefer learned predictions - if usePrediction and MonsterAI and MonsterAI.Predictor then - local pid = monsterId - local pattern = MonsterAI.Patterns.get(monster:getName()) - local isPred, confidence, timeToAttack = nil, nil, nil - if predCache[pid] then - isPred, confidence, timeToAttack = predCache[pid].isPred, predCache[pid].conf, predCache[pid].tta - else - local ok, p, c, tta = pcall(function() return MonsterAI.Predictor.predictWaveAttack(monster) end) - if ok then isPred, confidence, timeToAttack = p, c, tta else isPred, confidence, timeToAttack = false, 0, 999999 end - predCache[pid] = { isPred = isPred, conf = confidence, tta = timeToAttack } - end - - -- Track confidence for averaging - if confidence then - totalConfidence = totalConfidence + confidence - confCount = confCount + 1 - end - - -- NEW: Use learned tracker data for better cooldown estimation - local trackerData = nil - if MonsterAI and MonsterAI.Tracker and MonsterAI.Tracker.monsters then - trackerData = MonsterAI.Tracker.monsters[pid] - end - - -- Override timeToAttack with more accurate tracker estimate if available - if trackerData and trackerData.ewmaCooldown and trackerData.ewmaCooldown > 0 then - local lastAttack = trackerData.lastWaveTime or trackerData.lastAttackTime or 0 - local elapsed = (now or 0) - lastAttack - local cooldown = trackerData.ewmaCooldown - timeToAttack = math.max(0, cooldown - elapsed) - - -- Boost confidence if we have learned data - confidence = math.max(confidence or 0, trackerData.confidence or 0.5) - end - - local inPredPath = false - -- Use confidence threshold as the primary gating; allow TTA to be larger but scale urgency with a configurable window - if confidence and confidence >= AVOID_PREDICT_CONF then - inPredPath = pcall(function() - return MonsterAI.Predictor.isPositionInWavePath(pos, mpos, mdir, pattern.waveRange, pattern.waveWidth) - end) - end - if inPredPath then - local maxWindow = AVOID_PREDICT_TTA_WINDOW or 3000 - local urgency = 1 - math.max(0, math.min(timeToAttack, maxWindow)) / maxWindow - local pdanger = (pattern.dangerLevel or 1) * urgency * (confidence or 1) - -- Add a baseline predicted danger scaled by confidence to make avoidance decisive - pdanger = pdanger + AVOID_PREDICT_DANGER * (confidence or 1) - - -- NEW: Boost danger if RealTime shows imminent attack - if timeToAttack < 800 then - result.realTimeMetrics.imminentAttacks = result.realTimeMetrics.imminentAttacks + 1 - pdanger = pdanger * 1.3 -- 30% boost for imminent attacks - end - - threat.inWaveArc = true - threat.arcDistance = 0 - threat.predicted = true - threat.predConf = confidence - threat.predTTA = timeToAttack - result.waveThreats = result.waveThreats + 1 - result.totalDanger = result.totalDanger + pdanger - - end - else - -- Fallback to simple front arc detection when prediction is not enabled - local inArc, arcDist = isInFrontArc(pos, mpos, mdir, 5, 1) - if inArc then - threat.inWaveArc = true - threat.arcDistance = arcDist - result.waveThreats = result.waveThreats + 1 - result.totalDanger = result.totalDanger + (3 - arcDist) - end - end - - if dist == 1 then - result.meleeThreats = result.meleeThreats + 1 - result.totalDanger = result.totalDanger + 2 - elseif dist == 2 then - result.totalDanger = result.totalDanger + 0.5 - end - result.details[#result.details + 1] = threat - end - end - - -- Calculate average prediction confidence - if confCount > 0 then - result.realTimeMetrics.avgPredictionConfidence = totalConfidence / confCount - end - - -- NEW: Query MonsterAI.isPositionDangerous for additional validation - if usePrediction and MonsterAI and MonsterAI.isPositionDangerous then - local isDangerous, dangerLevel = MonsterAI.isPositionDangerous(pos) - if isDangerous then - -- Blend MonsterAI danger assessment (it considers all direction tracking) - result.totalDanger = result.totalDanger + dangerLevel * 2 - end - end - - return result -end - --- Pure function: Check if a position is dangerous (simplified wrapper) --- @param pos: position to check --- @param monsters: array of monster creatures --- @return boolean, number (isDangerous, dangerCount) -local function isDangerousPosition(pos, monsters) - local analysis = analyzePositionDanger(pos, monsters) - return analysis.totalDanger > 0, (analysis.waveThreats or 0) + (analysis.meleeThreats or 0) -end - --- Pure function: Find the safest adjacent tile with improved scoring --- Enhanced with MonsterAI.RealTime metrics for smarter avoidance --- @param playerPos: current player position --- @param monsters: array of monsters --- @param currentTarget: current attack target (to maintain range) --- @param scaling: scaling factors from calculateScaling() (optional, defaults to conservative) --- @return position or nil, score -local function findSafeAdjacentTile(playerPos, monsters, currentTarget, scaling) - local candidates = {} - local currentAnalysis = analyzePositionDanger(playerPos, monsters, true) - - -- Default scaling if not provided (conservative behavior) - scaling = scaling or calculateScaling(#monsters) - - -- Dynamic danger threshold based on monster count - local dynamicDangerThreshold = avoidanceState.baseDangerThreshold * scaling.dangerThresholdMultiplier - - -- ═══════════════════════════════════════════════════════════════════════════ - -- NEW: Check MonsterAI.RealTime for immediate threats - -- Lower threshold if under immediate threat for faster reaction - -- ═══════════════════════════════════════════════════════════════════════════ - local immediateThreat = false - if MonsterAI and MonsterAI.getImmediateThreat then - local threatData = MonsterAI.getImmediateThreat() - immediateThreat = threatData.immediateThreat or false - if immediateThreat then - dynamicDangerThreshold = dynamicDangerThreshold * 0.5 -- Lower threshold - end - end - - -- Prefer Core's safest tile search if available - if Core and Core.findSafestTile then - local coreRes = Core.findSafestTile(playerPos, monsters, currentTarget) - if coreRes and coreRes.pos then - return coreRes.pos, coreRes.score or 0 - end - end - - -- When many monsters (7+), any danger is concerning - -- Skip threshold check if under immediate threat - if not immediateThreat and currentAnalysis.totalDanger < dynamicDangerThreshold then - return nil, 0 - end - - -- ═══════════════════════════════════════════════════════════════════════════ - -- NEW: Get threat directions for perpendicular escape scoring - -- ═══════════════════════════════════════════════════════════════════════════ - local threatDirections = {} - if MonsterAI and MonsterAI.RealTime and MonsterAI.RealTime.directions then - for id, rtData in pairs(MonsterAI.RealTime.directions) do - if rtData.facingPlayerSince then - local dir = rtData.dir - if DIR_VECTORS[dir] then - threatDirections[#threatDirections + 1] = { - vec = DIR_VECTORS[dir], - turnRate = rtData.turnRate or 0, - consecutiveChanges = rtData.consecutiveChanges or 0 - } - end - end - end - end - - -- Score weights for decision making (enhanced with RealTime) - local WEIGHTS = { - DANGER = -25, -- Penalize danger heavily - TARGET_ADJACENT = 20, -- Bonus for being adjacent to target - TARGET_CLOSE = 10, -- Bonus for being close to target - TARGET_FAR = -5, -- Penalty per tile beyond range 3 - ESCAPE_ROUTES = 4, -- Bonus per escape route - STABILITY = 8, -- Bonus for not being in any wave arc - PREVIOUS_SAFE = 15, -- Bonus for returning to previous safe position - STAY_BONUS = 10, -- Bonus for current position (prefer staying) - -- NEW: MonsterAI-enhanced weights - PERPENDICULAR = 10, -- Bonus for perpendicular escape - NOT_FACING = 8, -- Bonus if no monster facing this tile - LOW_TURN_RATE = 5, -- Bonus for low turn rate zone - IMMINENT_SAFE = 12 -- Bonus if no imminent attacks here - } - - -- Check all adjacent tiles (8 directions) - for i = 1, 8 do - local dir = DIRECTIONS[i] - local checkPos = { - x = playerPos.x + dir.x, - y = playerPos.y + dir.y, - z = playerPos.z - } - - local tileSafe = (TargetCore and TargetCore.PathSafety and TargetCore.PathSafety.isTileSafe) - and TargetCore.PathSafety.isTileSafe(checkPos) - or (function() - local Client = getClient() - local tile = (Client and Client.getTile) and Client.getTile(checkPos) or (g_map and g_map.getTile and g_map.getTile(checkPos)) - local hasCreature = tile and tile.hasCreature and tile:hasCreature() - return tile and tile:isWalkable() and not hasCreature - end)() - if tileSafe then - local analysis = analyzePositionDanger(checkPos, monsters, true) - local score = 0 - - -- Factor 1: Danger level (most important) - score = score + analysis.totalDanger * WEIGHTS.DANGER - - -- Factor 2: Stability bonus (no wave threats at all) - if analysis.waveThreats == 0 then - score = score + WEIGHTS.STABILITY - end - - -- ═══════════════════════════════════════════════════════════════════════ - -- NEW: RealTime metrics scoring - -- ═══════════════════════════════════════════════════════════════════════ - if analysis.realTimeMetrics then - local rtm = analysis.realTimeMetrics - - -- Bonus if no monsters facing this position - if rtm.monstersFacingPos == 0 then - score = score + WEIGHTS.NOT_FACING - end - - -- Bonus if no imminent attacks at this position - if rtm.imminentAttacks == 0 then - score = score + WEIGHTS.IMMINENT_SAFE - end - - -- Bonus for low turn rate zone - if rtm.highTurnRateMonsters == 0 then - score = score + WEIGHTS.LOW_TURN_RATE - end - end - - -- Perpendicular escape bonus - if #threatDirections > 0 then - local moveVec = { x = dir.x, y = dir.y } - local perpBonus = 0 - for _, threat in ipairs(threatDirections) do - local threatVec = threat.vec - local dot = moveVec.x * threatVec.x + moveVec.y * threatVec.y - local moveMag = math.sqrt(moveVec.x^2 + moveVec.y^2) - local threatMag = math.sqrt(threatVec.x^2 + threatVec.y^2) - if moveMag > 0 and threatMag > 0 then - local normalizedDot = math.abs(dot) / (moveMag * threatMag) - perpBonus = perpBonus + (1 - normalizedDot) * WEIGHTS.PERPENDICULAR - - -- Extra bonus if moving away from high turn rate monster - if threat.turnRate > 0.5 then - perpBonus = perpBonus + (1 - normalizedDot) * 3 - end - end - end - score = score + perpBonus / math.max(1, #threatDirections) - end - - -- Factor 3: Distance to current target - if currentTarget then - local tpos = currentTarget:getPosition() - local targetDist = math.max(math.abs(checkPos.x - tpos.x), math.abs(checkPos.y - tpos.y)) - if targetDist <= 1 then - score = score + WEIGHTS.TARGET_ADJACENT - elseif targetDist <= 3 then - score = score + WEIGHTS.TARGET_CLOSE - else - score = score + (targetDist - 3) * WEIGHTS.TARGET_FAR - end - end - - -- Factor 4: Escape routes (walkable adjacent tiles) - local escapeRoutes = 0 - if Core and Core.countEscapeRoutes then - escapeRoutes = Core.countEscapeRoutes(checkPos) - else - for j = 1, 8 do - local escapeDir = DIRECTIONS[j] - local escapePos = { x = checkPos.x + escapeDir.x, y = checkPos.y + escapeDir.y, z = checkPos.z } - local escapeSafe = (TargetCore and TargetCore.PathSafety and TargetCore.PathSafety.isTileSafe) - and TargetCore.PathSafety.isTileSafe(escapePos) - or (function() - local Client = getClient() - local et = (Client and Client.getTile) and Client.getTile(escapePos) or (g_map and g_map.getTile and g_map.getTile(escapePos)) - return et and et:isWalkable() - end)() - if escapeSafe then - escapeRoutes = escapeRoutes + 1 - end - end - end - score = score + escapeRoutes * WEIGHTS.ESCAPE_ROUTES - - -- Factor 5: Previous safe position bonus (reduces oscillation) - if avoidanceState.lastSafePos then - local isPreviousSafe = checkPos.x == avoidanceState.lastSafePos.x and - checkPos.y == avoidanceState.lastSafePos.y - if isPreviousSafe then - score = score + WEIGHTS.PREVIOUS_SAFE - end - end - - candidates[#candidates + 1] = { - pos = checkPos, - score = score, - danger = analysis.totalDanger, - waveThreats = analysis.waveThreats - } - end - end - - if #candidates == 0 then - return nil, 0 - end - - -- Sort by score (highest first) - table.sort(candidates, function(a, b) - return a.score > b.score - end) - - -- Return best candidate if it's significantly safer than current position - -- Score threshold scales with monster count - local best = candidates[1] - local currentScore = currentAnalysis.totalDanger * WEIGHTS.DANGER + WEIGHTS.STAY_BONUS - - -- Base threshold of 12 points, scales down when many monsters - -- 7+ monsters: threshold = 12 * 0.4 = 4.8 (very willing to move) - -- 3-4 monsters: threshold = 12 * 0.75 = 9 (moderate) - -- 1-2 monsters: threshold = 12 * 1.0 = 12 (conservative) - local baseScoreThreshold = 12 - local dynamicScoreThreshold = baseScoreThreshold * scaling.scoreThresholdMultiplier - - if best.score > currentScore + dynamicScoreThreshold then - return best.pos, best.score - end - - return nil, 0 -end - --- Main avoidance function (called from walk logic) --- Dynamic scaling based on monster count --- More monsters = faster reactions, lower thresholds --- @return boolean: true if avoidance move was initiated -local function avoidWaveAttacks() - local currentTime = now - - -- Get monsters in range FIRST (needed for scaling) - local playerPos = player:getPosition() - local Client = getClient() - local creatures = (MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby) and MovementCoordinator.MonsterCache.getNearby(7) or (SpectatorCache and SpectatorCache.getNearby(7, 7) or ((Client and Client.getSpectatorsInRange) and Client.getSpectatorsInRange(playerPos, false, 7, 7) or (g_map and g_map.getSpectatorsInRange and g_map.getSpectatorsInRange(playerPos, false, 7, 7)))) - local monsters = {} - - for i = 1, #creatures do - local c = creatures[i] - if c and c:isMonster() and not c:isDead() then - monsters[#monsters + 1] = c - end - end - - local monsterCount = #monsters - - if monsterCount == 0 then - avoidanceState.consecutiveMoves = 0 - avoidanceState.lastSafePos = nil - avoidanceState.lastMonsterCount = 0 - return false - end - - -- Calculate dynamic scaling based on monster count - local scaling = calculateScaling(monsterCount) - avoidanceState.lastMonsterCount = monsterCount - - -- Dynamic cooldown: faster when surrounded - -- 7+ monsters: 350 * 0.4 = 140ms (fast reactions) - -- 3-4 monsters: 350 * 0.75 = 262ms (moderate) - -- 1-2 monsters: 350 * 1.0 = 350ms (conservative) - local dynamicCooldown = avoidanceState.baseCooldown * scaling.cooldownMultiplier - - -- Anti-oscillation: check consecutive moves - -- Allow more consecutive moves when surrounded (danger is real) - local maxConsecutive = avoidanceState.maxConsecutive - if monsterCount >= 5 then - maxConsecutive = maxConsecutive + 1 -- Allow 4 moves when heavily surrounded - end - - if avoidanceState.consecutiveMoves >= maxConsecutive then - -- Too many consecutive avoidance moves - take a break - -- But shorter break when many monsters (danger is real) - local pauseDuration = 1200 * scaling.cooldownMultiplier -- 480ms-1200ms - if currentTime - avoidanceState.lastMove < pauseDuration then - return false - end - avoidanceState.consecutiveMoves = 0 - end - - -- Cooldown check (dynamic) - if currentTime - avoidanceState.lastMove < dynamicCooldown then - return false - end - - -- Dynamic stickiness: shorter when many monsters - -- 7+ monsters: 600 * 0.4 = 240ms (don't stay still long) - -- 1-2 monsters: 600 * 1.0 = 600ms (stay at safe spots) - local dynamicStickiness = avoidanceState.baseStickiness * scaling.stickinessMultiplier - - if avoidanceState.lastSafePos then - local atSafePos = playerPos.x == avoidanceState.lastSafePos.x and - playerPos.y == avoidanceState.lastSafePos.y - - if atSafePos and currentTime - avoidanceState.lastMove < dynamicStickiness then - -- We're at a safe position and within stickiness window - -- Check if danger has increased (new threats) - local analysis = analyzePositionDanger(playerPos, monsters, true) - -- Dynamic threshold to leave safe position - local leaveThreshold = avoidanceState.baseDangerThreshold * scaling.dangerThresholdMultiplier + 0.5 - if analysis.totalDanger < leaveThreshold then - return false -- Still safe enough, don't move - end - -- Danger increased significantly, allow movement despite stickiness - end - end - - -- Find safe tile with dynamic thresholds - local currentTarget = target and target() - local safePos, score = findSafeAdjacentTile(playerPos, monsters, currentTarget, scaling) - - if safePos then - avoidanceState.lastMove = currentTime - avoidanceState.lastSafePos = safePos - avoidanceState.consecutiveMoves = avoidanceState.consecutiveMoves + 1 - if movementAllowed() then - TargetBot.walkTo(safePos, 2, {ignoreNonPathable = true, precision = 0}) - end - return true - end - - -- No safe tile found, but we tried - reset consecutive counter - avoidanceState.consecutiveMoves = 0 - return false -end - --- EventBus integration: Reset avoidance state when monsters change -if EventBus then - EventBus.on("monster:disappear", function(creature) - if tbOff() then return end - avoidanceState.lastSafePos = nil - avoidanceState.consecutiveMoves = 0 - end, 20) - - EventBus.on("player:move", function(newPos, oldPos) - if tbOff() then return end - -- Reset stickiness when player moves away from safe position - if avoidanceState.lastSafePos then - local atSafe = newPos.x == avoidanceState.lastSafePos.x and - newPos.y == avoidanceState.lastSafePos.y - if not atSafe then - avoidanceState.lastSafePos = nil - end - end - end, 20) - - -- ============================================================================ - -- EVENT-DRIVEN AVOIDANCE TRIGGERS - -- React immediately to monster direction changes and movements - -- ============================================================================ - - -- Track monster facing direction for instant wave prediction - local monsterDirections = {} -- id -> lastDirection - - EventBus.on("creature:move", function(creature, oldPos) - if tbOff() then return end - -- Safe creature checks using safe wrapper functions - if not safeIsMonster(creature) then return end - if safeIsDead(creature) then return end - - -- Safe property access - local id = safeGetId(creature) - local newDir = nil - if SC.getDirection then - newDir = SC.getDirection(creature) - else - local ok, dir = pcall(function() return creature:getDirection() end) - if ok then newDir = dir end - end - if not id or not newDir then return end - - local oldDir = monsterDirections[id] - - -- Store new direction - monsterDirections[id] = newDir - - -- If direction changed, monster might be turning to attack - if oldDir and oldDir ~= newDir then - local okPpos, playerPos = pcall(function() return player and player:getPosition() end) - local monsterPos = safeGetPosition(creature) - if not okPpos or not playerPos or not monsterPos then return end - - local dist = math.max(math.abs(playerPos.x - monsterPos.x), math.abs(playerPos.y - monsterPos.y)) - - -- Only react if monster is close - if dist <= 5 then - -- Check if player is now in the monster's attack arc - local inArc, arcDist = isInFrontArc(playerPos, monsterPos, newDir, 5, 1) - - if inArc then - -- Immediate danger! Register high-confidence wave avoidance - local monsters = {} - local creatures = (MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby) - and MovementCoordinator.MonsterCache.getNearby(7) - or {} - for _, c in ipairs(creatures) do - -- Safe monster check using safe wrapper functions - if safeIsMonster(c) and not safeIsDead(c) then - monsters[#monsters + 1] = c - end - end - - if #monsters > 0 then - local Client = getClient() - local currentTarget = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) - local safePos, score = findSafeAdjacentTile(playerPos, monsters, currentTarget) - - if safePos and MovementCoordinator and MovementCoordinator.Intent then - local confidence = 0.75 + (5 - dist) * 0.03 -- Higher confidence for closer monsters - local mName = safeGetName(creature) or "unknown" - MovementCoordinator.Intent.register( - MovementCoordinator.CONSTANTS.INTENT.WAVE_AVOIDANCE, - safePos, - confidence, - "wave_direction_change", - {triggered = "direction_change", monster = mName} - ) - end - end - end - end - end - end, 8) -- High priority for quick response - - -- Pure function: Count walkable tiles around a position - -- Uses TargetBotCore.Geometry if available, or PathUtils.findEveryPath for optimization - -- @param position: center position - -- @return number - local function countWalkableTiles(position) - -- OPTIMIZED: Use PathUtils.findEveryPath if available (native API is faster) - if PathUtils and PathUtils.findEveryPath then - local reachable = PathUtils.findEveryPath(position, 1, { - ignoreCreatures = false, -- Don't count tiles blocked by creatures - }) - if reachable then - return #reachable - end - end - - -- Fallback: manual check of adjacent tiles - local count = 0 - - for i = 1, 8 do - local dir = DIRECTIONS[i] - local checkPos = { - x = position.x + dir.x, - y = position.y + dir.y, - z = position.z - } - local safe = (PathUtils and PathUtils.isTileSafe and PathUtils.isTileSafe(checkPos)) - or (TargetCore and TargetCore.PathSafety and TargetCore.PathSafety.isTileSafe and TargetCore.PathSafety.isTileSafe(checkPos)) - or (function() - local Client = getClient() - local tile = (Client and Client.getTile) and Client.getTile(checkPos) or (g_map and g_map.getTile and g_map.getTile(checkPos)) - return tile and tile:isWalkable() - end)() - if safe then - count = count + 1 - end - end - - return count - end - - -- When monster appears close, immediately check if repositioning is needed - EventBus.on("monster:appear", function(creature) - if tbOff() then return end - -- Safe creature checks using safe wrapper functions - if not safeIsMonster(creature) then return end - - -- Safe position access - local okPpos, playerPos = pcall(function() return player and player:getPosition() end) - local monsterPos = safeGetPosition(creature) - if not okPpos or not playerPos or not monsterPos then return end - - local dist = math.max(math.abs(playerPos.x - monsterPos.x), math.abs(playerPos.y - monsterPos.y)) - - -- Monster appeared very close - immediate reposition check - if dist <= 2 then - -- Count walkable tiles - local walkable = countWalkableTiles(playerPos) - - if walkable < 5 then -- Getting cornered - -- Find better position immediately - local monsters = {} - local creatures = (MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby) - and MovementCoordinator.MonsterCache.getNearby(5) - or {} - for _, c in ipairs(creatures) do - -- Safe monster check using safe wrapper functions - if safeIsMonster(c) and not safeIsDead(c) then - monsters[#monsters + 1] = c - end - end - - -- Quick search for better tile - local bestPos, bestScore = nil, walkable * 12 - local Client = getClient() - for dx = -1, 1 do - for dy = -1, 1 do - if dx ~= 0 or dy ~= 0 then - local checkPos = {x = playerPos.x + dx, y = playerPos.y + dy, z = playerPos.z} - local tile = (Client and Client.getTile) and Client.getTile(checkPos) or (g_map and g_map.getTile and g_map.getTile(checkPos)) - local hasCreature = tile and tile.hasCreature and tile:hasCreature() - if tile and tile:isWalkable() and not hasCreature then - local newWalkable = countWalkableTiles(checkPos) - local score = newWalkable * 12 - if score > bestScore + 15 then - bestScore = score - bestPos = checkPos - end - end - end - end - end - - if bestPos and MovementCoordinator and MovementCoordinator.Intent then - MovementCoordinator.Intent.register( - MovementCoordinator.CONSTANTS.INTENT.REPOSITION, - bestPos, - 0.65, - "reposition_monster_appear", - {triggered = "monster_appear", walkable = walkable} - ) - end - end - end - end, 12) - - -- Clear direction tracking when monster disappears - EventBus.on("monster:disappear", function(creature) - if tbOff() then return end - if creature then - local id = creature:getId() - if id then - monsterDirections[id] = nil - end - end - end, 25) -end - --- Export functions for external use -nExBot.avoidWaveAttacks = avoidWaveAttacks -nExBot.isInFrontArc = isInFrontArc -nExBot.isDangerousPosition = isDangerousPosition -nExBot.analyzePositionDanger = analyzePositionDanger -nExBot.findSafeAdjacentTile = findSafeAdjacentTile - --- Event-driven hookup: debounce avoidWaveAttacks on creature changes nearby -if EventBus and nExBot and nExBot.EventUtil and nExBot.EventUtil.debounce then - local debounceAvoid = nExBot.EventUtil.debounce(200, function() - -- Run avoidance in schedule to avoid blocking EventBus handlers (debounced) - schedule(60, function() - pcall(avoidWaveAttacks) - end) - end) - - EventBus.on("creature:appear", function(creature) - if tbOff() then return end - if creature and creature:isMonster() then - local p = player and player:getPosition() - local cpos = creature and creature:getPosition() - if p and cpos and math.max(math.abs(p.x-cpos.x), math.abs(p.y-cpos.y)) <= 7 then - debounceAvoid() - end - end - end, 10) - - EventBus.on("creature:move", function(creature, oldPos) - if tbOff() then return end - if creature and creature:isMonster() then - local p = player and player:getPosition() - local cpos = creature and creature:getPosition() - if p and cpos and math.max(math.abs(p.x-cpos.x), math.abs(p.y-cpos.y)) <= 7 then - debounceAvoid() - end - end - end, 10) - - EventBus.on("monster:disappear", function(creature) - if tbOff() then return end - -- disappear may reduce danger; trigger a check - debounceAvoid() - end, 10) -end - --------------------------------------------------------------------------------- --- UTILITY FUNCTIONS (Optimized with TargetBotCore integration) --------------------------------------------------------------------------------- - --- Pure function: Check if player is trapped (no walkable adjacent tiles) --- @param playerPos: player position --- @return boolean -local function isPlayerTrapped(playerPos) - return countWalkableTiles(playerPos) == 0 -end - --- Reposition to tile with more escape routes and better tactical position --- Enhanced with MonsterAI.RealTime metrics for smarter tile selection --- @param minTiles: minimum walkable tiles threshold --- @param config: creature config for context (includes anchor settings) -local function rePosition(minTiles, config) - minTiles = minTiles or 6 - - -- Extended cooldown to prevent jitter (was 350) - if now - lastCall < 500 then return end - - local playerPos = player:getPosition() - local currentWalkable = countWalkableTiles(playerPos) - - -- ═══════════════════════════════════════════════════════════════════════════ - -- NEW: Check MonsterAI.RealTime for immediate threats - -- If imminent threat detected, reduce minimum tile requirement for faster escape - -- ═══════════════════════════════════════════════════════════════════════════ - local immediateThreat = false - local threatBoost = 0 - if MonsterAI and MonsterAI.getImmediateThreat then - local threatData = MonsterAI.getImmediateThreat() - immediateThreat = threatData.immediateThreat or false - if immediateThreat then - -- Under immediate threat - be more aggressive about repositioning - minTiles = math.max(3, minTiles - 2) -- Lower threshold - threatBoost = threatData.totalThreat * 5 -- Boost urgency - end - end - - -- Don't reposition if we have enough space (unless under immediate threat) - if currentWalkable >= minTiles and not immediateThreat then return end - - -- Get nearby monsters for scoring - local Client = getClient() - local creatures = (MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby) and MovementCoordinator.MonsterCache.getNearby(5) or (SpectatorCache and SpectatorCache.getNearby(5, 5) or ((Client and Client.getSpectatorsInRange) and Client.getSpectatorsInRange(playerPos, false, 5, 5) or (g_map and g_map.getSpectatorsInRange and g_map.getSpectatorsInRange(playerPos, false, 5, 5)))) - local monsters = {} - for i = 1, #creatures do - local c = creatures[i] - if c and c:isMonster() and not c:isDead() then - monsters[#monsters + 1] = c - end - end - - local currentTarget = target and target() - local bestPos = nil - local bestScore = -9999 - - -- Get anchor constraints - local anchorPos = config and config.anchor and anchorPosition - local anchorRange = config and config.anchorRange or 5 - - -- ═══════════════════════════════════════════════════════════════════════════ - -- NEW: Get high-threat monster directions for perpendicular escape preference - -- ═══════════════════════════════════════════════════════════════════════════ - local threatDirections = {} -- List of vectors monsters are facing - if MonsterAI and MonsterAI.RealTime and MonsterAI.RealTime.directions then - for id, rtData in pairs(MonsterAI.RealTime.directions) do - if rtData.facingPlayerSince then - -- This monster is facing player - record its direction - local dir = rtData.dir - if DIR_VECTORS[dir] then - threatDirections[#threatDirections + 1] = DIR_VECTORS[dir] - end - end - end - end - - -- Score weights (enhanced with RealTime metrics) - local WEIGHTS = { - WALKABLE = 15, -- Per walkable tile - DANGER = -22, -- Per danger point - TARGET_ADJ = 20, -- Adjacent to target - TARGET_CLOSE = 10, -- Within 3 tiles - TARGET_FAR = -4, -- Per tile beyond 3 - MOVE_COST = -4, -- Per movement tile - CARDINAL = 3, -- Bonus for cardinal movement - STAY_BONUS = 15, -- Bonus for not moving - -- NEW: MonsterAI-enhanced weights - PERPENDICULAR_ESCAPE = 12, -- Bonus for moving perpendicular to threats - AWAY_FROM_FACING = 8, -- Bonus for moving away from facing monsters - LOW_TURN_RATE_ZONE = 6, -- Bonus for positions where monsters have low turn rate - PREDICTION_SAFE = 10 -- Bonus if MonsterAI.isPositionDangerous returns false - } - - -- Search in a 2-tile radius for better positions - for dx = -2, 2 do - for dy = -2, 2 do - if dx ~= 0 or dy ~= 0 then - local checkPos = { - x = playerPos.x + dx, - y = playerPos.y + dy, - z = playerPos.z - } - - local shouldSkip = false - - -- Check anchor constraint FIRST (skip if violates anchor) - if anchorPos then - local anchorDist = math.max( - math.abs(checkPos.x - anchorPos.x), - math.abs(checkPos.y - anchorPos.y) - ) - if anchorDist > anchorRange then - shouldSkip = true -- Skip this position, violates anchor - end - end - - if not shouldSkip then - local tileSafe = (TargetCore and TargetCore.PathSafety and TargetCore.PathSafety.isTileSafe) - and TargetCore.PathSafety.isTileSafe(checkPos) - or (function() - local Client = getClient() - local t = (Client and Client.getTile) and Client.getTile(checkPos) or (g_map and g_map.getTile and g_map.getTile(checkPos)) - local hasCreature = t and t.hasCreature and t:hasCreature() - return t and t:isWalkable() and not hasCreature - end)() - if tileSafe then - -- Score this position using improved danger analysis - local score = 0 - - -- Factor 1: Walkable tiles (escape routes) - local walkable = countWalkableTiles(checkPos) - score = score + walkable * WEIGHTS.WALKABLE - - -- Factor 2: Danger analysis (uses improved analyzePositionDanger with RealTime metrics) - local analysis = analyzePositionDanger(checkPos, monsters, true) - score = score + analysis.totalDanger * WEIGHTS.DANGER - - -- ═══════════════════════════════════════════════════════════════ - -- NEW: MonsterAI.RealTime Enhanced Scoring - -- ═══════════════════════════════════════════════════════════════ - - -- Factor 2b: Bonus if no monsters facing this position - if analysis.realTimeMetrics and analysis.realTimeMetrics.monstersFacingPos == 0 then - score = score + WEIGHTS.AWAY_FROM_FACING - end - - -- Factor 2c: Bonus if no imminent attacks at this position - if analysis.realTimeMetrics and analysis.realTimeMetrics.imminentAttacks == 0 then - score = score + WEIGHTS.PREDICTION_SAFE - end - - -- Factor 2d: Bonus for low turn rate zone (monsters not actively tracking) - if analysis.realTimeMetrics and analysis.realTimeMetrics.highTurnRateMonsters == 0 then - score = score + WEIGHTS.LOW_TURN_RATE_ZONE - end - - -- Factor 2e: Perpendicular escape bonus - -- Moving perpendicular to threat direction is safer than moving along attack axis - if #threatDirections > 0 then - local moveVec = { x = dx, y = dy } - local perpBonus = 0 - for _, threatVec in ipairs(threatDirections) do - -- Calculate dot product (0 = perpendicular, 1/-1 = parallel) - local dot = moveVec.x * threatVec.x + moveVec.y * threatVec.y - local moveMag = math.sqrt(moveVec.x^2 + moveVec.y^2) - local threatMag = math.sqrt(threatVec.x^2 + threatVec.y^2) - if moveMag > 0 and threatMag > 0 then - local normalizedDot = math.abs(dot) / (moveMag * threatMag) - -- Lower dot = more perpendicular = better - perpBonus = perpBonus + (1 - normalizedDot) * WEIGHTS.PERPENDICULAR_ESCAPE - end - end - score = score + perpBonus / math.max(1, #threatDirections) - end - - -- Factor 3: Distance to current target - if currentTarget then - local tpos = currentTarget:getPosition() - local targetDist = math.max(math.abs(checkPos.x - tpos.x), math.abs(checkPos.y - tpos.y)) - if targetDist <= 1 then - score = score + WEIGHTS.TARGET_ADJ - elseif targetDist <= 3 then - score = score + WEIGHTS.TARGET_CLOSE - else - score = score + (targetDist - 3) * WEIGHTS.TARGET_FAR - end - end - - -- Factor 4: Movement cost - local moveDist = math.abs(dx) + math.abs(dy) - score = score + moveDist * WEIGHTS.MOVE_COST - - -- Factor 5: Cardinal direction bonus - if dx == 0 or dy == 0 then - score = score + WEIGHTS.CARDINAL - end - - -- Factor 6: Threat boost from immediate danger - if immediateThreat then - -- When under threat, prioritize safety over other factors - local safetyBonus = (8 - analysis.totalDanger) * 3 - score = score + safetyBonus - end - - if score > bestScore then - bestScore = score - bestPos = checkPos - end - end - end - end - end - end - - -- Only move if we found a significantly better position (was +8) - local currentScore = currentWalkable * WEIGHTS.WALKABLE + WEIGHTS.STAY_BONUS - if bestPos and bestScore > currentScore + 20 then - lastCall = now - return CaveBot.GoTo(bestPos, 0) - end -end - -TargetBot.Creature.attack = function(params, targets, isLooting) - -- CRITICAL: Do not attack if TargetBot is disabled or explicitly turned off - if TargetBot then - if TargetBot.canAttack and not TargetBot.canAttack() then - return - elseif TargetBot.explicitlyDisabled then - return - elseif TargetBot.isOn and not TargetBot.isOn() then - return - end - end - - if player:isWalking() then - lastWalk = now - end - - local config = params.config - local creature = params.creature - local creaturePos = creature:getPosition() - local playerPos = player:getPosition() - - -- DEBUG: Verify chase config is being passed correctly - -- Uncomment to debug: print(\"[Chase] config.chase=\" .. tostring(config.chase) .. \" keepDistance=\" .. tostring(config.keepDistance)) - - -- Update ActiveMovementConfig for EventBus-driven movement intents - if TargetBot.ActiveMovementConfig then - TargetBot.ActiveMovementConfig.chase = config.chase or false - TargetBot.ActiveMovementConfig.keepDistance = config.keepDistance or false - TargetBot.ActiveMovementConfig.keepDistanceRange = config.keepDistanceRange or 4 - TargetBot.ActiveMovementConfig.finishKillThreshold = storage.extras and storage.extras.killUnder or 30 - TargetBot.ActiveMovementConfig.anchor = config.anchor and playerPos or nil - TargetBot.ActiveMovementConfig.anchorRange = config.anchorRange or 5 - end - - -- ═══════════════════════════════════════════════════════════════════════════ - -- NATIVE CHASE MODE SETUP (MUST happen BEFORE g_game.attack()) - -- - -- OTClient Chase Mode (g_game.setChaseMode): - -- 0 = DontChase (Stand) - Player won't auto-walk to target - -- 1 = ChaseOpponent - Client automatically walks toward attacked creature - -- - -- CRITICAL: When chase mode is enabled, we should NOT use custom walking - -- (autoWalk, walkTo, etc.) as it interferes with the native chase behavior. - -- The client handles pathfinding and walking automatically. - -- - -- NOTE: avoidAttacks doesn't prevent chase - it only temporarily overrides - -- when an attack needs to be avoided. Chase is the default movement mode. - -- ═══════════════════════════════════════════════════════════════════════════ - -- Chase should be enabled unless keepDistance is ON (mutually exclusive) - -- avoidAttacks is handled separately in the walk function (temporary override) - local useNativeChase = config.chase and not config.keepDistance - - -- DEBUG: Log chase mode decision (can be commented out in production) - -- print(\"[Chase Debug] config.chase=\" .. tostring(config.chase) .. \" keepDistance=\" .. tostring(config.keepDistance) .. \" useNativeChase=\" .. tostring(useNativeChase)) - - -- Use ChaseController if available (unified chase management) - local Client = getClient() - if ChaseController then - ChaseController.setDesiredChase(useNativeChase) - ChaseController.syncMode() - elseif (Client and Client.setChaseMode) or (g_game and g_game.setChaseMode) then - -- Fallback: direct chase mode control - local desiredMode = useNativeChase and 1 or 0 - local currentMode = (Client and Client.getChaseMode) and Client.getChaseMode() or (g_game and g_game.getChaseMode and g_game.getChaseMode()) or -1 - if currentMode ~= desiredMode then - if Client and Client.setChaseMode then - Client.setChaseMode(desiredMode) - elseif g_game and g_game.setChaseMode then - g_game.setChaseMode(desiredMode) - end - -- Cache the mode for other modules - if TargetCore and TargetCore.Native then - TargetCore.Native.lastChaseMode = desiredMode - end - end - end - - -- Store whether we're using native chase for the walk function - TargetBot.usingNativeChase = useNativeChase - - -- ═══════════════════════════════════════════════════════════════════════════ - -- REACHABILITY VALIDATION (v2.1): Verify target before attack - -- Prevents "Creature not reachable" errors - -- ═══════════════════════════════════════════════════════════════════════════ - if MonsterAI and MonsterAI.Reachability and MonsterAI.Reachability.validateTarget then - -- Debounce temporary reachability failures to avoid rapid cancel/re-attack - if TargetBot then - TargetBot.UnreachableTracker = TargetBot.UnreachableTracker or { - entries = {}, - ttl = 800, - lastCleanup = 0, - cleanupInterval = 2000 - } - end - local tracker = TargetBot and TargetBot.UnreachableTracker or nil - local timeNow = now or (os.time() * 1000) - - local isValid, reason, path = MonsterAI.Reachability.validateTarget(creature) - local creatureId = nil - pcall(function() creatureId = creature:getId() end) - - if isValid and tracker and creatureId then - tracker.entries[creatureId] = nil - end - - if not isValid then - -- Target is not reachable - skip attack and allow CaveBot to proceed - if reason == "no_path" or reason == "blocked_tile" then - if tracker and creatureId then - local entry = tracker.entries[creatureId] - if not entry then - entry = { firstSeen = timeNow, lastSeen = timeNow } - tracker.entries[creatureId] = entry - else - entry.lastSeen = timeNow - end - if (timeNow - (entry.firstSeen or timeNow)) < tracker.ttl then - return -- Grace period: don't cancel or re-issue yet - end - - if (timeNow - (tracker.lastCleanup or 0)) > tracker.cleanupInterval then - for id, data in pairs(tracker.entries) do - if (timeNow - (data.lastSeen or timeNow)) > tracker.cleanupInterval then - tracker.entries[id] = nil - end - end - tracker.lastCleanup = timeNow - end - end - - -- Route through ASM so it can cleanly transition to IDLE - if AttackStateMachine and AttackStateMachine.isActive and AttackStateMachine.isActive() then - pcall(AttackStateMachine.stop) - else - local Client2 = getClient() - if Client2 and Client2.cancelAttackAndFollow then - pcall(Client2.cancelAttackAndFollow) - elseif g_game and g_game.cancelAttackAndFollow then - pcall(g_game.cancelAttackAndFollow) - end - end - - -- Allow CaveBot to walk away from blocked creature - if TargetBot.allowCaveBot then - TargetBot.allowCaveBot(300) - end - - return -- Skip attack - end - end - end - - -- ═══════════════════════════════════════════════════════════════════════════ - -- UNIFIED ATTACK MANAGEMENT (v4.0) - -- Use ID comparison (not reference) to detect target changes - -- Defer to AttackStateMachine for rate-limited, consistent attacks - -- ═══════════════════════════════════════════════════════════════════════════ - local currentTarget = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) - - -- Get IDs for proper comparison (reference comparison can give false positives) - local currentTargetId = nil - local wantedTargetId = nil - pcall(function() currentTargetId = currentTarget and currentTarget:getId() end) - pcall(function() wantedTargetId = creature and creature:getId() end) - - -- Only issue attack if we're not already attacking the correct target - local needsAttack = (currentTargetId ~= wantedTargetId) or (not currentTarget) - - if needsAttack and wantedTargetId then - -- Delegate to AttackStateMachine for rate-limited attack management - -- This prevents attack spam while ensuring continuous attacking - local attackIssued = false - if AttackStateMachine and AttackStateMachine.requestSwitch then - -- Use requestSwitch for rate-limited attack (sole attack issuer) - local priority = params.priority or (params.config and params.config.priority) or 100 - attackIssued = AttackStateMachine.requestSwitch(creature, priority * 100) - else - -- v4.1: NO direct Client.attack/g_game.attack fallback. - -- AttackStateMachine is the SOLE attack issuer (SRP). Competing callers - -- cause the visible attack-blink on OpenTibiaBR ACL. - log("[TargetBot] AttackStateMachine unavailable — skipping attack (no fallback)") - end - - -- IMPORTANT: Do NOT call g_game.follow() - it cancels the attack! - -- When chase mode is set to 1 (ChaseOpponent), OTClient handles walking automatically - - -- Notify EventTargeting of target acquisition - if EventTargeting and EventTargeting.CombatCoordinator then - local dist = math.max(math.abs(playerPos.x - creaturePos.x), math.abs(playerPos.y - creaturePos.y)) - if dist > 1 then - pcall(function() EventTargeting.CombatCoordinator.registerChaseIntent(creature, creaturePos, dist) end) - end - pcall(function() EventTargeting.CombatCoordinator.pauseCaveBot() end) - end - - -- Emit target acquired event for other modules - if EventBus then - pcall(function() EventBus.emit("targetbot/target_acquired", creature, creaturePos) end) - end - - schedule(200, function() - local atk = g_game.getAttackingCreature and g_game.getAttackingCreature() or nil - -- No debug info emitted about registration status - end) - end - - local lureTriggered = evaluateLureAndPull(creature, config, targets) - - if not isLooting then - -- When using native chase mode, skip custom walking - OTClient handles it - -- Only use custom walking for keepDistance, avoidAttacks, rePosition, or when chase is disabled - if lureTriggered then - -- Lure/pull requested CaveBot movement, skip custom walking this tick - elseif not useNativeChase then - TargetBot.Creature.walk(creature, config, targets) - elseif config.avoidAttacks or config.rePosition then - -- Still allow wave avoidance and repositioning even with chase enabled - -- These are safety features that should override chase temporarily - TargetBot.Creature.walk(creature, config, targets) - end - -- When useNativeChase is true and no safety features needed, - -- OTClient's chase mode handles the walking automatically - end - - -- Cache mana check - local mana = player:getMana() - local playerPos = player:getPosition() - - -end - -TargetBot.Creature.walk = function(creature, config, targets) - local cpos = creature:getPosition() - local pos = player:getPosition() - - -- ═══════════════════════════════════════════════════════════════════════════ - -- PARTY HUNT: Force Follow Mode Check - -- When force follow mode is active from Follow Player, skip TargetBot's - -- movement logic to allow the follower to catch up to the party leader - -- ═══════════════════════════════════════════════════════════════════════════ - if TargetBot.isForceFollowActive and TargetBot.isForceFollowActive() then - -- Skip all TargetBot movement - let Follow Player take control - -- The attack function will still run, but we won't chase or reposition - return - end - - --[[ - ═══════════════════════════════════════════════════════════════════════════ - TARGETBOT UNIFIED MOVEMENT SYSTEM v3 - ═══════════════════════════════════════════════════════════════════════════ - - All features work together with clear priority and mutual awareness. - - PHASE 1: CONTEXT GATHERING - - Collect all relevant state: health, distance, monsters, traps, etc. - - PHASE 2: LURE DECISIONS (CaveBot delegation) - - smartPull, dynamicLure, closeLure - - Only if target is NOT low health - - Only if NOT trapped - - PHASE 3: MOVEMENT PRIORITY - 1. SAFETY: avoidAttacks (wave avoidance) - 2. SURVIVAL: Chase low-health targets (ignore other settings) - 3. DISTANCE: keepDistance (ranged positioning) - 4. TACTICAL: rePosition (better tile when cornered) - 5. MELEE: chase (close the gap) - 6. FACING: faceMonster (diagonal correction) - - INTEGRATIONS: - - anchor is respected by keepDistance AND rePosition - - avoidAttacks considers target distance - - rePosition considers danger zones from monsters - - All lure features respect killUnder threshold - ]] - - if config.anchor and not anchorPosition then - anchorPosition = pos - end - - -- ═══════════════════════════════════════════════════════════════════════════ - -- PHASE 3: COORDINATED MOVEMENT SYSTEM - -- - -- Uses MovementCoordinator for unified decision making. - -- Each system registers its intent with confidence score. - -- Coordinator aggregates, resolves conflicts, and executes best decision. - -- ═══════════════════════════════════════════════════════════════════════════ - - -- Check if MovementCoordinator is available - local useCoordinator = MovementCoordinator and MovementCoordinator.Intent - - -- Get nearby monsters for danger analysis - local creatures = (MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby) - and MovementCoordinator.MonsterCache.getNearby(7) - or (SpectatorCache and SpectatorCache.getNearby(7, 7) or g_map.getSpectatorsInRange(pos, false, 7, 7)) - local monsters = {} - for i = 1, #creatures do - local c = creatures[i] - if c and c:isMonster() and not c:isDead() then - monsters[#monsters + 1] = c - end - end - - -- Update MonsterAI tracking if available - if MonsterAI and MonsterAI.updateAll then - MonsterAI.updateAll() - end - - -- ───────────────────────────────────────────────────────────────────────── - -- AUTO-FOLLOW MANAGEMENT: Handle native chase vs precision control - -- - -- OTClient chase mode (setChaseMode(1)) works WITH attacking - we don't - -- need to cancel it. Only g_game.follow() (which we're not using for - -- monsters) needs to be managed. - -- - -- Native chase is compatible with: - -- - Chase (that's what it's for!) - -- - Basic attacking (no movement override) - -- - rePosition (can coexist - rePosition is opportunistic) - -- - -- But we need to TEMPORARILY disable chase mode for: - -- - Wave avoidance (needs instant custom direction changes) - -- - Keep distance (needs precise range maintenance) - -- ───────────────────────────────────────────────────────────────────────── - -- Only avoidAttacks and keepDistance require disabling native chase - -- rePosition is opportunistic and works alongside native chase - local needsPrecisionControl = config.avoidAttacks or config.keepDistance - - -- When precision control is needed, temporarily set chase mode to Stand - -- This allows our custom walking to work without interference - local Client = getClient() - if needsPrecisionControl then - -- Set chase mode to Stand temporarily for precision control - local hasSetChaseMode = (Client and Client.setChaseMode) or (g_game and g_game.setChaseMode) - local hasGetChaseMode = (Client and Client.getChaseMode) or (g_game and g_game.getChaseMode) - if hasSetChaseMode and hasGetChaseMode then - local currentMode = (Client and Client.getChaseMode) and Client.getChaseMode() or (g_game and g_game.getChaseMode and g_game.getChaseMode()) - if currentMode == 1 then - if Client and Client.setChaseMode then - Client.setChaseMode(0) -- DontChase/Stand - elseif g_game and g_game.setChaseMode then - g_game.setChaseMode(0) -- DontChase/Stand - end - TargetBot.usingNativeChase = false - end - end - - -- Also cancel follow if active (shouldn't be for monsters, but safety check) - local hasCancelFollow = (Client and Client.cancelFollow) or (g_game and g_game.cancelFollow) - local hasGetFollowingCreature = (Client and Client.getFollowingCreature) or (g_game and g_game.getFollowingCreature) - if hasCancelFollow and hasGetFollowingCreature then - local currentFollow = (Client and Client.getFollowingCreature) and Client.getFollowingCreature() or (g_game and g_game.getFollowingCreature and g_game.getFollowingCreature()) - if currentFollow then - if Client and Client.cancelFollow then - Client.cancelFollow() - elseif g_game and g_game.cancelFollow then - g_game.cancelFollow() - end - end - end - elseif config.chase then - -- Chase mode without precision control - ensure native chase is active - local hasSetChaseMode = (Client and Client.setChaseMode) or (g_game and g_game.setChaseMode) - local hasGetChaseMode = (Client and Client.getChaseMode) or (g_game and g_game.getChaseMode) - if hasSetChaseMode and hasGetChaseMode then - local currentMode = (Client and Client.getChaseMode) and Client.getChaseMode() or (g_game and g_game.getChaseMode and g_game.getChaseMode()) - if currentMode ~= 1 then - if Client and Client.setChaseMode then - Client.setChaseMode(1) -- ChaseOpponent - elseif g_game and g_game.setChaseMode then - g_game.setChaseMode(1) -- ChaseOpponent - end - TargetBot.usingNativeChase = true - end - end - end - - -- ───────────────────────────────────────────────────────────────────────── - -- INTENT 1: WAVE AVOIDANCE (Highest priority movement) - -- Higher base confidence, only move when really needed - -- ───────────────────────────────────────────────────────────────────────── - if config.avoidAttacks then - local safePos, safeScore = findSafeAdjacentTile(pos, monsters, creature) - - if safePos then - -- Calculate confidence based on danger analysis - -- Start with higher base, require real danger - local confidence = 0.5 -- Base confidence (lower than threshold) - - -- Analyze current danger - local currentDanger = analyzePositionDanger(pos, monsters) - - -- Only boost confidence if we're actually in danger - if currentDanger.waveThreats >= 2 then - confidence = 0.85 -- Multiple wave threats = high confidence - elseif currentDanger.waveThreats == 1 and currentDanger.meleeThreats >= 2 then - confidence = 0.80 -- Wave + melee = high confidence - elseif currentDanger.totalDanger >= 4 then - confidence = 0.75 -- High total danger - elseif currentDanger.totalDanger >= 2 then - confidence = 0.70 -- Moderate danger (meets threshold) - end - - if useCoordinator then - MovementCoordinator.avoidWave(safePos, confidence) - else - -- Fallback: direct execution with confidence check - if confidence >= 0.70 then - avoidWaveAttacks() - return true - end - end - end - end - - -- ───────────────────────────────────────────────────────────────────────── - -- INTENT 2: FINISH KILL (High priority - chase wounded targets) - -- Higher thresholds, only for very low HP targets - -- ───────────────────────────────────────────────────────────────────────── - if targetIsLowHealth and pathLen > 1 then - local confidence = 0.55 -- Base (below threshold) - - -- Only high confidence for very low HP targets - if creatureHealth < 10 then - confidence = 0.85 -- Critical HP - elseif creatureHealth < 15 then - confidence = 0.75 -- Very low HP - elseif creatureHealth < 20 then - confidence = 0.70 -- Low HP (meets threshold) - end - - if useCoordinator then - MovementCoordinator.finishKill(cpos, confidence) - else - -- Fallback: direct execution only for critical targets - if confidence >= 0.70 then - if movementAllowed() then - return TargetBot.walkTo(cpos, 10, {ignoreNonPathable = true, precision = 1}) - end - end - end - end - - -- ───────────────────────────────────────────────────────────────────────── - -- INTENT 3: SPELL POSITION OPTIMIZATION - -- Position for maximum AoE damage (if SpellOptimizer available) - -- ───────────────────────────────────────────────────────────────────────── - if SpellOptimizer and config.optimizeSpellPosition and #monsters >= 2 then - -- Get configured spell shape from config (default to adjacent) - local spellShape = config.spellShape or SpellOptimizer.CONSTANTS.SHAPE.ADJACENT - - local optPos, score, confidence, details = SpellOptimizer.findOptimalPosition( - spellShape, monsters, { minMonsters = 2, avoidDanger = config.avoidAttacks } - ) - - if optPos and details and details.monstersHit >= 2 then - -- Only suggest movement if significantly better than current - if details.distance > 0 and confidence >= 0.6 then - if useCoordinator then - MovementCoordinator.positionForSpell(optPos, confidence, "AoE") - end - end - end - end - - -- ───────────────────────────────────────────────────────────────────────── - -- INTENT 4: KEEP DISTANCE (Ranged combat positioning) - -- ───────────────────────────────────────────────────────────────────────── - if config.keepDistance then - local keepRange = config.keepDistanceRange or 4 - local currentDist = pathLen - - if currentDist ~= keepRange and currentDist ~= keepRange + 1 then - -- Calculate position at correct distance - local dx = cpos.x - pos.x - local dy = cpos.y - pos.y - local dist = math.sqrt(dx * dx + dy * dy) - - if dist > 0 then - local targetDist = keepRange - local ratio = targetDist / dist - local keepPos = { - x = math.floor(cpos.x - dx * ratio + 0.5), - y = math.floor(cpos.y - dy * ratio + 0.5), - z = pos.z - } - - -- Check anchor constraint - local anchorValid = true - if config.anchor and anchorPosition then - local anchorDist = math.max( - math.abs(keepPos.x - anchorPosition.x), - math.abs(keepPos.y - anchorPosition.y) - ) - anchorValid = anchorDist <= (config.anchorRange or 5) - end - - if anchorValid then - local confidence = 0.55 - -- Higher confidence if too close (dangerous) - if currentDist < keepRange then - confidence = 0.7 - end - - if useCoordinator then - MovementCoordinator.keepDistance(keepPos, confidence) - else - local walkParams = { - ignoreNonPathable = true, - marginMin = keepRange, - marginMax = keepRange + 1 - } - if config.anchor and anchorPosition then - walkParams.maxDistanceFrom = {anchorPosition, config.anchorRange or 5} - end - if movementAllowed() then - return TargetBot.walkTo(cpos, 10, walkParams) - end - end - end - end - end - end - - -- ───────────────────────────────────────────────────────────────────────── - -- INTENT 5: REPOSITION (Better tactical tile) - -- ───────────────────────────────────────────────────────────────────────── - if config.rePosition and not isTrapped then - local currentWalkable = countWalkableTiles(pos) - local threshold = config.rePositionAmount or 5 - - if currentWalkable < threshold then - -- Find better position - local betterPos = nil - local bestScore = currentWalkable * 12 -- Current score - - -- Search nearby tiles - for dx = -2, 2 do - for dy = -2, 2 do - if dx ~= 0 or dy ~= 0 then - local checkPos = {x = pos.x + dx, y = pos.y + dy, z = pos.z} - local tileSafe = (TargetCore and TargetCore.PathSafety and TargetCore.PathSafety.isTileSafe) - and TargetCore.PathSafety.isTileSafe(checkPos) - or (function() - local Client = getClient() - local t = (Client and Client.getTile) and Client.getTile(checkPos) or (g_map and g_map.getTile and g_map.getTile(checkPos)) - local hasCreature = t and t.hasCreature and t:hasCreature() - return t and t:isWalkable() and not hasCreature - end)() - - if tileSafe then - -- Check anchor - local anchorValid = true - if config.anchor and anchorPosition then - local anchorDist = math.max( - math.abs(checkPos.x - anchorPosition.x), - math.abs(checkPos.y - anchorPosition.y) - ) - anchorValid = anchorDist <= (config.anchorRange or 5) - end - - if anchorValid then - local walkable = countWalkableTiles(checkPos) - local score = walkable * 12 - - -- Penalty for danger - local analysis = analyzePositionDanger(checkPos, monsters) - score = score - analysis.totalDanger * 15 - - if score > bestScore + 10 then - bestScore = score - betterPos = checkPos - end - end - end - end - end - end - - if betterPos then - local confidence = math.min(0.4 + (bestScore - currentWalkable * 12) / 100, 0.75) - - if useCoordinator then - MovementCoordinator.reposition(betterPos, confidence) - else - if confidence >= 0.5 then - return CaveBot.GoTo(betterPos, 0) - end - end - end - end - end - - -- ───────────────────────────────────────────────────────────────────────── - -- INTENT 6: CHASE (Close gap to target) - -- - -- OTClient native chase mode (setChaseMode(1)) handles basic chasing when - -- the player is attacking. When native chase is active, we should NOT - -- call autoWalk or custom pathfinding as it interferes. - -- - -- This fallback is ONLY used when: - -- 1. Native chase mode is not active (usingNativeChase = false) - -- 2. OR there's an anchor constraint that native chase can't respect - -- 3. OR the server doesn't support native chase - -- - -- IMPROVED: Only chase when monster is FAR from player (distance > 2) - -- This prevents unnecessary movement when already in melee/close range. - -- - -- NOTE: Do NOT use g_game.follow() - it cancels the attack! - -- ───────────────────────────────────────────────────────────────────────── - - -- Calculate direct distance (Chebyshev) to creature for chase decision - local chaseDistanceThreshold = config.chaseDistanceThreshold or 2 -- Only chase if farther than this - local directDist = math.max(math.abs(pos.x - cpos.x), math.abs(pos.y - cpos.y)) - - local chaseExecuted = false - -- Only trigger chase when monster is FAR (distance > threshold) - prevents chasing when already close - if config.chase and not config.keepDistance and pathLen > 1 and directDist > chaseDistanceThreshold then - -- First check: Is native chase already handling this? - local nativeChaseMayWork = false - local Client2 = getClient() - local hasGetChaseMode = (Client2 and Client2.getChaseMode) or (g_game and g_game.getChaseMode) - local hasIsAttacking = (Client2 and Client2.isAttacking) or (g_game and g_game.isAttacking) - if hasGetChaseMode and hasIsAttacking then - local isAttacking = (Client2 and Client2.isAttacking) and Client2.isAttacking() or (g_game and g_game.isAttacking and g_game.isAttacking()) - local chaseMode = (Client2 and Client2.getChaseMode) and Client2.getChaseMode() or (g_game and g_game.getChaseMode and g_game.getChaseMode()) - nativeChaseMayWork = isAttacking and chaseMode == 1 - end - - -- Check anchor constraint - local anchorValid = true - local hasAnchorConstraint = false - if config.anchor and anchorPosition then - hasAnchorConstraint = true - local anchorDist = math.max( - math.abs(cpos.x - anchorPosition.x), - math.abs(cpos.y - anchorPosition.y) - ) - anchorValid = anchorDist <= (config.anchorRange or 5) - end - - -- Only use custom chase if: - -- 1. Native chase isn't active/working, OR - -- 2. There's an anchor constraint (native chase doesn't know about anchors) - local needsCustomChase = not nativeChaseMayWork or hasAnchorConstraint - - if needsCustomChase and anchorValid then - -- Use player:autoWalk() for direct client-side movement - if player and player.autoWalk and not player:isWalking() then - pcall(function() player:autoWalk(cpos) end) - chaseExecuted = true - return true - end - elseif nativeChaseMayWork and anchorValid then - -- Native chase is working, just return success - chaseExecuted = true - return true - end - end - - -- ───────────────────────────────────────────────────────────────────────── - -- INTENT 7: FACE MONSTER (Diagonal correction) - -- ───────────────────────────────────────────────────────────────────────── - if config.faceMonster then - local dx = cpos.x - pos.x - local dy = cpos.y - pos.y - local dist = math.max(math.abs(dx), math.abs(dy)) - - if dist == 1 and math.abs(dx) == 1 and math.abs(dy) == 1 then - -- Need to move to cardinal position - local candidates = { - {x = pos.x + dx, y = pos.y, z = pos.z}, - {x = pos.x, y = pos.y + dy, z = pos.z} - } - - for i = 1, 2 do - local tileSafe = (TargetCore and TargetCore.PathSafety and TargetCore.PathSafety.isTileSafe) - and TargetCore.PathSafety.isTileSafe(candidates[i]) - or (function() - local C = getClient() - local t = (C and C.getTile) and C.getTile(candidates[i]) or (g_map and g_map.getTile and g_map.getTile(candidates[i])) - local hasCreature = t and t.hasCreature and t:hasCreature() - return t and t:isWalkable() and not hasCreature - end)() - if tileSafe then - -- Check anchor - local anchorValid = true - if config.anchor and anchorPosition then - local anchorDist = math.max( - math.abs(candidates[i].x - anchorPosition.x), - math.abs(candidates[i].y - anchorPosition.y) - ) - anchorValid = anchorDist <= (config.anchorRange or 5) - end - - if anchorValid then - if useCoordinator then - MovementCoordinator.faceMonster(candidates[i], 0.45) - else - if movementAllowed() then - return TargetBot.walkTo(candidates[i], 2, {ignoreNonPathable = true}) - end - end - break - end - end - end - elseif dist <= 1 then - -- Just face the monster (no movement needed) - local dir = player:getDirection() - if dx == 1 and dir ~= 1 then turn(1) - elseif dx == -1 and dir ~= 3 then turn(3) - elseif dy == 1 and dir ~= 2 then turn(2) - elseif dy == -1 and dir ~= 0 then turn(0) - end - end - end - - -- ═══════════════════════════════════════════════════════════════════════════ - -- EXECUTE COORDINATED MOVEMENT - -- ═══════════════════════════════════════════════════════════════════════════ - if useCoordinator then - local success, reason = MovementCoordinator.tick() - if success then - return true - end - - -- CHASE FALLBACK: If MovementCoordinator didn't execute but chase is enabled - -- Check if native chase is already working before using custom pathfinding - -- IMPROVED: Only chase when monster is FAR from player (distance > threshold) - local fallbackDirectDist = math.max(math.abs(pos.x - cpos.x), math.abs(pos.y - cpos.y)) - local fallbackChaseThreshold = config.chaseDistanceThreshold or 2 - - if config.chase and not config.keepDistance and pathLen > 1 and fallbackDirectDist > fallbackChaseThreshold then - -- First check if native chase is active and should be working - local nativeChaseMayWork = false - local Client = getClient() - local hasGetChaseMode = (Client and Client.getChaseMode) or (g_game and g_game.getChaseMode) - local hasIsAttacking = (Client and Client.isAttacking) or (g_game and g_game.isAttacking) - if hasGetChaseMode and hasIsAttacking then - local isAttacking = (Client and Client.isAttacking) and Client.isAttacking() or (g_game and g_game.isAttacking and g_game.isAttacking()) - local chaseMode = (Client and Client.getChaseMode) and Client.getChaseMode() or (g_game and g_game.getChaseMode and g_game.getChaseMode()) - nativeChaseMayWork = isAttacking and chaseMode == 1 - end - - -- If native chase is active, trust it and don't interfere - if nativeChaseMayWork then - return true -- Native chase is handling movement - end - - -- Only use custom fallback if native chase isn't working - if not player:isWalking() then - local anchorValid = true - if config.anchor and anchorPosition then - local anchorDist = math.max( - math.abs(cpos.x - anchorPosition.x), - math.abs(cpos.y - anchorPosition.y) - ) - anchorValid = anchorDist <= (config.anchorRange or 5) - end - - if anchorValid then - -- Use direct autoWalk for immediate chase - if player and player.autoWalk then - pcall(function() player:autoWalk(cpos) end) - return true - end - end - end - end - end -end - -onPlayerPositionChange(function(newPos, oldPos) - if zChanging() then - return - end - if not CaveBot or not CaveBot.isOff or CaveBot.isOff() then return end - if not TargetBot or not TargetBot.isOff or TargetBot.isOff() then return end - if not lureMax then return end - if storage.TargetBotDelayWhenPlayer then return end - if not dynamicLureDelay then return end - - local targetThreshold = delayFrom or lureMax * 0.5 - if targetCount < targetThreshold or not (target and target()) then return end - CaveBot.delay(delayValue or 0) -end) - --- ============================================================================ --- EVENT-DRIVEN LURE COORDINATION (DRY, SRP) --- --- Integrates lure system with EventBus for instant responsiveness: --- - Emits lure state changes for CaveBot coordination --- - Registers LURE intents with MovementCoordinator --- - Provides pure function for lure eligibility check --- ============================================================================ - --- Pure function: Calculate lure eligibility (no side effects) --- @param config: creature config with lure settings --- @param targets: current target count --- @return table { shouldLure: boolean, confidence: number, reason: string } -local function calculateLureEligibility(config, targets) - if not config then - return { shouldLure = false, confidence = 0, reason = "no_config" } - end - - if not config.dynamicLure then - return { shouldLure = false, confidence = 0, reason = "disabled" } - end - - local lureMin = config.lureMin or 3 - local lurMax = config.lureMax or 6 - - -- Not enough targets - should lure more - if targets < lureMin then - local deficit = lureMin - targets - local confidence = 0.5 + (deficit / lureMin) * 0.3 - return { - shouldLure = true, - confidence = math.min(0.85, confidence), - reason = "below_min", - deficit = deficit - } - end - - -- At max capacity - stop luring - if targets >= lurMax then - return { shouldLure = false, confidence = 0.9, reason = "at_max" } - end - - -- Between min and max - prefer fighting - return { shouldLure = false, confidence = 0.6, reason = "sufficient" } -end - --- Export pure function for external use -nExBot.calculateLureEligibility = calculateLureEligibility - --- Event-driven lure state management -if EventBus then - -- Track lure state for change detection - local lastLureState = { active = false, time = 0 } - - -- React to target count changes for lure decisions - EventBus.on("targetbot/target_count_change", function(newCount, oldCount) - if not TargetBot or not TargetBot.isOn or not TargetBot.isOn() then return end - - -- Get current creature config - local activeConfig = TargetBot.ActiveMovementConfig - if not activeConfig then return end - - local eligibility = calculateLureEligibility(activeConfig, newCount) - - -- State changed - emit event - if eligibility.shouldLure ~= lastLureState.active then - lastLureState.active = eligibility.shouldLure - lastLureState.time = now - - if eligibility.shouldLure then - -- Start luring - emit event for CaveBot - pcall(function() - EventBus.emit("targetbot/lure_start", { - reason = eligibility.reason, - confidence = eligibility.confidence, - deficit = eligibility.deficit - }) - end) - - -- Register LURE intent with MovementCoordinator - if MovementCoordinator and MovementCoordinator.Intent then - local playerPos = player and player:getPosition() - if playerPos then - -- Lure intent uses player's current position (CaveBot handles destination) - MovementCoordinator.Intent.register( - MovementCoordinator.CONSTANTS.INTENT.LURE, - playerPos, - eligibility.confidence, - "lure_event", - { triggered = "target_count", targets = newCount, deficit = eligibility.deficit } - ) - end - end - else - -- Stop luring - emit event - pcall(function() - EventBus.emit("targetbot/lure_stop", { - reason = eligibility.reason, - targets = newCount - }) - end) - end - end - end, 15) - - -- React to monster deaths to update lure decisions quickly - EventBus.on("monster:disappear", function(creature) - if tbOff() then return end - if not creature then return end - - -- Check if this affects our target count significantly - local monsterCount = 0 - if MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby then - local nearby = MovementCoordinator.MonsterCache.getNearby(7) - monsterCount = #nearby - end - - -- Emit target count change event - pcall(function() - EventBus.emit("targetbot/target_count_change", monsterCount, monsterCount + 1) - end) - end, 18) - - -- React to monster appearances - EventBus.on("monster:appear", function(creature) - if tbOff() then return end - if not creature then return end - - local playerPos = player and player:getPosition() - local creaturePos = creature:getPosition() - if not playerPos or not creaturePos then return end - - local dist = math.max(math.abs(playerPos.x - creaturePos.x), math.abs(playerPos.y - creaturePos.y)) - - -- Only count nearby monsters - if dist <= 7 then - local monsterCount = 0 - if MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby then - local nearby = MovementCoordinator.MonsterCache.getNearby(7) - monsterCount = #nearby - end - - -- Emit target count change event - pcall(function() - EventBus.emit("targetbot/target_count_change", monsterCount, monsterCount - 1) - end) - end - end, 18) - - -- Emit pull system state changes for CaveBot coordination - local lastPullState = false - - EventBus.on("targetbot/combat_start", function(creature, data) - if tbOff() then return end - -- When combat starts, evaluate pull system state - schedule(100, function() - if TargetBot and TargetBot.smartPullActive ~= lastPullState then - lastPullState = TargetBot.smartPullActive - if TargetBot.smartPullActive then - pcall(function() - EventBus.emit("targetbot/pull_active", { - creature = creature, - time = now - }) - end) - end - end - end) - end, 12) - - EventBus.on("targetbot/combat_end", function() - if tbOff() then return end - -- Combat ended - clear pull state - if lastPullState then - lastPullState = false - pcall(function() - EventBus.emit("targetbot/pull_inactive") - end) - end - end, 12) -end \ No newline at end of file +-- TargetBot creature attack shim +-- Loads the split module chain in dependency order +-- Spells → Wave avoidance → Attack/walk coordinator + lure +dofile("/targetbot/attack_spells.lua") +dofile("/targetbot/attack_waves.lua") +dofile("/targetbot/attack_coordinator.lua") diff --git a/targetbot/creature_editor.lua b/targetbot/creature_editor.lua index 4c16d2d..ab9d2fb 100644 --- a/targetbot/creature_editor.lua +++ b/targetbot/creature_editor.lua @@ -161,7 +161,6 @@ CROSS (4): Cardinal directions only (N/E/S/W). table.insert(values, {"smartPullShape", function() return widget.scroll:getValue() end}) end - addCheckBox("chase", "Chase", true, "Chase the target, walking towards it until adjacent.") addCheckBox("keepDistance", "Keep Distance", false, "Maintain a specific distance from the target (set in Keep Distance slider).") addCheckBox("anchor", "Anchoring", false, "Stay within a radius of your initial position (set in Anchoring Range slider).") diff --git a/targetbot/event_targeting.lua b/targetbot/event_targeting.lua index 0fcd576..bfb22f9 100644 --- a/targetbot/event_targeting.lua +++ b/targetbot/event_targeting.lua @@ -23,83 +23,38 @@ - EventTargeting.CombatCoordinator: CaveBot integration ]] --- ============================================================================ -- MODULE NAMESPACE --- ============================================================================ EventTargeting = EventTargeting or {} EventTargeting.VERSION = "2.2" EventTargeting.DEBUG = false -- Use shared ClientHelper aliases (loaded by _Loader.lua) for cross-client compatibility +local zChanging = nExBot.zChanging or function() return false end local getClient = nExBot.Shared.getClient local getClientVersion = nExBot.Shared.getClientVersion +if not nExBot.target_pathfinding then + dofile("/targetbot/target_pathfinding.lua") +end +local targetPathfinding = nExBot.target_pathfinding + -- SafeCreature module for safe creature access (DRY) -- Defensive wrapper to handle cases where SafeCreature isn't fully loaded local SC = SafeCreature or {} --- Defensive helper functions (fallback if SafeCreature methods missing) -local function safeIsMonster(creature) - if SC.isMonster then return SC.isMonster(creature) end - if not creature then return false end - local ok, result = pcall(function() return creature:isMonster() end) - return ok and result == true -end - -local function safeIsDead(creature) - if SC.isDead then return SC.isDead(creature) end - if not creature then return true end - local ok, result = pcall(function() return creature:isDead() end) - return ok and result == true -end - -local function safeGetHealthPercent(creature) - if SC.getHealthPercent then return SC.getHealthPercent(creature) end - if not creature then return 0 end - local ok, hp = pcall(function() return creature:getHealthPercent() end) - return ok and hp or 0 -end - -local function safeGetPosition(creature) - if SC.getPosition then return SC.getPosition(creature) end - if not creature then return nil end - local ok, pos = pcall(function() return creature:getPosition() end) - return ok and pos or nil -end - -local function safeGetId(creature) - if SC.getId then return SC.getId(creature) end - if not creature then return nil end - local ok, id = pcall(function() return creature:getId() end) - return ok and id or nil -end - -local function safeGetName(creature) - if SC.getName then return SC.getName(creature) end - if not creature then return nil end - local ok, name = pcall(function() return creature:getName() end) - return ok and name or nil -end - --- ============================================================================ -- DEPENDENCIES --- ============================================================================ local SafeCall = SafeCall or require("core.safe_call") -- Load PathUtils if available (shared module for DRY) -- OTClient compatible - no _G usage local PathUtils = PathUtils -- Try existing global +local SharedHelpers = nExBot.SharedHelpers or {} local function ensurePathUtils() if PathUtils then return PathUtils end - local success = pcall(function() - dofile("nExBot/utils/path_utils.lua") - end) - -- After dofile, PathUtils should be global - if success then - PathUtils = PathUtils -- Re-check global after dofile - end + SharedHelpers.ensurePathUtils() + PathUtils = PathUtils -- Re-check global after dofile return PathUtils end ensurePathUtils() @@ -119,9 +74,7 @@ local function ensureChaseController() end ensureChaseController() --- ============================================================================ -- CONSTANTS (Tunable for performance) --- ============================================================================ EventTargeting.CONSTANTS = { -- Detection range (tiles from player) - matches visible screen @@ -183,15 +136,14 @@ end applyClientTuning() --- ============================================================================ -- INTERNAL STATE --- ============================================================================ -- Fast creature cache with path info local creatureCache = { entries = {}, -- {id -> {creature, path, pathTime, priority, config, lastSeen}} count = 0, - accessOrder = {}, -- LRU tracking + accessOrder = {}, -- LRU tracking (array) + posMap = {}, lastCleanup = 0, CLEANUP_INTERVAL = 1500 } @@ -220,9 +172,7 @@ local PATH_PARAMS = { ignoreCreatures = true } --- ============================================================================ -- STATE MANAGEMENT --- ============================================================================ -- Clear all EventTargeting state (called when TargetBot is disabled) function EventTargeting.clearState() @@ -238,6 +188,7 @@ function EventTargeting.clearState() creatureCache.entries = {} creatureCache.count = 0 creatureCache.accessOrder = {} + creatureCache.posMap = {} creatureCache.lastCleanup = 0 -- Clear live monster state @@ -264,11 +215,9 @@ local function canAttack() return true end --- ============================================================================ -- LIVE MONSTER COUNTING (Direct API - Most Accurate) -- Uses g_map.getSpectators/getSpectatorsInRange directly for accurate counting -- This bypasses the cache which may have stale data --- ============================================================================ local liveMonsterState = { count = 0, -- Live count from direct API call @@ -277,37 +226,6 @@ local liveMonsterState = { oldTibia = getClientVersion() < 960 } --- Check if a creature is a targetable monster (not summon) --- OPTIMIZED: Uses PathUtils.validateCreature for reduced pcall overhead -local function isTargetableMonster(creature) - if not creature then return false end - - -- Use PathUtils for optimized validation (single pcall, covers dead/monster/summon) - if PathUtils and PathUtils.isValidMonsterTarget then - if not PathUtils.isValidMonsterTarget(creature) then return false end - -- For old Tibia, all monsters are targetable (summon check already handled) - return true - end - - -- Fallback: Use safe wrapper functions for DRY - if safeIsDead(creature) then return false end - if not safeIsMonster(creature) then return false end - if safeGetHealthPercent(creature) <= 0 then return false end - - -- For old Tibia, all monsters are targetable - if liveMonsterState.oldTibia then return true end - - -- For new Tibia, check creature type to exclude other player's summons - local creatureType = nil - local okType, cType = pcall(function() return creature:getType() end) - if okType then creatureType = cType end - if creatureType and creatureType >= 3 then - return false -- Summon - end - - return true -end - -- Get live count of targetable monsters using direct API -- This is the AUTHORITATIVE count - always accurate function EventTargeting.getLiveMonsterCount() @@ -328,21 +246,9 @@ function EventTargeting.getLiveMonsterCount() return liveMonsterState.count, liveMonsterState.creatures end - -- Get creatures using the most reliable API available - local Client = getClient() - local creatures = nil + -- Get creatures from cache local range = CONST.LIVE_COUNT_RANGE - - -- Try getSpectatorsInRange first (most common) - if Client and Client.getSpectatorsInRange then - creatures = Client.getSpectatorsInRange(playerPos, false, range, range) - elseif Client and Client.getSpectators then - creatures = Client.getSpectators(playerPos, false) - elseif g_map and g_map.getSpectatorsInRange then - creatures = g_map.getSpectatorsInRange(playerPos, false, range, range) - elseif g_map and g_map.getSpectators then - creatures = g_map.getSpectators(playerPos, false) - end + local creatures = BotCore.Creatures.getNearby(range, range) if not creatures then return liveMonsterState.count, liveMonsterState.creatures @@ -355,9 +261,9 @@ function EventTargeting.getLiveMonsterCount() for i = 1, #creatures do local creature = creatures[i] - if isTargetableMonster(creature) then - local okPos, cpos = pcall(function() return creature:getPosition() end) - if okPos and cpos and cpos.z == playerZ then + if targetPathfinding.isTargetableMonster(creature) then + local cpos = SC.getPosition(creature) + if cpos and cpos.z == playerZ then count = count + 1 monsters[#monsters + 1] = creature end @@ -373,26 +279,16 @@ function EventTargeting.getLiveMonsterCount() end -- Check if there are ANY monsters on screen (fast check) -function EventTargeting.hasAnyMonsters() - local count = EventTargeting.getLiveMonsterCount() - return count > 0 -end - -- Force refresh of live count (useful after events) function EventTargeting.refreshLiveCount() liveMonsterState.lastUpdate = 0 return EventTargeting.getLiveMonsterCount() end --- ============================================================================ -- UTILITY FUNCTIONS --- ============================================================================ --- Chebyshev distance (O(1)) -local function chebyshev(p1, p2) - if not p1 or not p2 then return 999 end - return math.max(math.abs(p1.x - p2.x), math.abs(p1.y - p2.y)) -end + +local chebyshev = nExBot.target_pathfinding.chebyshev -- Manhattan distance (O(1)) local function manhattan(p1, p2) @@ -411,9 +307,7 @@ local function updatePlayerRef() player = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) or player end --- ============================================================================ -- FLOOR CHANGE DETECTION (Prevents chasing across stairs/ropes) --- ============================================================================ -- Use centralized floor-change items from constants (DRY principle) if not FloorItems then @@ -445,34 +339,42 @@ local function isFloorChangeTile(pos) return false end --- ============================================================================ -- CACHE MANAGEMENT --- ============================================================================ --- Touch entry (move to end of LRU) +-- Touch entry (move to end of LRU) — O(1) via posMap local function touchEntry(id) local order = creatureCache.accessOrder - for i = #order, 1, -1 do - if order[i] == id then - table.remove(order, i) - break + local pmap = creatureCache.posMap + local oldIdx = pmap[id] + if oldIdx then + local last = #order + if oldIdx ~= last then + local movedId = order[last] + order[oldIdx] = movedId + pmap[movedId] = oldIdx end + order[last] = id + pmap[id] = last + else + order[#order + 1] = id + pmap[id] = #order end - order[#order + 1] = id end -- Evict oldest entries when over capacity local function evictOldEntries() local order = creatureCache.accessOrder + local pmap = creatureCache.posMap + local entries = creatureCache.entries while #order > CONST.CREATURE_CACHE_SIZE do local oldestId = order[1] - -- Shift array left - for i = 1, #order - 1 do - order[i] = order[i + 1] - end - order[#order] = nil - if creatureCache.entries[oldestId] then - creatureCache.entries[oldestId] = nil + local last = #order + order[1] = order[last] + pmap[order[1]] = 1 + order[last] = nil + pmap[oldestId] = nil + if entries[oldestId] then + entries[oldestId] = nil creatureCache.count = creatureCache.count - 1 end end @@ -487,6 +389,7 @@ local function cleanupCache() local cutoff = now - CONST.CREATURE_CACHE_TTL local newEntries = {} local newOrder = {} + local newPmap = {} local count = 0 for i = 1, #creatureCache.accessOrder do @@ -495,24 +398,23 @@ local function cleanupCache() if entry and entry.lastSeen > cutoff then local creature = entry.creature -- Safe dead check - local okDead, isDead = pcall(function() return creature and creature:isDead() end) - if creature and (not okDead or not isDead) then + if creature and not SC.isDead(creature) then newEntries[id] = entry - newOrder[#newOrder + 1] = id count = count + 1 + newOrder[count] = id + newPmap[id] = count end end end creatureCache.entries = newEntries creatureCache.accessOrder = newOrder + creatureCache.posMap = newPmap creatureCache.count = count creatureCache.lastCleanup = now end --- ============================================================================ -- PATH VALIDATION --- ============================================================================ EventTargeting.PathValidator = {} @@ -571,16 +473,7 @@ function EventTargeting.PathValidator.validate(playerPos, targetPos) end else -- Fallback: manual check - local DIR_OFFSET = (PathUtils and PathUtils.DIR_TO_OFFSET) or { - [North or 0] = {x = 0, y = -1}, - [East or 1] = {x = 1, y = 0}, - [South or 2] = {x = 0, y = 1}, - [West or 3] = {x = -1, y = 0}, - [NorthEast or 4] = {x = 1, y = -1}, - [SouthEast or 5] = {x = 1, y = 1}, - [SouthWest or 6] = {x = -1, y = 1}, - [NorthWest or 7] = {x = -1, y = -1} - } + local DIR_OFFSET = Directions.DIR_TO_OFFSET local probe = {x = playerPos.x, y = playerPos.y, z = playerPos.z} for i = 1, pathLen do local off = DIR_OFFSET[path[i]] @@ -604,9 +497,9 @@ function EventTargeting.PathValidator.getPath(creature) if not creature then return nil, 999, false end -- Safe ID access using safe wrapper - local id = safeGetId(creature) + local id = SC.getId(creature) if not id then return nil, 999, false end - + local entry = creatureCache.entries[id] -- Check cached path @@ -619,19 +512,17 @@ function EventTargeting.PathValidator.getPath(creature) if not player then return nil, 999, false end -- Safe position access - local okPpos, playerPos = pcall(function() return player:getPosition() end) - local okCpos, creaturePos = pcall(function() return creature:getPosition() end) + local playerPos = SC.getPosition(player) + local creaturePos = SC.getPosition(creature) - if not okPpos or not playerPos or not okCpos or not creaturePos then + if not playerPos or not creaturePos then return nil, 999, false end return EventTargeting.PathValidator.validate(playerPos, creaturePos) end --- ============================================================================ -- TARGET ACQUISITION (Event-Driven) --- ============================================================================ EventTargeting.TargetAcquisition = {} @@ -640,12 +531,10 @@ function EventTargeting.TargetAcquisition.isValidTarget(creature) if not creature then return false end -- Safe dead check - local okDead, isDead = pcall(function() return creature:isDead() end) - if okDead and isDead then return false end + if SC.isDead(creature) then return false end -- Safe monster check - local okMonster, isMonster = pcall(function() return creature:isMonster() end) - if not okMonster or not isMonster then return false end + if not SC.isMonster(creature) then return false end -- Check against targetbot configs if TargetBot and TargetBot.Creature and TargetBot.Creature.getConfigs then @@ -680,7 +569,7 @@ function EventTargeting.TargetAcquisition.calculatePriority(creature, path) local priority = 0 -- Start at 0, build up from config priority -- Safe HP access using safe wrapper - local hp = safeGetHealthPercent(creature) + local hp = SC.getHealthPercent(creature) if hp == 0 then hp = 100 end -- Default to 100 if not available local pathLen = path and #path or 10 @@ -737,11 +626,11 @@ function EventTargeting.TargetAcquisition.calculatePriority(creature, path) -- Current attack target bonus (safe) local Client = getClient() - local currentTarget = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) + local currentTarget = ClientService.getAttackingCreature() if currentTarget then - local okCid, cid = pcall(function() return creature:getId() end) - local okTid, tid = pcall(function() return currentTarget:getId() end) - if okCid and okTid and cid == tid then + local cid = SC.getId(creature) + local tid = SC.getId(currentTarget) + if cid and tid and cid == tid then priority = priority + 25 -- Extra bonus for wounded current target if hp < 50 then @@ -767,11 +656,11 @@ function EventTargeting.TargetAcquisition.processCreature(creature) if not player then return end -- Safe access to positions and ID - local okId, id = pcall(function() return creature:getId() end) - local okPpos, playerPos = pcall(function() return player:getPosition() end) - local okCpos, creaturePos = pcall(function() return creature:getPosition() end) + local id = SC.getId(creature) + local playerPos = SC.getPosition(player) + local creaturePos = SC.getPosition(creature) - if not okId or not id or not okPpos or not playerPos or not okCpos or not creaturePos then return end + if not id or not playerPos or not creaturePos then return end -- Check same floor and range if not sameFloor(playerPos, creaturePos) then return end @@ -839,7 +728,7 @@ function EventTargeting.TargetAcquisition.evaluateTarget(creature, priority, pat end local Client = getClient() - local currentTarget = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) + local currentTarget = ClientService.getAttackingCreature() -- If no current target, acquire immediately if not currentTarget or currentTarget:isDead() then @@ -1020,17 +909,17 @@ function EventTargeting.TargetAcquisition.acquireTarget(creature, path, priority -- Scenario gate: avoid illegal switches (anti-zigzag) if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.shouldAllowTargetSwitch then - local currentTarget = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) - if currentTarget and not currentTarget:isDead() then - local okNewId, newId = pcall(function() return creature:getId() end) - local okCurId, curId = pcall(function() return currentTarget:getId() end) - if okNewId and okCurId and newId ~= curId then + local currentTarget = ClientService.getAttackingCreature() + if currentTarget and not (SC and SC.isDead and SC.isDead(currentTarget)) then + local newId = SC.getId(creature) + local curId = SC.getId(currentTarget) + if newId and curId and newId ~= curId then local newPriority = priorityHint if newPriority == nil then newPriority = EventTargeting.TargetAcquisition.calculatePriority(creature, path) end - local okHp, hp = pcall(function() return creature:getHealthPercent() end) - local allowed = MonsterAI.Scenario.shouldAllowTargetSwitch(newId, newPriority or 0, okHp and hp or nil) + local hp = SC.getHealthPercent(creature) + local allowed = MonsterAI.Scenario.shouldAllowTargetSwitch(newId, newPriority or 0, hp) if not allowed then return end @@ -1077,9 +966,9 @@ function EventTargeting.TargetAcquisition.acquireTarget(creature, path, priority -- If attack was throttled and we are not already attacking this creature, bail local Client = getClient() - local currentAttack = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) - local okCurId, curId = pcall(function() return currentAttack and currentAttack:getId() end) - if not sent and not (currentAttack and okCurId and curId == id) then + local currentAttack = ClientService.getAttackingCreature() + local curId = currentAttack and SC.getId(currentAttack) or nil + if not sent and not (currentAttack and curId == id) then return end @@ -1095,8 +984,8 @@ function EventTargeting.TargetAcquisition.acquireTarget(creature, path, priority -- Update MonsterAI target lock for anti-zigzag stability if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.lockTarget then - local okHp, hp = pcall(function() return creature:getHealthPercent() end) - MonsterAI.Scenario.lockTarget(id, okHp and hp or 100) + local hp = SC.getHealthPercent(creature) + MonsterAI.Scenario.lockTarget(id, hp or 100) end -- Emit combat start event @@ -1176,12 +1065,14 @@ function EventTargeting.TargetAcquisition.processPending() end end --- ============================================================================ -- COMBAT COORDINATOR (CaveBot Integration) --- ============================================================================ EventTargeting.CombatCoordinator = {} +function EventTargeting.CombatCoordinator.onTargetChanged(creature, oldCreature) + EventTargeting.CombatCoordinator.checkCombatStatus() +end + -- Check if lure mode is active (should NOT pause CaveBot) function EventTargeting.CombatCoordinator.isLureModeActive() -- Check dynamicLure @@ -1215,7 +1106,7 @@ function EventTargeting.CombatCoordinator.shouldPauseCaveBot() end -- Check if we're in combat with a valid target - local currentTarget = g_game and g_game.getAttackingCreature and g_game.getAttackingCreature() + local currentTarget = ClientService.getAttackingCreature() if not currentTarget or currentTarget:isDead() then return false end @@ -1236,7 +1127,6 @@ function EventTargeting.CombatCoordinator.pauseCaveBot() -- Set combat active flag for CaveBot to check targetState.combatActive = true - storage.eventTargetingCombat = true -- Reset CaveBot walking if available if CaveBot and CaveBot.resetWalking then @@ -1251,7 +1141,6 @@ end -- Resume CaveBot walking after combat function EventTargeting.CombatCoordinator.resumeCaveBot() targetState.combatActive = false - storage.eventTargetingCombat = false if EventTargeting.DEBUG then print("[EventTargeting] CaveBot resumed") @@ -1308,7 +1197,7 @@ function EventTargeting.CombatCoordinator.checkCombatStatus() targetState.lastCombatCheck = now local Client = getClient() - local currentTarget = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) + local currentTarget = ClientService.getAttackingCreature() if not currentTarget or currentTarget:isDead() then -- Combat ended @@ -1331,9 +1220,7 @@ function EventTargeting.CombatCoordinator.checkCombatStatus() end end --- ============================================================================ -- EVENTBUS INTEGRATION (High-Performance Event Handlers) --- ============================================================================ -- Debounce helper local lastProcessTime = 0 @@ -1420,7 +1307,7 @@ if EventBus then end -- Check if this is a higher priority than current target - local currentTarget = g_game and g_game.getAttackingCreature and g_game.getAttackingCreature() + local currentTarget = ClientService.getAttackingCreature() if newConfigPriority > 0 and currentTarget and not currentTarget:isDead() then local currentConfigPriority = 0 if TargetBot and TargetBot.Creature and TargetBot.Creature.getConfigs then @@ -1475,12 +1362,18 @@ if EventBus then creatureCache.entries[id] = nil creatureCache.count = creatureCache.count - 1 - -- Remove from access order - for i = #creatureCache.accessOrder, 1, -1 do - if creatureCache.accessOrder[i] == id then - table.remove(creatureCache.accessOrder, i) - break + -- Remove from access order — O(1) via posMap + local idx = creatureCache.posMap[id] + if idx then + local order = creatureCache.accessOrder + local last = #order + if idx ~= last then + local movedId = order[last] + order[idx] = movedId + creatureCache.posMap[movedId] = idx end + order[last] = nil + creatureCache.posMap[id] = nil end end @@ -1493,7 +1386,7 @@ if EventBus then end end, 25) - -- Monster health changed - update priority + -- Monster health changed - update priority (no target switching) EventBus.on("monster:health", function(creature, percent, oldPercent) -- Skip processing if TargetBot is disabled if TargetBot and TargetBot.isOn and not TargetBot.isOn() then @@ -1507,15 +1400,9 @@ if EventBus then if entry then -- Recalculate priority local newPriority = EventTargeting.TargetAcquisition.calculatePriority(creature, entry.path) - local priorityChange = newPriority - (entry.priority or 0) entry.priority = newPriority entry.lastSeen = now touchEntry(id) - - -- If priority increased significantly, reevaluate as target - if priorityChange > 20 and entry.reachable then - EventTargeting.TargetAcquisition.evaluateTarget(creature, newPriority, entry.path) - end end end, 20) @@ -1610,6 +1497,7 @@ if EventBus then updatePlayerRef() creatureCache.entries = {} creatureCache.accessOrder = {} + creatureCache.posMap = {} creatureCache.count = 0 targetState.currentTarget = nil targetState.currentTargetId = nil @@ -1623,9 +1511,7 @@ if EventBus then end, 50) end --- ============================================================================ -- MAIN PROCESSING MACRO --- ============================================================================ -- Scan interval for full screen scan (catch any monsters EventBus missed) local lastFullScan = 0 @@ -1650,8 +1536,8 @@ local function scanVisibleMonsters() for i = 1, #liveCreatures do local creature = liveCreatures[i] if creature then - local okId, id = pcall(function() return creature:getId() end) - if okId and id then + local id = SC.getId(creature) + if id then -- Only process if not already in cache or cache entry is stale local entry = creatureCache.entries[id] if not entry or (currentTime - (entry.lastSeen or 0)) > 500 then @@ -1671,15 +1557,8 @@ local function scanVisibleMonsters() end -- Fallback: Use direct API if live count didn't work - local creatures = nil local range = CONST.DETECTION_RANGE - local Client = getClient() - - if Client and Client.getSpectatorsInRange then - creatures = Client.getSpectatorsInRange(playerPos, false, range, range) - elseif g_map and g_map.getSpectatorsInRange then - creatures = g_map.getSpectatorsInRange(playerPos, false, range, range) - end + local creatures = BotCore.Creatures.getNearby(range, range) if not creatures or #creatures == 0 then return end @@ -1688,11 +1567,11 @@ local function scanVisibleMonsters() local playerZ = playerPos.z for i = 1, #creatures do local creature = creatures[i] - if isTargetableMonster(creature) then - local okPos, cpos = pcall(function() return creature:getPosition() end) - if okPos and cpos and cpos.z == playerZ then - local okId, id = pcall(function() return creature:getId() end) - if okId and id then + if targetPathfinding.isTargetableMonster(creature) then + local cpos = SC.getPosition(creature) + if cpos and cpos.z == playerZ then + local id = SC.getId(creature) + if id then -- Only process if not already in cache or cache entry is stale local entry = creatureCache.entries[id] if not entry or (currentTime - (entry.lastSeen or 0)) > 500 then @@ -1749,9 +1628,7 @@ macro(100, function() cleanupCache() end) --- ============================================================================ -- PUBLIC API --- ============================================================================ -- Get current combat state function EventTargeting.isInCombat() @@ -1763,40 +1640,6 @@ function EventTargeting.getCurrentTarget() return targetState.currentTarget end --- Get cached creature count -function EventTargeting.getCacheCount() - return creatureCache.count -end - --- Force target acquisition -function EventTargeting.forceAcquire(creature) - if creature and not creature:isDead() then - EventTargeting.TargetAcquisition.processCreature(creature) - end -end - --- Get all reachable targets -function EventTargeting.getReachableTargets() - local targets = {} - for id, entry in pairs(creatureCache.entries) do - if entry.reachable and entry.creature and not entry.creature:isDead() then - targets[#targets + 1] = { - creature = entry.creature, - priority = entry.priority, - distance = entry.distance, - path = entry.path - } - end - end - - -- Sort by priority - table.sort(targets, function(a, b) - return a.priority > b.priority - end) - - return targets -end - -- Debug: Print cache status (only outputs when DEBUG is true) function EventTargeting.debugStatus() if not EventTargeting.DEBUG then return end @@ -1807,9 +1650,7 @@ function EventTargeting.debugStatus() end end --- ============================================================================ -- CAVEBOT INTEGRATION HOOK --- ============================================================================ -- Export function for CaveBot to check function EventTargeting.shouldPauseCaveBot() @@ -1842,16 +1683,9 @@ function EventTargeting.isCombatActive() end -- Get authoritative monster count for external modules -function EventTargeting.getMonsterCount() - local count = EventTargeting.getLiveMonsterCount() - return count -end - --- ============================================================================ -- NATIVE OTCLIENT CALLBACK INTEGRATION -- Direct hook into OTClient's onCreatureAppear for fastest possible detection -- This bypasses EventBus for even faster high-priority monster switching --- ============================================================================ -- Register native callback if available (fastest path) if onCreatureAppear then @@ -1871,8 +1705,8 @@ if onCreatureAppear then updatePlayerRef() if not player then return end - local okPpos, playerPos = pcall(function() return player:getPosition() end) - local okCpos, creaturePos = pcall(function() return creature:getPosition() end) + local playerPos = SC.getPosition(player) + local creaturePos = SC.getPosition(creature) if not okPpos or not playerPos or not okCpos or not creaturePos then return end if playerPos.z ~= creaturePos.z then return end @@ -1892,8 +1726,8 @@ if onCreatureAppear then end -- Check current target's priority - local Client = getClient() - local currentTarget = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) + local Client = getClient() + local currentTarget = Client and Client.getAttackingCreature and Client.getAttackingCreature() if newConfigPriority > 0 then local currentConfigPriority = 0 if currentTarget and not currentTarget:isDead() then @@ -1924,13 +1758,13 @@ if onCreatureAppear then -- Scenario gate: avoid illegal switches (anti-zigzag) if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.shouldAllowTargetSwitch then local Client2 = getClient() - local currentTarget = (Client2 and Client2.getAttackingCreature) and Client2.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) + local currentTarget = ClientService.getAttackingCreature() if currentTarget and not currentTarget:isDead() then - local okNewId, newId = pcall(function() return creature:getId() end) - local okCurId, curId = pcall(function() return currentTarget:getId() end) - if okNewId and okCurId and newId ~= curId then - local okHp, hp = pcall(function() return creature:getHealthPercent() end) - local allowed = MonsterAI.Scenario.shouldAllowTargetSwitch(newId, (newConfigPriority or 0) * 100, okHp and hp or nil) + local newId = SC.getId(creature) + local curId = SC.getId(currentTarget) + if newId and curId and newId ~= curId then + local hp = SC.getHealthPercent(creature) + local allowed = MonsterAI.Scenario.shouldAllowTargetSwitch(newId, (newConfigPriority or 0) * 100, hp) if not allowed then return end @@ -1945,22 +1779,22 @@ if onCreatureAppear then local priority = EventTargeting.TargetAcquisition and EventTargeting.TargetAcquisition.calculatePriority and EventTargeting.TargetAcquisition.calculatePriority(creature) or 100 - sent = AttackStateMachine.requestSwitch(creature, priority + 200) -- +200 for high-priority event + sent = AttackStateMachine.requestSwitch(creature, priority + 10) -- +10 tiebreaker for new creature elseif TargetBot and TargetBot.requestAttack then sent = TargetBot.requestAttack(creature, "event_high_priority") end -- If attack was throttled and we are not already attacking this creature, bail - local currentAttack = (ClientService and ClientService.getAttackingCreature) and ClientService.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) - local okCurId2, curId2 = pcall(function() return currentAttack and currentAttack:getId() end) - local okNewId2, newId2 = pcall(function() return creature:getId() end) - if not sent and not (currentAttack and okCurId2 and okNewId2 and curId2 == newId2) then + local currentAttack = (ClientService and ClientService.getAttackingCreature) and ClientService.getAttackingCreature() or nil + local curId2 = currentAttack and SC.getId(currentAttack) or nil + local newId2 = SC.getId(creature) + if not sent and not (currentAttack and curId2 and newId2 and curId2 == newId2) then return end -- Also update our state - local okId, id = pcall(function() return creature:getId() end) - if okId and id then + local id = SC.getId(creature) + if id then targetState.currentTarget = creature targetState.currentTargetId = id targetState.lastAcquisition = now or (os.time() * 1000) @@ -1968,9 +1802,9 @@ if onCreatureAppear then end -- Update MonsterAI target lock for anti-zigzag stability - if okId and id and MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.lockTarget then - local okHp, hp = pcall(function() return creature:getHealthPercent() end) - MonsterAI.Scenario.lockTarget(id, okHp and hp or 100) + if id and MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.lockTarget then + local hp = SC.getHealthPercent(creature) + MonsterAI.Scenario.lockTarget(id, hp or 100) end -- Emit event for other systems diff --git a/targetbot/helpers.lua b/targetbot/helpers.lua new file mode 100644 index 0000000..7db6f53 --- /dev/null +++ b/targetbot/helpers.lua @@ -0,0 +1,31 @@ +local SC = SafeCreature or {} +local Helpers = {} + +function Helpers.cId(creature) + return SC.getId(creature) +end + +function Helpers.cHp(creature) + return SC.getHealthPercent(creature) or 100 +end + +function Helpers.cDead(creature) + return SC.isRemoved(creature) or SC.getHealthPercent(creature) == 0 +end + +function Helpers.cName(creature) + return SC.getName(creature) or "unknown" +end + +function Helpers.gameTarget() + local Client = nExBot.Shared.getClient() + if Client and Client.getAttackingCreature then + return Client.getAttackingCreature() + end + if g_game and g_game.getAttackingCreature then + return g_game.getAttackingCreature() + end + return nil +end + +return Helpers diff --git a/targetbot/looting.lua b/targetbot/looting.lua index 1fc9a3c..44a0cbe 100644 --- a/targetbot/looting.lua +++ b/targetbot/looting.lua @@ -1,9 +1,8 @@ -- Safe function calls to prevent "attempt to call global function (a nil value)" errors +local zChanging = nExBot.zChanging or function() return false end local SafeCall = SafeCall or require("core.safe_call") --------------------------------------------------------------------------------- -- CLIENTSERVICE HELPERS (shared aliases) --------------------------------------------------------------------------------- local getClient = nExBot.Shared.getClient local getClientVersion = nExBot.Shared.getClientVersion @@ -260,8 +259,7 @@ TargetBot.Looting.process = function(targets, dangerLevel) if waitTill > now then return true end - local Client = getClient() - local containers = (Client and Client.getContainers) and Client.getContainers() or (g_game and g_game.getContainers and g_game.getContainers()) + local containers = nExBot.Shared.getContainers() local lootContainers = TargetBot.Looting.getLootContainers(containers) -- check if there's container for loot and has empty space for it @@ -626,21 +624,6 @@ TargetBot.Looting.getLootContainers = function(containers) end end - -- ═══════════════════════════════════════════════════════════════════════ - -- PHASE 5: Use ContainerOpener module if available (advanced opening) - -- ═══════════════════════════════════════════════════════════════════════ - if ContainerOpener and ContainerOpener.ensureLootContainerSpace then - local containerIdList = {} - for id, _ in pairs(containersById) do - table.insert(containerIdList, id) - end - - if ContainerOpener.ensureLootContainerSpace(containerIdList) then - waitTill = now + 300 - return lootContainers - end - end - return lootContainers end diff --git a/targetbot/monster_ai.lua b/targetbot/monster_ai.lua index 6feecb3..49f1701 100644 --- a/targetbot/monster_ai.lua +++ b/targetbot/monster_ai.lua @@ -30,23 +30,22 @@ See docs/TARGETBOT.md for the full design rationale. ]] --- ============================================================================ -- MODULE NAMESPACE --- ============================================================================ MonsterAI = MonsterAI or {} MonsterAI.VERSION = "3.0" -- BoundedPush/TrimArray are set as globals by utils/ring_buffer.lua (Phase 3) +local zChanging = nExBot.zChanging or function() return false end local BoundedPush = BoundedPush local TrimArray = TrimArray --------------------------------------------------------------------------------- -- CLIENTSERVICE HELPERS (shared aliases) --------------------------------------------------------------------------------- local getClient = nExBot.Shared.getClient local getClientVersion = nExBot.Shared.getClientVersion +local SC = SafeCreature or {} + -- Time helper (use ClientHelper for DRY) local nowMs = ClientHelper and ClientHelper.nowMs or function() if now then return now end @@ -54,109 +53,17 @@ local nowMs = ClientHelper and ClientHelper.nowMs or function() return os.time() * 1000 end --- ============================================================================ -- SAFE CREATURE VALIDATION (Prevents C++ crashes) -- The OTClient C++ layer can crash even when methods exist if the creature -- object is in an invalid internal state. These helpers prevent that. --- ============================================================================ - --- Cache for recently validated creatures to reduce overhead -local validatedCreatures = {} -local validatedCreaturesTTL = 100 -- ms - --- Check if a creature is valid and safe to call methods on --- Returns true only if the creature can be safely accessed -local function isCreatureValid(creature) - if not creature then return false end - if type(creature) ~= "userdata" and type(creature) ~= "table" then return false end - - -- Try the most basic operation possible - if this fails, creature is invalid - local ok, id = pcall(function() return creature:getId() end) - if not ok or not id then return false end - - -- Check validation cache - local nowt = nowMs() - local cached = validatedCreatures[id] - if cached and (nowt - cached.time) < validatedCreaturesTTL then - return cached.valid - end - - -- Perform full validation - try to access position (critical method) - local okPos, pos = pcall(function() return creature:getPosition() end) - local valid = okPos and pos ~= nil - - -- Cache result - validatedCreatures[id] = { valid = valid, time = nowt } - - -- Cleanup old cache entries periodically - if math.random(1, 50) == 1 then - for cid, data in pairs(validatedCreatures) do - if (nowt - data.time) > validatedCreaturesTTL * 10 then - validatedCreatures[cid] = nil - end - end - end - - return valid -end - --- Safely call a method on a creature, returning default if it fails --- This wraps the entire call including method lookup in pcall -local function safeCreatureCall(creature, methodName, default) - if not creature then return default end - - local ok, result = pcall(function() - local method = creature[methodName] - if not method then return nil end - return method(creature) - end) - - if ok then - return result ~= nil and result or default - else - return default - end -end - --- Safely get creature ID (most common operation) -local function safeGetId(creature) - if not creature then return nil end - local ok, id = pcall(function() return creature:getId() end) - return ok and id or nil -end --- Safely check if creature is dead -local function safeIsDead(creature) - if not creature then return true end - local ok, dead = pcall(function() return creature:isDead() end) - return ok and dead or true -end - --- Safely check if creature is a monster -local function safeIsMonster(creature) - if not creature then return false end - local ok, monster = pcall(function() return creature:isMonster() end) - return ok and monster or false -end - --- Safely check if creature is removed -local function safeIsRemoved(creature) - if not creature then return true end - local ok, removed = pcall(function() return creature:isRemoved() end) - if not ok then return true end - return removed or false -end - --- Combined safe check: is the creature a valid, alive monster? -local function isValidAliveMonster(creature) - if not creature then return false end - - local ok, result = pcall(function() - return creature:isMonster() and not creature:isDead() and not creature:isRemoved() - end) - - return ok and result or false -end +local isCreatureValid = MonsterAI._helpers.isCreatureValid +local safeCreatureCall = MonsterAI._helpers.safeCreatureCall +local safeGetId = MonsterAI._helpers.safeGetId +local safeIsDead = MonsterAI._helpers.safeIsDead +local safeIsMonster = MonsterAI._helpers.safeIsMonster +local safeIsRemoved = MonsterAI._helpers.safeIsRemoved +local isValidAliveMonster = MonsterAI._helpers.isValidAliveMonster -- Extended telemetry defaults MonsterAI.COLLECT_EXTENDED = (MonsterAI.COLLECT_EXTENDED == nil) and true or MonsterAI.COLLECT_EXTENDED @@ -164,209 +71,27 @@ MonsterAI.DPS_WINDOW = MonsterAI.DPS_WINDOW or 5000 -- ms window for DPS calcula MonsterAI.AUTO_TUNE_ENABLED = (MonsterAI.AUTO_TUNE_ENABLED == nil) and true or MonsterAI.AUTO_TUNE_ENABLED MonsterAI.TELEMETRY_INTERVAL = MonsterAI.TELEMETRY_INTERVAL or 200 -- ms between telemetry samples --- ============================================================================ --- VOLUME ADAPTATION MODULE (NEW in v2.2) --- Automatically adjusts processing parameters based on monster count --- Optimizes CPU usage while maintaining responsiveness --- ============================================================================ - -MonsterAI.VolumeAdaptation = MonsterAI.VolumeAdaptation or { - -- Current adaptation state - currentVolume = "normal", -- "low", "normal", "high", "extreme" - lastVolumeChange = 0, - - -- Volume thresholds - THRESHOLDS = { - LOW = 2, -- 1-2 monsters = low volume - NORMAL = 5, -- 3-5 monsters = normal - HIGH = 10, -- 6-10 monsters = high - EXTREME = 15 -- 11+ monsters = extreme - }, - - -- Adaptation parameters per volume level - PARAMS = { - low = { - description = "Few monsters - high precision mode", - telemetryInterval = 100, -- ms (more frequent sampling) - threatCacheTTL = 50, -- ms (fresher cache) - updatePriority = "precision", -- Focus on accuracy - ewmaAlpha = 0.35, -- More responsive EWMA - minSamplesForPrediction = 3, - maxTrackedPerCycle = 10 - }, - normal = { - description = "Normal load - balanced mode", - telemetryInterval = 200, - threatCacheTTL = 100, - updatePriority = "balanced", - ewmaAlpha = 0.25, - minSamplesForPrediction = 5, - maxTrackedPerCycle = 8 - }, - high = { - description = "Many monsters - efficiency mode", - telemetryInterval = 350, - threatCacheTTL = 150, - updatePriority = "efficiency", - ewmaAlpha = 0.20, - minSamplesForPrediction = 7, - maxTrackedPerCycle = 6 - }, - extreme = { - description = "Overload - survival mode", - telemetryInterval = 500, - threatCacheTTL = 200, - updatePriority = "survival", - ewmaAlpha = 0.15, -- Smoother, less CPU - minSamplesForPrediction = 10, - maxTrackedPerCycle = 4 -- Process fewer per cycle - } - }, - - -- Performance metrics - metrics = { - volumeChanges = 0, - avgMonsterCount = 0, - peakMonsterCount = 0, - adaptationsSaved = 0 -- Estimated CPU cycles saved - } -} - --- Determine volume level from monster count -function MonsterAI.VolumeAdaptation.getVolumeLevel(monsterCount) - local th = MonsterAI.VolumeAdaptation.THRESHOLDS - if monsterCount <= th.LOW then - return "low" - elseif monsterCount <= th.NORMAL then - return "normal" - elseif monsterCount <= th.HIGH then - return "high" - else - return "extreme" - end -end - --- Get current adaptation parameters -function MonsterAI.VolumeAdaptation.getParams() - local volume = MonsterAI.VolumeAdaptation.currentVolume - return MonsterAI.VolumeAdaptation.PARAMS[volume] or MonsterAI.VolumeAdaptation.PARAMS.normal -end - --- Update volume state based on current monster count -function MonsterAI.VolumeAdaptation.update() - local va = MonsterAI.VolumeAdaptation - local nowt = nowMs() - - -- Count currently tracked monsters - local monsterCount = 0 - if MonsterAI.Tracker and MonsterAI.Tracker.monsters then - for _ in pairs(MonsterAI.Tracker.monsters) do - monsterCount = monsterCount + 1 - end - end - - -- Update metrics - va.metrics.avgMonsterCount = (va.metrics.avgMonsterCount or 0) * 0.95 + monsterCount * 0.05 - if monsterCount > (va.metrics.peakMonsterCount or 0) then - va.metrics.peakMonsterCount = monsterCount - end - - -- Determine new volume level - local newVolume = va.getVolumeLevel(monsterCount) - - -- Apply hysteresis to prevent rapid switching - -- Only change if we've been in current state for at least 500ms - if newVolume ~= va.currentVolume then - if (nowt - (va.lastVolumeChange or 0)) > 500 then - local oldVolume = va.currentVolume - va.currentVolume = newVolume - va.lastVolumeChange = nowt - va.metrics.volumeChanges = (va.metrics.volumeChanges or 0) + 1 - - -- Apply new parameters - local params = va.PARAMS[newVolume] - if params then - -- Update global settings - MonsterAI.TELEMETRY_INTERVAL = params.telemetryInterval - - -- Update EWMA alpha in constants - if MonsterAI.CONSTANTS and MonsterAI.CONSTANTS.EWMA then - MonsterAI.CONSTANTS.EWMA.ALPHA_DEFAULT = params.ewmaAlpha - end - - -- Update threat cache TTL - if MonsterAI.CONSTANTS and MonsterAI.CONSTANTS.EVENT_DRIVEN then - MonsterAI.CONSTANTS.EVENT_DRIVEN.THREAT_CACHE_TTL = params.threatCacheTTL - end - end - - -- Emit volume change event - if EventBus and EventBus.emit then - EventBus.emit("monsterai:volume_changed", { - oldVolume = oldVolume, - newVolume = newVolume, - monsterCount = monsterCount, - params = params - }) - end - - if MonsterAI.DEBUG then - print(string.format("[MonsterAI] Volume adapted: %s -> %s (%d monsters) - %s", - oldVolume, newVolume, monsterCount, params and params.description or "")) - end - end - end - - return va.currentVolume, va.getParams() -end - --- Check if we should process this monster in current cycle (load balancing) -function MonsterAI.VolumeAdaptation.shouldProcessMonster(monsterId) - local va = MonsterAI.VolumeAdaptation - local params = va.getParams() - - -- In low/normal volume, always process - if va.currentVolume == "low" or va.currentVolume == "normal" then - return true - end - - -- In high/extreme, use round-robin based on monster ID - -- This distributes processing across multiple cycles - local cycleNumber = math.floor(nowMs() / 100) -- 100ms cycles - local hash = monsterId % (params.maxTrackedPerCycle * 2) - local slot = cycleNumber % (params.maxTrackedPerCycle * 2) - - return hash <= slot and hash > (slot - params.maxTrackedPerCycle) -end - --- Get volume adaptation stats for UI -function MonsterAI.VolumeAdaptation.getStats() - local va = MonsterAI.VolumeAdaptation - return { - currentVolume = va.currentVolume, - params = va.getParams(), - metrics = va.metrics - } -end -- ============================================================================ --- REAL-TIME EVENT-DRIVEN STATE (O(1) lookups) +-- REALTIME THREAT DETECTION MODULE +-- Direction tracking, threat cache, prediction queue -- ============================================================================ + MonsterAI.RealTime = MonsterAI.RealTime or { -- Direction tracking: id -> {dir, lastChangeTime, consecutiveChanges, turnRate} directions = {}, - + -- Threat level cache: refreshed on every direction/position change threatCache = { lastUpdate = 0, totalThreat = 0, - highThreatMonsters = {}, -- monsters facing player - immediateThreat = false -- true if any monster about to attack + highThreatMonsters = {}, + immediateThreat = false }, - + -- Attack prediction queue: sorted by predicted attack time predictedAttacks = {}, - + -- Performance metrics metrics = { eventsProcessed = 0, @@ -378,10 +103,8 @@ MonsterAI.RealTime = MonsterAI.RealTime or { } } --- ============================================================================ -- EXTENDED TELEMETRY MODULE (OTClient API Integration) -- Collects rich creature data for analysis and auto-tuning --- ============================================================================ MonsterAI.Telemetry = MonsterAI.Telemetry or { -- Per-creature extended data: id -> telemetry snapshot @@ -489,8 +212,7 @@ function MonsterAI.Telemetry.collectSnapshot(creature) -- Calculate distance from player (safely) local playerPos = nil if player then - local okPlayer, pPos = pcall(function() return player:getPosition() end) - if okPlayer then playerPos = pPos end + playerPos = SC.getPosition(player) end if playerPos and snapshot.position then @@ -528,10 +250,8 @@ function MonsterAI.Telemetry.collectSnapshot(creature) return snapshot end --- ============================================================================ -- METRICS AGGREGATOR (NEW in v2.2) -- Centralized metrics collection for analysis and debugging --- ============================================================================ MonsterAI.Metrics = MonsterAI.Metrics or { -- Aggregate metrics across all subsystems @@ -707,10 +427,8 @@ function MonsterAI.Metrics.getSummary() } end --- ============================================================================ -- PER-CHARACTER METRICS PERSISTENCE (via UnifiedStorage) -- Cumulative metrics survive across sessions for each character. --- ============================================================================ local METRICS_KEY = "targetbot.monsterMetrics.aggregate" local TYPE_STATS_KEY = "targetbot.monsterMetrics.typeStats" @@ -871,39 +589,39 @@ function MonsterAI.Telemetry.getTypeSummary(name) return MonsterAI.Telemetry.typeStats[name:lower()] end --- ============================================================================ -- CONST alias for remaining code (full definition in monster_ai_core.lua) --- ============================================================================ local CONST = MonsterAI.CONSTANTS +-- ============================================================================ +-- REALTIME METHODS +-- ============================================================================ + function MonsterAI.RealTime.onDirectionChange(creature, oldDir, newDir) if not creature then return end if not isCreatureValid(creature) then return end local id = safeGetId(creature) if not id then return end - + local nowt = nowMs() local rt = MonsterAI.RealTime.directions[id] - + if not rt then rt = { dir = newDir, lastChangeTime = nowt, consecutiveChanges = 0, turnRate = 0, positions = {} } MonsterAI.RealTime.directions[id] = rt end - - -- Calculate turn rate (changes per second) + local dt = nowt - (rt.lastChangeTime or nowt) if dt > 0 and dt < CONST.EVENT_DRIVEN.TURN_RATE_WINDOW then - rt.turnRate = rt.turnRate * 0.7 + (1000 / dt) * 0.3 -- EWMA + rt.turnRate = rt.turnRate * 0.7 + (1000 / dt) * 0.3 rt.consecutiveChanges = rt.consecutiveChanges + 1 else rt.consecutiveChanges = 1 rt.turnRate = 0 end - + rt.dir = newDir rt.lastChangeTime = nowt - - -- Check if now facing player (immediate threat signal) + local playerPos = nil if player then local okP, pPos = pcall(function() return player:getPosition() end) @@ -911,15 +629,10 @@ function MonsterAI.RealTime.onDirectionChange(creature, oldDir, newDir) end local monsterPos = safeCreatureCall(creature, "getPosition", nil) if playerPos and monsterPos then - local isFacing = MonsterAI.Predictor.isFacingPosition(monsterPos, newDir, playerPos) - + local isFacing = MonsterAI.Predictor and MonsterAI.Predictor.isFacingPosition and MonsterAI.Predictor.isFacingPosition(monsterPos, newDir, playerPos) if isFacing then - -- Monster just turned to face player - potential attack incoming rt.facingPlayerSince = nowt - - -- High turn rate + now facing = high threat if rt.consecutiveChanges >= CONST.EVENT_DRIVEN.CONSECUTIVE_TURNS_ALERT then - -- Emit immediate threat event MonsterAI.RealTime.registerImmediateThreat(creature, "direction_lock", 0.75 + rt.turnRate * 0.05) else MonsterAI.RealTime.registerImmediateThreat(creature, "facing", 0.50) @@ -928,49 +641,38 @@ function MonsterAI.RealTime.onDirectionChange(creature, oldDir, newDir) rt.facingPlayerSince = nil end end - + MonsterAI.RealTime.metrics.eventsProcessed = (MonsterAI.RealTime.metrics.eventsProcessed or 0) + 1 end --- Register an immediate threat from a monster function MonsterAI.RealTime.registerImmediateThreat(creature, reason, confidence) if not creature then return end if not isCreatureValid(creature) then return end local id = safeGetId(creature) if not id then return end - + local nowt = nowMs() local pos = safeCreatureCall(creature, "getPosition", nil) local dir = safeCreatureCall(creature, "getDirection", 0) - - -- Get learned cooldown for prediction - local data = MonsterAI.Tracker.monsters[id] - local pattern = MonsterAI.Patterns.get(safeCreatureCall(creature, "getName", "Unknown")) + + local data = MonsterAI.Tracker and MonsterAI.Tracker.monsters and MonsterAI.Tracker.monsters[id] + local pattern = MonsterAI.Patterns and MonsterAI.Patterns.get and MonsterAI.Patterns.get(safeCreatureCall(creature, "getName", "Unknown")) local cooldown = (data and data.ewmaCooldown) or (pattern and pattern.waveCooldown) or 2000 - - -- Calculate time to attack based on cooldown and last attack + local lastAttack = (data and data.lastWaveTime) or (data and data.lastAttackTime) or 0 local elapsed = nowt - lastAttack local timeToAttack = math.max(0, cooldown - elapsed) - - -- If cooldown is almost up and facing player, this is very high threat + if timeToAttack < CONST.EVENT_DRIVEN.IMMEDIATE_THREAT_WINDOW then confidence = math.min(0.95, confidence + 0.25) end - - -- Add to prediction queue + local prediction = { - id = id, - creature = creature, - pos = pos, - dir = dir, - reason = reason, - confidence = confidence, - predictedTime = nowt + timeToAttack, - registeredAt = nowt + id = id, creature = creature, pos = pos, dir = dir, + reason = reason, confidence = confidence, + predictedTime = nowt + timeToAttack, registeredAt = nowt } - - -- Insert sorted by predicted time + local queue = MonsterAI.RealTime.predictedAttacks local inserted = false for i = 1, #queue do @@ -980,40 +682,24 @@ function MonsterAI.RealTime.registerImmediateThreat(creature, reason, confidence break end end - if not inserted then - table.insert(queue, prediction) - end - - -- Cap queue size - while #queue > 20 do - table.remove(queue) - end - - -- Emit event for immediate avoidance + if not inserted then table.insert(queue, prediction) end + + while #queue > 20 do table.remove(queue) end + if confidence >= CONST.EVENT_DRIVEN.FACING_PLAYER_THRESHOLD and EventBus then pcall(function() EventBus.emit("monsterai/threat_detected", creature, { - reason = reason, - confidence = confidence, - timeToAttack = timeToAttack, - pos = pos, - dir = dir + reason = reason, confidence = confidence, timeToAttack = timeToAttack, pos = pos, dir = dir }) end) - - -- Also register intent with MovementCoordinator if high confidence if confidence >= 0.65 and MovementCoordinator and MovementCoordinator.Intent then local playerPos = player and player:getPosition() if playerPos and pos then - -- Find safe tile away from attack arc local safeTile = MonsterAI.RealTime.findSafeTileFromArc(playerPos, pos, dir, pattern) if safeTile then MovementCoordinator.Intent.register( - MovementCoordinator.CONSTANTS.INTENT.WAVE_AVOIDANCE, - safeTile, - confidence, - "MonsterAI.RealTime", - { reason = reason, timeToAttack = timeToAttack, source = id } + MovementCoordinator.CONSTANTS.INTENT.WAVE_AVOIDANCE, safeTile, confidence, + "MonsterAI.RealTime", { reason = reason, timeToAttack = timeToAttack, source = id } ) end end @@ -1021,139 +707,87 @@ function MonsterAI.RealTime.registerImmediateThreat(creature, reason, confidence end end --- Find safe tile outside of monster's attack arc function MonsterAI.RealTime.findSafeTileFromArc(playerPos, monsterPos, monsterDir, pattern) if not playerPos or not monsterPos then return nil end - local range = (pattern and pattern.waveRange) or 5 local width = (pattern and pattern.waveWidth) or 1 - - -- Direction vectors local dirVecs = { [0] = {x=0, y=-1}, [1] = {x=1, y=-1}, [2] = {x=1, y=0}, [3] = {x=1, y=1}, [4] = {x=0, y=1}, [5] = {x=-1, y=1}, [6] = {x=-1, y=0}, [7] = {x=-1, y=-1} } - local dirVec = dirVecs[monsterDir] or {x=0, y=0} - - -- Get perpendicular directions (safe directions) local perpX, perpY = -dirVec.y, dirVec.x - local candidates = {} - -- Check tiles perpendicular to attack direction + for dist = 1, 2 do for _, mult in ipairs({1, -1}) do - local tile = { - x = playerPos.x + perpX * dist * mult, - y = playerPos.y + perpY * dist * mult, - z = playerPos.z - } - - -- Verify not in attack arc - if not MonsterAI.Predictor.isPositionInWavePath(tile, monsterPos, monsterDir, range, width) then - -- Check walkability + local tile = { x = playerPos.x + perpX * dist * mult, y = playerPos.y + perpY * dist * mult, z = playerPos.z } + local inWave = MonsterAI.Predictor and MonsterAI.Predictor.isPositionInWavePath and MonsterAI.Predictor.isPositionInWavePath(tile, monsterPos, monsterDir, range, width) + if not inWave then + local Client = getClient and getClient() local walkable = true - local Client = getClient() if Client and Client.isTileWalkable then - local ok, result = pcall(Client.isTileWalkable, tile) - walkable = ok and result - elseif Client and Client.getTile then - local ok, mapTile = pcall(Client.getTile, tile) - walkable = ok and mapTile and mapTile:isWalkable() - elseif g_map and g_map.isTileWalkable then - local ok, result = pcall(g_map.isTileWalkable, tile) - walkable = ok and result + local ok, r = pcall(Client.isTileWalkable, tile); walkable = ok and r elseif g_map and g_map.getTile then - local ok, mapTile = pcall(g_map.getTile, tile) - walkable = ok and mapTile and mapTile:isWalkable() + local ok, t = pcall(g_map.getTile, tile); walkable = ok and t and t:isWalkable() end - if walkable then - -- Score by distance from monster (further = safer) - local distFromMonster = math.abs(tile.x - monsterPos.x) + math.abs(tile.y - monsterPos.y) - table.insert(candidates, { pos = tile, score = distFromMonster }) + table.insert(candidates, { pos = tile, score = math.abs(tile.x - monsterPos.x) + math.abs(tile.y - monsterPos.y) }) end end end end - - -- Also try diagonal escapes + for dx = -1, 1 do for dy = -1, 1 do if dx ~= 0 or dy ~= 0 then local tile = { x = playerPos.x + dx, y = playerPos.y + dy, z = playerPos.z } - if not MonsterAI.Predictor.isPositionInWavePath(tile, monsterPos, monsterDir, range, width) then + local inWave = MonsterAI.Predictor and MonsterAI.Predictor.isPositionInWavePath and MonsterAI.Predictor.isPositionInWavePath(tile, monsterPos, monsterDir, range, width) + if not inWave then + local Client2 = getClient and getClient() local walkable = true - local Client2 = getClient() if Client2 and Client2.getTile then - local ok, mapTile = pcall(Client2.getTile, tile) - walkable = ok and mapTile and mapTile:isWalkable() + local ok, t = pcall(Client2.getTile, tile); walkable = ok and t and t:isWalkable() elseif g_map and g_map.getTile then - local ok, mapTile = pcall(g_map.getTile, tile) - walkable = ok and mapTile and mapTile:isWalkable() + local ok, t = pcall(g_map.getTile, tile); walkable = ok and t and t:isWalkable() end if walkable then - local distFromMonster = math.abs(tile.x - monsterPos.x) + math.abs(tile.y - monsterPos.y) - table.insert(candidates, { pos = tile, score = distFromMonster + 0.5 }) -- Slight penalty vs perpendicular + table.insert(candidates, { pos = tile, score = math.abs(tile.x - monsterPos.x) + math.abs(tile.y - monsterPos.y) + 0.5 }) end end end end end - - -- Sort by score (higher = better) + table.sort(candidates, function(a, b) return a.score > b.score end) - return candidates[1] and candidates[1].pos or nil end --- Update threat cache (called periodically or on significant events) function MonsterAI.RealTime.updateThreatCache() local nowt = nowMs() local cache = MonsterAI.RealTime.threatCache - - -- Skip if recently updated - if (nowt - cache.lastUpdate) < CONST.EVENT_DRIVEN.THREAT_CACHE_TTL then - return cache - end - + if (nowt - cache.lastUpdate) < CONST.EVENT_DRIVEN.THREAT_CACHE_TTL then return cache end local playerPos = player and player:getPosition() if not playerPos then return cache end - - cache.totalThreat = 0 - cache.highThreatMonsters = {} - cache.immediateThreat = false - - -- Check all tracked directions for monsters facing player + cache.totalThreat = 0; cache.highThreatMonsters = {}; cache.immediateThreat = false for id, rt in pairs(MonsterAI.RealTime.directions) do - local data = MonsterAI.Tracker.monsters[id] + local data = MonsterAI.Tracker and MonsterAI.Tracker.monsters and MonsterAI.Tracker.monsters[id] if data and data.creature and not safeIsDead(data.creature) then local monsterPos = safeCreatureCall(data.creature, "getPosition", nil) if monsterPos and monsterPos.z == playerPos.z then local dist = math.max(math.abs(monsterPos.x - playerPos.x), math.abs(monsterPos.y - playerPos.y)) - - if dist <= 7 then -- Within threat range - local isFacing = MonsterAI.Predictor.isFacingPosition(monsterPos, rt.dir, playerPos) + if dist <= 7 then + local isFacing = MonsterAI.Predictor and MonsterAI.Predictor.isFacingPosition and MonsterAI.Predictor.isFacingPosition(monsterPos, rt.dir, playerPos) if isFacing then - local pattern = MonsterAI.Patterns.get(data.name or "") + local pattern = MonsterAI.Patterns and MonsterAI.Patterns.get and MonsterAI.Patterns.get(data.name or "") local cooldown = data.ewmaCooldown or (pattern and pattern.waveCooldown) or 2000 local lastAttack = data.lastWaveTime or data.lastAttackTime or 0 local elapsed = nowt - lastAttack local timeToAttack = math.max(0, cooldown - elapsed) - - local threat = { - id = id, - creature = data.creature, - timeToAttack = timeToAttack, - confidence = data.confidence or 0.5, - turnRate = rt.turnRate or 0 - } - + local threat = { id = id, creature = data.creature, timeToAttack = timeToAttack, confidence = data.confidence or 0.5, turnRate = rt.turnRate or 0 } table.insert(cache.highThreatMonsters, threat) - if timeToAttack < CONST.EVENT_DRIVEN.IMMEDIATE_THREAT_WINDOW then - cache.immediateThreat = true - cache.totalThreat = cache.totalThreat + 1.5 + cache.immediateThreat = true; cache.totalThreat = cache.totalThreat + 1.5 else cache.totalThreat = cache.totalThreat + 0.5 end @@ -1162,39 +796,29 @@ function MonsterAI.RealTime.updateThreatCache() end end end - - cache.lastUpdate = nowt - return cache + cache.lastUpdate = nowt; return cache end --- Clean up stale direction tracking function MonsterAI.RealTime.cleanup() local nowt = nowMs() - local staleThreshold = 10000 -- 10 seconds - for id, rt in pairs(MonsterAI.RealTime.directions) do - if (nowt - (rt.lastChangeTime or 0)) > staleThreshold then + if (nowt - (rt.lastChangeTime or 0)) > 10000 then MonsterAI.RealTime.directions[id] = nil end end - - -- Clean prediction queue local queue = MonsterAI.RealTime.predictedAttacks local i = 1 while i <= #queue do - if (nowt - queue[i].registeredAt) > 5000 then - table.remove(queue, i) - else - i = i + 1 - end + if (nowt - queue[i].registeredAt) > 5000 then table.remove(queue, i) + else i = i + 1 end end end --- ============================================================================ +MonsterAI.RealTime.getImmediateThreat = MonsterAI.getImmediateThreat + -- GET IMMEDIATE THREAT (Pure function for wave avoidance integration) -- Returns a threat assessment suitable for instant decision-making -- @return table { immediateThreat: boolean, totalThreat: number, threatCount: number, highestConfidence: number } --- ============================================================================ function MonsterAI.getImmediateThreat() local nowt = nowMs() local result = { @@ -1265,15 +889,11 @@ function MonsterAI.getImmediateThreat() end -- Alias for backward compatibility -MonsterAI.RealTime.getImmediateThreat = MonsterAI.getImmediateThreat - --- Guard: returns true when TargetBot is disabled (used by EventBus handlers) -local function tbOff() return not TargetBot or not TargetBot.isOn or not TargetBot.isOn() end if EventBus then -- Track monsters when they appear EventBus.on("monster:appear", function(creature) - if tbOff() then return end + if TargetBot.isOff() then return end MonsterAI.Tracker.track(creature) -- Initialize direction tracking immediately @@ -1290,8 +910,7 @@ if EventBus then -- Check if already facing player (instant threat check) local playerPos = nil if player then - local okP, pPos = pcall(function() return player:getPosition() end) - if okP then playerPos = pPos end + playerPos = SC.getPosition(player) end local monsterPos = safeCreatureCall(creature, "getPosition", nil) if playerPos and monsterPos then @@ -1309,7 +928,7 @@ if EventBus then -- Untrack monsters when they disappear EventBus.on("monster:disappear", function(creature) - if tbOff() then return end + if TargetBot.isOff() then return end if creature then local id = safeGetId(creature) if id then @@ -1329,7 +948,7 @@ if EventBus then -- CRITICAL: Direction change detection (primary wave anticipation) EventBus.on("creature:move", function(creature, oldPos) - if tbOff() then return end + if TargetBot.isOff() then return end if not creature then return end if not safeIsMonster(creature) then return end @@ -1361,7 +980,7 @@ if EventBus then -- Update tracking on monster health change (potential attack indicator) EventBus.on("monster:health", function(creature, percent) - if tbOff() then return end + if TargetBot.isOff() then return end if creature then MonsterAI.Tracker.update(creature) @@ -1378,7 +997,7 @@ if EventBus then -- Record when player takes damage (learning opportunity) EventBus.on("player:damage", function(damage, source) - if tbOff() then return end + if TargetBot.isOff() then return end MonsterAI.Tracker.stats.totalDamageReceived = MonsterAI.Tracker.stats.totalDamageReceived + damage @@ -1386,8 +1005,7 @@ if EventBus then local nowt = nowMs() local playerPos = nil if player then - local okP, pPos = pcall(function() return player:getPosition() end) - if okP then playerPos = pPos end + playerPos = SC.getPosition(player) end if not playerPos then return end @@ -1413,10 +1031,7 @@ if EventBus then return score, data end - local Client = getClient() - local creatures = (MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby) - and MovementCoordinator.MonsterCache.getNearby(CONST.DAMAGE.CORRELATION_RADIUS) - or ((Client and Client.getSpectatorsInRange) and Client.getSpectatorsInRange(playerPos, false, CONST.DAMAGE.CORRELATION_RADIUS, CONST.DAMAGE.CORRELATION_RADIUS) or (g_map and g_map.getSpectatorsInRange and g_map.getSpectatorsInRange(playerPos, false, CONST.DAMAGE.CORRELATION_RADIUS, CONST.DAMAGE.CORRELATION_RADIUS))) + local creatures = BotCore.Creatures.getNearby(CONST.DAMAGE.CORRELATION_RADIUS) or {} local bestScore, bestData, bestMonster = 0, nil, nil for i = 1, #creatures do @@ -1568,7 +1183,7 @@ if EventBus then -- Also listen for effect:missile EventBus event for additional coverage if EventBus then EventBus.on("effect:missile", function(missile) - if tbOff() then return end + if TargetBot.isOff() then return end if not missile then return end local srcPos = missile.getSource and missile:getSource() @@ -1652,8 +1267,7 @@ if EventBus then -- Check if now facing player local playerPos = nil if player then - local okP, pPos = pcall(function() return player:getPosition() end) - if okP then playerPos = pPos end + playerPos = SC.getPosition(player) end local monsterPos = safeCreatureCall(creature, "getPosition", nil) if playerPos and monsterPos then @@ -1697,7 +1311,7 @@ if EventBus then -- PLAYER ATTACK EVENT - Track engagement with monsters -- ═══════════════════════════════════════════════════════════════════════════ EventBus.on("player:attack", function(target) - if tbOff() then return end + if TargetBot.isOff() then return end if not target then return end if not safeIsMonster(target) then return end @@ -1729,7 +1343,7 @@ if EventBus then -- CREATURE DEATH EVENT - Finalize tracking and collect kill stats -- ═══════════════════════════════════════════════════════════════════════════ EventBus.on("creature:death", function(creature) - if tbOff() then return end + if TargetBot.isOff() then return end if not creature then return end if not safeIsMonster(creature) then return end @@ -1783,7 +1397,7 @@ if EventBus then -- PLAYER DEATH EVENT - Track session death stats -- ═══════════════════════════════════════════════════════════════════════════ EventBus.on("player:death", function() - if tbOff() then return end + if TargetBot.isOff() then return end local nowt = nowMs() MonsterAI.Telemetry.session.deathCount = (MonsterAI.Telemetry.session.deathCount or 0) + 1 @@ -1795,8 +1409,7 @@ if EventBus then local pos = safeCreatureCall(data.creature, "getPosition", nil) local playerPos = nil if player then - local okP, pPos = pcall(function() return player:getPosition() end) - if okP then playerPos = pPos end + playerPos = SC.getPosition(player) end if pos and playerPos then local dist = math.max(math.abs(pos.x - playerPos.x), math.abs(pos.y - playerPos.y)) @@ -1832,7 +1445,7 @@ if EventBus then -- SPELL CAST EVENT - Track player damage output for kill time calculation -- ═══════════════════════════════════════════════════════════════════════════ EventBus.on("player:spell", function(spellName, target) - if tbOff() then return end + if TargetBot.isOff() then return end if not target or not target:isMonster() then return end local id = target:getId() @@ -1849,7 +1462,7 @@ if EventBus then -- COMBAT STATUS CHANGES - Track when entering/leaving combat -- ═══════════════════════════════════════════════════════════════════════════ EventBus.on("player:combat_start", function() - if tbOff() then return end + if TargetBot.isOff() then return end local nowt = nowMs() MonsterAI.Telemetry.session.lastCombatStart = nowt @@ -1874,7 +1487,7 @@ if EventBus then end, 20) EventBus.on("player:combat_end", function() - if tbOff() then return end + if TargetBot.isOff() then return end local nowt = nowMs() local combatStart = MonsterAI.Telemetry.session.lastCombatStart or nowt local combatDuration = nowt - combatStart @@ -1891,9 +1504,7 @@ if EventBus then end, 20) end --- ============================================================================ -- PERIODIC UPDATE (Enhanced with RealTime threat processing) --- ============================================================================ -- Update all tracked monsters periodically function MonsterAI.updateAll() @@ -1910,30 +1521,7 @@ function MonsterAI.updateAll() pcall(function() MonsterAI.VolumeAdaptation.update() end) end - -- OPTIMIZED: Prefer MonsterCache for O(1) cached creature lookup - -- This avoids expensive g_map.getSpectatorsInRange calls - local creatures = nil - if MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby then - creatures = MovementCoordinator.MonsterCache.getNearby(8) - end - - -- Fallback only if MonsterCache is empty or unavailable - if not creatures or #creatures == 0 then - if SpectatorCache and SpectatorCache.getNearby then - creatures = SpectatorCache.getNearby(8, 8) or {} - else - local Client = getClient() - local ok, result = pcall(function() - if Client and Client.getSpectatorsInRange then - return Client.getSpectatorsInRange(playerPos, false, 8, 8) - elseif g_map and g_map.getSpectatorsInRange then - return g_map.getSpectatorsInRange(playerPos, false, 8, 8) - end - return {} - end) - creatures = ok and result or {} - end - end + local creatures = BotCore.Creatures.getNearby(8) or {} if not creatures then return @@ -2031,11 +1619,9 @@ function MonsterAI.updateAll() MonsterAI.lastUpdate = nowMs() end --- ============================================================================ -- PUBLIC API: Real-Time Threat Queries -- NOTE: getImmediateThreat() is defined earlier (after cleanup function) -- for better integration with wave avoidance and prediction queue --- ============================================================================ -- Get prediction accuracy stats function MonsterAI.getPredictionStats() @@ -2115,199 +1701,9 @@ end nExBot = nExBot or {} nExBot.MonsterAI = MonsterAI --- ============================================================================ -- ENHANCED PUBLIC API --- ============================================================================ -- Get full statistics summary for UI or debugging -function MonsterAI.getStatsSummary() - local nowt = nowMs() - local session = MonsterAI.Telemetry.session - local sessionDuration = (nowt - (session.startTime or nowt)) / 1000 -- seconds - - return { - version = MonsterAI.VERSION, - - -- Session stats - session = { - duration = sessionDuration, - monstersTracked = session.totalMonstersTracked or 0, - killCount = session.killCount or 0, - avgKillTime = session.avgKillTime or 0, - damageReceived = MonsterAI.Tracker.stats.totalDamageReceived or 0, - avgDPSReceived = sessionDuration > 0 and (MonsterAI.Tracker.stats.totalDamageReceived or 0) / sessionDuration or 0 - }, - - -- Prediction stats - predictions = MonsterAI.getPredictionStats(), - - -- Tracking stats - tracking = { - activeMonsters = 0, -- Will be counted below - waveAttacksObserved = MonsterAI.Tracker.stats.waveAttacksObserved or 0, - areaAttacksObserved = MonsterAI.Tracker.stats.areaAttacksObserved or 0 - }, - - -- Auto-tuner stats - autoTuner = { - enabled = MonsterAI.AUTO_TUNE_ENABLED, - adjustmentsMade = MonsterAI.RealTime.metrics.autoTuneAdjustments or 0, - pendingSuggestions = 0 -- Will be counted below - }, - - -- Telemetry stats - telemetry = { - samplesCollected = MonsterAI.RealTime.metrics.telemetrySamples or 0, - typesClassified = 0 -- Will be counted below - } - } -end - --- Count active stats -pcall(function() - local stats = MonsterAI.getStatsSummary() - local activeCount = 0 - for id, _ in pairs(MonsterAI.Tracker.monsters) do activeCount = activeCount + 1 end - stats.tracking.activeMonsters = activeCount - - local suggestionCount = 0 - for name, _ in pairs(MonsterAI.AutoTuner.suggestions) do suggestionCount = suggestionCount + 1 end - stats.autoTuner.pendingSuggestions = suggestionCount - - local classifiedCount = 0 - for name, _ in pairs(MonsterAI.Classifier.cache) do classifiedCount = classifiedCount + 1 end - stats.telemetry.typesClassified = classifiedCount -end) - --- Get all classifications -function MonsterAI.getClassifications() - return MonsterAI.Classifier.cache -end - --- Get classification for specific monster -function MonsterAI.getClassification(name) - return MonsterAI.Classifier.get(name) -end - --- Force classification of a monster -function MonsterAI.classifyMonster(name, forceReclassify) - if not name then return nil end - - -- Find tracking data for this monster type - local targetData = nil - for id, data in pairs(MonsterAI.Tracker.monsters) do - if data.name and data.name:lower() == name:lower() then - targetData = data - break - end - end - - if not targetData then - -- No active tracking data, use stored patterns if available - local pattern = MonsterAI.Patterns.get(name) - if pattern then - -- Create minimal data from pattern - targetData = { - name = name, - movementSamples = 20, -- Minimum to trigger classification - stationaryCount = 0, - chaseCount = 10, - facingCount = 5, - waveCount = pattern.waveCount or 0, - avgSpeed = 0 - } - end - end - - if targetData then - return MonsterAI.Classifier.classify(name, targetData) - end - - return nil -end - --- Get danger suggestion for a monster -function MonsterAI.getDangerSuggestion(name) - return MonsterAI.AutoTuner.suggestDanger(name) -end - --- Apply danger suggestion -function MonsterAI.applyDangerSuggestion(name, force) - return MonsterAI.AutoTuner.applyDangerSuggestion(name, force) -end - --- Get all pending suggestions -function MonsterAI.getPendingSuggestions() - return MonsterAI.AutoTuner.getSuggestions() -end - --- Get telemetry for a creature by ID -function MonsterAI.getTelemetry(creatureId) - return MonsterAI.Telemetry.snapshots[creatureId] -end - --- Get type statistics -function MonsterAI.getTypeStats(name) - return MonsterAI.Telemetry.getTypeSummary(name) -end - --- Get all type statistics -function MonsterAI.getAllTypeStats() - return MonsterAI.Telemetry.typeStats -end - --- Reset session statistics -function MonsterAI.resetSession() - MonsterAI.Telemetry.session = { - startTime = nowMs(), - totalMonstersTracked = 0, - totalDamageDealt = 0, - totalDamageReceived = 0, - killCount = 0, - deathCount = 0, - avgKillTime = 0, - avgDPSReceived = 0 - } - - MonsterAI.Tracker.stats = { - waveAttacksObserved = 0, - areaAttacksObserved = 0, - totalDamageReceived = 0, - avoidanceSuccesses = 0, - avoidanceFailures = 0 - } - - MonsterAI.RealTime.metrics = { - eventsProcessed = 0, - predictionsCorrect = 0, - predictionsMissed = 0, - avgPredictionAccuracy = 0, - telemetrySamples = 0, - autoTuneAdjustments = 0 - } - - if MonsterAI.DEBUG then - print("[MonsterAI] Session statistics reset") - end -end - --- ============================================================================ --- ENHANCED EVENTBUS EMISSIONS --- ============================================================================ - --- Emit periodic stats update for interested modules (gated by TargetBot state) -if EventBus then - schedule(5000, function() - local function emitStatsUpdate() - if not tbOff() and EventBus and EventBus.emit then - local stats = MonsterAI.getStatsSummary() - EventBus.emit("monsterai:stats_update", stats) - end - schedule(10000, emitStatsUpdate) -- Every 10 seconds - end - emitStatsUpdate() - end) -end -- Enable automatic collection by default so Monster Insights shows data without console commands -- Collection is now gated by TargetBot.isOn() to prevent CPU waste when targeting is off @@ -2321,10 +1717,8 @@ local function shouldCollect() return true end --- ============================================================================ -- UNIFIED TICK INTEGRATION (Performance: consolidates macro overhead) -- Uses UnifiedTick system if available, falls back to individual macros --- ============================================================================ if UnifiedTick and UnifiedTick.register then -- Periodic background updater (500ms) - NORMAL priority diff --git a/targetbot/monster_ai_core.lua b/targetbot/monster_ai_core.lua index 41f2acd..eb01114 100644 --- a/targetbot/monster_ai_core.lua +++ b/targetbot/monster_ai_core.lua @@ -24,17 +24,14 @@ updateAll, public API, tick registration) ]] --- ============================================================================ -- MODULE NAMESPACE --- ============================================================================ MonsterAI = MonsterAI or {} MonsterAI.VERSION = "3.0" --- ============================================================================ -- CLIENT SERVICE HELPERS (shared aliases) --- ============================================================================ +local SC = SafeCreature or {} local getClient = nExBot.Shared.getClient local getClientVersion = nExBot.Shared.getClientVersion @@ -45,23 +42,24 @@ local nowMs = ClientHelper and ClientHelper.nowMs or function() return os.time() * 1000 end --- ============================================================================ -- SAFE CREATURE VALIDATION (Prevents C++ crashes) -- The OTClient C++ layer can crash even when methods exist if the creature -- object is in an invalid internal state. These helpers prevent that. --- ============================================================================ -- Cache for recently validated creatures to reduce overhead local validatedCreatures = {} local validatedCreaturesTTL = 100 -- ms +-- Safely get creature ID (must be defined before isCreatureValid) +local safeGetId = SafeCreature.getId + -- Check if a creature is valid and safe to call methods on local function isCreatureValid(creature) if not creature then return false end if type(creature) ~= "userdata" and type(creature) ~= "table" then return false end - local ok, id = pcall(function() return creature:getId() end) - if not ok or not id then return false end + local id = safeGetId(creature) + if not id then return false end -- Check validation cache local nowt = nowMs() @@ -71,8 +69,8 @@ local function isCreatureValid(creature) end -- Perform full validation - local okPos, pos = pcall(function() return creature:getPosition() end) - local valid = okPos and pos ~= nil + local pos = SafeCreature.getPosition(creature) + local valid = pos ~= nil -- Cache result validatedCreatures[id] = { valid = valid, time = nowt } @@ -106,50 +104,27 @@ local function safeCreatureCall(creature, methodName, default) end end --- Safely get creature ID -local function safeGetId(creature) - if not creature then return nil end - local ok, id = pcall(function() return creature:getId() end) - return ok and id or nil -end - -- Safely check if creature is dead -local function safeIsDead(creature) - if not creature then return true end - local ok, dead = pcall(function() return creature:isDead() end) - return ok and dead or true -end +local safeIsDead = SafeCreature.isDead -- Safely check if creature is a monster -local function safeIsMonster(creature) - if not creature then return false end - local ok, monster = pcall(function() return creature:isMonster() end) - return ok and monster or false -end +local safeIsMonster = SafeCreature.isMonster -- Safely check if creature is removed local function safeIsRemoved(creature) if not creature then return true end - local ok, removed = pcall(function() return creature:isRemoved() end) - if not ok then return true end + local removed = SafeCreature.isRemoved(creature) return removed or false end -- Combined safe check: is the creature a valid, alive monster? local function isValidAliveMonster(creature) - if not creature then return false end - - local ok, result = pcall(function() - return creature:isMonster() and not creature:isDead() and not creature:isRemoved() - end) - - return ok and result or false + if not creature or not SC then return false end + return SC.isMonster(creature) and not SC.isDead(creature) and not SC.isRemoved(creature) end --- ============================================================================ -- EXPORT HELPERS AS MODULE-LEVEL GLOBALS FOR OTHER MonsterAI FILES -- These are used by monster_tracking, monster_prediction, etc. --- ============================================================================ MonsterAI._helpers = { getClient = getClient, @@ -164,9 +139,7 @@ MonsterAI._helpers = { isValidAliveMonster = isValidAliveMonster, } --- ============================================================================ -- CONSTANTS (Shared across all subsystems) --- ============================================================================ MonsterAI.CONSTANTS = { -- Behavior analysis window (in ms) @@ -234,9 +207,7 @@ MonsterAI.CONSTANTS = { } } --- ============================================================================ -- CONFIGURATION FLAGS --- ============================================================================ MonsterAI.COLLECT_EXTENDED = (MonsterAI.COLLECT_EXTENDED == nil) and true or MonsterAI.COLLECT_EXTENDED MonsterAI.DPS_WINDOW = MonsterAI.DPS_WINDOW or 5000 diff --git a/targetbot/monster_combat_feedback.lua b/targetbot/monster_combat_feedback.lua index ea2f34a..38643e3 100644 --- a/targetbot/monster_combat_feedback.lua +++ b/targetbot/monster_combat_feedback.lua @@ -17,9 +17,7 @@ local TrimArray = TrimArray local H = MonsterAI._helpers local nowMs = H.nowMs --- ============================================================================ -- COMBAT FEEDBACK STATE --- ============================================================================ MonsterAI.CombatFeedback = MonsterAI.CombatFeedback or { predictions = { @@ -53,9 +51,7 @@ MonsterAI.CombatFeedback = MonsterAI.CombatFeedback or { PREDICTION_WINDOW = 2000 } --- ============================================================================ -- RECORDING --- ============================================================================ function MonsterAI.CombatFeedback.recordPrediction(monsterId, monsterName, predictedTime, confidence) local nowt = nowMs() @@ -107,9 +103,7 @@ function MonsterAI.CombatFeedback.recordDamage(amount, attributedMonsterId, attr fb.updateAccuracyMetrics() end --- ============================================================================ -- TIMEOUT CHECKS (false positives) --- ============================================================================ function MonsterAI.CombatFeedback.checkTimeouts() local nowt = nowMs() @@ -129,9 +123,7 @@ function MonsterAI.CombatFeedback.checkTimeouts() end end --- ============================================================================ -- ACCURACY METRICS (EWMA) --- ============================================================================ function MonsterAI.CombatFeedback.updateAccuracyMetrics() local fb = MonsterAI.CombatFeedback @@ -158,9 +150,7 @@ function MonsterAI.CombatFeedback.updateAccuracyMetrics() end end --- ============================================================================ -- TARGET SELECTION FEEDBACK --- ============================================================================ function MonsterAI.CombatFeedback.recordTargetSelection(selectedId, wasOptimal) local fb = MonsterAI.CombatFeedback @@ -177,9 +167,7 @@ function MonsterAI.CombatFeedback.recordTargetSelection(selectedId, wasOptimal) end end --- ============================================================================ -- ACCESSORS --- ============================================================================ function MonsterAI.CombatFeedback.getWeights() return MonsterAI.CombatFeedback.weights end function MonsterAI.CombatFeedback.getAccuracy() return MonsterAI.CombatFeedback.accuracy end @@ -195,9 +183,7 @@ function MonsterAI.CombatFeedback.getSummary() } end --- ============================================================================ -- RESET (new hunting session) --- ============================================================================ function MonsterAI.CombatFeedback.reset() local fb = MonsterAI.CombatFeedback diff --git a/targetbot/monster_inspector.lua b/targetbot/monster_inspector.lua index 8c56c03..9efc324 100644 --- a/targetbot/monster_inspector.lua +++ b/targetbot/monster_inspector.lua @@ -62,7 +62,6 @@ local function createWindowIfMissing() -- Ensure it's hidden initially pcall(function() MonsterInspectorWindow:hide() end) - -- Rebind buttons and visibility handlers (same logic as below) -- Setup actual buttons if present - use direct property access (OTClient pattern) local function bindButtons() @@ -170,8 +169,6 @@ local function updateWidgetRefs() end end - - -- Populate refs now (also called again on visibility change) updateWidgetRefs() @@ -809,7 +806,8 @@ nExBot.MonsterInspector.showWindow = function() -- If storage is empty, retry after a short delay to let updater collect samples local patterns = safeUnifiedGet("targetbot.monsterPatterns", {}) - local hasPatterns = patterns and next(patterns) ~= nil + local hasPatterns = false + if patterns then for _ in pairs(patterns) do hasPatterns = true; break end end if not hasPatterns then schedule(500, function() if MonsterAI and MonsterAI.updateAll then pcall(function() MonsterAI.updateAll() end) end @@ -833,7 +831,9 @@ nExBot.MonsterInspector.toggleWindow = function() refreshPatterns() -- Retry shortly if no patterns yet local patterns2 = safeUnifiedGet("targetbot.monsterPatterns", {}) - if not (patterns2 and next(patterns2) ~= nil) then + local has2 = false + if patterns2 then for _ in pairs(patterns2) do has2 = true; break end end + if not has2 then schedule(500, function() if MonsterAI and MonsterAI.updateAll then pcall(function() MonsterAI.updateAll() end) end; refreshPatterns() end) end end @@ -843,4 +843,3 @@ end -- Expose refreshPatterns function nExBot.MonsterInspector.refreshPatterns = refreshPatterns - diff --git a/targetbot/monster_patterns.lua b/targetbot/monster_patterns.lua index 51d8beb..2a91e5b 100644 --- a/targetbot/monster_patterns.lua +++ b/targetbot/monster_patterns.lua @@ -12,18 +12,14 @@ Populates: MonsterAI.Patterns ]] --- ============================================================================ -- HELPERS (from core) --- ============================================================================ local H = MonsterAI._helpers local nowMs = H.nowMs local CONST = MonsterAI.CONSTANTS --- ============================================================================ -- PATTERNS NAMESPACE --- ============================================================================ MonsterAI.Patterns = MonsterAI.Patterns or { knownMonsters = {}, @@ -42,9 +38,7 @@ MonsterAI.Patterns = MonsterAI.Patterns or { } } --- ============================================================================ -- STORAGE HELPERS (UnifiedStorage only — no dual fallback) --- ============================================================================ local function getStoredPatterns() if UnifiedStorage and UnifiedStorage.isReady and UnifiedStorage.isReady() then @@ -62,9 +56,7 @@ local function setStoredPatterns(patterns) end end --- ============================================================================ -- PATTERN API --- ============================================================================ -- Register a known monster pattern function MonsterAI.Patterns.register(monsterName, pattern) @@ -113,9 +105,7 @@ function MonsterAI.savePattern(monsterName) end end --- ============================================================================ -- PATTERN DECAY (reduce confidence of stale patterns) --- ============================================================================ function MonsterAI.decayPatterns() local nowt = nowMs() @@ -132,9 +122,7 @@ function MonsterAI.decayPatterns() setStoredPatterns(patterns) end --- ============================================================================ -- INITIALIZATION — Load persisted patterns --- ============================================================================ local storedPatterns = getStoredPatterns() for k, v in pairs(storedPatterns) do diff --git a/targetbot/monster_prediction.lua b/targetbot/monster_prediction.lua index c308376..f200727 100644 --- a/targetbot/monster_prediction.lua +++ b/targetbot/monster_prediction.lua @@ -10,9 +10,9 @@ Depends on: monster_ai_core.lua, monster_patterns.lua, monster_tracking.lua ]] --- ============================================================================ -- HELPERS (from core) --- ============================================================================ + +local SC = SafeCreature or {} local H = MonsterAI._helpers local nowMs = H.nowMs @@ -23,9 +23,7 @@ local safeIsDead = H.safeIsDead local CONST = MonsterAI.CONSTANTS --- ============================================================================ -- PREDICTOR --- ============================================================================ MonsterAI.Predictor = MonsterAI.Predictor or {} @@ -54,8 +52,7 @@ function MonsterAI.Predictor.predictWaveAttack(creature) local playerPos = nil if player then - local okP, pPos = pcall(function() return player:getPosition() end) - if okP then playerPos = pPos end + playerPos = SC.getPosition(player) end if not playerPos then return false, 0, 999999 end @@ -96,9 +93,7 @@ function MonsterAI.Predictor.predictWaveAttack(creature) return timeToAttack < 500, confidence, timeToAttack end --- ============================================================================ -- DIRECTION HELPERS (pure functions) --- ============================================================================ local FALLBACK_DIRS = { [0] = { x = 0, y = -1 }, @@ -132,9 +127,7 @@ function MonsterAI.Predictor.isFacingPosition(monsterPos, monsterDir, targetPos) end end --- ============================================================================ -- POSITION DANGER ASSESSMENT --- ============================================================================ --- Predict danger level for a position given nearby monsters. -- @return dangerLevel (WAVE_DANGER enum), confidence @@ -215,10 +208,8 @@ function MonsterAI.Predictor.isPositionInWavePath(pos, monsterPos, monsterDir, r end end --- ============================================================================ -- CONFIDENCE SYSTEM -- Aggregates confidence from multiple sources for decision making. --- ============================================================================ MonsterAI.Confidence = MonsterAI.Confidence or {} diff --git a/targetbot/monster_reachability.lua b/targetbot/monster_reachability.lua index f231f7e..907a2c1 100644 --- a/targetbot/monster_reachability.lua +++ b/targetbot/monster_reachability.lua @@ -9,22 +9,19 @@ Populates: MonsterAI.Reachability ]] +local zChanging = nExBot.zChanging or function() return false end local H = MonsterAI._helpers local nowMs = H.nowMs local safeGetId = H.safeGetId local safeIsDead = H.safeIsDead local safeIsRemoved = H.safeIsRemoved --- Guard: returns true when TargetBot is disabled -local function tbOff() return not TargetBot or not TargetBot.isOn or not TargetBot.isOn() end local safeIsMonster = H.safeIsMonster local safeCreatureCall = H.safeCreatureCall local getClient = H.getClient local isCreatureValid = H.isCreatureValid --- ============================================================================ -- STATE --- ============================================================================ MonsterAI.Reachability = MonsterAI.Reachability or {} local R = MonsterAI.Reachability @@ -40,14 +37,9 @@ R.stats = { byReason = { no_path = 0, blocked_tile = 0, elevation = 0, too_far = 0, no_los = 0 } } -local DIR_OFFSETS = { - [0] = {x=0,y=-1}, [1] = {x=1,y=0}, [2] = {x=0,y=1}, [3] = {x=-1,y=0}, - [4] = {x=1,y=-1}, [5] = {x=1,y=1}, [6] = {x=-1,y=1}, [7] = {x=-1,y=-1} -} +local DIR_OFFSETS = Directions.DIR_TO_OFFSET --- ============================================================================ -- CORE CHECK --- ============================================================================ function R.isReachable(creature, forceRecheck) if not creature then return false, "invalid", nil end @@ -74,7 +66,7 @@ function R.isReachable(creature, forceRecheck) R.stats.checksPerformed = R.stats.checksPerformed + 1 - local playerPos = player and (function() local ok,p = pcall(function() return player:getPosition() end); return ok and p end)() + local playerPos = player and SafeCreature.getPosition(player) or nil local creaturePos = safeCreatureCall(creature, "getPosition", nil) if not playerPos or not creaturePos then return R.cacheResult(id, false, "no_position", nil) end @@ -148,9 +140,7 @@ function R.isReachable(creature, forceRecheck) return R.cacheResult(id, true, hasLOS and "clear" or "no_los_melee_ok", result) end --- ============================================================================ -- CACHE / BLOCKED MANAGEMENT --- ============================================================================ function R.cacheResult(id, reachable, reason, path) R.cache[id] = { reachable = reachable, reason = reason, path = path } @@ -179,9 +169,7 @@ function R.cleanup() end end --- ============================================================================ -- BATCH & ACCESSORS --- ============================================================================ function R.filterReachable(creatures) local reach, unreach = {}, {} @@ -217,9 +205,7 @@ function R.getStats() byReason = R.stats.byReason, blockedCount = bc, cacheSize = cc } end --- ============================================================================ -- TICK REGISTRATION --- ============================================================================ if UnifiedTick and UnifiedTick.register then UnifiedTick.register({ id = "monsterai_reachability_cleanup", interval = 10000, @@ -237,14 +223,14 @@ end -- EventBus hooks if EventBus and EventBus.on then EventBus.on("player:position", function(newPos, oldPos) - if tbOff() then return end + if TargetBot.isOff() then return end if oldPos then local d = math.max(math.abs(newPos.x - oldPos.x), math.abs(newPos.y - oldPos.y)) if d > 2 then R.clearCache() end end end) EventBus.on("creature:move", function(creature) - if tbOff() then return end + if TargetBot.isOff() then return end if creature and safeIsMonster(creature) then local id = safeGetId(creature) if id and R.blockedCreatures[id] then R.clearBlocked(id); R.cache[id] = nil; R.cacheTime[id] = nil end diff --git a/targetbot/monster_scenario.lua b/targetbot/monster_scenario.lua index ff9fc77..40dd9da 100644 --- a/targetbot/monster_scenario.lua +++ b/targetbot/monster_scenario.lua @@ -17,6 +17,7 @@ ]] -- BoundedPush/TrimArray are set as globals by utils/ring_buffer.lua (Phase 3) +local zChanging = nExBot.zChanging or function() return false end local BoundedPush = BoundedPush local TrimArray = TrimArray @@ -26,15 +27,11 @@ local safeGetId = H.safeGetId local safeIsDead = H.safeIsDead local safeIsRemoved = H.safeIsRemoved --- Guard: returns true when TargetBot is disabled -local function tbOff() return not TargetBot or not TargetBot.isOn or not TargetBot.isOn() end local safeCreatureCall = H.safeCreatureCall local getClient = H.getClient local isValidAliveMonster = H.isValidAliveMonster --- ============================================================================ -- SCENARIO TYPES & STATE --- ============================================================================ MonsterAI.Scenario = MonsterAI.Scenario or {} local S = MonsterAI.Scenario @@ -75,9 +72,7 @@ S.state = { ENGAGEMENT_GRACE_MS = (CombatConstants and CombatConstants.GRACE_PERIOD) or 1500 } --- ============================================================================ -- SCENARIO CONFIGS (v3.0 — stricter anti-zigzag) --- ============================================================================ S.configs = { [S.TYPES.IDLE] = { @@ -121,9 +116,7 @@ S.configs = { } } --- ============================================================================ -- SCENARIO DETECTION --- ============================================================================ function S.detectScenario() local ppos = player and player:getPosition() @@ -134,9 +127,7 @@ function S.detectScenario() S.state.lastUpdate = nowt local monsters, totalDanger, mc = {}, 0, 0 - local C = getClient() - local creatures = (C and C.getSpectators) and C.getSpectators(ppos, false) - or (g_map and g_map.getSpectators and g_map.getSpectators(ppos, false)) or {} + local creatures = BotCore.Creatures.getNearby(14) or {} for _, cr in ipairs(creatures) do if cr and isValidAliveMonster(cr) then @@ -180,9 +171,7 @@ function S.detectScenario() return nt end --- ============================================================================ -- CLUSTER ANALYSIS --- ============================================================================ function S.analyzeCluster(monsters) if #monsters < 2 then S.state.clusterInfo = nil; return end @@ -199,9 +188,7 @@ function S.analyzeCluster(monsters) S.state.clusterInfo = { centroid = {x=cx, y=cy}, spread = avg, type = ct, monsters = monsters } end --- ============================================================================ -- TARGET LOCK (prevents rapid switching) --- ============================================================================ function S.lockTarget(creatureId, health) local nowt = nowMs() @@ -229,11 +216,9 @@ function S.clearTargetLock() S.state.targetLockHealth = 100 end --- ============================================================================ -- ENGAGEMENT LOCK (v3.0 — LINEAR TARGETING) -- Once we start attacking, we STAY on it until it dies or becomes -- unreachable. endEngagement does NOT clear the target lock. --- ============================================================================ function S.startEngagement(creatureId, health) if not creatureId then return end @@ -329,9 +314,7 @@ function S.getEngagedTarget() return d and d.creature or nil end --- ============================================================================ -- TARGET SWITCH EVALUATION (v3.0 — strict linear targeting) --- ============================================================================ function S.shouldAllowTargetSwitch(newId, newPri, newHp) local nowt = nowMs() @@ -397,9 +380,7 @@ function S.shouldAllowTargetSwitch(newId, newPri, newHp) return true, "allowed" end --- ============================================================================ -- ZIGZAG DETECTION --- ============================================================================ function S.recordMovement() local pp = player and player:getPosition() @@ -425,20 +406,16 @@ function S.isZigzagging() end if EventBus and EventBus.on then - EventBus.on("player:move", function() if tbOff() then return end; S.recordMovement() end, 60) + EventBus.on("player:move", function() if TargetBot.isOff() then return end; S.recordMovement() end, 60) end --- ============================================================================ -- OPTIMAL TARGET SELECTION — Removed (PriorityEngine is the sole authority) --- ============================================================================ function S.getOptimalTarget() return nil end --- ============================================================================ -- STATS --- ============================================================================ function S.getStats() return { currentScenario = S.state.type, monsterCount = S.state.monsterCount, @@ -448,18 +425,16 @@ function S.getStats() config = S.configs[S.state.type] or {} } end --- ============================================================================ -- EVENTBUS INTEGRATION --- ============================================================================ if EventBus and EventBus.on then EventBus.on("targetbot:target_changed", function(creature) - if tbOff() then return end + if TargetBot.isOff() then return end if creature then S.lockTarget(creature:getId(), creature:getHealthPercent() or 100) else S.clearTargetLock() end end) EventBus.on("creature:death", function(creature) - if tbOff() then return end + if TargetBot.isOff() then return end if creature and creature:getId() == S.state.targetLockId then S.clearTargetLock() end if creature and creature:getId() == S.state.engagementLockId then S.endEngagement("target_dead") end end) diff --git a/targetbot/monster_spell_tracker.lua b/targetbot/monster_spell_tracker.lua index 8f70491..67fb087 100644 --- a/targetbot/monster_spell_tracker.lua +++ b/targetbot/monster_spell_tracker.lua @@ -21,9 +21,7 @@ local safeGetId = H.safeGetId local CONST = MonsterAI.CONSTANTS --- ============================================================================ -- SPELL TRACKER STATE --- ============================================================================ MonsterAI.SpellTracker = MonsterAI.SpellTracker or { stats = { @@ -41,9 +39,7 @@ MonsterAI.SpellTracker = MonsterAI.SpellTracker or { typeSpellStats = {} } --- ============================================================================ -- PER-MONSTER INIT --- ============================================================================ function MonsterAI.SpellTracker.initMonster(creature) if not creature or not isCreatureValid(creature) then return nil end @@ -77,9 +73,7 @@ function MonsterAI.SpellTracker.initMonster(creature) return data end --- ============================================================================ -- RECORD SPELL --- ============================================================================ function MonsterAI.SpellTracker.recordSpell(creatureId, missileType, sourcePos, targetPos) local nowt = nowMs() @@ -217,9 +211,7 @@ function MonsterAI.SpellTracker.recordSpell(creatureId, missileType, sourcePos, end end --- ============================================================================ -- ACCESSORS --- ============================================================================ function MonsterAI.SpellTracker.getMonsterSpells(creatureId) return MonsterAI.SpellTracker.monsterSpells[creatureId] @@ -246,9 +238,7 @@ function MonsterAI.SpellTracker.getStats() return stats end --- ============================================================================ -- REACTIVITY ANALYSIS --- ============================================================================ function MonsterAI.SpellTracker.analyzeReactivity() local result = { @@ -288,9 +278,7 @@ function MonsterAI.SpellTracker.analyzeReactivity() return result end --- ============================================================================ -- CLEANUP + SUMMARY --- ============================================================================ function MonsterAI.SpellTracker.cleanup(creatureId) if not creatureId then return end diff --git a/targetbot/monster_tbi.lua b/targetbot/monster_tbi.lua index 5d37423..da66bd2 100644 --- a/targetbot/monster_tbi.lua +++ b/targetbot/monster_tbi.lua @@ -13,18 +13,16 @@ local H = MonsterAI._helpers local nowMs = H.nowMs local safeGetId = H.safeGetId + +local SC = SafeCreature or {} local safeIsDead = H.safeIsDead --- Guard: returns true when TargetBot is disabled -local function tbOff() return not TargetBot or not TargetBot.isOn or not TargetBot.isOn() end local safeIsRemoved = H.safeIsRemoved local safeCreatureCall = H.safeCreatureCall local getClient = H.getClient local isValidAliveMonster = H.isValidAliveMonster --- ============================================================================ -- STATE & CONFIG --- ============================================================================ MonsterAI.TargetBot = MonsterAI.TargetBot or {} local TBI = MonsterAI.TargetBot @@ -47,9 +45,7 @@ TBI.config = { slowMonsterThreshold = 100 } --- ============================================================================ -- PRIORITY CALCULATION (9-STAGE) --- ============================================================================ function TBI.calculatePriority(creature, options) if not creature then return 0, {} end @@ -62,7 +58,7 @@ function TBI.calculatePriority(creature, options) local cid = safeGetId(creature) local cname = safeCreatureCall(creature, "getName", "unknown") local cpos = safeCreatureCall(creature, "getPosition", nil) - local ppos = player and (function() local ok,p = pcall(function() return player:getPosition() end); return ok and p end)() + local ppos = player and SC.getPosition(player) or nil if not ppos or not cpos then return 0, bk end local priority = 100 * cfg.baseWeight @@ -195,9 +191,7 @@ function TBI.calculatePriority(creature, options) return priority, bk end --- ============================================================================ -- HELPERS --- ============================================================================ function TBI.isCreatureFacingPosition(cpos, dir, tpos) if not cpos or not dir or not tpos then return false end @@ -221,9 +215,7 @@ function TBI.predictPosition(pos, dir, steps) return { x = pos.x + d[1]*steps, y = pos.y + d[2]*steps, z = pos.z } end --- ============================================================================ -- SORTED TARGETS --- ============================================================================ function TBI.getSortedTargets(options) options = options or {} @@ -231,9 +223,7 @@ function TBI.getSortedTargets(options) local ppos = player and player:getPosition() if not ppos then return targets end local maxR = options.maxRange or 10 - local C = getClient() - local creatures = (C and C.getSpectators) and C.getSpectators(ppos, false) - or (g_map and g_map.getSpectators and g_map.getSpectators(ppos, false)) or {} + local creatures = BotCore.Creatures.getNearby(maxR) or {} for _, cr in ipairs(creatures) do if cr and isValidAliveMonster(cr) then @@ -257,9 +247,7 @@ function TBI.getBestTarget(options) return t[1] end --- ============================================================================ -- DANGER LEVEL --- ============================================================================ function TBI.getDangerLevel() local ppos = player and player:getPosition() @@ -273,9 +261,7 @@ function TBI.getDangerLevel() return math.min(10, level), threats end --- ============================================================================ -- STATS / DEBUG --- ============================================================================ function TBI.getStats() local s = { config = TBI.config, @@ -296,13 +282,11 @@ function TBI.debugCreature(creature) for k, v in pairs(bk) do print(" " .. k .. ": " .. tostring(v)) end end --- ============================================================================ -- EVENTBUS --- ============================================================================ if EventBus and EventBus.on then EventBus.on("targetbot:request_priority", function(creature, callback) - if tbOff() then return end + if TargetBot.isOff() then return end if creature and callback then local p, bk = TBI.calculatePriority(creature) callback(p, bk) diff --git a/targetbot/monster_tracking.lua b/targetbot/monster_tracking.lua index e43b18e..64a75dd 100644 --- a/targetbot/monster_tracking.lua +++ b/targetbot/monster_tracking.lua @@ -11,9 +11,7 @@ Populates: MonsterAI.Tracker ]] --- ============================================================================ -- HELPERS (from core) --- ============================================================================ -- BoundedPush/TrimArray are set as globals by utils/ring_buffer.lua (Phase 3) local BoundedPush = BoundedPush @@ -30,9 +28,7 @@ local safeIsRemoved = H.safeIsRemoved local CONST = MonsterAI.CONSTANTS --- ============================================================================ -- TRACKER STATE --- ============================================================================ MonsterAI.Tracker = MonsterAI.Tracker or { monsters = {}, @@ -45,9 +41,7 @@ MonsterAI.Tracker = MonsterAI.Tracker or { } } --- ============================================================================ -- TRACK / UNTRACK --- ============================================================================ function MonsterAI.Tracker.track(creature) if not creature then return end @@ -191,9 +185,7 @@ function MonsterAI.Tracker.untrack(creatureId) end end --- ============================================================================ -- UPDATE (per-creature tick) --- ============================================================================ function MonsterAI.Tracker.update(creature) if not creature then return end @@ -375,9 +367,7 @@ function MonsterAI.Tracker.update(creature) data.confidence = 0.1 + 0.6 * sampleRatio end --- ============================================================================ -- EWMA LEARNING --- ============================================================================ function MonsterAI.Tracker.updateEWMA(data, observed) if not data or not observed or observed <= 0 then return end @@ -404,9 +394,7 @@ function MonsterAI.Tracker.updateEWMA(data, observed) }) end --- ============================================================================ -- UTILITY: DPS + PREDICTED PATTERN --- ============================================================================ function MonsterAI.Tracker.getDPS(creatureId, windowMs) windowMs = windowMs or (MonsterAI.DPS_WINDOW or 5000) diff --git a/targetbot/movement_coordinator.lua b/targetbot/movement_coordinator.lua index d77aeb4..d4ea1ae 100644 --- a/targetbot/movement_coordinator.lua +++ b/targetbot/movement_coordinator.lua @@ -32,21 +32,15 @@ - MovementCoordinator.Scaling: Dynamic threshold scaling ]] --- ============================================================================ -- MODULE NAMESPACE --- ============================================================================ MovementCoordinator = MovementCoordinator or {} MovementCoordinator.VERSION = "1.0" --- Guard: returns true when TargetBot is disabled (used by EventBus handlers) -local function tbOff() return not TargetBot or not TargetBot.isOn or not TargetBot.isOn() end -- Toggle to enable movement coordinator debugging output MovementCoordinator.DEBUG = MovementCoordinator.DEBUG or false --- ============================================================================ -- CONSTANTS --- ============================================================================ MovementCoordinator.CONSTANTS = { -- Movement intent types (priority order) @@ -60,7 +54,8 @@ MovementCoordinator.CONSTANTS = { CHASE = 7, -- Close gap to target FACE_MONSTER = 8, -- Diagonal correction LURE = 9, -- Pull more monsters (CaveBot) - IDLE = 10 -- No movement needed + IDLE = 10, -- No movement needed + FOLLOW = 11 -- Follow leader }, -- Intent priorities (higher = more important) @@ -74,7 +69,8 @@ MovementCoordinator.CONSTANTS = { [7] = 35, -- CHASE [8] = 20, -- FACE_MONSTER [9] = 15, -- LURE - [10] = 0 -- IDLE + [10] = 0, -- IDLE + [11] = 95 -- FOLLOW }, -- Minimum confidence to execute movement (tuned for responsiveness) @@ -88,7 +84,8 @@ MovementCoordinator.CONSTANTS = { [7] = 0.50, -- CHASE: Lower for faster target acquisition [8] = 0.45, -- FACE_MONSTER: Quick diagonal correction [9] = 0.50, -- LURE: Responsive to lure needs - [10] = 1.0 -- IDLE: Never execute + [10] = 1.0, -- IDLE: Never execute + [11] = 0.50 -- FOLLOW: Responsive to leader }, -- Timing (tuned for responsiveness while preventing oscillation) @@ -115,10 +112,8 @@ local PRIORITY = CONST.PRIORITY local THRESHOLDS = CONST.CONFIDENCE_THRESHOLDS local TIMING = CONST.TIMING --- ============================================================================ -- DYNAMIC SCALING -- Adjusts thresholds based on monster count for reactive behavior --- ============================================================================ MovementCoordinator.Scaling = {} @@ -139,10 +134,6 @@ MovementCoordinator.MonsterCache = MovementCoordinator.MonsterCache or { } -- Expose simple stats getter -function MovementCoordinator.MonsterCache.getStats() - return MovementCoordinator.MonsterCache.stats -end - -- Periodic cleanup of stale/dead entries (runs every 5s) local _lastCacheCleanup = 0 local function cleanupMonsterCache() @@ -201,26 +192,14 @@ end -- Subscribe to creature events to maintain a local monster cache (lower latency) if EventBus then -- Debounced updater to avoid storms - local function makeDebounce(ms, fn) - if nExBot and nExBot.EventUtil and nExBot.EventUtil.debounce then - return nExBot.EventUtil.debounce(ms, fn) - end - -- Simple fallback debounce using schedule - local scheduled = false - return function() - if scheduled then return end - scheduled = true - schedule(ms, function() - scheduled = false - pcall(fn) - end) - end - end + local SharedHelpers = nExBot.SharedHelpers + if not SharedHelpers then return end + local makeDebounce = SharedHelpers.makeDebounce local debounceUpdate = makeDebounce(100, function() scalingCache.lastUpdate = 0 end) EventBus.on("creature:appear", function(c) - if tbOff() then return end + if TargetBot.isOff() then return end if c and c:isMonster() and not c:isDead() then updateMonsterCacheFromCreature(c) -- Notify WavePredictor if available (non-blocking) @@ -232,7 +211,7 @@ if EventBus then end, 10) EventBus.on("creature:move", function(c, oldPos) - if tbOff() then return end + if TargetBot.isOff() then return end if c and c:isMonster() and not c:isDead() then updateMonsterCacheFromCreature(c) -- Update WavePredictor about movement @@ -244,7 +223,7 @@ if EventBus then end, 10) EventBus.on("monster:disappear", function(c) - if tbOff() then return end + if TargetBot.isOff() then return end removeCreatureFromCache(c) -- IMPROVED: Also clean up MonsterAI tracker data for this creature @@ -271,7 +250,7 @@ if EventBus then -- When our target moves, instantly register chase intent with walk prediction EventBus.on("creature:move", function(creature, oldPos) - if tbOff() then return end + if TargetBot.isOff() then return end if not creature then return end -- Check if this is our current attack target @@ -344,7 +323,7 @@ if EventBus then -- When monster appears nearby, check for danger EventBus.on("monster:appear", function(creature) - if tbOff() then return end + if TargetBot.isOff() then return end if not creature then return end local playerPos = player and player:getPosition() local creaturePos = creature:getPosition() @@ -363,7 +342,7 @@ if EventBus then -- When monster health changes to low, register finish kill intent EventBus.on("monster:health", function(creature, percent) - if tbOff() then return end + if TargetBot.isOff() then return end if not creature or not percent then return end -- Check if this is our target @@ -390,7 +369,7 @@ if EventBus then -- When player takes damage, consider emergency escape EventBus.on("player:health", function(health, maxHealth, oldHealth, oldMax) - if tbOff() then return end + if TargetBot.isOff() then return end if not health or not maxHealth then return end local percent = (health / maxHealth) * 100 local oldPercent = oldHealth and oldMax and ((oldHealth / oldMax) * 100) or 100 @@ -435,13 +414,13 @@ if EventBus then -- Clear stale intents when combat ends EventBus.on("targetbot/combat_end", function() - if tbOff() then return end + if TargetBot.isOff() then return end MovementCoordinator.Intent.clear() end, 20) -- Clear chase intents when target dies EventBus.on("monster:disappear", function(creature) - if tbOff() then return end + if TargetBot.isOff() then return end if not creature then return end local attackingCreature = g_game and g_game.getAttackingCreature and g_game.getAttackingCreature() if attackingCreature and creature:getId() == attackingCreature:getId() then @@ -475,6 +454,31 @@ function MovementCoordinator.Scaling.getMonsterCount() return count end +function MovementCoordinator.Scaling.getFactor() + local monsterCount = MovementCoordinator.Scaling.getMonsterCount() + if monsterCount >= 7 then return 0.5 + elseif monsterCount >= 5 then return 0.7 + elseif monsterCount >= 3 then return 0.85 + else return 1.0 end +end + +function MovementCoordinator.Scaling.getThreshold(intentType) + local baseThreshold = THRESHOLDS[intentType] or 0.7 + local scaleFactor = MovementCoordinator.Scaling.getFactor() + if intentType == INTENT.WAVE_AVOIDANCE or intentType == INTENT.EMERGENCY_ESCAPE then + return baseThreshold * scaleFactor + elseif intentType == INTENT.KEEP_DISTANCE or intentType == INTENT.REPOSITION then + return baseThreshold * (0.3 + scaleFactor * 0.7) + else + return baseThreshold * (0.15 + scaleFactor * 0.85) + end +end + +function MovementCoordinator.Scaling.getHysteresis() + local scaleFactor = MovementCoordinator.Scaling.getFactor() + return TIMING.HYSTERESIS_BONUS * scaleFactor +end + -- Get list of nearby monsters within given chebyshev radius function MovementCoordinator.MonsterCache.getNearby(radius) radius = radius or 7 @@ -502,40 +506,19 @@ function MovementCoordinator.MonsterCache.getNearby(radius) return res end --- ============================================================================ -- WALK PREDICTION (OTClient API Enhancement) -- Predicts where a creature will be based on current walk state --- ============================================================================ MovementCoordinator.WalkPrediction = {} -- Direction vectors for walk prediction -local DIR_VECTORS = { - [0] = {x = 0, y = -1}, -- North - [1] = {x = 1, y = 0}, -- East - [2] = {x = 0, y = 1}, -- South - [3] = {x = -1, y = 0}, -- West - [4] = {x = 1, y = -1}, -- NE - [5] = {x = 1, y = 1}, -- SE - [6] = {x = -1, y = 1}, -- SW - [7] = {x = -1, y = -1}, -- NW -} +local DIR_VECTORS = Directions.DIR_TO_OFFSET -- Predict where creature will be after its current step completes -- @param creature The creature to predict -- @return pos, isWalking, ticksLeft - predicted position, walk state, time until arrival -function MovementCoordinator.WalkPrediction.predictPosition(creature) - if not creature then return nil, false, 0 end - +local function predictPosition(creature) local currentPos = creature:getPosition() - if not currentPos then return nil, false, 0 end - - -- Check if creature has OTClient walk API - local isWalking = creature.isWalking and creature:isWalking() or false - if not isWalking then - return currentPos, false, 0 - end - -- Get walk completion time local ticksLeft = creature.getStepTicksLeft and creature:getStepTicksLeft() or 0 @@ -563,53 +546,30 @@ function MovementCoordinator.WalkPrediction.predictPosition(creature) return currentPos, true, ticksLeft end --- Calculate optimal intercept position for chasing a walking creature --- @param creature The creature to intercept --- @param playerPos Player's current position --- @return interceptPos, confidence - best position to move to +MovementCoordinator.WalkPrediction.predictPosition = predictPosition + function MovementCoordinator.WalkPrediction.calculateIntercept(creature, playerPos) if not creature or not playerPos then return nil, 0 end - - local predictedPos, isWalking, ticksLeft = MovementCoordinator.WalkPrediction.predictPosition(creature) + local predictedPos, isWalking, ticksLeft = predictPosition(creature) if not predictedPos then return nil, 0 end - - -- If not walking, just chase to current position - if not isWalking then - return predictedPos, 0.8 - end - - -- Get creature speed for prediction accuracy + if not isWalking then return predictedPos, 0.8 end local creatureSpeed = creature.getSpeed and creature:getSpeed() or 200 local playerSpeed = player and player.getSpeed and player:getSpeed() or 220 - - -- If we're faster, intercept at predicted position if playerSpeed >= creatureSpeed then - -- High confidence - we can catch up return predictedPos, 0.85 else - -- Slower than target - try to cut them off - -- Calculate where creature will be after 2 steps local direction = creature.getDirection and creature:getDirection() if direction and DIR_VECTORS[direction] then local vec = DIR_VECTORS[direction] - local futurePos = { - x = predictedPos.x + vec.x, - y = predictedPos.y + vec.y, - z = predictedPos.z - } - -- Lower confidence since we're predicting further ahead + local futurePos = { x = predictedPos.x + vec.x, y = predictedPos.y + vec.y, z = predictedPos.z } return futurePos, 0.65 end end - return predictedPos, 0.7 end --- Get walk state information for a creature --- @return table with isWalking, progress, ticksLeft, speed function MovementCoordinator.WalkPrediction.getWalkState(creature) if not creature then return nil end - return { isWalking = creature.isWalking and creature:isWalking() or false, progress = creature.getStepProgress and creature:getStepProgress() or 0, @@ -620,58 +580,7 @@ function MovementCoordinator.WalkPrediction.getWalkState(creature) } end --- Calculate scaling factor based on monster count --- More monsters = lower thresholds = more reactive movement --- @return number between 0.5 (many monsters) and 1.0 (few monsters) -function MovementCoordinator.Scaling.getFactor() - local monsterCount = MovementCoordinator.Scaling.getMonsterCount() - - -- Scale from 1.0 (few monsters) to 0.5 (many monsters) - -- 1-2 monsters: 1.0 (full conservative) - -- 3-4 monsters: 0.85 (slight reactivity) - -- 5-6 monsters: 0.7 (moderate reactivity) - -- 7+ monsters: 0.5 (maximum reactivity) - if monsterCount >= 7 then - return 0.5 - elseif monsterCount >= 5 then - return 0.7 - elseif monsterCount >= 3 then - return 0.85 - else - return 1.0 - end -end - --- Get adjusted confidence threshold for an intent type --- @param intentType: INTENT constant --- @return adjusted threshold (lower when many monsters) -function MovementCoordinator.Scaling.getThreshold(intentType) - local baseThreshold = THRESHOLDS[intentType] or 0.7 - local scaleFactor = MovementCoordinator.Scaling.getFactor() - - -- WAVE_AVOIDANCE and EMERGENCY_ESCAPE scale more aggressively - if intentType == INTENT.WAVE_AVOIDANCE or intentType == INTENT.EMERGENCY_ESCAPE then - -- These can drop to 50% of base threshold when surrounded - return baseThreshold * scaleFactor - elseif intentType == INTENT.KEEP_DISTANCE or intentType == INTENT.REPOSITION then - -- These scale moderately (down to 70% of base) - return baseThreshold * (0.3 + scaleFactor * 0.7) - else - -- Other intents scale minimally (down to 85% of base) - return baseThreshold * (0.15 + scaleFactor * 0.85) - end -end - --- Get adjusted hysteresis bonus (less sticky when surrounded) -function MovementCoordinator.Scaling.getHysteresis() - local scaleFactor = MovementCoordinator.Scaling.getFactor() - -- Full hysteresis when few monsters, minimal when many - return TIMING.HYSTERESIS_BONUS * scaleFactor -end - --- ============================================================================ -- STATE --- ============================================================================ MovementCoordinator.State = { -- Current intents from each system @@ -707,9 +616,7 @@ MovementCoordinator.State = { local State = MovementCoordinator.State --- ============================================================================ -- INTENT MANAGEMENT --- ============================================================================ MovementCoordinator.Intent = {} @@ -801,32 +708,23 @@ function MovementCoordinator.Intent.getSorted() return sorted end --- ============================================================================ -- VOTING SYSTEM -- Multiple intents can vote for same/similar positions --- ============================================================================ MovementCoordinator.Vote = {} --- Check if two positions are similar (within threshold) function MovementCoordinator.Vote.positionsAreSimilar(pos1, pos2, threshold) threshold = threshold or CONST.CONFLICT.SAME_POSITION_THRESHOLD - return math.abs(pos1.x - pos2.x) <= threshold and - math.abs(pos1.y - pos2.y) <= threshold + return math.abs(pos1.x - pos2.x) <= threshold and math.abs(pos1.y - pos2.y) <= threshold end --- Check if two intents conflict (want to go opposite directions) function MovementCoordinator.Vote.intentsConflict(intent1, intent2) local playerPos = player:getPosition() if not playerPos then return false end - - -- Calculate direction vectors local dx1 = intent1.position.x - playerPos.x local dy1 = intent1.position.y - playerPos.y local dx2 = intent2.position.x - playerPos.x local dy2 = intent2.position.y - playerPos.y - - -- Dot product: negative means opposite directions local dot = dx1 * dx2 + dy1 * dy2 return dot < 0 end @@ -907,12 +805,45 @@ function MovementCoordinator.Vote.aggregate() return nil, 0 end --- ============================================================================ -- DECISION MAKER --- ============================================================================ MovementCoordinator.Decide = {} +function MovementCoordinator.Decide.markCurrentAsSafe() + local playerPos = player:getPosition() + if playerPos then + State.safePosition = {x = playerPos.x, y = playerPos.y, z = playerPos.z} + State.safePositionTime = now + end +end + +function MovementCoordinator.Decide.isOscillating() + local cutoff = now - TIMING.OSCILLATION_WINDOW + local newMoves = {} + for i = 1, #State.recentMoves do + if State.recentMoves[i].time > cutoff then + table.insert(newMoves, State.recentMoves[i]) + end + end + State.recentMoves = newMoves + if #State.recentMoves >= TIMING.MAX_OSCILLATIONS then + local positionCounts = {} + for i = 1, #State.recentMoves do + local pos = State.recentMoves[i].position + local key = math.floor(pos.x) .. "," .. math.floor(pos.y) + positionCounts[key] = (positionCounts[key] or 0) + 1 + end + local uniqueCount = 0 + local maxRevisits = 0 + for _, count in pairs(positionCounts) do + uniqueCount = uniqueCount + 1 + if count > maxRevisits then maxRevisits = count end + end + if uniqueCount <= 2 or maxRevisits >= 2 then return true end + end + return false +end + -- Make final movement decision with dynamic scaling -- @return decision { shouldMove, intent, confidence, blocked, reason } function MovementCoordinator.Decide.make() @@ -1019,64 +950,7 @@ function MovementCoordinator.Decide.make() } end --- Mark current position as safe (for hysteresis) -function MovementCoordinator.Decide.markCurrentAsSafe() - local playerPos = player:getPosition() - if playerPos then - State.safePosition = {x = playerPos.x, y = playerPos.y, z = playerPos.z} - State.safePositionTime = now - end -end - --- Check if player is oscillating (moving back and forth) -function MovementCoordinator.Decide.isOscillating() - local cutoff = now - TIMING.OSCILLATION_WINDOW - - -- Clean old moves - local newMoves = {} - for i = 1, #State.recentMoves do - if State.recentMoves[i].time > cutoff then - table.insert(newMoves, State.recentMoves[i]) - end - end - State.recentMoves = newMoves - - -- Check if too many moves in window (reduced from 4 to 3) - if #State.recentMoves >= TIMING.MAX_OSCILLATIONS then - -- Check if positions are similar (bouncing between same spots) - local uniquePositions = {} - local positionCounts = {} - - for i = 1, #State.recentMoves do - local pos = State.recentMoves[i].position - local key = math.floor(pos.x) .. "," .. math.floor(pos.y) - uniquePositions[key] = true - positionCounts[key] = (positionCounts[key] or 0) + 1 - end - - local uniqueCount = 0 - local maxRevisits = 0 - for key, count in pairs(positionCounts) do - uniqueCount = uniqueCount + 1 - if count > maxRevisits then - maxRevisits = count - end - end - - -- Oscillating if: - -- 1. Few unique positions (bouncing between 2-3 spots) - -- 2. OR any position visited multiple times - if uniqueCount <= 2 or maxRevisits >= 2 then - return true - end - end - - return false -end - --- ============================================================================ -- EXECUTION --- ============================================================================ MovementCoordinator.Execute = {} @@ -1269,10 +1143,8 @@ function MovementCoordinator.Execute.move(decision) return success, success and "executed" or "execution_failed" end --- ============================================================================ -- INTEGRATION HELPERS -- Easy functions for other systems to register intents --- ============================================================================ -- Register wave avoidance intent function MovementCoordinator.avoidWave(safePos, confidence) @@ -1282,12 +1154,6 @@ function MovementCoordinator.avoidWave(safePos, confidence) end -- Register chase intent -function MovementCoordinator.chase(targetPos, confidence) - MovementCoordinator.Intent.register( - INTENT.CHASE, targetPos, confidence, "chase" - ) -end - -- Register finish kill intent (high priority chase) function MovementCoordinator.finishKill(targetPos, confidence) MovementCoordinator.Intent.register( @@ -1317,12 +1183,6 @@ function MovementCoordinator.reposition(betterPos, confidence) end -- Register lure intent -function MovementCoordinator.lure(lurePos, confidence) - MovementCoordinator.Intent.register( - INTENT.LURE, lurePos, confidence, "lure" - ) -end - -- Register face monster intent function MovementCoordinator.faceMonster(cardinalPos, confidence) MovementCoordinator.Intent.register( @@ -1331,15 +1191,8 @@ function MovementCoordinator.faceMonster(cardinalPos, confidence) end -- Emergency escape disabled per user request (no-op) -function MovementCoordinator.emergencyEscape(escapePos, confidence) - - return -end - --- ============================================================================ -- MAIN TICK -- Call this from main TargetBot loop --- ============================================================================ function MovementCoordinator.tick() local decision = MovementCoordinator.Decide.make() @@ -1351,149 +1204,7 @@ function MovementCoordinator.tick() return false, decision.reason end --- TUNING utilities: analyze telemetry and suggest conservative adjustments -MovementCoordinator.Tuning = {} - --- Analyze telemetry counters and return a list of human-friendly suggestions and raw counters -function MovementCoordinator.Tuning.analyze() - local tele = nExBot and nExBot.Telemetry and nExBot.Telemetry.get and nExBot.Telemetry.get() - tele = tele or {} - local suggestions = {} - - local executed = tele["movement.execution.success"] or 0 - local failed = tele["movement.execution.failed"] or 0 - local oscillations = tele["movement.oscillation"] or 0 - local blocked_low = tele["movement.decision.blocked.low_confidence"] or 0 - local totalBlocked = tele["movement.decision.blocked"] or 0 - local registeredWave = tele["movement.intent.registered.WAVE_AVOIDANCE"] or 0 - - -- Heuristic: if oscillations are high relative to executed moves, suggest increasing hysteresis - if executed > 0 and (oscillations / math.max(1, executed)) > 0.15 then - table.insert(suggestions, "High oscillation rate: consider increasing TIMING.MAX_OSCILLATIONS by 1 or increasing HYSTERESIS_BONUS by ~0.05") - end - - -- Heuristic: many low confidence blocks while wave predictions are frequent - if registeredWave > 0 and blocked_low > executed * 1.5 then - table.insert(suggestions, "Many low-confidence blocks for wave avoidance: consider lowering WAVE_AVOIDANCE threshold or reduce its scale factor") - end - - -- Heuristic: many execution failures relative to attempts - local attempts = tele["movement.execution.attempt"] or 0 - if attempts > 0 and (failed / attempts) > 0.25 then - table.insert(suggestions, "High execution failure rate: inspect pathing and consider raising EXECUTION_COOLDOWN or increasing PATH safety checks") - end - - return suggestions, tele -end - -function MovementCoordinator.Tuning.report() - local suggestions, tele = MovementCoordinator.Tuning.analyze() - - for k,v in pairs(tele) do - -- data: k,v (silent) - end - if #suggestions == 0 then - -- No suggestions (metrics look healthy) - else - -- Suggestions available (silent) - for i,s in ipairs(suggestions) do - -- suggestion: s (silent) - end - end -end - --- Run a short synthetic trace to generate representative telemetry for tuning -function MovementCoordinator.Tuning.runSyntheticTrace() - if not (nExBot and nExBot.Telemetry and nExBot.Telemetry.increment) then - print("[MovementCoordinator][Tuning] telemetry not available; cannot run synthetic trace") - return false - end - - -- Run synthetic trace (silent) - nExBot.Telemetry.increment("movement.execution.attempt", 100) - nExBot.Telemetry.increment("movement.execution.success", 70) - nExBot.Telemetry.increment("movement.execution.failed", 30) - nExBot.Telemetry.increment("movement.oscillation", 20) - nExBot.Telemetry.increment("movement.decision.blocked.low_confidence", 200) - nExBot.Telemetry.increment("movement.decision.blocked", 220) - nExBot.Telemetry.increment("movement.intent.registered.WAVE_AVOIDANCE", 20) - - local suggestions, tele = MovementCoordinator.Tuning.analyze() - -- suggestions handled silently - if #suggestions > 0 then - MovementCoordinator.Tuning.applyRecommendations(suggestions) - end - return true -end - --- Apply conservative adjustments based on analyzer suggestions -function MovementCoordinator.Tuning.applyRecommendations(suggestions) - suggestions = suggestions or MovementCoordinator.Tuning.analyze() - if type(suggestions) == "table" and suggestions[1] then - suggestions = suggestions - else - -- If passed (suggestions, tele) pair - suggestions = suggestions - end - - local applied = {} - - for _, s in ipairs(suggestions) do - -- Oscillation suggestion: increase MAX_OSCILLATIONS by 1 and HYSTERESIS_BONUS by 0.05 - if s:find("High oscillation rate") then - local old = TIMING.MAX_OSCILLATIONS - TIMING.MAX_OSCILLATIONS = math.max(1, TIMING.MAX_OSCILLATIONS + 1) - table.insert(applied, string.format("MAX_OSCILLATIONS: %d -> %d", old, TIMING.MAX_OSCILLATIONS)) - local oldH = TIMING.HYSTERESIS_BONUS - TIMING.HYSTERESIS_BONUS = TIMING.HYSTERESIS_BONUS + 0.05 - table.insert(applied, string.format("HYSTERESIS_BONUS: %.3f -> %.3f", oldH, TIMING.HYSTERESIS_BONUS)) - end - - -- Low-confidence/wave suggestion: reduce WAVE_AVOIDANCE threshold by 0.05 (clamped) - if s:find("lowering WAVE_AVOIDANCE") then - local old = THRESHOLDS[INTENT.WAVE_AVOIDANCE] - local newv = math.max(0.4, old - 0.05) - THRESHOLDS[INTENT.WAVE_AVOIDANCE] = newv - table.insert(applied, string.format("WAVE_AVOIDANCE threshold: %.2f -> %.2f", old, newv)) - end - - -- Execution failure suggestion: raise EXECUTION_COOLDOWN by +100ms - if s:find("High execution failure rate") then - local old = TIMING.EXECUTION_COOLDOWN - TIMING.EXECUTION_COOLDOWN = TIMING.EXECUTION_COOLDOWN + 100 - table.insert(applied, string.format("EXECUTION_COOLDOWN: %d -> %d", old, TIMING.EXECUTION_COOLDOWN)) - end - end - - -- Additional conservative adjustments regardless of which suggestions matched - -- Slightly increase OSCILLATION_WINDOW to make detection a bit more forgiving - local oldWin = TIMING.OSCILLATION_WINDOW - TIMING.OSCILLATION_WINDOW = TIMING.OSCILLATION_WINDOW + 500 - table.insert(applied, string.format("OSCILLATION_WINDOW: %d -> %d", oldWin, TIMING.OSCILLATION_WINDOW)) - - -- Print applied adjustments - print("[MovementCoordinator][Tuning] Applied adjustments:") - for _, a in ipairs(applied) do print(" - " .. a) end - - -- Record telemetry for applied tuning ops - if nExBot and nExBot.Telemetry and nExBot.Telemetry.increment then - nExBot.Telemetry.increment("movement.tuning.applied") - end -end - --- Get current state for debugging -function MovementCoordinator.getState() - return { - intents = State.intents, - lastDecision = State.lastDecision, - recentMoves = #State.recentMoves, - stats = State.stats - } -end - --- ============================================================================ -- EXPORTS --- ============================================================================ nExBot = nExBot or {} nExBot.MovementCoordinator = MovementCoordinator diff --git a/targetbot/opentibiabr_targeting.lua b/targetbot/opentibiabr_targeting.lua index 2d78837..515856a 100644 --- a/targetbot/opentibiabr_targeting.lua +++ b/targetbot/opentibiabr_targeting.lua @@ -15,17 +15,15 @@ - Provides ~30-50% performance improvement for targeting calculations ]] --- ============================================================================ -- MODULE NAMESPACE --- ============================================================================ local OpenTibiaBRTargeting = {} OpenTibiaBRTargeting.VERSION = "1.0" OpenTibiaBRTargeting.DEBUG = false --- ============================================================================ +local SC = SafeCreature or {} + -- CLIENT SERVICE HELPER (using global ClientHelper) --- ============================================================================ local getClient = nExBot.Shared.getClient @@ -40,9 +38,7 @@ local function log(msg) end end --- ============================================================================ -- FEATURE DETECTION --- ============================================================================ OpenTibiaBRTargeting.features = { findEveryPath = false, @@ -75,10 +71,8 @@ local function detectFeatures() return true end --- ============================================================================ -- BATCH PATH CALCULATION -- Calculate paths to multiple destinations at once (much faster than one by one) --- ============================================================================ -- Cache for batch path results local batchPathCache = { @@ -168,10 +162,8 @@ function OpenTibiaBRTargeting.getCachedPath(monsterId) return data and data.path or nil end --- ============================================================================ -- LINE-OF-SIGHT TARGETING -- Only get creatures that are in direct line of sight (no obstacles) --- ============================================================================ function OpenTibiaBRTargeting.getVisibleCreatures(pos, multifloor) if not OpenTibiaBRTargeting.features.getSightSpectators then @@ -190,10 +182,8 @@ function OpenTibiaBRTargeting.getVisibleCreatures(pos, multifloor) return creatures or {} end --- ============================================================================ -- ENHANCED CREATURE LOOKUP -- Direct creature lookup by ID (faster than iterating all spectators) --- ============================================================================ function OpenTibiaBRTargeting.getCreatureById(creatureId) if not OpenTibiaBRTargeting.features.getCreatureById then @@ -225,10 +215,8 @@ function OpenTibiaBRTargeting.isCreatureValid(creatureId) return ok and result end --- ============================================================================ -- PATTERN-BASED AOE DETECTION -- Get creatures matching a specific attack pattern (for AoE optimization) --- ============================================================================ -- Diamond pattern (3x3 rotated 45°) - common for arrows/bolts local DIAMOND_PATTERN = { @@ -286,8 +274,8 @@ function OpenTibiaBRTargeting.countDiamondArrowHits(targetPos) local count = 0 for _, creature in ipairs(creatures) do - local ok, isMonster = pcall(function() return creature:isMonster() and not creature:isDead() end) - if ok and isMonster then + local isMonster = SC.isMonster(creature) and not SC.isDead(creature) + if isMonster then count = count + 1 end end @@ -302,8 +290,8 @@ function OpenTibiaBRTargeting.countLargeAreaHits(targetPos) local count = 0 for _, creature in ipairs(creatures) do - local ok, isMonster = pcall(function() return creature:isMonster() and not creature:isDead() end) - if ok and isMonster then + local isMonster = SC.isMonster(creature) and not SC.isDead(creature) + if isMonster then count = count + 1 end end @@ -339,13 +327,13 @@ function OpenTibiaBRTargeting.findBestAoEPosition(playerPos, range, pattern, pat if tilePos then local creatures = OpenTibiaBRTargeting.getCreaturesInPattern(tilePos, pattern, patternWidth, patternHeight) if creatures then - local count = 0 - for _, creature in ipairs(creatures) do - local ok, isMonster = pcall(function() return creature:isMonster() and not creature:isDead() end) - if ok and isMonster then - count = count + 1 - end - end + local count = 0 + for _, creature in ipairs(creatures) do + local isMonster = SC.isMonster(creature) and not SC.isDead(creature) + if isMonster then + count = count + 1 + end + end if count > bestCount then bestCount = count @@ -358,10 +346,8 @@ function OpenTibiaBRTargeting.findBestAoEPosition(playerPos, range, pattern, pat return bestPos, bestCount end --- ============================================================================ -- ASYMMETRIC RANGE DETECTION -- Get creatures with different ranges in X and Y (useful for beam targeting) --- ============================================================================ function OpenTibiaBRTargeting.getCreaturesInAsymmetricRange(pos, multifloor, minRangeX, maxRangeX, minRangeY, maxRangeY) if not OpenTibiaBRTargeting.features.getSpectatorsInRangeEx then @@ -412,10 +398,8 @@ function OpenTibiaBRTargeting.getCreaturesInFront(playerPos, direction, range) return OpenTibiaBRTargeting.getCreaturesInAsymmetricRange(playerPos, false, minX, maxX, minY, maxY) end --- ============================================================================ -- TARGETBOT INTEGRATION -- Hook into TargetBot to use enhanced features --- ============================================================================ function OpenTibiaBRTargeting.integrate() if not detectFeatures() then @@ -445,9 +429,7 @@ function OpenTibiaBRTargeting.integrate() return true end --- ============================================================================ -- INITIALIZATION --- ============================================================================ -- Auto-integrate when module loads schedule(100, function() diff --git a/targetbot/priority_engine.lua b/targetbot/priority_engine.lua index 399c2a1..fe18935 100644 --- a/targetbot/priority_engine.lua +++ b/targetbot/priority_engine.lua @@ -30,17 +30,13 @@ Dependencies: CombatConstants, MonsterAI (optional), ASM (optional). ]] --- ============================================================================ -- MODULE --- ============================================================================ PriorityEngine = PriorityEngine or {} PriorityEngine.VERSION = "1.0" PriorityEngine.DEBUG = false --- ============================================================================ -- LAZY DEPS --- ============================================================================ local CC -- CombatConstants @@ -48,9 +44,7 @@ local function ensureDeps() if not CC then CC = CombatConstants or {} end end --- ============================================================================ -- HELPERS --- ============================================================================ local nowMs = nExBot.Shared.nowMs @@ -101,7 +95,7 @@ end local player local function getPlayer() - if not player or not pcall(function() return player:getPosition() end) then + if not player or not (SC and SC.getPosition and SC.getPosition(player)) then local C = getClient() player = (C and C.getLocalPlayer and C.getLocalPlayer()) or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) @@ -122,9 +116,7 @@ local function gameTarget() return nil end --- ============================================================================ -- CONSTANTS (tuning knobs) --- ============================================================================ local SCORE = { -- Config priority scaling (user-set 1-10 × this = dominant factor) @@ -197,9 +189,7 @@ local SCORE = { PriorityEngine.SCORE = SCORE --- ============================================================================ -- SUB-SCORERS (pure functions) --- ============================================================================ -- 1. Config base score local function baseScore(config) @@ -558,9 +548,7 @@ local function mobilityScore(creature, config) return s end --- ============================================================================ -- MAIN ENTRY POINT --- ============================================================================ --- Calculate total priority for a creature. --- @param creature userdata — the creature object @@ -603,9 +591,7 @@ function PriorityEngine.calculate(creature, config, path) return math.max(0, total) end --- ============================================================================ -- SWITCH GATE (called by ASM v3.0) --- ============================================================================ --- Evaluate whether a target switch should be allowed. --- @return boolean, string (allowed, reason) @@ -637,9 +623,7 @@ function PriorityEngine.shouldAllowSwitch(newId, newPriority, newHp) return true, "allowed" end --- ============================================================================ -- EVENTBUS --- ============================================================================ if EventBus and EventBus.on then EventBus.on("priority_engine:recalibrate", function(overrides) diff --git a/targetbot/target.lua b/targetbot/target.lua index f3b63a4..a87425a 100644 --- a/targetbot/target.lua +++ b/targetbot/target.lua @@ -1,2916 +1,6 @@ -local targetbotMacro = nil -local config = nil -lastAction = 0 -local cavebotAllowance = 0 -local lureEnabled = true -local recalculateBestTarget -- forward declaration: defined at ~L2054, used by EventBus closures above it - --- Guard: returns true when TargetBot is disabled (used by EventBus handlers) -local function tbOff() return not TargetBot or not TargetBot.isOn or not TargetBot.isOn() end - -local getClient = nExBot.Shared.getClient - -local getClientVersion = nExBot.Shared.getClientVersion - --- ═══════════════════════════════════════════════════════════════════════════ --- OPENTIBIABR TARGETING ENHANCEMENTS (v3.1) --- Load enhanced targeting module for OpenTibiaBR-specific optimizations --- Provides: batch pathfinding, line-of-sight detection, pattern-based AoE --- ═══════════════════════════════════════════════════════════════════════════ -local OpenTibiaBRTargeting = nil -local function loadOpenTibiaBRTargeting() - if OpenTibiaBRTargeting then return OpenTibiaBRTargeting end - local ok, result = pcall(function() - return dofile("nExBot/targetbot/opentibiabr_targeting.lua") - end) - if ok and result then - OpenTibiaBRTargeting = result - print("[TargetBot] OpenTibiaBR targeting enhancements loaded") - end - return OpenTibiaBRTargeting -end - --- Lazy-load on first use -local function getOpenTibiaBRTargeting() - if OpenTibiaBRTargeting == nil then - loadOpenTibiaBRTargeting() - end - return OpenTibiaBRTargeting -end - --- Check if OpenTibiaBR batch pathfinding is available -local function hasBatchPathfinding() - local otbr = getOpenTibiaBRTargeting() - return otbr and otbr.features and otbr.features.findEveryPath -end - --- Check if OpenTibiaBR sight spectators is available -local function hasSightSpectators() - local otbr = getOpenTibiaBRTargeting() - return otbr and otbr.features and otbr.features.getSightSpectators -end - --- Load PathUtils if available (shared module for creature validation) -local PathUtils = nil -local function ensurePathUtils() - if PathUtils then return PathUtils end - -- OTClient compatible - just try dofile - local success = pcall(function() - dofile("nExBot/utils/path_utils.lua") - end) - -- After dofile, PathUtils should be global - if success then - PathUtils = PathUtils -- Re-check global - end - return PathUtils -end -ensurePathUtils() - --- ═══════════════════════════════════════════════════════════════════════════ --- OPTIMIZED CREATURE VALIDATION (Reduce pcall overhead) --- Single pcall wrapper that validates multiple creature properties at once --- ═══════════════════════════════════════════════════════════════════════════ -local function validateCreature(creature) - if not creature then - return { valid = false } - end - - -- Fallback: single pcall to get all properties at once - local ok, result = pcall(function() - return { - valid = true, - isDead = creature:isDead(), - isMonster = creature:isMonster(), - isPlayer = creature:isPlayer(), - isNpc = creature:isNpc(), - id = creature:getId(), - position = creature:getPosition(), - name = creature:getName(), - healthPercent = (type(creature.getHealthPercent) == "function" and creature:getHealthPercent()) or 100, - } - end) - - if not ok then - return { valid = false } - end - - return result -end - --- Quick dead check (single pcall, cached result) -local function isCreatureDead(creature) - if not creature then return true end - local ok, isDead = pcall(function() return creature:isDead() end) - return ok and isDead -end - --- Quick ID check (single pcall) -local function getCreatureId(creature) - if not creature then return nil end - local ok, id = pcall(function() return creature:getId() end) - return ok and id or nil -end - --- ═══════════════════════════════════════════════════════════════════════════ --- MONSTER DETECTION RANGE (v3.0) --- IMPROVED: Increased range for better monster detection --- This prevents the bot from leaving monsters behind when moving to waypoints --- ═══════════════════════════════════════════════════════════════════════════ -local MONSTER_DETECTION_RANGE = 14 -- INCREASED from 10 to 14 (covers full visible screen) -local MONSTER_TARGETING_RANGE = 12 -- INCREASED from 10 to 12 (targeting range) - -local dangerValue = 0 -local looterStatus = "" - --- ═══════════════════════════════════════════════════════════════════════════ --- ATTACK STATE MACHINE INTEGRATION (Default Attack System) --- The AttackStateMachine is now the PRIMARY attack handler for TargetBot. --- It provides linear, consistent targeting with automatic recovery. --- ═══════════════════════════════════════════════════════════════════════════ - --- initialization state -local pendingEnable = false -local pendingEnableDesired = nil -local moduleInitialized = false -local _lastTargetbotSlowWarn = 0 - --- Local cached reference to local player (updated on relogin) -local Client = getClient() -local player = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) or nil - --- Safe function calls to prevent "attempt to call global function (a nil value)" errors -local SafeCall = SafeCall or require("core.safe_call") - --- Compatibility: robust safe unpack (works when neither table.unpack nor unpack exist) -local function _unpack(tbl) - if not tbl then return end - if table and table.unpack then return table.unpack(tbl) end - if unpack then return unpack(tbl) end - local n = #tbl - if n == 0 then return end - if n == 1 then return tbl[1] end - if n == 2 then return tbl[1], tbl[2] end - if n == 3 then return tbl[1], tbl[2], tbl[3] end - if n == 4 then return tbl[1], tbl[2], tbl[3], tbl[4] end - if n == 5 then return tbl[1], tbl[2], tbl[3], tbl[4], tbl[5] end - if n == 6 then return tbl[1], tbl[2], tbl[3], tbl[4], tbl[5], tbl[6] end - if n == 7 then return tbl[1], tbl[2], tbl[3], tbl[4], tbl[5], tbl[6], tbl[7] end - if n == 8 then return tbl[1], tbl[2], tbl[3], tbl[4], tbl[5], tbl[6], tbl[7], tbl[8] end - if n == 9 then return tbl[1], tbl[2], tbl[3], tbl[4], tbl[5], tbl[6], tbl[7], tbl[8], tbl[9] end - if n == 10 then return tbl[1], tbl[2], tbl[3], tbl[4], tbl[5], tbl[6], tbl[7], tbl[8], tbl[9], tbl[10] end - if n == 11 then return tbl[1], tbl[2], tbl[3], tbl[4], tbl[5], tbl[6], tbl[7], tbl[8], tbl[9], tbl[10], tbl[11] end - if n == 12 then return tbl[1], tbl[2], tbl[3], tbl[4], tbl[5], tbl[6], tbl[7], tbl[8], tbl[9], tbl[10], tbl[11], tbl[12] end - -- Fallback: return first 12 elements - return tbl[1], tbl[2], tbl[3], tbl[4], tbl[5], tbl[6], tbl[7], tbl[8], tbl[9], tbl[10], tbl[11], tbl[12] -end - --- Attack watchdog to recover from indecision (rate-limited) -local attackWatchdog = { - lastForce = 0, - attempts = 0, - cooldown = 800, - maxAttempts = 2 -} - --- Aggressive relogin recovery: force re-attempts for a short window after relogin -local reloginRecovery = { - active = false, -- whether the aggressive recovery is active - endTime = 0, -- when to stop aggressive retries - duration = 5000, -- default aggressive recovery duration (ms) - lastAttempt = 0, -- last forced attempt timestamp - interval = 400 -- attempt every 400ms while active -} - --- Pull System state (shared with CaveBot) -TargetBot = TargetBot or {} -TargetBot.smartPullActive = false -- When true, CaveBot pauses waypoint walking - --- Centralized attack controller (anti-spam + anti-zigzag switching) --- IMPROVED: Faster intervals for more responsive attacking -local AttackController = { - lastCommandTime = 0, - lastTargetId = nil, - lastReason = nil, - minInterval = 100, -- Reduced: Minimum time between any attack commands - sameTargetInterval = 150, -- Reduced: Minimum time between same-target commands - minSwitchInterval = 400, -- Reduced: Minimum time between target switches - lastConfirmedTime = 0, -- When attack was last confirmed by server - attackState = "idle" -- idle, pending, confirmed -} - -TargetBot.AttackController = AttackController - --- ═══════════════════════════════════════════════════════════════════════════ --- REQUEST ATTACK (Unified Attack Interface) --- This is the SINGLE entry point for all attack requests in TargetBot. --- Uses AttackStateMachine for state-based attack management. --- OPTIMIZED: Uses consolidated validateCreature for reduced pcall overhead --- ═══════════════════════════════════════════════════════════════════════════ -TargetBot.requestAttack = function(creature, reason, force) - if not creature then return false end - - -- OPTIMIZED: Use isCreatureDead helper (single pcall) - if isCreatureDead(creature) then return false end - - local Client = getClient() - if not (Client and Client.attack) and not (g_game and g_game.attack) then return false end - - -- OPTIMIZED: Use getCreatureId helper (single pcall) - local id = getCreatureId(creature) - if not id then return false end - - -- Calculate priority for this creature - -- v2.4: Config priority scaled by 1000x for consistency with creature_priority.lua - local priority = 1000 -- Base priority (config priority 1) - if TargetBot.Creature and TargetBot.Creature.getConfigs then - local cfgs = TargetBot.Creature.getConfigs(creature) - if cfgs and cfgs[1] then - priority = (cfgs[1].priority or 1) * 1000 - end - end - - -- Use AttackStateMachine directly (always loaded as default) - if force then - return AttackStateMachine.forceSwitch(creature) - else - return AttackStateMachine.requestSwitch(creature, priority) - end -end - --- Use TargetBotCore if available (DRY principle) -local Core = TargetCore or {} - --- Creature type constants for clarity -local CREATURE_TYPE = { - PLAYER = 0, - MONSTER = 1, -- Targetable monster - NPC = 2, - SUMMON = 3 -- Non-targetable (other player's summons) -} - --- Pre-allocated constants for pathfinding (PERFORMANCE: avoid table creation in loop) -local PATH_PARAMS = { - ignoreLastCreature = true, - ignoreNonPathable = true, - ignoreCost = true, - ignoreCreatures = true, - allowOnlyVisibleTiles = true, -- OTCLIENT API: Safety first - precision = 1 -} - --- Pre-allocated status strings (PERFORMANCE: avoid string concatenation) -local STATUS_WAITING = "Waiting" - --------------------------------------------------------------------------------- --- PERFORMANCE: Optimized Creature Cache --- Uses event-driven updates with LRU eviction and TargetCore integration --------------------------------------------------------------------------------- -local CreatureCache = { - monsters = {}, -- {id -> {creature, path, params, lastUpdate, priority}} - monsterCount = 0, - bestTarget = nil, - bestPriority = 0, - totalDanger = 0, - dirty = true, -- Flag to recalculate on next tick - lastFullUpdate = 0, - FULL_UPDATE_INTERVAL = 400, -- Reduced for faster adaptation - PATH_TTL = 250, -- Path cache valid for 250ms (faster invalidation) - lastCleanup = 0, - CLEANUP_INTERVAL = 1500, - -- LRU eviction - accessOrder = {}, -- Array of IDs in access order - maxSize = 50 -- Max cached creatures -} - --- Mark cache as dirty (needs recalculation) -local function invalidateCache() - CreatureCache.dirty = true -end - --- Helper to set UI status text on the right side only when changed (reduces layout churn) -local _lastStatusRight = nil -local function setStatusRight(text) - if not ui or not ui.status or not ui.status.right then return end - local cur = nil - pcall(function() cur = ui.status.right:getText() end) - if cur ~= text then - pcall(function() ui.status.right:setText(text) end) - _lastStatusRight = text - end -end - --- Generic safe setter for UI labels/widgets -local function setWidgetTextSafe(widget, text) - if not widget or not text then return end - pcall(function() - local cur = nil - if type(widget.getText) == 'function' then cur = widget:getText() end - if cur ~= text then widget:setText(text) end - end) -end - --- Event-driven hooks: mark cache dirty and optionally schedule a quick recalc --- Default no-op in case EventBus or debounce util isn't available (prevents nil calls) -local debouncedInvalidateAndRecalc = function() end -if EventBus then - -- Safe debounce factory (works even if nExBot.EventUtil isn't initialized yet) - local function makeDebounce(ms, fn) - if nExBot and nExBot.EventUtil and nExBot.EventUtil.debounce then - return nExBot.EventUtil.debounce(ms, fn) - end - local scheduled = false - return function(...) - if scheduled then return end - scheduled = true - local args = {...} - schedule(ms, function() - scheduled = false - pcall(fn, _unpack(args)) - end) - end - end - - -- Debounced invalidation + optional immediate lightweight recalc for responsiveness - -- Assign to outer variable (do NOT use local here) so external callers use the debounce - -- IMPROVED: Faster debounce (50ms) for quicker monster detection - debouncedInvalidateAndRecalc = makeDebounce(50, function() - invalidateCache() - -- Also refresh live count for accuracy - if EventTargeting and EventTargeting.refreshLiveCount then - EventTargeting.refreshLiveCount() - end - -- Schedule a lightweight recalc to update cache quickly (non-blocking) - schedule(20, function() - pcall(function() - if recalculateBestTarget then - recalculateBestTarget() - end - end) - end) - end) - - EventBus.on("monster:appear", function(creature) - if tbOff() then return end - if creature then debouncedInvalidateAndRecalc() end - end, 20) - - EventBus.on("monster:disappear", function(creature) - if tbOff() then return end - debouncedInvalidateAndRecalc() - end, 20) - - EventBus.on("creature:move", function(creature, oldPos) - if tbOff() then return end - -- Safe check for monster - local okMonster, isMonster = pcall(function() return creature and creature:isMonster() end) - if okMonster and isMonster then - debouncedInvalidateAndRecalc() - end - end, 20) - - EventBus.on("monster:health", function(creature, percent) - if tbOff() then return end - debouncedInvalidateAndRecalc() - end, 20) - - EventBus.on("player:move", function(newPos, oldPos) - if tbOff() then return end - -- FLOOR CHANGE OPTIMIZATION: Skip recalc on z-change; no valid targets on new floor yet. - if newPos and oldPos and newPos.z ~= oldPos.z then return end - -- Player movement changes proximity; trigger recalculation - debouncedInvalidateAndRecalc() - end, 10) - - -- Z-CHANGE: Clear stale caches so TargetBot retargets on new floor instantly - EventBus.on("player:z_change_settled", function() - if tbOff() then return end - if MonsterAI and MonsterAI.Reachability then - MonsterAI.Reachability.clearCache() - MonsterAI.Reachability.blockedCreatures = {} - end - invalidateCache() - if recalculateBestTarget then - recalculateBestTarget() - end - end, 5) - - EventBus.on("combat:target", function(creature, oldCreature) - if tbOff() then return end - debouncedInvalidateAndRecalc() - end, 20) - - -------------------------------------------------------------------------------- - -- FOLLOW PLAYER INTEGRATION (Party Hunt Support) - -- When Force Follow mode is active, TargetBot should reduce aggression - -- to allow the follower to catch up with the party leader - -------------------------------------------------------------------------------- - local followPlayerForceMode = false - local followPlayerForceExpiry = 0 - local FORCE_FOLLOW_COMBAT_WINDOW_MS = 2500 -- How long to allow combat before forcing follow - - -- Listen for force follow mode activation from tools.lua - EventBus.on("followplayer/force_follow", function(leaderPos, distance) - if tbOff() then return end - pcall(function() - followPlayerForceMode = true - followPlayerForceExpiry = now + FORCE_FOLLOW_COMBAT_WINDOW_MS - -- When force follow triggers, allow CaveBot/walking immediately - -- This ensures the follower can catch up to the leader - cavebotAllowance = now + 100 - end) - end, 100) -- High priority to ensure timely response - - -- Listen for follow player being enabled/disabled - EventBus.on("followplayer/enabled", function(playerName) - if tbOff() then return end - pcall(function() - -- Reset force mode when following starts - followPlayerForceMode = false - followPlayerForceExpiry = 0 - end) - end, 80) - - EventBus.on("followplayer/disabled", function() - if tbOff() then return end - pcall(function() - -- Clear force mode when following stops - followPlayerForceMode = false - followPlayerForceExpiry = 0 - end) - end, 80) - - -- Export force follow check for use in main targeting loop - TargetBot.isForceFollowActive = function() - if not followPlayerForceMode then return false end - if now > followPlayerForceExpiry then - followPlayerForceMode = false - return false - end - return true - end - - -- Allow external access to reset force follow (e.g., when monsters are all dead) - TargetBot.clearForceFollow = function() - followPlayerForceMode = false - followPlayerForceExpiry = 0 - end -end - --- Fallback: If EventBus isn't available (older clients or different environments), hook into global creature events -if not EventBus then - if onCreatureAppear then - onCreatureAppear(function(creature) - if creature then - invalidateCache() - -- Debounced recalc to reduce CPU churn on rapid events - if debouncedInvalidateAndRecalc then debouncedInvalidateAndRecalc() end - end - end) - end - if onCreatureDisappear then - onCreatureDisappear(function(creature) - invalidateCache() - if debouncedInvalidateAndRecalc then debouncedInvalidateAndRecalc() end - end) - end - if onCreatureMove then - onCreatureMove(function(creature, oldPos) - -- Safe check for monster - local okMonster, isMonster = pcall(function() return creature and creature:isMonster() end) - if okMonster and isMonster then - invalidateCache() - if debouncedInvalidateAndRecalc then debouncedInvalidateAndRecalc() end - end - end) - end -end - --- Reset attack watchdog on relogin (player health re-appears) -if onPlayerHealthChange then - onPlayerHealthChange(function(healthPercent) - if healthPercent and healthPercent > 0 then - attackWatchdog.attempts = 0 - attackWatchdog.lastForce = 0 - -- Start an aggressive relogin recovery window if TargetBot was enabled before or is currently on - -- Check UnifiedStorage first, only fallback to storage.targetbotEnabled if UnifiedStorage value is nil - local storedEnabled = nil - if UnifiedStorage then - storedEnabled = UnifiedStorage.get("targetbot.enabled") - end - if storedEnabled == nil then - storedEnabled = storage.targetbotEnabled - end - - -- Only start recovery if user EXPLICITLY enabled it (storedEnabled == true) - -- If storedEnabled is nil or false, don't auto-enable - if storedEnabled ~= true then - -- User didn't explicitly enable it, don't auto-enable - return - end - - -- CRITICAL: Check explicitlyDisabled flag - if user turned it off, don't recover - if TargetBot and TargetBot.explicitlyDisabled then - return - end - - if TargetBot and TargetBot.isOn then - reloginRecovery.active = true - reloginRecovery.endTime = now + reloginRecovery.duration - reloginRecovery.lastAttempt = 0 - - -- Force immediate cache refresh and attempt recovery hits - -- Update local player reference (in case object changed on relogin) - local Client = getClient() - player = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) or player - debouncedInvalidateAndRecalc() - - -- If TargetBot was previously enabled via storage, ensure it's on now to allow recovery - -- CRITICAL: Never auto-enable if user explicitly disabled it this session - if storedEnabled == true and not TargetBot.isOn() and not TargetBot.explicitlyDisabled then - pcall(function() TargetBot.setOn() end) - end - - -- Update UI status so user sees recovery in progress - if ui and ui.status and ui.status.right then setStatusRight("Recovering...") end - - -- Schedule repeated attempts (aggressive recovery window) - if targetbotMacro then - local function attemptRecovery() - -- CRITICAL: Never recover if explicitly disabled by user - if TargetBot and TargetBot.explicitlyDisabled then - reloginRecovery.active = false - return - end - - -- Only attempt recovery runs if targetbot should be enabled - local storedEnabled2 = (UnifiedStorage and UnifiedStorage.get("targetbot.enabled")) - if storedEnabled2 == nil then storedEnabled2 = storage.targetbotEnabled end - -- Only attempt recovery if EXPLICITLY enabled, not just nil - if storedEnabled2 == true and TargetBot.isOn() then - pcall(targetbotMacro) - -- After macro run, try recalc and a direct attack as a backup - local ok2, best2 = pcall(function() return recalculateBestTarget() end) - if ok2 then - local count = CreatureCache.monsterCount or 0 - if ui and ui.status and ui.status.right then - if best2 and best2.creature then - setStatusRight("Recovering ("..tostring(count)..") best: "..best2.creature:getName()) - else - setStatusRight("Recovering ("..tostring(count)..")") - end - end - if best2 and best2.creature then - pcall(function() TargetBot.requestAttack(best2.creature, "relogin_recovery") end) - end - end - end - end - schedule(200, attemptRecovery) - schedule(600, attemptRecovery) - schedule(1200, attemptRecovery) - schedule(2500, attemptRecovery) - schedule(5000, attemptRecovery) - schedule(8000, attemptRecovery) - schedule(12000, attemptRecovery) - end - - -- Mark recovery window active (already set) and let watchdog handle disabling later - end - end - end) -end - --- LRU eviction helper: move ID to end of access order -local function touchCreature(id) - local order = CreatureCache.accessOrder - -- Remove existing position - for i = #order, 1, -1 do - if order[i] == id then - table.remove(order, i) - break - end - end - -- Add to end (most recently used) - order[#order + 1] = id -end - --- LRU eviction: remove oldest entries when over capacity -local function evictOldestCreatures() - local order = CreatureCache.accessOrder - while #order > CreatureCache.maxSize do - local oldestId = order[1] - -- Shift array left by 1 - for i = 1, #order - 1 do - order[i] = order[i + 1] - end - order[#order] = nil - if CreatureCache.monsters[oldestId] then - CreatureCache.monsters[oldestId] = nil - CreatureCache.monsterCount = CreatureCache.monsterCount - 1 - end - end -end - --- Clean up stale cache entries (improved with LRU) -local function cleanupCache() - if now - CreatureCache.lastCleanup < CreatureCache.CLEANUP_INTERVAL then - return - end - - local cutoff = now - 3000 -- Reduced from 5s to 3s for faster cleanup - local newMonsters = {} - local newOrder = {} - local count = 0 - - -- Keep only recent entries in access order - for i = 1, #CreatureCache.accessOrder do - local id = CreatureCache.accessOrder[i] - local data = CreatureCache.monsters[id] - if data and data.lastUpdate > cutoff and data.creature and not data.creature:isDead() then - newMonsters[id] = data - newOrder[#newOrder + 1] = id - count = count + 1 - end - end - - CreatureCache.monsters = newMonsters - CreatureCache.accessOrder = newOrder - CreatureCache.monsterCount = count - CreatureCache.lastCleanup = now - invalidateCache() -end - --- Update a single creature in cache (called on events) --- OPTIMIZED: Uses consolidated validateCreature for reduced pcall overhead -local function updateCreatureInCache(creature) - -- Use optimized validation (single pcall for all properties) - local cv = validateCreature(creature) - - -- Handle dead or invalid creature - if not cv.valid or cv.isDead then - local id = cv.id or getCreatureId(creature) - if id and CreatureCache.monsters[id] then - CreatureCache.monsters[id] = nil - CreatureCache.monsterCount = CreatureCache.monsterCount - 1 - -- Remove from access order - for i = #CreatureCache.accessOrder, 1, -1 do - if CreatureCache.accessOrder[i] == id then - table.remove(CreatureCache.accessOrder, i) - break - end - end - end - invalidateCache() - return - end - - -- Skip non-monsters - if not cv.isMonster then return end - - local id = cv.id - if not id then return end - - -- Get player position (separate call as player is a different object) - local okPos, pos = pcall(function() return player:getPosition() end) - local cpos = cv.position - if not okPos or not pos or not cpos then return end - - -- Use TargetBotCore distance if available, otherwise calculate - local dist - if Core.Geometry and Core.Geometry.chebyshevDistance then - dist = Core.Geometry.chebyshevDistance(pos, cpos) - else - dist = math.max(math.abs(pos.x - cpos.x), math.abs(pos.y - cpos.y)) - end - - -- Skip if too far (reduced from 10 to 8 for better performance) - if dist > 8 then - if CreatureCache.monsters[id] then - CreatureCache.monsters[id] = nil - CreatureCache.monsterCount = CreatureCache.monsterCount - 1 - for i = #CreatureCache.accessOrder, 1, -1 do - if CreatureCache.accessOrder[i] == id then - table.remove(CreatureCache.accessOrder, i) - break - end - end - end - return - end - - local entry = CreatureCache.monsters[id] - if not entry then - entry = { - creature = creature, - lastUpdate = now, - distance = dist - } - CreatureCache.monsters[id] = entry - CreatureCache.monsterCount = CreatureCache.monsterCount + 1 - touchCreature(id) - -- Check if we need to evict - evictOldestCreatures() - else - entry.creature = creature - entry.lastUpdate = now - entry.distance = dist - touchCreature(id) - end - - -- Recalculate path if needed - if not entry.path or now - (entry.pathTime or 0) > CreatureCache.PATH_TTL then - entry.path = findPath(pos, cpos, 10, PATH_PARAMS) - entry.pathTime = now - end - - invalidateCache() -end - --- Remove creature from cache (with LRU cleanup) -local function removeCreatureFromCache(creature) - if not creature then return end - local id = creature:getId() - if CreatureCache.monsters[id] then - CreatureCache.monsters[id] = nil - CreatureCache.monsterCount = CreatureCache.monsterCount - 1 - -- Remove from LRU order - for i = #CreatureCache.accessOrder, 1, -1 do - if CreatureCache.accessOrder[i] == id then - table.remove(CreatureCache.accessOrder, i) - break - end - end - invalidateCache() - end -end - --------------------------------------------------------------------------------- --- EventBus Integration for Event-Driven Targeting --------------------------------------------------------------------------------- -if EventBus then - -- Monster appears - add to cache - EventBus.on("monster:appear", function(creature) - if tbOff() then return end - updateCreatureInCache(creature) - end, 50) - - -- Monster disappears - remove from cache - EventBus.on("monster:disappear", function(creature) - if tbOff() then return end - removeCreatureFromCache(creature) - end, 50) - - -- Monster health changes - update priority (high priority for targeting decisions) - EventBus.on("monster:health", function(creature, percent) - if tbOff() then return end - if percent <= 0 then - removeCreatureFromCache(creature) - else - -- Invalidate to recalculate priority for wounded monsters - invalidateCache() - end - end, 80) - - -- Player moves - need to recalculate paths - EventBus.on("player:move", function(newPos, oldPos) - if tbOff() then return end - - -- FLOOR CHANGE OPTIMIZATION: On z-change, old-floor creatures are irrelevant. - -- Skip the expensive iteration; just invalidate + clear skip list. - if newPos and oldPos and newPos.z ~= oldPos.z then - invalidateCache() - if AttackStateMachine and AttackStateMachine.clearSkipList then - AttackStateMachine.clearSkipList() - end - return - end - - -- Same-floor move: invalidate paths so distances are recalculated - for id, data in pairs(CreatureCache.monsters) do - data.path = nil - data.pathTime = nil - end - invalidateCache() - end, 60) - - -- Target changes - -- CRITICAL FIX: OpenTibiaBR transiently fires combat:target(nil). - -- Immediately emitting "targetbot/combat_end" disables ChaseModeEnforcer and - -- triggers chase disable cascades. Defer the nil branch. - local lastCombatTargetId = nil - local _combatEndPending = nil - local COMBAT_END_GRACE_MS = 1200 - - EventBus.on("combat:target", function(creature, oldCreature) - if tbOff() then return end - invalidateCache() - - local newId = creature and creature:getId() or nil - if newId ~= lastCombatTargetId then - if creature then - -- Cancel any pending combat_end - if _combatEndPending then - removeEvent(_combatEndPending) - _combatEndPending = nil - end - -- Combat started - if UnifiedStorage then UnifiedStorage.set("targetbot.combatActive", true) else storage.targetbotCombatActive = true end - pcall(function() - EventBus.emit("targetbot/combat_start", creature, { id = newId, pos = creature:getPosition() }) - end) - lastCombatTargetId = newId - else - -- Defer combat_end to survive transient nils - if _combatEndPending then return end - _combatEndPending = schedule(COMBAT_END_GRACE_MS, function() - _combatEndPending = nil - -- Only emit combat_end if ASM also agrees target is gone - if AttackStateMachine and AttackStateMachine.isActive and AttackStateMachine.isActive() then - return -- ASM still managing — transient nil - end - if UnifiedStorage then UnifiedStorage.set("targetbot.combatActive", false) else storage.targetbotCombatActive = false end - pcall(function() EventBus.emit("targetbot/combat_end") end) - lastCombatTargetId = nil - end) - end - end - end, 70) - - -- Monitor player health to emit emergency events - EventBus.on("player:health", function(health, maxHealth, oldHealth, oldMax) - if tbOff() then return end - local cfg = (UnifiedStorage and UnifiedStorage.get("targetbot.priority")) or (ProfileStorage and ProfileStorage.get and ProfileStorage.get('targetPriority')) or {} - local threshold = cfg and cfg.emergencyHP or 25 - local percent = 100 - if maxHealth and maxHealth > 0 then percent = math.floor(health / maxHealth * 100) end - local currentEmergency = (UnifiedStorage and UnifiedStorage.get("targetbot.emergency")) or storage.targetbotEmergency - if percent <= threshold and not currentEmergency then - if UnifiedStorage then UnifiedStorage.set("targetbot.emergency", true) else storage.targetbotEmergency = true end - pcall(function() EventBus.emit("targetbot/emergency", percent) end) - elseif percent > threshold and currentEmergency then - if UnifiedStorage then UnifiedStorage.set("targetbot.emergency", false) else storage.targetbotEmergency = false end - pcall(function() EventBus.emit("targetbot/emergency_cleared", percent) end) - end - end, 90) - - -- ============================================================================ - -- EVENT-DRIVEN MOVEMENT INTENTS (Chase, KeepDistance, FinishKill) - -- React instantly to creature movements instead of polling - -- ============================================================================ - - -- Track active movement config (set by creature_attack when processing targets) - TargetBot.ActiveMovementConfig = TargetBot.ActiveMovementConfig or { - chase = false, - keepDistance = false, - keepDistanceRange = 4, - finishKillThreshold = 30, - anchor = nil, - anchorRange = 5 - } - - -- Event-driven keepDistance: when target moves, adjust position - EventBus.on("creature:move", function(creature, oldPos) - -- Skip if TargetBot is disabled - if TargetBot and TargetBot.isOn and not TargetBot.isOn() then - return - end - - -- Safe check for monster - local okMonster, isMonster = pcall(function() return creature and creature:isMonster() end) - if not okMonster or not isMonster then return end - - -- Check if this is our current target - local Client = getClient() - local target = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) - if not target then return end - - -- Safe ID comparison - local okCid, cid = pcall(function() return creature:getId() end) - local okTid, tid = pcall(function() return target:getId() end) - if not okCid or not okTid or cid ~= tid then return end - - local config = TargetBot.ActiveMovementConfig - if not config then return end - - -- Safe position access - local okPpos, playerPos = pcall(function() return player and player:getPosition() end) - local okCpos, creaturePos = pcall(function() return creature:getPosition() end) - if not okPpos or not playerPos or not okCpos or not creaturePos then return end - - local dist = math.max(math.abs(playerPos.x - creaturePos.x), math.abs(playerPos.y - creaturePos.y)) - - -- KeepDistance: target moved, check if we need to reposition - if config.keepDistance then - local keepRange = config.keepDistanceRange or 4 - - -- Only react if distance changed significantly - if dist < keepRange - 1 or dist > keepRange + 2 then - -- Calculate ideal position - local dx = creaturePos.x - playerPos.x - local dy = creaturePos.y - playerPos.y - local currentDist = math.sqrt(dx * dx + dy * dy) - - if currentDist > 0 then - local ratio = keepRange / currentDist - local keepPos = { - x = math.floor(creaturePos.x - dx * ratio + 0.5), - y = math.floor(creaturePos.y - dy * ratio + 0.5), - z = playerPos.z - } - - -- Check anchor constraint - local anchorValid = true - if config.anchor then - local anchorDist = math.max( - math.abs(keepPos.x - config.anchor.x), - math.abs(keepPos.y - config.anchor.y) - ) - anchorValid = anchorDist <= (config.anchorRange or 5) - end - - if anchorValid and MovementCoordinator and MovementCoordinator.Intent then - local confidence = 0.55 - if dist < keepRange - 1 then - confidence = 0.70 -- Too close - higher urgency - end - - MovementCoordinator.Intent.register( - MovementCoordinator.CONSTANTS.INTENT.KEEP_DISTANCE, - keepPos, - confidence, - "keepdist_event", - {triggered = "target_move", currentDist = dist, targetDist = keepRange} - ) - end - end - end - end - - -- Chase: target moved away, register chase intent - if config.chase and not config.keepDistance and dist > 1 then - -- Confidence values adjusted to pass CHASE threshold (0.60) - local confidence = 0.62 -- Base now passes threshold - if dist <= 3 then confidence = 0.68 end - if dist >= 5 then confidence = 0.75 end - - -- Check anchor constraint - local anchorValid = true - if config.anchor then - local anchorDist = math.max( - math.abs(creaturePos.x - config.anchor.x), - math.abs(creaturePos.y - config.anchor.y) - ) - anchorValid = anchorDist <= (config.anchorRange or 5) - end - - if anchorValid and MovementCoordinator and MovementCoordinator.Intent then - MovementCoordinator.Intent.register( - MovementCoordinator.CONSTANTS.INTENT.CHASE, - creaturePos, - confidence, - "chase_target_move", - {triggered = "target_move", dist = dist} - ) - end - end - end, 15) -- Medium-high priority - - -- Event-driven finish kill: when low-HP target moves - EventBus.on("monster:health", function(creature, percent) - if tbOff() then return end - if not creature then return end - - -- Check if this is our target (safe) - local Client = getClient() - local target = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) - if not target then return end - - -- Safe ID comparison - local okCid, cid = pcall(function() return creature:getId() end) - local okTid, tid = pcall(function() return target:getId() end) - if not okCid or not okTid or cid ~= tid then return end - - local config = TargetBot.ActiveMovementConfig - local threshold = config and config.finishKillThreshold or 30 - - if percent and percent < threshold and percent > 0 then - -- Safe position access - local okPpos, playerPos = pcall(function() return player and player:getPosition() end) - local okCpos, creaturePos = pcall(function() return creature:getPosition() end) - if not okPpos or not playerPos or not okCpos or not creaturePos then return end - - local dist = math.max(math.abs(playerPos.x - creaturePos.x), math.abs(playerPos.y - creaturePos.y)) - - if dist > 1 and MovementCoordinator and MovementCoordinator.Intent then - local confidence = 0.65 - if percent < 15 then confidence = 0.80 end - if percent < 10 then confidence = 0.90 end - - MovementCoordinator.Intent.register( - MovementCoordinator.CONSTANTS.INTENT.FINISH_KILL, - creaturePos, - confidence, - "finish_kill_hp", - {triggered = "health_change", hp = percent, dist = dist} - ) - end - end - end, 25) - - -- Emit event when target is acquired for other modules - EventBus.on("combat:target", function(creature, oldCreature) - if tbOff() then return end - if creature and MovementCoordinator then - -- Notify MovementCoordinator of new target for chase tracking (safe) - pcall(function() - local okPos, pos = pcall(function() return creature:getPosition() end) - EventBus.emit("targetbot/target_acquired", creature, okPos and pos or nil) - end) - end - end, 65) -end - --------------------------------------------------------------------------------- --- NATIVE CHASE MODE ENFORCEMENT (EventBus-Driven) --- --- When TargetBot has "Chase" enabled for the current target config, force the --- client's native chase mode to stay active. This uses the OTClient API: --- g_game.setChaseMode(0) = DontChase (Stand) --- g_game.setChaseMode(1) = ChaseOpponent (Chase) --- --- The client emits "onChaseModeChange" when the UI or other modules change it. --- We listen for this and re-enforce chase mode if TargetBot requires it. --------------------------------------------------------------------------------- - --- Chase mode enforcement state -local ChaseModeEnforcer = { - enabled = false, -- Whether enforcement is active - lastEnforcedMode = nil, -- Last mode we enforced - lastEnforceTime = 0, -- Timestamp of last enforcement - enforceCooldown = 100, -- Minimum ms between enforcements to avoid spam - shouldChase = false, -- Current desired chase state from config - keepDistance = false -- Keep distance mode (overrides chase) -} - --- Update chase mode based on current config -local function updateChaseModeFromConfig() - local config = TargetBot.ActiveMovementConfig - if not config then return end - - ChaseModeEnforcer.shouldChase = config.chase == true - ChaseModeEnforcer.keepDistance = config.keepDistance == true -end - --- Enforce the chase mode based on TargetBot config -local function enforceChaseModeNow() - if not TargetBot.isOn or not TargetBot.isOn() then - ChaseModeEnforcer.enabled = false - return - end - - -- Rate limiting - local currentTime = now or (os.time() * 1000) - if (currentTime - ChaseModeEnforcer.lastEnforceTime) < ChaseModeEnforcer.enforceCooldown then - return - end - - updateChaseModeFromConfig() - - -- Determine desired mode: chase=1, keepDistance or no chase=0 - local desiredMode = 0 -- Default: Stand - if ChaseModeEnforcer.shouldChase and not ChaseModeEnforcer.keepDistance then - desiredMode = 1 -- ChaseOpponent - end - - -- Only enforce if we're actively attacking - local Client = getClient() - local isAttacking = (Client and Client.isAttacking) and Client.isAttacking() or (g_game and g_game.isAttacking and g_game.isAttacking()) - if not isAttacking then - ChaseModeEnforcer.enabled = false - return - end - - ChaseModeEnforcer.enabled = true - - -- Check current mode and enforce if different - local currentMode = (Client and Client.getChaseMode) and Client.getChaseMode() or (g_game and g_game.getChaseMode and g_game.getChaseMode()) or 0 - if currentMode ~= desiredMode then - if Client and Client.setChaseMode then - Client.setChaseMode(desiredMode) - ChaseModeEnforcer.lastEnforcedMode = desiredMode - ChaseModeEnforcer.lastEnforceTime = currentTime - - -- Emit event for other modules - if EventBus then - pcall(function() - EventBus.emit("targetbot/chase_mode_enforced", desiredMode, desiredMode == 1 and "chase" or "stand") - end) - end - elseif g_game and g_game.setChaseMode then - g_game.setChaseMode(desiredMode) - ChaseModeEnforcer.lastEnforcedMode = desiredMode - ChaseModeEnforcer.lastEnforceTime = currentTime - - if EventBus then - pcall(function() - EventBus.emit("targetbot/chase_mode_enforced", desiredMode, desiredMode == 1 and "chase" or "stand") - end) - end - end - end -end - --- EventBus integration for chase mode enforcement -if EventBus then - -- When target is acquired, enforce chase mode - EventBus.on("targetbot/target_acquired", function(creature, creaturePos) - if tbOff() then return end - pcall(function() - enforceChaseModeNow() - end) - end, 60) - - -- When combat starts, enforce chase mode - EventBus.on("targetbot/combat_start", function(creature, data) - if tbOff() then return end - pcall(function() - enforceChaseModeNow() - end) - end, 60) - - -- When combat ends, reset enforcement - EventBus.on("targetbot/combat_end", function() - if tbOff() then return end - pcall(function() - ChaseModeEnforcer.enabled = false - end) - end, 60) - - -- Listen for player movement - re-check chase mode - EventBus.on("player:move", function(newPos, oldPos) - if tbOff() then return end - -- FLOOR CHANGE OPTIMIZATION: No target to chase on new floor yet. - if newPos and oldPos and newPos.z ~= oldPos.z then return end - if ChaseModeEnforcer.enabled then - pcall(function() - enforceChaseModeNow() - end) - end - end, 5) - - -- Listen for target movement - re-check chase mode - EventBus.on("creature:move", function(creature, oldPos) - if tbOff() then return end - if not ChaseModeEnforcer.enabled then return end - local target = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) - if not target then return end - - local okCid, cid = pcall(function() return creature:getId() end) - local okTid, tid = pcall(function() return target:getId() end) - if okCid and okTid and cid == tid then - pcall(function() - enforceChaseModeNow() - end) - end - end, 5) -end - --- Hook into OTClient's native chase mode change callback --- This fires when the UI or other modules change chase mode -if g_game and type(g_game) == "table" then - -- Use connect to listen for onChaseModeChange if available - local function onNativeChaseModeChange(newMode) - if not ChaseModeEnforcer.enabled then return end - if not TargetBot.isOn or not TargetBot.isOn() then return end - - updateChaseModeFromConfig() - - local desiredMode = 0 - if ChaseModeEnforcer.shouldChase and not ChaseModeEnforcer.keepDistance then - desiredMode = 1 - end - - -- If someone changed it to something we don't want, re-enforce after a short delay - if newMode ~= desiredMode then - schedule(50, function() - if ChaseModeEnforcer.enabled and TargetBot.isOn and TargetBot.isOn() then - enforceChaseModeNow() - end - end) - end - end - - -- Try to connect to the native callback - pcall(function() - connect(g_game, { onChaseModeChange = onNativeChaseModeChange }) - end) -end - --- Public API for other modules -TargetBot.ChaseModeEnforcer = ChaseModeEnforcer -TargetBot.enforceChaseModeNow = enforceChaseModeNow - --- PERFORMANCE: Path cache for backward compatibility (optimized) -local PathCache = { - paths = {}, - TTL = 500, - lastCleanup = 0, - cleanupInterval = 2000, - size = 0, - maxSize = 30 -} - --- Pre-allocated cache entry to reduce GC -local cacheEntryPool = {} - -local function acquireCacheEntry() - local entry = table.remove(cacheEntryPool) - return entry or { path = nil, timestamp = 0 } -end - -local function releaseCacheEntry(entry) - if #cacheEntryPool < 20 then - entry.path = nil - entry.timestamp = 0 - cacheEntryPool[#cacheEntryPool + 1] = entry - end -end - -local function cleanupPathCache() - if now - PathCache.lastCleanup < PathCache.cleanupInterval then - return - end - - local cutoff = now - PathCache.TTL - for id, data in pairs(PathCache.paths) do - if data.timestamp < cutoff then - releaseCacheEntry(data) - PathCache.paths[id] = nil - PathCache.size = PathCache.size - 1 - end - end - PathCache.lastCleanup = now -end - --- ui -local configWidget = UI.Config() -local ui = UI.createWidget("TargetBotPanel") - -ui.list = ui.listPanel.list -- shortcut -TargetBot.targetList = ui.list -TargetBot.Looting.setup() - --- Setup eat food feature if available -if TargetBot.EatFood and TargetBot.EatFood.setup then - TargetBot.EatFood.setup() -end - -ui.status.left:setText("Status:") -setStatusRight("Off") -ui.target.left:setText("Target:") -setWidgetTextSafe(ui.target.right, "-") -ui.config.left:setText("Config:") -setWidgetTextSafe(ui.config.right, "-") -ui.danger.left:setText("Danger:") -setWidgetTextSafe(ui.danger.right, "0") - -if ui and ui.editor and ui.editor.debug then ui.editor.debug:destroy() end - -local oldTibia = getClientVersion() < 960 - --- config, its callback is called immediately, data can be nil --- Config setup moved down to after macro (to ensure macro and recalc exist before callback runs) --- See vBot for reference: https://github.com/Vithrax/vBot - --- Setup UI tooltips -ui.editor.buttons.add:setTooltip("Add a new creature targeting configuration.\nDefine which creatures to attack and how.") -ui.editor.buttons.edit:setTooltip("Edit the selected creature targeting configuration.\nModify priority, distance, and behavior settings.") -ui.editor.buttons.remove:setTooltip("Remove the selected creature targeting configuration.\nThis action cannot be undone.") - -ui.configButton:setTooltip("Show/hide the target editor panel.\nUse to add, edit, or remove creature configurations.") - --- setup ui -ui.editor.buttons.add.onClick = function() - TargetBot.Creature.edit(nil, function(newConfig) - TargetBot.Creature.addConfig(newConfig, true) - TargetBot.save() - end) -end - -ui.editor.buttons.edit.onClick = function() - local entry = ui.list:getFocusedChild() - if not entry then return end - TargetBot.Creature.edit(entry.value, function(newConfig) - entry:setText(newConfig.name) - entry.value = newConfig - TargetBot.Creature.resetConfigsCache() - TargetBot.save() - end) -end - -ui.editor.buttons.remove.onClick = function() - local entry = ui.list:getFocusedChild() - if not entry then return end - entry:destroy() - TargetBot.Creature.resetConfigsCache() - TargetBot.save() -end - --- public function, you can use them in your scripts -TargetBot.isActive = function() -- return true if attacking or looting takes place - return lastAction + 300 > now -end - -TargetBot.isCaveBotActionAllowed = function() - return cavebotAllowance > now -end - --- ═══════════════════════════════════════════════════════════════════════════ --- MONSTER DETECTION FOR CAVEBOT (v3.0) --- Check if there are targetable monsters on screen that should block cavebot --- This prevents the bot from leaving monsters behind --- ═══════════════════════════════════════════════════════════════════════════ -TargetBot.hasTargetableMonstersOnScreen = function() - if not TargetBot.isOn() then return false end - - local p = player and player:getPosition() - if not p then return false end - - -- Get all creatures in detection range - local Client = getClient() - local creatures = (SpectatorCache and SpectatorCache.getNearby(MONSTER_DETECTION_RANGE, MONSTER_DETECTION_RANGE)) - or ((Client and Client.getSpectatorsInRange) and Client.getSpectatorsInRange(p, false, MONSTER_DETECTION_RANGE, MONSTER_DETECTION_RANGE)) - or (g_map and g_map.getSpectatorsInRange and g_map.getSpectatorsInRange(p, false, MONSTER_DETECTION_RANGE, MONSTER_DETECTION_RANGE)) - - if not creatures or #creatures == 0 then return false end - - local monsterCount = 0 - local playerZ = p.z - - for i = 1, #creatures do - local creature = creatures[i] - if creature then - local okMonster, isMonster = pcall(function() return creature:isMonster() end) - if okMonster and isMonster then - local okDead, isDead = pcall(function() return creature:isDead() end) - if not isDead then - local okPos, cpos = pcall(function() return creature:getPosition() end) - if okPos and cpos and cpos.z == playerZ then - -- Check if this creature has a matching TargetBot config (it's targetable) - local cfgs = TargetBot.Creature.getConfigs and TargetBot.Creature.getConfigs(creature) - if cfgs and cfgs[1] then - monsterCount = monsterCount + 1 - end - end - end - end - end - end - - return monsterCount > 0, monsterCount -end - --- Check if bot should wait for monsters (stricter version) -TargetBot.shouldWaitForMonsters = function() - -- If actively attacking, definitely wait - if TargetBot.isActive() then return true end - - -- Check for targetable monsters on screen - local hasMonsters, count = TargetBot.hasTargetableMonstersOnScreen() - if hasMonsters then return true end - - -- Check MonsterAI engagement lock - if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.isEngaged then - local isEngaged = MonsterAI.Scenario.isEngaged() - if isEngaged then return true end - end - - return false -end - -TargetBot.setStatus = function(text) - setStatusRight(text) -end - -TargetBot.getStatus = function() - local t = nil - pcall(function() t = ui.status.right:getText() end) - return t -end - -TargetBot.isOn = function() - if not config then return false end - -- config.isOn may be a function or a boolean - if type(config.isOn) == 'function' then - local ok, res = pcall(config.isOn) - return ok and not not res - end - if type(config.isOn) == 'boolean' then - return config.isOn - end - return false -end - -TargetBot.isOff = function() - if not config or not config.isOff then return true end - return config.isOff() -end - --- ═══════════════════════════════════════════════════════════════════════════ --- TARGETBOT ON/OFF HANDLERS v2.5 - Safer state management with persistence --- Uses explicitlyDisabled flag to prevent auto-enable from any source --- Flag is now persisted to storage to survive reloads --- ═══════════════════════════════════════════════════════════════════════════ - --- Central flag: when true, NO automatic enabling is allowed (recovery, events, etc.) --- v2.5: Load persisted state from storage to survive reloads -local function loadExplicitlyDisabledState() - -- Check storage for persisted state - if UnifiedStorage then - local stored = UnifiedStorage.get("targetbot.explicitlyDisabled") - if stored == true then return true end - end - if storage and storage.targetbotExplicitlyDisabled == true then - return true - end - return false -end - -TargetBot.explicitlyDisabled = loadExplicitlyDisabledState() - --- Timestamp of last user action (for debouncing) -TargetBot._lastUserToggle = 0 - --- v2.4: setOn now checks explicitlyDisabled and has optional 'force' parameter --- Regular calls from CaveBot/lure scripts will be blocked when explicitlyDisabled is true --- Only direct user clicks (force=true) or clearing explicitlyDisabled first will work -TargetBot.setOn = function(val, force) - if val == false then - return TargetBot.setOff(true) - end - - -- CRITICAL: If explicitly disabled and this is NOT a forced (user-initiated) call, block it - if TargetBot.explicitlyDisabled and not force then - -- Don't enable - user explicitly turned it off - return - end - - -- Clear the explicit disable flag - user wants it ON (or force was used) - TargetBot.explicitlyDisabled = false - TargetBot._lastUserToggle = now or os.time() * 1000 - - -- v2.5: Persist the flag to storage so it survives reloads - if UnifiedStorage then - UnifiedStorage.set("targetbot.explicitlyDisabled", false) - end - storage.targetbotExplicitlyDisabled = false - - -- Stop any pending recovery attempts - reloginRecovery.active = false - - -- Save user's choice to storage BEFORE triggering config callback - if UnifiedStorage then - UnifiedStorage.set("targetbot.enabled", true) - else - storage.targetbotEnabled = true - end - - -- Notify MonsterAI and other modules - if EventBus then - pcall(function() EventBus.emit("targetbot/enabled") end) - end - - config.setOn() -- This triggers callback which handles UI update -end - -TargetBot.setOff = function(val) - if val == false then - return TargetBot.setOn(true) - end - - -- SET the explicit disable flag - user wants it OFF, prevent ALL auto-enable - TargetBot.explicitlyDisabled = true - TargetBot._lastUserToggle = now or os.time() * 1000 - - -- v2.5: Persist the flag to storage so it survives reloads - if UnifiedStorage then - UnifiedStorage.set("targetbot.explicitlyDisabled", true) - end - storage.targetbotExplicitlyDisabled = true - - -- IMMEDIATELY stop all recovery and pending operations - reloginRecovery.active = false - reloginRecovery.endTime = 0 - attackWatchdog.attempts = 0 - attackWatchdog.lastForce = 0 - - -- Clear any pending targets in EventTargeting - if EventTargeting and EventTargeting.clearState then - pcall(function() EventTargeting.clearState() end) - end - - -- Cancel current attack - local Client = getClient() - if Client and Client.cancelAttackAndFollow then - pcall(function() Client.cancelAttackAndFollow() end) - elseif g_game and g_game.cancelAttackAndFollow then - pcall(function() g_game.cancelAttackAndFollow() end) - end - - -- Save user's choice to storage BEFORE triggering config callback - if UnifiedStorage then - UnifiedStorage.set("targetbot.enabled", false) - else - storage.targetbotEnabled = false - end - - -- Clear local target lock state - if TargetBot.LocalTargetLock then - TargetBot.LocalTargetLock.targetId = nil - TargetBot.LocalTargetLock.targetHealth = nil - TargetBot.LocalTargetLock.switchCount = 0 - TargetBot.LocalTargetLock.recentTargets = {} - end - - -- Clear creature cache to stop targeting - CreatureCache.monsters = {} - CreatureCache.monsterCount = 0 - CreatureCache.bestTarget = nil - CreatureCache.bestPriority = 0 - CreatureCache.dirty = false - - -- Notify MonsterAI and other modules - if EventBus then - pcall(function() EventBus.emit("targetbot/disabled") end) - end - - -- Update status - setStatusRight("Off") - - config.setOff() -- This triggers callback which handles UI update -end - --- Helper function to check if TargetBot should be active --- This respects the explicitlyDisabled flag -TargetBot.canAttack = function() - -- If explicitly disabled by user, NEVER allow attacks - if TargetBot.explicitlyDisabled then - return false - end - -- Check the normal isOn state - if not TargetBot.isOn or not TargetBot.isOn() then - return false - end - return true -end - -TargetBot.getCurrentProfile = function() - if UnifiedStorage and UnifiedStorage.get("targetbot.selectedConfig") then - return UnifiedStorage.get("targetbot.selectedConfig") - end - return storage._configs.targetbot_configs.selected -end - --- Use shared BotConfigName from configs.lua (DRY) -local botConfigName = BotConfigName or modules.game_bot.contentsPanel.config:getCurrentOption().text -TargetBot.setCurrentProfile = function(name) - if not g_resources.fileExists("/bot/"..botConfigName.."/targetbot_configs/"..name..".json") then - return warn("there is no targetbot profile with that name!") - end - local wasOn = TargetBot.isOn() - TargetBot.setOff() - storage._configs.targetbot_configs.selected = name - -- Save to UnifiedStorage for per-character persistence - if UnifiedStorage then - UnifiedStorage.set("targetbot.selectedConfig", name) - if EventBus and EventBus.emitConfigChange then - EventBus.emitConfigChange("targetbot", name) - end - end - -- Save character's profile preference for multi-client support - if setCharacterProfile then - setCharacterProfile("targetbotProfile", name) - end - -- Only restore enabled state if not explicitly disabled by user - if wasOn and not TargetBot.explicitlyDisabled then - TargetBot.setOn() - end -end - -TargetBot.delay = function(value) - targetbotMacro.delay = now + value -end - -TargetBot.save = function() - local data = {targeting={}, looting={}} - for _, entry in ipairs(ui.list:getChildren()) do - table.insert(data.targeting, entry.value) - end - TargetBot.Looting.save(data.looting) - config.save(data) -end - -TargetBot.allowCaveBot = function(time) - local ms = tonumber(time) or 200 - if ms < 50 then - ms = 50 - end - cavebotAllowance = now + ms -end - -TargetBot.disableLuring = function() - lureEnabled = false -end - -TargetBot.enableLuring = function() - lureEnabled = true -end - --- Relogin recovery configuration and controls -TargetBot.setReloginRecoveryDuration = function(ms) - if type(ms) == 'number' and ms >= 0 then - reloginRecovery.duration = ms - end -end - -TargetBot.enableReloginRecovery = function(duration) - if type(duration) == 'number' and duration >= 0 then - reloginRecovery.duration = duration - end - reloginRecovery.active = true - reloginRecovery.endTime = now + reloginRecovery.duration - reloginRecovery.lastAttempt = 0 -end - -TargetBot.disableReloginRecovery = function() - reloginRecovery.active = false - reloginRecovery.endTime = 0 - reloginRecovery.lastAttempt = 0 -end - -TargetBot.Danger = function() - return dangerValue -end - -TargetBot.lootStatus = function() - return looterStatus -end - - --- attacks -local lastSpell = 0 - - - -local function doSay(text) - if type(text) ~= 'string' or text:len() < 1 then return false end - -- primary: global say - if type(say) == 'function' then - local ok, res = SafeCall.call(say, text) - if ok then return true end - warn("[TargetBot] doSay: say(...) failed") - return false - end - -- fallback: g_game.say - if g_game and type(g_game.say) == 'function' then - local ok, res = SafeCall.call(g_game.say, text) - if ok then return true end - warn("[TargetBot] doSay: g_game.say(...) failed") - return false - end - -- fallback: g_game.talk or g_game.talkLocal - if g_game and type(g_game.talk) == 'function' then - local ok, res = SafeCall.call(g_game.talk, text) - if ok then return true end - warn("[TargetBot] doSay: g_game.talk(...) failed") - return false - end - if g_game and type(g_game.talkLocal) == 'function' then - local ok, res = SafeCall.call(g_game.talkLocal, text) - if ok then return true end - warn("[TargetBot] doSay: g_game.talkLocal(...) failed") - return false - end - return false -end - -TargetBot.saySpell = function(text, delay) - if type(text) ~= 'string' or text:len() < 1 then return false end - if not delay then delay = 500 end - if lastSpell + delay < now then - if not doSay(text) then - warn("[TargetBot] no suitable say/talk method; cannot cast: " .. tostring(text)) - return false - end - lastSpell = now - return true - end - return false -end - -TargetBot.sayAttackSpell = function(text, delay) - if type(text) ~= 'string' or text:len() < 1 then return end - if not delay then delay = 2000 end - - -- Use new AttackSystem if available (EventBus integration, better cooldown tracking) - if BotCore and BotCore.AttackSystem and BotCore.AttackSystem.isEnabled and BotCore.AttackSystem.isEnabled() then - return BotCore.AttackSystem.executeSingleSpell(text, delay) - end - - if lastAttackSpell + delay < now then - say(text) - lastAttackSpell = now - -- Track attack spell for Hunt Analyzer - if HuntAnalytics and HuntAnalytics.trackAttackSpell then - HuntAnalytics.trackAttackSpell(text, 0) -- Mana cost unknown from here - end - return true - end - return false -end - -local lastItemUse = 0 -local lastRuneAttack = 0 - -TargetBot.useItem = function(item, subType, target, delay) - -- Prefer AttackBot implementation if available - if AttackBot and type(AttackBot.useItem) == 'function' then - return AttackBot.useItem(item, subType, target, delay) - end - if not delay then delay = 200 end - if lastItemUse + delay < now then - warn("[TargetBot] useItem called but AttackBot.useItem not available; item=" .. tostring(item)) - lastItemUse = now - end - return false -end - -TargetBot.useAttackItem = function(item, subType, target, delay) - -- Prefer AttackBot implementation if available - if AttackBot and type(AttackBot.useAttackItem) == 'function' then - return AttackBot.useAttackItem(item, subType, target, delay) - end - if not delay then delay = 2000 end - - -- Use new AttackSystem if available (EventBus integration, better cooldown tracking) - if BotCore and BotCore.AttackSystem and BotCore.AttackSystem.isEnabled and BotCore.AttackSystem.isEnabled() then - return BotCore.AttackSystem.executeSingleRune(item, target, delay) - end - - if lastRuneAttack + delay < now then - warn("[TargetBot] useAttackItem called but AttackBot.useAttackItem not available; item=" .. tostring(item)) - lastRuneAttack = now - else - warn("[TargetBot] Rune on cooldown: last=" .. tostring(lastRuneAttack) .. ", now=" .. tostring(now) .. ", delay=" .. tostring(delay)) - end - return false -end - -TargetBot.canLure = function() - return lureEnabled -end - --- Kill Before Walk: Always enabled - wait for monsters to be killed before walking --- This is the default behavior. DynamicLure and SmartPull can bypass when needed. -TargetBot.isKillBeforeWalkEnabled = function() - return true -- Always ON -end - --- Check if there are any targetable monsters on screen --- IMPROVED: Uses EventTargeting live count for accuracy, falls back to cache -TargetBot.hasTargetableMonsters = function() - -- PRIORITY 1: Use EventTargeting live count (most accurate) - if EventTargeting and EventTargeting.getLiveMonsterCount then - local liveCount = EventTargeting.getLiveMonsterCount() - if liveCount > 0 then - return true - end - end - -- PRIORITY 2: Fall back to cache - return CreatureCache.monsterCount > 0 -end - --- Get count of targetable monsters on screen --- IMPROVED: Uses EventTargeting live count for accuracy -TargetBot.getTargetableMonsterCount = function() - -- PRIORITY 1: Use EventTargeting live count (most accurate) - if EventTargeting and EventTargeting.getLiveMonsterCount then - local liveCount = EventTargeting.getLiveMonsterCount() - if liveCount > 0 then - return liveCount - end - end - -- PRIORITY 2: Fall back to cache - return CreatureCache.monsterCount or 0 -end - --- Helper function to check if creature is targetable -local function isTargetableCreature(creature) - if not creature then return false end - - -- Safe check for isDead - local okDead, isDead = pcall(function() return creature:isDead() end) - if okDead and isDead then return false end - - -- Safe check for isMonster - local okMonster, isMonster = pcall(function() return creature:isMonster() end) - if not okMonster or not isMonster then return false end - - -- Health check - skip monsters with 0 HP - local okHp, hp = pcall(function() return creature:getHealthPercent() end) - if okHp and hp and hp <= 0 then return false end - - -- Old Tibia clients don't have creature types - if oldTibia then - return true - end - - -- For new Tibia, check creature type to exclude other player's summons - local okType, creatureType = pcall(function() return creature:getType() end) - if okType and creatureType then - return creatureType < 3 - end - - return true -- Default to true if we can't determine type -end - --------------------------------------------------------------------------------- --- Optimized Main TargetBot Loop --- Uses EventBus-driven cache for reduced CPU usage and better accuracy --- Only recalculates when cache is dirty (events occurred) --------------------------------------------------------------------------------- - --- PERFORMANCE: Path cache to avoid recalculating paths every tick -local pathCache = { - entries = {}, -- {id -> {path, time, pos}} - TTL = 400 -- Path valid for 400ms -} - -local function getCachedPath(creatureId, playerPos, creaturePos) - local entry = pathCache.entries[creatureId] - local currentTime = now or (os.time() * 1000) - if entry and (currentTime - entry.time) < pathCache.TTL then - -- Check if positions are still valid - if entry.playerZ == playerPos.z and entry.creatureZ == creaturePos.z then - return entry.path - end - end - return nil -end - -local function setCachedPath(creatureId, path, playerPos, creaturePos) - pathCache.entries[creatureId] = { - path = path, - time = now or (os.time() * 1000), - playerZ = playerPos.z, - creatureZ = creaturePos.z - } -end - -local function cleanupPathCache() - local currentTime = now or (os.time() * 1000) - local cutoff = currentTime - pathCache.TTL * 2 - for id, entry in pairs(pathCache.entries) do - if entry.time < cutoff then - pathCache.entries[id] = nil - end - end -end - --- Helper: process a creature into target params (returns params, path) - pure helper to reduce duplication --- IMPROVED v2.2: More lenient path validation to prevent "leaving monsters behind" --- PERFORMANCE: Uses path cache to avoid expensive recalculations -local function processCandidate(creature, pos, isCurrentTarget, batchPaths) - if not creature or creature:isDead() or not isTargetableCreature(creature) then return nil, nil end - local cpos = creature:getPosition() - if not cpos then return nil, nil end - - -- Get creature ID for path caching - local okId, creatureId = pcall(function() return creature:getId() end) - creatureId = okId and creatureId or nil - - -- v2.2: Calculate distance first - adjacent creatures should ALWAYS be targetable - local dist = math.max(math.abs(cpos.x - pos.x), math.abs(cpos.y - pos.y)) - - -- ═══════════════════════════════════════════════════════════════════════════ - -- ADJACENCY CHECK (v2.2): Adjacent creatures are ALWAYS reachable - -- This prevents the common issue of skipping monsters that are right next to us - -- ═══════════════════════════════════════════════════════════════════════════ - if dist <= 1 then - -- Creature is adjacent - it's definitely reachable - local params = TargetBot.Creature.calculateParams(creature, {1}) -- Fake minimal path - if params and params.config then - return params, {1} -- Return simple path - end - end - - -- ═══════════════════════════════════════════════════════════════════════════ - -- REACHABILITY CHECK (v3.1): Enhanced with OpenTibiaBR batch pathfinding - -- PERFORMANCE: Check batch paths first, then cache, then calculate new - -- ═══════════════════════════════════════════════════════════════════════════ - - local path = nil - local isReachable = true - - -- OPENTIBIABR ENHANCEMENT: Check batch paths first (fastest) - if creatureId and batchPaths and batchPaths[creatureId] then - path = batchPaths[creatureId].path - if path and #path > 0 then - -- Cache this path for future use - setCachedPath(creatureId, path, pos, cpos) - end - end - - -- PERFORMANCE: Try cached path if no batch path - if not path and creatureId then - path = getCachedPath(creatureId, pos, cpos) - end - - -- If no cached path, calculate new one - if not path then - -- Use MonsterAI.Reachability if available (but don't let it block adjacent/current targets) - if MonsterAI and MonsterAI.Reachability and MonsterAI.Reachability.isReachable and not isCurrentTarget then - local reachResult, reason, cachedPath = MonsterAI.Reachability.isReachable(creature) - if reachResult and cachedPath then - path = cachedPath - elseif not reachResult and dist > 3 then - -- Only skip if truly far and blocked - return nil, nil - end - end - - -- If we don't have a path yet, try pathfinding - if not path then - -- PERFORMANCE: Use only one strategy for non-current targets - local maxStrategies = isCurrentTarget and 2 or 1 - local pathStrategies = { - -- Strategy 1: Standard path params - {ignoreLastCreature = true, ignoreNonPathable = true, ignoreCost = true, ignoreCreatures = true, allowOnlyVisibleTiles = true, precision = 1}, - -- Strategy 2: More relaxed (only for current target) - {ignoreLastCreature = true, ignoreNonPathable = true, ignoreCost = true, ignoreCreatures = true, allowOnlyVisibleTiles = false, precision = 1} - } - - for strategyIdx = 1, maxStrategies do - local params = pathStrategies[strategyIdx] - path = findPath(pos, cpos, 12, params) - if path and #path > 0 then - break - end - end - end - - -- Cache the path result - if creatureId and path then - setCachedPath(creatureId, path, pos, cpos) - end - end - - -- If still no path for nearby creatures, create a simple direction-based path - if (not path or #path == 0) and dist <= 3 then - -- Create a simple path towards the creature - local simplePath = {} - local dx = cpos.x - pos.x - local dy = cpos.y - pos.y - - -- Determine direction - local dir = nil - if dx > 0 and dy < 0 then dir = NorthEast or 4 - elseif dx > 0 and dy > 0 then dir = SouthEast or 5 - elseif dx < 0 and dy > 0 then dir = SouthWest or 6 - elseif dx < 0 and dy < 0 then dir = NorthWest or 7 - elseif dx > 0 then dir = East or 1 - elseif dx < 0 then dir = West or 3 - elseif dy > 0 then dir = South or 2 - elseif dy < 0 then dir = North or 0 - end - - if dir then - for i = 1, dist do - simplePath[i] = dir - end - path = simplePath - end - end - - if not path or #path == 0 then - -- v2.2: Only mark as blocked if really far - if dist > 5 then - if MonsterAI and MonsterAI.Reachability and MonsterAI.Reachability.markBlocked then - MonsterAI.Reachability.markBlocked(creature:getId(), "no_path") - end - end - return nil, nil - end - - -- v2.2: Skip excessive path validation for close targets - -- The original validation was too strict and caused targets to be skipped - local pathLength = #path - if pathLength > 20 and dist > 8 then - -- Only do strict validation for far targets with long paths - local probe = {x = pos.x, y = pos.y, z = pos.z} - for i = 1, math.min(3, pathLength) do -- Reduced from 5 to 3 - local dir = path[i] - local offset = nil - if dir == North or dir == 0 then offset = {x = 0, y = -1} - elseif dir == East or dir == 1 then offset = {x = 1, y = 0} - elseif dir == South or dir == 2 then offset = {x = 0, y = 1} - elseif dir == West or dir == 3 then offset = {x = -1, y = 0} - elseif dir == NorthEast or dir == 4 then offset = {x = 1, y = -1} - elseif dir == SouthEast or dir == 5 then offset = {x = 1, y = 1} - elseif dir == SouthWest or dir == 6 then offset = {x = -1, y = 1} - elseif dir == NorthWest or dir == 7 then offset = {x = -1, y = -1} - end - - if offset then - probe = {x = probe.x + offset.x, y = probe.y + offset.y, z = probe.z} - local Client = getClient() - local tile = (Client and Client.getTile) and Client.getTile(probe) or (g_map and g_map.getTile and g_map.getTile(probe)) - local hasCreature = tile and tile.hasCreature and tile:hasCreature() - if tile and not tile:isWalkable() and not hasCreature then - -- Only fail if truly blocked (not just creature in the way) - return nil, nil - end - end - end - end - - local params = TargetBot.Creature.calculateParams(creature, path) - - -- v2.2: Be more lenient with priority check for close targets - if not params or not params.config then - return nil, nil - end - - -- Only require positive priority for far targets - if params.priority <= 0 and dist > 3 then - return nil, nil - end - - return params, path -end - --- Recalculate best target from cache --- IMPROVED: Uses live creature detection for accuracy --- FIXED: Only count creatures with valid reachable paths -recalculateBestTarget = function() - local pos = player:getPosition() - if not pos then return end - - local function getAdjustedPriority(creature, params, dist) - if not creature or not params then return (params and params.priority) or 0 end - local base = params.priority or 0 - local okHp, hp = pcall(function() return creature:getHealthPercent() end) - hp = okHp and hp or 100 - if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.modifyPriority then - local okId, id = pcall(function() return creature:getId() end) - if okId and id then - base = MonsterAI.Scenario.modifyPriority(id, base, hp) - end - end - if dist then - base = base + math.max(0, (8 - dist)) * 2 - end - base = base + ((100 - hp) * 0.15) - return base - end - - local bestTarget = nil - local bestPriority = 0 - local totalDanger = 0 - local targetCount = 0 - local reachableCount = 0 -- Track only reachable creatures - local unreachableCount = 0 -- Track blocked path creatures - - -- v2.2: Track current target for stickiness - DO NOT lose it during recalculation - local Client = getClient() - local currentAttackTarget = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) - local currentTargetId = currentAttackTarget and currentAttackTarget:getId() or nil - local currentTargetStillValid = false -- Will be set to true if current target is found - - -- IMPROVED: Get creatures from live detection first - local creatures = nil - local liveCount, liveCreatures = 0, nil - if EventTargeting and EventTargeting.getLiveMonsterCount then - liveCount, liveCreatures = EventTargeting.getLiveMonsterCount() - end - - -- If we have live creatures, use those as the authoritative source - if liveCreatures and #liveCreatures > 0 then - creatures = liveCreatures - elseif CreatureCache.monsterCount > 0 and not CreatureCache.dirty then - -- Fall back to cache only if live detection found nothing - -- This handles edge cases where EventTargeting hasn't initialized - local cacheCreatures = {} - for id, data in pairs(CreatureCache.monsters) do - if data.creature and not data.creature:isDead() then - cacheCreatures[#cacheCreatures + 1] = data.creature - end - end - creatures = cacheCreatures - end - - -- ═══════════════════════════════════════════════════════════════════════════ - -- OPENTIBIABR ENHANCEMENT: Use getSightSpectators for line-of-sight detection - -- This gives us only creatures we can actually see (no obstacles between) - -- Falls back to standard detection on non-OpenTibiaBR clients - -- ═══════════════════════════════════════════════════════════════════════════ - if not creatures or #creatures == 0 then - local otbr = getOpenTibiaBRTargeting() - if otbr and hasSightSpectators() then - -- Use OpenTibiaBR's optimized sight spectators - local sightCreatures = otbr.getVisibleCreatures(pos, false) - if sightCreatures and #sightCreatures > 0 then - creatures = sightCreatures - end - end - end - - -- If still no creatures, do a fresh scan with standard methods - if not creatures or #creatures == 0 then - creatures = (MovementCoordinator and MovementCoordinator.MonsterCache and MovementCoordinator.MonsterCache.getNearby) - and MovementCoordinator.MonsterCache.getNearby(MONSTER_DETECTION_RANGE) - or (SpectatorCache and SpectatorCache.getNearby(MONSTER_DETECTION_RANGE, MONSTER_DETECTION_RANGE) - or g_map.getSpectatorsInRange(pos, false, MONSTER_DETECTION_RANGE, MONSTER_DETECTION_RANGE)) - end - - if not creatures then return nil, 0, 0 end - - -- ═══════════════════════════════════════════════════════════════════════════ - -- OPENTIBIABR ENHANCEMENT: Pre-calculate batch paths to all monsters - -- Instead of calculating paths one by one, batch them for ~30-50% speedup - -- ═══════════════════════════════════════════════════════════════════════════ - local batchPaths = nil - if hasBatchPathfinding() then - local otbr = getOpenTibiaBRTargeting() - if otbr then - -- Filter to only targetable monsters first - local targetableMonsters = {} - for i = 1, #creatures do - local creature = creatures[i] - if creature and isTargetableCreature(creature) then - local okPos, cpos = pcall(function() return creature:getPosition() end) - if okPos and cpos and cpos.z == pos.z then - targetableMonsters[#targetableMonsters + 1] = creature - end - end - end - -- Calculate all paths at once - if #targetableMonsters > 0 then - batchPaths = otbr.calculateBatchPaths(pos, targetableMonsters, 50, 0) - end - end - end - - -- Rebuild cache from live creatures - CreatureCache.monsters = {} - CreatureCache.monsterCount = 0 - - local playerZ = pos.z - - -- v2.2: First pass - find current target to ensure it's not skipped - -- This prevents the "leaving monsters behind" issue - local currentTargetParams = nil - local currentTargetPath = nil - - for i = 1, #creatures do - local creature = creatures[i] - if creature and isTargetableCreature(creature) then - local okPos, cpos = pcall(function() return creature:getPosition() end) - if okPos and cpos and cpos.z == playerZ then - local okId, id = pcall(function() return creature:getId() end) - id = id or i - - -- v2.2: Special handling for current target - be more lenient with path validation - local isCurrentTarget = (currentTargetId and id == currentTargetId) - - -- Calculate path and params (pass isCurrentTarget for enhanced path finding) - -- v3.1: Pass batchPaths for OpenTibiaBR optimization - local dist = math.max(math.abs(cpos.x - pos.x), math.abs(cpos.y - pos.y)) - local params, path = processCandidate(creature, pos, isCurrentTarget, batchPaths) - - -- For current target, try harder to find a path if initial attempt failed - if isCurrentTarget and (not path or not params or not params.config) then - -- Try with relaxed path params - local relaxedPath = findPath(pos, cpos, 12, { - ignoreLastCreature = true, - ignoreNonPathable = true, - ignoreCost = true, - ignoreCreatures = true, - allowOnlyVisibleTiles = false -- More relaxed - }) - if relaxedPath and #relaxedPath > 0 then - path = relaxedPath - params = TargetBot.Creature.calculateParams(creature, path) - end - end - - -- IMPROVED: Track all creatures, not just those with perfect paths - -- Creatures with blocked paths can still be targeted if they're close - if path and params and params.config then - params.priority = getAdjustedPriority(creature, params, dist) - -- Creature is reachable - add to cache - CreatureCache.monsters[id] = { - creature = creature, - path = path, - pathTime = now, - lastUpdate = now, - reachable = true - } - CreatureCache.monsterCount = CreatureCache.monsterCount + 1 - reachableCount = reachableCount + 1 - targetCount = targetCount + 1 - totalDanger = totalDanger + (params.danger or 0) - - -- v2.2: Track if current target is still valid - if isCurrentTarget then - currentTargetStillValid = true - currentTargetParams = params - currentTargetPath = path - end - - if params.priority > bestPriority then - bestPriority = params.priority - bestTarget = params - end - elseif isCurrentTarget and not creature:isDead() then - -- v2.2: Current target has no path but is not dead - -- Still add it to cache to prevent "losing" the target - local dist = math.max(math.abs(cpos.x - pos.x), math.abs(cpos.y - pos.y)) - if dist <= 2 then - -- Very close - might just be blocked by other creatures, keep targeting - local fallbackParams = TargetBot.Creature.calculateParams(creature, {1}) -- Fake short path - if fallbackParams and fallbackParams.config then - fallbackParams.priority = getAdjustedPriority(creature, fallbackParams, dist) - CreatureCache.monsters[id] = { - creature = creature, - path = nil, - pathTime = now, - lastUpdate = now, - reachable = false, - closeButBlocked = true - } - CreatureCache.monsterCount = CreatureCache.monsterCount + 1 - currentTargetStillValid = true - currentTargetParams = fallbackParams - - -- Give it a slightly reduced priority but don't abandon it - if fallbackParams.priority * 0.8 > bestPriority then - bestPriority = fallbackParams.priority * 0.8 - bestTarget = fallbackParams - end - end - else - unreachableCount = unreachableCount + 1 - end - else - -- Creature has blocked path - don't add to active targeting - unreachableCount = unreachableCount + 1 - end - end - end - end - - -- v2.2: If current target is still valid, ensure it's not replaced by a marginally better target - -- This implements "target stickiness" at the recalculation level - if currentTargetStillValid and currentTargetParams and bestTarget then - local currentHP = currentAttackTarget:getHealthPercent() - -- If current target is wounded, require higher priority to switch - if currentHP < 70 then - local switchThreshold = 15 -- Need 15+ priority advantage to switch - if currentHP < 50 then switchThreshold = 25 end - if currentHP < 30 then switchThreshold = 40 end - if currentHP < 15 then switchThreshold = 75 end - - if bestTarget ~= currentTargetParams then - local priorityAdvantage = bestTarget.priority - currentTargetParams.priority - if priorityAdvantage < switchThreshold then - -- Not enough priority advantage - keep current target - bestTarget = currentTargetParams - bestPriority = currentTargetParams.priority - end - end - end - end - - -- MonsterAI Scenario integration: prevent illegal switches (anti-zigzag) - if currentTargetStillValid and currentTargetParams and bestTarget and bestTarget ~= currentTargetParams then - if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.shouldAllowTargetSwitch then - local okNewId, newId = pcall(function() return bestTarget.creature and bestTarget.creature:getId() end) - local okNewHp, newHp = pcall(function() return bestTarget.creature and bestTarget.creature:getHealthPercent() end) - if okNewId and newId then - local allowed = MonsterAI.Scenario.shouldAllowTargetSwitch(newId, bestTarget.priority or 0, okNewHp and newHp or nil) - if not allowed then - bestTarget = currentTargetParams - bestPriority = currentTargetParams.priority - end - end - end - end - - CreatureCache.lastFullUpdate = now - - -- Update cache state - CreatureCache.bestTarget = bestTarget - CreatureCache.bestPriority = bestPriority - CreatureCache.totalDanger = totalDanger - CreatureCache.dirty = false - CreatureCache.unreachableCount = unreachableCount - - return bestTarget, reachableCount, totalDanger -end - --- If we deferred enabling steps because core functions weren't ready, perform them now (with retries) -local function performPendingEnableOnce() - if not pendingEnable then - -- Nothing to do - return true - end - if type(recalculateBestTarget) ~= 'function' or not (targetbotMacro and (type(targetbotMacro) == 'function' or type(targetbotMacro.setOn) == 'function')) then - -- core not ready yet; will retry silently - return false - end - pendingEnable = false - -- performing deferred enable steps (core ready) - -- If user requested a particular enabled state, apply it - if pendingEnableDesired ~= nil then - pcall(function() - if targetbotMacro and type(targetbotMacro.setOn) == 'function' then - targetbotMacro.setOn(pendingEnableDesired) - targetbotMacro.delay = nil - end - end) - pendingEnableDesired = nil - end - pcall(function() primeCreatureCache() end) - invalidateCache() - if debouncedInvalidateAndRecalc then debouncedInvalidateAndRecalc() end - schedule(10, function() pcall(function() if type(recalculateBestTarget) == 'function' then recalculateBestTarget() end end) end) - -- DON'T force enable here - respect the user's choice from pendingEnableDesired or storage - -- The macro was already set in the block above if pendingEnableDesired was set - return true -end - --- Schedule multiple retries with exponential backoff to cover different load timings -schedule(20, performPendingEnableOnce) -schedule(200, function() if not performPendingEnableOnce() then end end) -schedule(600, function() if not performPendingEnableOnce() then end end) -schedule(1600, function() if not performPendingEnableOnce() then warn('[TargetBot] post-init: deferred enable attempts exhausted') end end) - --- Module load diagnostics: print whether key functions are available shortly after load --- Module init check (silent): mark module as initialized after a short delay and attempt pending enable -schedule(1500, function() - moduleInitialized = true - pcall(function() performPendingEnableOnce() end) - -- Startup sanity log to confirm TargetBot module loaded - -- warn("[TargetBot] module initialized. TargetBot._removed=" .. tostring(TargetBot and TargetBot._removed) .. ", TargetBot.isOn=" .. tostring(TargetBot and TargetBot.isOn and TargetBot.isOn())) -end) - --- Prime the CreatureCache directly from current spectators (used when enabling targetbot) -local function primeCreatureCache() - local p = player and player:getPosition() - if not p then return end - local creatures = (SpectatorCache and SpectatorCache.getNearby(MONSTER_DETECTION_RANGE, MONSTER_DETECTION_RANGE)) or g_map.getSpectatorsInRange(p, false, MONSTER_DETECTION_RANGE, MONSTER_DETECTION_RANGE) - if not creatures or #creatures == 0 then - return - end - - CreatureCache.monsters = {} - CreatureCache.monsterCount = 0 - local snapshotCreatures = {} - for i = 1, #creatures do - local creature = creatures[i] - if isTargetableCreature(creature) then - local id = creature:getId() - CreatureCache.monsters[id] = { - creature = creature, - path = nil, - pathTime = 0, - lastUpdate = now - } - table.insert(snapshotCreatures, { id = id, pos = creature:getPosition(), creature = creature }) - CreatureCache.monsterCount = CreatureCache.monsterCount + 1 - end - end - CreatureCache.lastFullUpdate = now - CreatureCache.dirty = false - CreatureCache.primeSnapshot = { ts = now, pos = p, creatures = snapshotCreatures } -end - --- Main TargetBot loop - optimized with EventBus caching --- PERFORMANCE: 250ms macro interval balances responsiveness and CPU usage -local lastRecalcTime = 0 -local RECALC_COOLDOWN_MS = 150 -- PERFORMANCE: Increased from 100ms -local lastPathCacheCleanup = 0 -targetbotMacro = macro(250, function() - local _msStart = os.clock() - - if not config or not config.isOn or not config.isOn() then - return - end - - -- Z-change guard: pause target processing during floor transitions - if zChanging() then - return - end - - -- CRITICAL: Respect explicit disable flag - user turned it off manually - if TargetBot and TargetBot.explicitlyDisabled then - return - end - - -- Update AttackStateMachine (only when TargetBot is ON) - if AttackStateMachine and AttackStateMachine.update then - pcall(AttackStateMachine.update) - end - - -- Prevent execution before login is complete to avoid freezing - local Client = getClient() - local isOnline = (Client and Client.isOnline) and Client.isOnline() or (g_game and g_game.isOnline and g_game.isOnline()) - if not isOnline then return end - - -- MonsterAI imminent-attack pause DISABLED (was blocking chase mode) - -- If re-enabling, reduce the wait time significantly (max 100ms, not 900ms) - -- if monsterAIWaitUntil and now < monsterAIWaitUntil then - -- cavebotAllowance = now + 100 - -- setStatusRight("Evading (MonsterAI)") - -- return - -- end - - -- TargetBot never triggers friend-heal; keep that path dormant to save cycles - if HealEngine and HealEngine.setFriendHealingEnabled then - HealEngine.setFriendHealingEnabled(false) - end - - -- FAST PATH: If EventTargeting already has a valid target, use it - -- This skips the expensive recalculation when event-driven targeting is handling things - -- BUT we still need to run the walk/positioning logic for features like: - -- avoidAttacks, keepDistance, dynamicLure, smartPull, etc. - if EventTargeting and EventTargeting.isInCombat and EventTargeting.isInCombat() then - local eventTarget = EventTargeting.getCurrentTarget and EventTargeting.getCurrentTarget() - if eventTarget and not eventTarget:isDead() then - -- EventTargeting is handling combat - ensure we're attacking AND chase mode is set - local Client = getClient() - local currentAttack = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) - - -- CRITICAL: Chase is only active if enabled AND keepDistance is disabled - local chaseEnabled = TargetBot.ActiveMovementConfig and TargetBot.ActiveMovementConfig.chase - local keepDistanceEnabled = TargetBot.ActiveMovementConfig and TargetBot.ActiveMovementConfig.keepDistance - local useNativeChase = chaseEnabled and not keepDistanceEnabled - - if ChaseController then - ChaseController.setDesiredChase(useNativeChase) - else - if useNativeChase then - local currentMode = (Client and Client.getChaseMode) and Client.getChaseMode() or (g_game and g_game.getChaseMode and g_game.getChaseMode()) or 0 - if currentMode ~= 1 then - if Client and Client.setChaseMode then - Client.setChaseMode(1) - elseif g_game and g_game.setChaseMode then - g_game.setChaseMode(1) - end - if TargetBot then TargetBot.usingNativeChase = true end - end - elseif not useNativeChase then - -- Chase disabled OR keepDistance enabled - ensure Stand mode - local currentMode = (Client and Client.getChaseMode) and Client.getChaseMode() or (g_game and g_game.getChaseMode and g_game.getChaseMode()) or 0 - if currentMode ~= 0 then - if Client and Client.setChaseMode then - Client.setChaseMode(0) - elseif g_game and g_game.setChaseMode then - g_game.setChaseMode(0) - end - if TargetBot then TargetBot.usingNativeChase = false end - end - end - end - - if not currentAttack or currentAttack:getId() ~= eventTarget:getId() then - -- Sync our attack target with EventTargeting's choice - pcall(function() TargetBot.requestAttack(eventTarget, "event_sync") end) - end - - -- CRITICAL FIX: Still run creature_attack logic for movement features - -- (avoidAttacks, keepDistance, dynamicLure, smartPull, rePosition, etc.) - -- Get configs for this creature and build params - local configs = TargetBot.Creature.getConfigs and TargetBot.Creature.getConfigs(eventTarget) - if configs and #configs > 0 then - local config = configs[1] -- Use first matching config - local targetCount = CreatureCache.monsterCount or 1 - local params = { - config = config, - creature = eventTarget, - danger = config.danger or 0, - priority = config.priority or 1 - } - -- Update MonsterAI target lock for anti-zigzag stability - if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.lockTarget then - local okId, id = pcall(function() return eventTarget:getId() end) - local okHp, hp = pcall(function() return eventTarget:getHealthPercent() end) - if okId and id then - MonsterAI.Scenario.lockTarget(id, okHp and hp or 100) - end - end - -- Run the full attack/walk logic with proper config - pcall(function() TargetBot.Creature.attack(params, targetCount, false) end) - end - - setStatusRight("Targeting (Event)") - lastAction = now - return - end - end - - -- Danger-based auto-stop disabled per user request (no-op) - -- if HealContext and HealContext.isDanger and HealContext.isDanger() then - -- TargetBot.clearWalk() - -- TargetBot.stopAttack(true) - -- setStatusRight(STATUS_WAITING) - -- return - -- end - - local pos = player:getPosition() - if not pos then return end - - -- Periodic cache cleanup - cleanupCache() - cleanupPathCache() - - -- Handle walking if destination is set (safety check for load order) - if TargetBot.walk then - TargetBot.walk() - end - - -- Check for looting first (event-driven: only process when dirty or when actively looting) - local shouldProcessLoot = TargetBot.Looting.isDirty and TargetBot.Looting.isDirty() or (#TargetBot.Looting.list > 0) - local lootResult = false - if shouldProcessLoot then - lootResult = TargetBot.Looting.process() - TargetBot.Looting.clearDirty() - end - if lootResult then - lastAction = now - looterStatus = TargetBot.Looting.getStatus and TargetBot.Looting.getStatus() or "Looting" - return - else - looterStatus = "" - end - - -- Get best target (uses cache when possible) - local bestTarget, targetCount, totalDanger - -- If cache is clean and recent and we recalculated very recently, use cached values to avoid heavy work - if not CreatureCache.dirty and (now - (CreatureCache.lastFullUpdate or 0)) < (CreatureCache.FULL_UPDATE_INTERVAL or 400) and (now - lastRecalcTime) < RECALC_COOLDOWN_MS then - bestTarget = CreatureCache.bestTarget - targetCount = 0 - totalDanger = CreatureCache.totalDanger or 0 - else - lastRecalcTime = now - bestTarget, targetCount, totalDanger = recalculateBestTarget() - end - - -- MonsterAI scenario integration: prefer scenario optimal target when allowed - local Client = getClient() - local currentAttack = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) - if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.getOptimalTarget then - local optimal = MonsterAI.Scenario.getOptimalTarget() - if optimal and optimal.creature and not optimal.creature:isDead() then - local okPos, cpos = pcall(function() return optimal.creature:getPosition() end) - if okPos and cpos and cpos.z == pos.z then - local isCurrent = currentAttack and currentAttack:getId() == optimal.id - local params, path = processCandidate(optimal.creature, pos, isCurrent) - if params and params.config then - local okSwitch = true - if MonsterAI.Scenario.shouldAllowTargetSwitch and currentAttack and not isCurrent then - local okHp, hp = pcall(function() return optimal.creature:getHealthPercent() end) - okSwitch = MonsterAI.Scenario.shouldAllowTargetSwitch(optimal.id, params.priority or 100, okHp and hp or 100) - end - if okSwitch then - bestTarget = params - end - end - end - end - end - - -- IMPROVED: Get live monster count from EventTargeting (most accurate) - local liveMonsterCount = 0 - if EventTargeting and EventTargeting.getLiveMonsterCount then - liveMonsterCount = EventTargeting.getLiveMonsterCount() - else - liveMonsterCount = CreatureCache.monsterCount or 0 - end - - -- HARD STICKY: If we already attack a valid nearby monster on screen, keep it - -- Only applies when current target is within 3 tiles (allow switching to adjacent when far) - -- Use ACL-safe client getter instead of raw g_game (respects ACL pattern) - local Client_hs = getClient() - local currentAttack = (Client_hs and Client_hs.getAttackingCreature) and Client_hs.getAttackingCreature() - or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) - if currentAttack and not currentAttack:isDead() then - local okPos, cpos = pcall(function() return currentAttack:getPosition() end) - if okPos and cpos and cpos.z == pos.z then - local dist = getDistanceBetween(pos, cpos) - if dist and dist <= 3 then - local cfgs = TargetBot.Creature.getConfigs and TargetBot.Creature.getConfigs(currentAttack) - if cfgs and cfgs[1] then - bestTarget = { - config = cfgs[1], - creature = currentAttack, - danger = cfgs[1].danger or 0, - priority = cfgs[1].priority or 1 - } - -- prevent walking away while target is still alive - cavebotAllowance = now + 600 - end - end - end - end - - if not bestTarget then - setWidgetTextSafe(ui.target.right, "-") - setWidgetTextSafe(ui.danger.right, "0") - setWidgetTextSafe(ui.config.right, "-") - dangerValue = 0 - - -- FIXED: Check for unreachable creatures (blocked paths) - -- If there are only unreachable creatures, allow CaveBot to proceed immediately - local unreachableCount = CreatureCache.unreachableCount or 0 - local reachableOnScreen = CreatureCache.monsterCount or 0 - - -- v5.0: Also check AttackStateMachine for skipped creatures - local smSkippedCount = 0 - if AttackStateMachine and AttackStateMachine.getSkippedCount then - smSkippedCount = AttackStateMachine.getSkippedCount() - end - local totalBlocked = unreachableCount + smSkippedCount - - -- Check if there are REACHABLE monsters on screen - if reachableOnScreen > 0 and reachableOnScreen > smSkippedCount then - -- There are reachable monsters but no valid target (edge case) - setStatusRight("Targeting (" .. tostring(reachableOnScreen) .. ")") - if EventTargeting and EventTargeting.refreshLiveCount then - EventTargeting.refreshLiveCount() - end - invalidateCache() - cavebotAllowance = now + 300 - return - elseif totalBlocked > 0 then - -- All creatures have blocked paths - allow CaveBot to proceed - -- Don't waste time trying to attack creatures we can't reach - setStatusRight("Blocked (" .. tostring(totalBlocked) .. ")") - cavebotAllowance = now + 100 -- Allow CaveBot immediately - - -- Emit event for CaveBot to know it can proceed - if EventBus and EventBus.emit then - pcall(function() - EventBus.emit("targetbot/all_blocked", totalBlocked) - end) - end - return - end - - cavebotAllowance = now + 100 - setStatusRight(STATUS_WAITING) - return - end - - -- Update danger value - dangerValue = totalDanger - setWidgetTextSafe(ui.danger.right, tostring(totalDanger)) - - -- PARTY HUNT: Check if force follow mode is active - -- When force follow is triggered and combat window expires, pause targeting to let follower catch up - if TargetBot.isForceFollowActive and TargetBot.isForceFollowActive() then - -- Allow CaveBot to walk (which triggers follow movement) - cavebotAllowance = now + 100 - setStatusRight("Following Leader") - -- Still show target info but don't attack - if bestTarget.creature then - pcall(function() setWidgetTextSafe(ui.target.right, bestTarget.creature:getName() .. " (paused)") end) - end - return - end - - -- Attack best target - if bestTarget.creature and bestTarget.config then - - lastAction = now - setWidgetTextSafe(ui.target.right, bestTarget.creature:getName()) - setWidgetTextSafe(ui.config.right, bestTarget.config.name or "-") - - -- KILL BEFORE WALK: Block CaveBot while we have monsters to kill - -- DynamicLure and SmartPull can bypass by calling allowCaveBot() in creature_attack.lua - -- Do NOT set cavebotAllowance here - this keeps CaveBot paused until all monsters are dead - -- IMPROVED: Use live count for accurate status - local displayCount = liveMonsterCount > 0 and liveMonsterCount or targetCount - setStatusRight("Killing (" .. tostring(displayCount) .. ")") - - -- Update MonsterAI target lock for anti-zigzag stability - if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.lockTarget then - local okId, id = pcall(function() return bestTarget.creature:getId() end) - local okHp, hp = pcall(function() return bestTarget.creature:getHealthPercent() end) - if okId and id then - MonsterAI.Scenario.lockTarget(id, okHp and hp or 100) - end - end - - -- ═══════════════════════════════════════════════════════════════════════════ - -- LINEAR ATTACK SYSTEM: AttackStateMachine handles all attack persistence - -- Ensures continuous attacking of same target until death - no fallbacks - -- ═══════════════════════════════════════════════════════════════════════════ - local nowt = now or (os.time() * 1000) - local okId, id = pcall(function() return bestTarget.creature:getId() end) - - if okId and id then - -- Use AttackStateMachine for all attack management - local smState = AttackStateMachine.getState() - local smTargetId = AttackStateMachine.getTargetId() - local allowSync = true - - if EventTargeting and EventTargeting.isInCombat and EventTargeting.isInCombat() then - local evtTarget = EventTargeting.getCurrentTarget and EventTargeting.getCurrentTarget() - if evtTarget then - local okEvtId, evtId = pcall(function() return evtTarget:getId() end) - if okEvtId and evtId and evtId ~= id then - allowSync = false - end - end - end - - -- v5.0: ALWAYS call requestAttack — ASM v3.0 handles REAFFIRM, - -- pendingSwitch, and duplicate-target logic internally. - -- The old "IDLE only" guard was a root cause of "attack once then stop". - if allowSync then - AttackStateMachine.requestAttack(bestTarget.creature, 1000) - end - - -- Update AttackController based on state machine status - if smState == "LOCKED" then - AttackController.attackState = "confirmed" - AttackController.lastConfirmedTime = nowt - AttackController.lastTargetId = id - elseif smState == "ENGAGING" then - AttackController.attackState = "pending" - AttackController.lastCommandTime = nowt - AttackController.lastTargetId = id - end - end - - -- Delegate to unified attack/walk logic from creature_attack - -- This ensures chase, positioning, avoidance and AttackBot integration run correctly - -- DynamicLure/SmartPull will call allowCaveBot() if lure conditions are met - pcall(function() TargetBot.Creature.attack(bestTarget, targetCount, false) end) - else - setWidgetTextSafe(ui.target.right, "-") - setWidgetTextSafe(ui.config.right, "-") - - -- No valid target config - check if monsters still exist - -- IMPROVED: Use live count for accuracy - if liveMonsterCount > 0 then - setStatusRight("Clearing (" .. tostring(liveMonsterCount) .. ")") - -- FIXED: Set cavebotAllowance to prevent indefinite blocking - cavebotAllowance = now + 300 - return - end - - cavebotAllowance = now + 100 - setStatusRight(STATUS_WAITING) - end - - -- Check macro execution time (throttled warning) - local _msElapsed = os.clock() - _msStart - if _msElapsed > 0.1 and (now - (_lastTargetbotSlowWarn or 0)) > 5000 then - warn("[TargetBot] Slow macro detected: " .. tostring(math.floor(_msElapsed * 1000)) .. "ms") - _lastTargetbotSlowWarn = now - end -end) - --- Module ready: mark initialized and attempt to process pending enable immediately -moduleInitialized = true -pcall(function() performPendingEnableOnce() end) - --- Config setup (moved here so macro/recalc are defined before callback runs) -config = Config.setup("targetbot_configs", configWidget, "json", function(name, enabled, data) - -- Track if this callback was triggered by user clicking the switch - -- The 'enabled' parameter comes from the UI switch state - local isUserToggle = (TargetBot._initialized == true) -- After init, changes are user-driven - - -- Save character's profile preference when profile changes (multi-client support) - if enabled and name and name ~= "" then - if setCharacterProfile then - setCharacterProfile("targetbotProfile", name) - end - -- Persist to UnifiedStorage for character isolation - if UnifiedStorage then - UnifiedStorage.set("targetbot.selectedConfig", name) - end - end - - if not data then - setStatusRight("Off") - if targetbotMacro and targetbotMacro.setOff then - return targetbotMacro.setOff() - end - return - end - TargetBot.Creature.resetConfigs() - for _, value in ipairs(data["targeting"] or {}) do - TargetBot.Creature.addConfig(value) - end - TargetBot.Looting.update(data["looting"] or {}) - - -- Determine final enabled state (check UnifiedStorage first for character isolation) - -- PRIORITY: stored enabled state ALWAYS takes precedence over config file's enabled state - local finalEnabled = enabled - local storedEnabled = (UnifiedStorage and UnifiedStorage.get("targetbot.enabled")) - if storedEnabled == nil then - storedEnabled = storage.targetbotEnabled - end - - -- If user explicitly set an enabled state (true or false), always respect it - if storedEnabled == true or storedEnabled == false then - finalEnabled = storedEnabled - end - - -- Track that we've initialized (for other purposes) - if not TargetBot._initialized then - TargetBot._initialized = true - end - - -- v2.4: Handle user-initiated toggle via the UI switch - -- If user clicked the switch AFTER init, update explicitlyDisabled accordingly - if isUserToggle then - if enabled == false then - -- User clicked to disable - set explicitlyDisabled - TargetBot.explicitlyDisabled = true - TargetBot._lastUserToggle = now or os.time() * 1000 - finalEnabled = false - -- v2.5: Persist explicitlyDisabled flag to storage - if UnifiedStorage then - UnifiedStorage.set("targetbot.enabled", false) - UnifiedStorage.set("targetbot.explicitlyDisabled", true) - else - storage.targetbotEnabled = false - end - storage.targetbotExplicitlyDisabled = true - elseif enabled == true then - -- User clicked to enable - clear explicitlyDisabled - TargetBot.explicitlyDisabled = false - TargetBot._lastUserToggle = now or os.time() * 1000 - finalEnabled = true - -- v2.5: Persist explicitlyDisabled flag to storage - if UnifiedStorage then - UnifiedStorage.set("targetbot.enabled", true) - UnifiedStorage.set("targetbot.explicitlyDisabled", false) - else - storage.targetbotEnabled = true - end - storage.targetbotExplicitlyDisabled = false - end - else - -- Not user-initiated - respect explicitlyDisabled flag - if TargetBot.explicitlyDisabled then - finalEnabled = false - end - end - - -- Update UI to reflect final state - if finalEnabled then - setStatusRight("On") - else - setStatusRight("Off") - end - - if targetbotMacro and targetbotMacro.setOn then - targetbotMacro.setOn(finalEnabled) - targetbotMacro.delay = nil - end - -- Force immediate cache refresh & recalc when enabling so existing monsters are picked up - if finalEnabled then - player = g_game and g_game.getLocalPlayer() or player - pcall(function() primeCreatureCache() end) - invalidateCache() - if debouncedInvalidateAndRecalc then debouncedInvalidateAndRecalc() end - schedule(50, function() pcall(function() if type(recalculateBestTarget) == 'function' then recalculateBestTarget() end end) end) - schedule(100, function() pcall(function() if targetbotMacro then pcall(targetbotMacro) end end) end) - end - lureEnabled = true -end) - --- Stop attacking the current target -TargetBot.stopAttack = function(clearWalk) - if clearWalk then - TargetBot.clearWalk() - end - -- OTClient has a built-in autoAttackTarget() that toggles attack - -- Calling it when attacking will stop the attack - if autoAttackTarget then - autoAttackTarget(nil) - end -end - --- Note: Profile restoration is handled early in configs.lua --- before Config.setup() is called, so the dropdown loads correctly - - - --- End of TargetBot module +-- TargetBot targeting pipeline shim +-- Loads the split module chain in dependency order +-- Pathfinding → Core coordinator → Event handler glue +dofile("/targetbot/target_pathfinding.lua") +dofile("/targetbot/target_coordinator.lua") +dofile("/targetbot/target_events.lua") diff --git a/targetbot/target_coordinator.lua b/targetbot/target_coordinator.lua new file mode 100644 index 0000000..4644309 --- /dev/null +++ b/targetbot/target_coordinator.lua @@ -0,0 +1,1952 @@ +local zChanging = nExBot.zChanging or function() return false end +local targetbotMacro = nil +local config = nil +lastAction = 0 +local cavebotAllowance = 0 +local lureEnabled = true +local recalculateBestTarget -- forward declaration: defined at ~L2054, used by EventBus closures above it + + +-- Values based on PRIORITY_SCALE = 1000 (config priority 1 = 1000 base) +local STICKY_BONUS = 800 -- ~80% of one config priority level +local STICKY_BONUS_FINISH = 1200 -- extra bonus when target is low HP +local lastEngagementAt = 0 -- minimum engagement duration guard (ms) + +local getClient = nExBot.Shared.getClient + +local getClientVersion = nExBot.Shared.getClientVersion + +if not nExBot.target_pathfinding then + dofile("/targetbot/target_pathfinding.lua") +end +local targetPathfinding = nExBot.target_pathfinding + +-- ═══════════════════════════════════════════════════════════════════════════ +-- OPENTIBIABR TARGETING ENHANCEMENTS (v3.1) +-- Load enhanced targeting module for OpenTibiaBR-specific optimizations +-- Provides: batch pathfinding, line-of-sight detection, pattern-based AoE +-- ═══════════════════════════════════════════════════════════════════════════ +local OpenTibiaBRTargeting = nil + +local function getOpenTibiaBRTargeting() + if OpenTibiaBRTargeting ~= nil then return OpenTibiaBRTargeting end + if nExBot and nExBot.OpenTibiaBRTargeting then + OpenTibiaBRTargeting = nExBot.OpenTibiaBRTargeting + return OpenTibiaBRTargeting + end + return nil +end + +local function hasSightSpectators() + local otbr = getOpenTibiaBRTargeting() + return otbr and otbr.getVisibleCreatures ~= nil +end + +local function hasBatchPathfinding() + local otbr = getOpenTibiaBRTargeting() + return otbr and otbr.batchPath ~= nil +end +-- Load PathUtils if available (shared module for creature validation) +local PathUtils = nil +local SharedHelpers = nExBot.SharedHelpers or {} +local function ensurePathUtils() + if PathUtils then return PathUtils end + SharedHelpers.ensurePathUtils() + PathUtils = PathUtils -- Re-check global after dofile + return PathUtils +end +ensurePathUtils() + +-- ═══════════════════════════════════════════════════════════════════════════ +-- OPTIMIZED CREATURE VALIDATION (Reduce pcall overhead) +-- Single pcall wrapper that validates multiple creature properties at once +-- ═══════════════════════════════════════════════════════════════════════════ +local function validateCreature(creature) + if not creature then + return { valid = false } + end + + -- Fallback: single pcall to get all properties at once + local ok, result = pcall(function() + return { + valid = true, + isDead = creature:isDead(), + isMonster = creature:isMonster(), + isPlayer = creature:isPlayer(), + isNpc = creature:isNpc(), + id = creature:getId(), + position = creature:getPosition(), + name = creature:getName(), + healthPercent = (type(creature.getHealthPercent) == "function" and creature:getHealthPercent()) or 100, + } + end) + + if not ok then + return { valid = false } + end + + return result +end + +-- Quick dead check (single pcall, cached result) +local function isCreatureDead(creature) + return SC.isDead(creature) +end + +-- Quick ID check (single pcall) +local function getCreatureId(creature) + return SC.getId(creature) +end + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MONSTER DETECTION RANGE (v3.0) +-- IMPROVED: Increased range for better monster detection +-- This prevents the bot from leaving monsters behind when moving to waypoints +-- ═══════════════════════════════════════════════════════════════════════════ +local MONSTER_DETECTION_RANGE = 14 -- INCREASED from 10 to 14 (covers full visible screen) +local MONSTER_TARGETING_RANGE = 12 -- INCREASED from 10 to 12 (targeting range) + +local dangerValue = 0 +local looterStatus = "" + +-- ═══════════════════════════════════════════════════════════════════════════ +-- ATTACK STATE MACHINE INTEGRATION (Default Attack System) +-- The AttackStateMachine is now the PRIMARY attack handler for TargetBot. +-- It provides linear, consistent targeting with automatic recovery. +-- ═══════════════════════════════════════════════════════════════════════════ + +-- initialization state +local pendingEnable = false +local pendingEnableDesired = nil +local moduleInitialized = false +local _lastTargetbotSlowWarn = 0 + +-- Local cached reference to local player (updated on relogin) +local Client = getClient() +local player = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) or nil + +-- Safe function calls to prevent "attempt to call global function (a nil value)" errors +local SafeCall = SafeCall or require("core.safe_call") + + +local SC = SafeCreature or {} + +-- Compatibility: robust safe unpack (works when neither table.unpack nor unpack exist) +-- Attack watchdog to recover from indecision (rate-limited) +local attackWatchdog = { + lastForce = 0, + attempts = 0, + cooldown = 800, + maxAttempts = 2 +} + +-- Aggressive relogin recovery: force re-attempts for a short window after relogin +local reloginRecovery = { + active = false, -- whether the aggressive recovery is active + endTime = 0, -- when to stop aggressive retries + duration = 5000, -- default aggressive recovery duration (ms) + lastAttempt = 0, -- last forced attempt timestamp + interval = 400 -- attempt every 400ms while active +} + +-- Pull System state (shared with CaveBot) +TargetBot = TargetBot or {} +TargetBot.smartPullActive = false -- When true, CaveBot pauses waypoint walking + +-- Centralized attack controller (anti-spam + anti-zigzag switching) +-- IMPROVED: Faster intervals for more responsive attacking +local AttackController = { + lastCommandTime = 0, + lastTargetId = nil, + lastReason = nil, + minInterval = 100, -- Reduced: Minimum time between any attack commands + sameTargetInterval = 150, -- Reduced: Minimum time between same-target commands + minSwitchInterval = 400, -- Reduced: Minimum time between target switches + lastConfirmedTime = 0, -- When attack was last confirmed by server + attackState = "idle" -- idle, pending, confirmed +} + +TargetBot.AttackController = AttackController + +-- ═══════════════════════════════════════════════════════════════════════════ +-- REQUEST ATTACK (Unified Attack Interface) +-- This is the SINGLE entry point for all attack requests in TargetBot. +-- Uses AttackStateMachine for state-based attack management. +-- OPTIMIZED: Uses consolidated validateCreature for reduced pcall overhead +-- ═══════════════════════════════════════════════════════════════════════════ +TargetBot.requestAttack = function(creature, reason, force) + if not creature then return false end + + -- OPTIMIZED: Use isCreatureDead helper (single pcall) + if isCreatureDead(creature) then return false end + + local Client = getClient() + if not (Client and Client.attack) and not (g_game and g_game.attack) then return false end + + -- OPTIMIZED: Use getCreatureId helper (single pcall) + local id = getCreatureId(creature) + if not id then return false end + + -- Calculate priority for this creature + -- v2.4: Config priority scaled by 1000x for consistency with creature_priority.lua + local priority = 1000 -- Base priority (config priority 1) + if TargetBot.Creature and TargetBot.Creature.getConfigs then + local cfgs = TargetBot.Creature.getConfigs(creature) + if cfgs and cfgs[1] then + priority = (cfgs[1].priority or 1) * 1000 + end + end + + -- Use AttackStateMachine directly (always loaded as default) + if force then + return AttackStateMachine.forceSwitch(creature) + else + return AttackStateMachine.requestSwitch(creature, priority) + end +end + +-- Use TargetBotCore if available (DRY principle) +local Core = TargetCore or {} + +-- Creature type constants for clarity +local CREATURE_TYPE = { + PLAYER = 0, + MONSTER = 1, -- Targetable monster + NPC = 2, + SUMMON = 3 -- Non-targetable (other player's summons) +} + +-- Pre-allocated constants for pathfinding (PERFORMANCE: avoid table creation in loop) +local PATH_PARAMS = { + ignoreLastCreature = true, + ignoreNonPathable = true, + ignoreCost = true, + ignoreCreatures = true, + allowOnlyVisibleTiles = true, -- OTCLIENT API: Safety first + precision = 1 +} + +-- Pre-allocated status strings (PERFORMANCE: avoid string concatenation) +local STATUS_WAITING = "Waiting" + +-- PERFORMANCE: Optimized Creature Cache +-- Uses event-driven updates with LRU eviction and TargetCore integration +local monsterCache = { + monsters = {}, -- {id -> {creature, path, params, lastUpdate, priority}} + monsterCount = 0, + bestTarget = nil, + bestPriority = 0, + totalDanger = 0, + dirty = true, -- Flag to recalculate on next tick + lastFullUpdate = 0, + FULL_UPDATE_INTERVAL = 400, -- Reduced for faster adaptation + PATH_TTL = 400, -- Path cache valid for 400ms + lastCleanup = 0, + CLEANUP_INTERVAL = 1500, + -- LRU eviction + accessOrder = {}, -- Array of IDs in access order + posMap = {}, + maxSize = 50 -- Max cached creatures +} + +-- Mark cache as dirty (needs recalculation) +local function invalidateCache() + monsterCache.dirty = true +end + +local function clearPaths() + for id, data in pairs(monsterCache.monsters) do + data.path = nil + data.pathTime = nil + end +end + +-- Helper to set UI status text on the right side only when changed (reduces layout churn) +local _lastStatusRight = nil +local function setStatusRight(text) + if not ui or not ui.status or not ui.status.right then return end + local cur = nil + pcall(function() cur = ui.status.right:getText() end) + if cur ~= text then + pcall(function() ui.status.right:setText(text) end) + _lastStatusRight = text + end +end + +-- Generic safe setter for UI labels/widgets +local function setWidgetTextSafe(widget, text) + if not widget or not text then return end + pcall(function() + local cur = nil + if type(widget.getText) == 'function' then cur = widget:getText() end + if cur ~= text then widget:setText(text) end + end) +end + +-- Event-driven hooks: mark cache dirty and optionally schedule a quick recalc +-- Default no-op in case EventBus or debounce util isn't available (prevents nil calls) +local debouncedInvalidateAndRecalc = function() end +if EventBus then + -- Safe debounce factory (works even if nExBot.EventUtil isn't initialized yet) +local SharedHelpers = nExBot.SharedHelpers or {} + local makeDebounce = SharedHelpers.makeDebounce + + -- Debounced invalidation + optional immediate lightweight recalc for responsiveness + -- Assign to outer variable (do NOT use local here) so external callers use the debounce + -- IMPROVED: Faster debounce (50ms) for quicker monster detection + debouncedInvalidateAndRecalc = makeDebounce(50, function() + invalidateCache() + -- Also refresh live count for accuracy + if EventTargeting and EventTargeting.refreshLiveCount then + EventTargeting.refreshLiveCount() + end + -- Schedule a lightweight recalc to update cache quickly (non-blocking) + schedule(20, function() + pcall(function() + if recalculateBestTarget then + recalculateBestTarget() + end + end) + end) + end) +end + +-- LRU eviction helper: move ID to end of access order — O(1) via posMap +local function touchCreature(id) + local order = monsterCache.accessOrder + local pmap = monsterCache.posMap + local oldIdx = pmap[id] + if oldIdx then + local last = #order + if oldIdx ~= last then + local movedId = order[last] + order[oldIdx] = movedId + pmap[movedId] = oldIdx + end + order[last] = id + pmap[id] = last + else + order[#order + 1] = id + pmap[id] = #order + end +end + +-- LRU eviction: remove oldest entries when over capacity +local function evictOldestCreatures() + local order = monsterCache.accessOrder + local pmap = monsterCache.posMap + while #order > monsterCache.maxSize do + local oldestId = order[1] + local last = #order + order[1] = order[last] + pmap[order[1]] = 1 + order[last] = nil + pmap[oldestId] = nil + if monsterCache.monsters[oldestId] then + monsterCache.monsters[oldestId] = nil + monsterCache.monsterCount = monsterCache.monsterCount - 1 + end + end +end + +-- Clean up stale cache entries (improved with LRU) +local function cleanupCache() + if now - monsterCache.lastCleanup < monsterCache.CLEANUP_INTERVAL then + return + end + + local cutoff = now - 3000 -- Reduced from 5s to 3s for faster cleanup + local newMonsters = {} + local newOrder = {} + local newPmap = {} + local count = 0 + + -- Keep only recent entries in access order + for i = 1, #monsterCache.accessOrder do + local id = monsterCache.accessOrder[i] + local data = monsterCache.monsters[id] + if data and data.lastUpdate > cutoff and data.creature and not data.creature:isDead() then + newMonsters[id] = data + count = count + 1 + newOrder[count] = id + newPmap[id] = count + end + end + + monsterCache.monsters = newMonsters + monsterCache.accessOrder = newOrder + monsterCache.posMap = newPmap + monsterCache.monsterCount = count + monsterCache.lastCleanup = now + invalidateCache() +end + +-- Update a single creature in cache (called on events) +-- OPTIMIZED: Uses consolidated validateCreature for reduced pcall overhead +local function updateCreatureInCache(creature) + -- Use optimized validation (single pcall for all properties) + local cv = validateCreature(creature) + + -- Handle dead or invalid creature + if not cv.valid or cv.isDead then + local id = cv.id or getCreatureId(creature) + if id and monsterCache.monsters[id] then + monsterCache.monsters[id] = nil + monsterCache.monsterCount = monsterCache.monsterCount - 1 + -- Remove from access order — O(1) via posMap + local idx = monsterCache.posMap[id] + if idx then + local order = monsterCache.accessOrder + local last = #order + if idx ~= last then + local movedId = order[last] + order[idx] = movedId + monsterCache.posMap[movedId] = idx + end + order[last] = nil + monsterCache.posMap[id] = nil + end + end + invalidateCache() + return + end + + -- Skip non-monsters + if not cv.isMonster then return end + + local id = cv.id + if not id then return end + + -- Get player position (separate call as player is a different object) + local pos = SC.getPosition(player) + local cpos = cv.position + if not pos or not cpos then return end + + -- Use TargetBotCore distance if available, otherwise calculate + local dist + if Core.Geometry and Core.Geometry.chebyshevDistance then + dist = Core.Geometry.chebyshevDistance(pos, cpos) + else + dist = math.max(math.abs(pos.x - cpos.x), math.abs(pos.y - cpos.y)) + end + + -- Skip if too far (reduced from 10 to 8 for better performance) + if dist > 8 then + if monsterCache.monsters[id] then + monsterCache.monsters[id] = nil + monsterCache.monsterCount = monsterCache.monsterCount - 1 + local idx = monsterCache.posMap[id] + if idx then + local order = monsterCache.accessOrder + local last = #order + if idx ~= last then + local movedId = order[last] + order[idx] = movedId + monsterCache.posMap[movedId] = idx + end + order[last] = nil + monsterCache.posMap[id] = nil + end + end + return + end + + local entry = monsterCache.monsters[id] + if not entry then + entry = { + creature = creature, + lastUpdate = now, + distance = dist + } + monsterCache.monsters[id] = entry + monsterCache.monsterCount = monsterCache.monsterCount + 1 + touchCreature(id) + -- Check if we need to evict + evictOldestCreatures() + else + entry.creature = creature + entry.lastUpdate = now + entry.distance = dist + touchCreature(id) + end + + -- Recalculate path if needed + if not entry.path or now - (entry.pathTime or 0) > monsterCache.PATH_TTL then + entry.path = findPath(pos, cpos, 10, PATH_PARAMS) + entry.pathTime = now + end + + invalidateCache() +end + +-- Public API for other modules +TargetBot.ChaseModeEnforcer = ChaseModeEnforcer +TargetBot.enforceChaseModeNow = enforceChaseModeNow + +-- ui +local configWidget = UI.Config() +local ui = UI.createWidget("TargetBotPanel") + +ui.list = ui.listPanel.list -- shortcut +TargetBot.targetList = ui.list +TargetBot.Looting.setup() + +-- Setup eat food feature if available +if TargetBot.EatFood and TargetBot.EatFood.setup then + TargetBot.EatFood.setup() +end + +ui.status.left:setText("Status:") +setStatusRight("Off") +ui.target.left:setText("Target:") +setWidgetTextSafe(ui.target.right, "-") +ui.config.left:setText("Config:") +setWidgetTextSafe(ui.config.right, "-") +ui.danger.left:setText("Danger:") +setWidgetTextSafe(ui.danger.right, "0") + +if ui and ui.editor and ui.editor.debug then ui.editor.debug:destroy() end + +local oldTibia = getClientVersion() < 960 + +-- config, its callback is called immediately, data can be nil +-- Config setup moved down to after macro (to ensure macro and recalc exist before callback runs) +-- See vBot for reference: https://github.com/Vithrax/vBot + +-- Setup UI tooltips +ui.editor.buttons.add:setTooltip("Add a new creature targeting configuration.\nDefine which creatures to attack and how.") +ui.editor.buttons.edit:setTooltip("Edit the selected creature targeting configuration.\nModify priority, distance, and behavior settings.") +ui.editor.buttons.remove:setTooltip("Remove the selected creature targeting configuration.\nThis action cannot be undone.") + +ui.configButton:setTooltip("Show/hide the target editor panel.\nUse to add, edit, or remove creature configurations.") + +-- setup ui +ui.editor.buttons.add.onClick = function() + TargetBot.Creature.edit(nil, function(newConfig) + TargetBot.Creature.addConfig(newConfig, true) + TargetBot.save() + end) +end + +ui.editor.buttons.edit.onClick = function() + local entry = ui.list:getFocusedChild() + if not entry then return end + TargetBot.Creature.edit(entry.value, function(newConfig) + entry:setText(newConfig.name) + entry.value = newConfig + TargetBot.Creature.resetConfigsCache() + TargetBot.save() + end) +end + +ui.editor.buttons.remove.onClick = function() + local entry = ui.list:getFocusedChild() + if not entry then return end + entry:destroy() + TargetBot.Creature.resetConfigsCache() + TargetBot.save() +end + +-- public function, you can use them in your scripts +TargetBot.isActive = function() -- return true if attacking or looting takes place + return lastAction + 1000 > now +end + +TargetBot.isCaveBotActionAllowed = function() + return cavebotAllowance > now +end + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MONSTER DETECTION FOR CAVEBOT (v3.0) +-- Check if there are targetable monsters on screen that should block cavebot +-- This prevents the bot from leaving monsters behind +-- ═══════════════════════════════════════════════════════════════════════════ +TargetBot.hasTargetableMonstersOnScreen = function() + if not TargetBot.isOn() then return false end + + local p = player and player:getPosition() + if not p then return false end + + -- Get all creatures in detection range + local creatures = BotCore.Creatures.getNearby(MONSTER_DETECTION_RANGE, MONSTER_DETECTION_RANGE) + + if not creatures or #creatures == 0 then return false end + + local monsterCount = 0 + local playerZ = p.z + + for i = 1, #creatures do + local creature = creatures[i] + if creature then + if SC.isMonster(creature) then + if not SC.isDead(creature) then + local cpos = SC.getPosition(creature) + if cpos and cpos.z == playerZ then + -- Check if this creature has a matching TargetBot config (it's targetable) + local cfgs = TargetBot.Creature.getConfigs and TargetBot.Creature.getConfigs(creature) + if cfgs and cfgs[1] then + monsterCount = monsterCount + 1 + end + end + end + end + end + end + + return monsterCount > 0, monsterCount +end + +-- Check if bot should wait for monsters (stricter version) +TargetBot.shouldWaitForMonsters = function() + -- If actively attacking, definitely wait + if TargetBot.isActive() then return true end + + -- Check for targetable monsters on screen + local hasMonsters, count = TargetBot.hasTargetableMonstersOnScreen() + if hasMonsters then return true end + + -- Check MonsterAI engagement lock + if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.isEngaged then + local isEngaged = MonsterAI.Scenario.isEngaged() + if isEngaged then return true end + end + + return false +end + +TargetBot.setStatus = function(text) + setStatusRight(text) +end + +TargetBot.getStatus = function() + local t = nil + pcall(function() t = ui.status.right:getText() end) + return t +end + +TargetBot.isOn = function() + if not config then return false end + -- config.isOn may be a function or a boolean + if type(config.isOn) == 'function' then + local ok, res = pcall(config.isOn) + return ok and not not res + end + if type(config.isOn) == 'boolean' then + return config.isOn + end + return false +end + +-- ═══════════════════════════════════════════════════════════════════════════ +-- TARGETBOT ON/OFF HANDLERS v2.5 - Safer state management with persistence +-- Uses explicitlyDisabled flag to prevent auto-enable from any source +-- Flag is now persisted to storage to survive reloads +-- ═══════════════════════════════════════════════════════════════════════════ + +local function loadExplicitlyDisabledState() + local storage = type(nExBotStorageGet) == "function" and nExBotStorageGet("targetbot") or nil + if storage and storage.targetbotExplicitlyDisabled == true then + return true + end + return false +end + +TargetBot.explicitlyDisabled = loadExplicitlyDisabledState() + +-- Timestamp of last user action (for debouncing) +TargetBot._lastUserToggle = 0 + +-- v2.4: setOn now checks explicitlyDisabled and has optional 'force' parameter +-- Regular calls from CaveBot/lure scripts will be blocked when explicitlyDisabled is true +-- Only direct user clicks (force=true) or clearing explicitlyDisabled first will work +TargetBot.setOn = function(val, force) + if val == false then + return TargetBot.setOff(true) + end + + -- CRITICAL: If explicitly disabled and this is NOT a forced (user-initiated) call, block it + if TargetBot.explicitlyDisabled and not force then + -- Don't enable - user explicitly turned it off + return + end + + -- Clear the explicit disable flag - user wants it ON (or force was used) + TargetBot.explicitlyDisabled = false + TargetBot._lastUserToggle = now or os.time() * 1000 + + -- v2.5: Persist the flag to storage so it survives reloads + if UnifiedStorage then + UnifiedStorage.set("targetbot.explicitlyDisabled", false) + end + storage.targetbotExplicitlyDisabled = false + + -- Stop any pending recovery attempts + reloginRecovery.active = false + + -- Save user's choice to storage BEFORE triggering config callback + if UnifiedStorage then + UnifiedStorage.set("targetbot.enabled", true) + else + storage.targetbotEnabled = true + end + + -- Notify MonsterAI and other modules + if EventBus then + pcall(function() EventBus.emit("targetbot/enabled") end) + end + + config.setOn() -- This triggers callback which handles UI update +end + +TargetBot.setOff = function(val) + if val == false then + return TargetBot.setOn(true) + end + + -- SET the explicit disable flag - user wants it OFF, prevent ALL auto-enable + TargetBot.explicitlyDisabled = true + TargetBot._lastUserToggle = now or os.time() * 1000 + + -- v2.5: Persist the flag to storage so it survives reloads + if UnifiedStorage then + UnifiedStorage.set("targetbot.explicitlyDisabled", true) + end + storage.targetbotExplicitlyDisabled = true + + -- IMMEDIATELY stop all recovery and pending operations + reloginRecovery.active = false + reloginRecovery.endTime = 0 + attackWatchdog.attempts = 0 + attackWatchdog.lastForce = 0 + + -- Clear any pending targets in EventTargeting + if EventTargeting and EventTargeting.clearState then + pcall(function() EventTargeting.clearState() end) + end + + -- Cancel current attack + pcall(function() ClientService.cancelAttackAndFollow() end) + + -- Save user's choice to storage BEFORE triggering config callback + if UnifiedStorage then + UnifiedStorage.set("targetbot.enabled", false) + else + storage.targetbotEnabled = false + end + + -- Clear local target lock state + if TargetBot.LocalTargetLock then + TargetBot.LocalTargetLock.targetId = nil + TargetBot.LocalTargetLock.targetHealth = nil + TargetBot.LocalTargetLock.switchCount = 0 + TargetBot.LocalTargetLock.recentTargets = {} + end + + -- Clear creature cache to stop targeting + monsterCache.monsters = {} + monsterCache.monsterCount = 0 + monsterCache.bestTarget = nil + monsterCache.bestPriority = 0 + monsterCache.dirty = false + monsterCache.accessOrder = {} + monsterCache.posMap = {} + + -- Notify MonsterAI and other modules + if EventBus then + pcall(function() EventBus.emit("targetbot/disabled") end) + end + + -- Update status + setStatusRight("Off") + + config.setOff() -- This triggers callback which handles UI update +end + +-- Helper function to check if TargetBot should be active +-- This respects the explicitlyDisabled flag +TargetBot.canAttack = function() + -- If explicitly disabled by user, NEVER allow attacks + if TargetBot.explicitlyDisabled then + return false + end + -- Check the normal isOn state + if not TargetBot.isOn or not TargetBot.isOn() then + return false + end + return true +end + +TargetBot.getCurrentProfile = function() + if UnifiedStorage and UnifiedStorage.get("targetbot.selectedConfig") then + return UnifiedStorage.get("targetbot.selectedConfig") + end + return storage._configs.targetbot_configs.selected +end + +-- Use shared BotConfigName from configs.lua (DRY) +local botConfigName = BotConfigName or modules.game_bot.contentsPanel.config:getCurrentOption().text +TargetBot.setCurrentProfile = function(name) + if not g_resources.fileExists("/bot/"..botConfigName.."/targetbot_configs/"..name..".json") then + return warn("there is no targetbot profile with that name!") + end + local wasOn = TargetBot.isOn() + TargetBot.setOff() + storage._configs.targetbot_configs.selected = name + -- Save to UnifiedStorage for per-character persistence + if UnifiedStorage then + UnifiedStorage.set("targetbot.selectedConfig", name) + if EventBus and EventBus.emitConfigChange then + EventBus.emitConfigChange("targetbot", name) + end + end + -- Save character's profile preference for multi-client support + if setCharacterProfile then + setCharacterProfile("targetbotProfile", name) + end + -- Only restore enabled state if not explicitly disabled by user + if wasOn and not TargetBot.explicitlyDisabled then + TargetBot.setOn() + end +end + +TargetBot.delay = function(value) + targetbotMacro.delay = now + value +end + +TargetBot.save = function() + local data = {targeting={}, looting={}} + for _, entry in ipairs(ui.list:getChildren()) do + table.insert(data.targeting, entry.value) + end + TargetBot.Looting.save(data.looting) + config.save(data) +end + +TargetBot.allowCaveBot = function(time) + local ms = tonumber(time) or 200 + if ms < 50 then + ms = 50 + end + cavebotAllowance = now + ms +end + +TargetBot.disableLuring = function() + lureEnabled = false +end + +TargetBot.enableLuring = function() + lureEnabled = true +end + +-- Relogin recovery configuration and controls +TargetBot.setReloginRecoveryDuration = function(ms) + if type(ms) == 'number' and ms >= 0 then + reloginRecovery.duration = ms + end +end + +TargetBot.enableReloginRecovery = function(duration) + if type(duration) == 'number' and duration >= 0 then + reloginRecovery.duration = duration + end + reloginRecovery.active = true + reloginRecovery.endTime = now + reloginRecovery.duration + reloginRecovery.lastAttempt = 0 +end + +TargetBot.disableReloginRecovery = function() + reloginRecovery.active = false + reloginRecovery.endTime = 0 + reloginRecovery.lastAttempt = 0 +end + +TargetBot.Danger = function() + return dangerValue +end + +TargetBot.lootStatus = function() + return looterStatus +end + +TargetBot.canLure = function() + return lureEnabled +end + +-- Kill Before Walk: Always enabled - wait for monsters to be killed before walking +-- This is the default behavior. DynamicLure and SmartPull can bypass when needed. +TargetBot.isKillBeforeWalkEnabled = function() + return true -- Always ON +end + +-- Check if there are any targetable monsters on screen +-- IMPROVED: Uses EventTargeting live count for accuracy, falls back to cache +TargetBot.hasTargetableMonsters = function() + -- PRIORITY 1: Use EventTargeting live count (most accurate) + if EventTargeting and EventTargeting.getLiveMonsterCount then + local liveCount = EventTargeting.getLiveMonsterCount() + if liveCount > 0 then + return true + end + end + -- PRIORITY 2: Fall back to cache + return monsterCache.monsterCount > 0 +end + +-- Get count of targetable monsters on screen +-- IMPROVED: Uses EventTargeting live count for accuracy +TargetBot.getTargetableMonsterCount = function() + -- PRIORITY 1: Use EventTargeting live count (most accurate) + if EventTargeting and EventTargeting.getLiveMonsterCount then + local liveCount = EventTargeting.getLiveMonsterCount() + if liveCount > 0 then + return liveCount + end + end + -- PRIORITY 2: Fall back to cache + return monsterCache.monsterCount or 0 +end + +-- Optimized Main TargetBot Loop +-- Uses EventBus-driven cache for reduced CPU usage and better accuracy +-- Only recalculates when cache is dirty (events occurred) + +-- Process a single candidate creature for targeting +local function processCandidate(creature, pos, isCurrentTarget, batchPaths) + if not creature or creature:isDead() or not targetPathfinding.isTargetableCreature(creature) then return nil, nil end + local cpos = creature:getPosition() + if not cpos then return nil, nil end + + local okId, creatureId = pcall(function() return creature:getId() end) + creatureId = okId and creatureId or nil + + local dist = math.max(math.abs(cpos.x - pos.x), math.abs(cpos.y - pos.y)) + + if dist <= 1 then + local params = TargetBot.Creature.calculateParams(creature, {1}) + if params and params.config then + return params, {1} + end + end + + local path = nil + + if creatureId and batchPaths and batchPaths[creatureId] then + path = batchPaths[creatureId].path + if path and #path > 0 then + targetPathfinding.setCachedPath(creatureId, path, pos, cpos) + end + end + + if not path and creatureId then + path = targetPathfinding.getCachedPath(creatureId, pos, cpos) + end + + if not path then + if MonsterAI and MonsterAI.Reachability and MonsterAI.Reachability.isReachable and not isCurrentTarget then + local reachResult, reason, cachedPath = MonsterAI.Reachability.isReachable(creature) + if reachResult and cachedPath then + path = cachedPath + elseif not reachResult and dist > 3 then + return nil, nil + end + end + + if not path then + local maxStrategies = isCurrentTarget and 2 or 1 + local pathStrategies = { + {ignoreLastCreature = true, ignoreNonPathable = true, ignoreCost = true, ignoreCreatures = true, allowOnlyVisibleTiles = true, precision = 1}, + {ignoreLastCreature = true, ignoreNonPathable = true, ignoreCost = true, ignoreCreatures = true, allowOnlyVisibleTiles = false, precision = 1} + } + for strategyIdx = 1, maxStrategies do + local params = pathStrategies[strategyIdx] + path = findPath(pos, cpos, 12, params) + if path and #path > 0 then break end + end + end + + if creatureId and path then + targetPathfinding.setCachedPath(creatureId, path, pos, cpos) + end + end + + if (not path or #path == 0) and dist <= 3 then + local simplePath = {} + local dx = cpos.x - pos.x + local dy = cpos.y - pos.y + local dir = nil + if dx > 0 and dy < 0 then dir = NorthEast or 4 + elseif dx > 0 and dy > 0 then dir = SouthEast or 5 + elseif dx < 0 and dy > 0 then dir = SouthWest or 6 + elseif dx < 0 and dy < 0 then dir = NorthWest or 7 + elseif dx > 0 then dir = East or 1 + elseif dx < 0 then dir = West or 3 + elseif dy > 0 then dir = South or 2 + elseif dy < 0 then dir = North or 0 + end + if dir then + for i = 1, dist do simplePath[i] = dir end + path = simplePath + end + end + + if not path or #path == 0 then + if dist > 5 and MonsterAI and MonsterAI.Reachability and MonsterAI.Reachability.markBlocked then + MonsterAI.Reachability.markBlocked(creature:getId(), "no_path") + end + return nil, nil + end + + local pathLength = #path + if pathLength > 20 and dist > 8 then + local probe = {x = pos.x, y = pos.y, z = pos.z} + for i = 1, math.min(3, pathLength) do + local dir = path[i] + local offset = nil + if dir == North or dir == 0 then offset = {x = 0, y = -1} + elseif dir == East or dir == 1 then offset = {x = 1, y = 0} + elseif dir == South or dir == 2 then offset = {x = 0, y = 1} + elseif dir == West or dir == 3 then offset = {x = -1, y = 0} + elseif dir == NorthEast or dir == 4 then offset = {x = 1, y = -1} + elseif dir == SouthEast or dir == 5 then offset = {x = 1, y = 1} + elseif dir == SouthWest or dir == 6 then offset = {x = -1, y = 1} + elseif dir == NorthWest or dir == 7 then offset = {x = -1, y = -1} + end + if offset then + probe = {x = probe.x + offset.x, y = probe.y + offset.y, z = probe.z} + local Client = getClient() + local tile = (Client and Client.getTile) and Client.getTile(probe) or (g_map and g_map.getTile and g_map.getTile(probe)) + local hasCreature = tile and tile.hasCreature and tile:hasCreature() + if tile and not tile:isWalkable() and not hasCreature then + return nil, nil + end + end + end + end + + local params = TargetBot.Creature.calculateParams(creature, path) + if not params or not params.config then return nil, nil end + if params.priority <= 0 and dist > 3 then return nil, nil end + return params, path +end + +-- Recalculate best target from cache +-- IMPROVED: Uses live creature detection for accuracy +-- FIXED: Only count creatures with valid reachable paths +recalculateBestTarget = function() + local pos = player:getPosition() + if not pos then return end + + local function getAdjustedPriority(creature, params, dist) + if not creature or not params then return (params and params.priority) or 0 end + local base = params.priority or 0 + local okHp, hp = pcall(function() return creature:getHealthPercent() end) + hp = okHp and hp or 100 + if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.modifyPriority then + local okId, id = pcall(function() return creature:getId() end) + if okId and id then + base = MonsterAI.Scenario.modifyPriority(id, base, hp) + end + end + if dist then + base = base + math.max(0, (8 - dist)) * 2 + end + base = base + ((100 - hp) * 0.15) + return base + end + + local bestTarget = nil + local bestPriority = 0 + local totalDanger = 0 + local targetCount = 0 + local reachableCount = 0 -- Track only reachable creatures + local unreachableCount = 0 -- Track blocked path creatures + + -- v2.2: Track current target for stickiness - DO NOT lose it during recalculation + local Client = getClient() + local currentAttackTarget = ClientService.getAttackingCreature() + local currentTargetId = currentAttackTarget and currentAttackTarget:getId() or nil + local currentTargetStillValid = false -- Will be set to true if current target is found + + -- IMPROVED: Get creatures from live detection first + local creatures = nil + local liveCount, liveCreatures = 0, nil + if EventTargeting and EventTargeting.getLiveMonsterCount then + liveCount, liveCreatures = EventTargeting.getLiveMonsterCount() + end + + -- If we have live creatures, use those as the authoritative source + if liveCreatures and #liveCreatures > 0 then + creatures = liveCreatures + elseif monsterCache.monsterCount > 0 and not monsterCache.dirty then + -- Fall back to cache only if live detection found nothing + -- This handles edge cases where EventTargeting hasn't initialized + local cacheCreatures = {} + for id, data in pairs(monsterCache.monsters) do + if data.creature and not data.creature:isDead() then + cacheCreatures[#cacheCreatures + 1] = data.creature + end + end + creatures = cacheCreatures + end + + -- ═══════════════════════════════════════════════════════════════════════════ + -- OPENTIBIABR ENHANCEMENT: Use getSightSpectators for line-of-sight detection + -- This gives us only creatures we can actually see (no obstacles between) + -- Falls back to standard detection on non-OpenTibiaBR clients + -- ═══════════════════════════════════════════════════════════════════════════ + if not creatures or #creatures == 0 then + local otbr = getOpenTibiaBRTargeting() + if otbr and hasSightSpectators() then + -- Use OpenTibiaBR's optimized sight spectators + local sightCreatures = otbr.getVisibleCreatures(pos, false) + if sightCreatures and #sightCreatures > 0 then + creatures = sightCreatures + end + end + end + + -- If still no creatures, do a fresh scan with standard methods + if not creatures or #creatures == 0 then + creatures = BotCore.Creatures.getNearby(MONSTER_DETECTION_RANGE, MONSTER_DETECTION_RANGE) + end + + if not creatures then return nil, 0, 0 end + + -- ═══════════════════════════════════════════════════════════════════════════ + -- OPENTIBIABR ENHANCEMENT: Pre-calculate batch paths to all monsters + -- Instead of calculating paths one by one, batch them for ~30-50% speedup + -- ═══════════════════════════════════════════════════════════════════════════ + local batchPaths = nil + if hasBatchPathfinding() then + local otbr = getOpenTibiaBRTargeting() + if otbr then + -- Filter to only targetable monsters first + local targetableMonsters = {} + for i = 1, #creatures do + local creature = creatures[i] + if creature and targetPathfinding.isTargetableCreature(creature) then + local cpos = SC.getPosition(creature) + if cpos and cpos.z == pos.z then + targetableMonsters[#targetableMonsters + 1] = creature + end + end + end + -- Calculate all paths at once + if #targetableMonsters > 0 then + batchPaths = otbr.calculateBatchPaths(pos, targetableMonsters, 50, 0) + end + end + end + + -- Rebuild cache from live creatures + monsterCache.monsters = {} + monsterCache.monsterCount = 0 + + local playerZ = pos.z + + -- v2.2: First pass - find current target to ensure it's not skipped + -- This prevents the "leaving monsters behind" issue + local currentTargetParams = nil + local currentTargetPath = nil + + for i = 1, #creatures do + local creature = creatures[i] + if creature and targetPathfinding.isTargetableCreature(creature) then + local cpos = SC.getPosition(creature) + if cpos and cpos.z == playerZ then + local id = SC.getId(creature) + id = id or i + + -- v2.2: Special handling for current target - be more lenient with path validation + local isCurrentTarget = (currentTargetId and id == currentTargetId) + + -- Calculate path and params (pass isCurrentTarget for enhanced path finding) + -- v3.1: Pass batchPaths for OpenTibiaBR optimization + local dist = math.max(math.abs(cpos.x - pos.x), math.abs(cpos.y - pos.y)) + local params, path = processCandidate(creature, pos, isCurrentTarget, batchPaths) + + -- For current target, try harder to find a path if initial attempt failed + if isCurrentTarget and (not path or not params or not params.config) then + -- Try with relaxed path params + local relaxedPath = findPath(pos, cpos, 12, { + ignoreLastCreature = true, + ignoreNonPathable = true, + ignoreCost = true, + ignoreCreatures = true, + allowOnlyVisibleTiles = false -- More relaxed + }) + if relaxedPath and #relaxedPath > 0 then + path = relaxedPath + params = TargetBot.Creature.calculateParams(creature, path) + end + end + + -- IMPROVED: Track all creatures, not just those with perfect paths + -- Creatures with blocked paths can still be targeted if they're close + if path and params and params.config then + params.priority = getAdjustedPriority(creature, params, dist) + -- Creature is reachable - add to cache + monsterCache.monsters[id] = { + creature = creature, + path = path, + pathTime = now, + lastUpdate = now, + reachable = true + } + monsterCache.monsterCount = monsterCache.monsterCount + 1 + reachableCount = reachableCount + 1 + targetCount = targetCount + 1 + totalDanger = totalDanger + (params.danger or 0) + + -- v2.2: Track if current target is still valid + if isCurrentTarget then + currentTargetStillValid = true + currentTargetParams = params + currentTargetPath = path + end + + if params.priority > bestPriority then + bestPriority = params.priority + bestTarget = params + end + elseif isCurrentTarget and not creature:isDead() then + -- v2.2: Current target has no path but is not dead + -- Still add it to cache to prevent "losing" the target + local dist = math.max(math.abs(cpos.x - pos.x), math.abs(cpos.y - pos.y)) + if dist <= 2 then + -- Very close - might just be blocked by other creatures, keep targeting + local fallbackParams = TargetBot.Creature.calculateParams(creature, {1}) -- Fake short path + if fallbackParams and fallbackParams.config then + fallbackParams.priority = getAdjustedPriority(creature, fallbackParams, dist) + monsterCache.monsters[id] = { + creature = creature, + path = nil, + pathTime = now, + lastUpdate = now, + reachable = false, + closeButBlocked = true + } + monsterCache.monsterCount = monsterCache.monsterCount + 1 + currentTargetStillValid = true + currentTargetParams = fallbackParams + + -- Give it a slightly reduced priority but don't abandon it + if fallbackParams.priority * 0.8 > bestPriority then + bestPriority = fallbackParams.priority * 0.8 + bestTarget = fallbackParams + end + end + else + unreachableCount = unreachableCount + 1 + end + else + -- Creature has blocked path - don't add to active targeting + unreachableCount = unreachableCount + 1 + end + end + end + end + + -- v2.2: If current target is still valid, ensure it's not replaced by a marginally better target + -- This implements "target stickiness" at the recalculation level + + -- threshold approach kept as safety net for edge cases + if currentTargetStillValid and currentTargetParams and bestTarget then + local currentHP = currentAttackTarget:getHealthPercent() + if currentHP < 70 then + local switchThreshold = 15 + if currentHP < 50 then switchThreshold = 25 end + if currentHP < 30 then switchThreshold = 40 end + if currentHP < 15 then switchThreshold = 75 end + if bestTarget ~= currentTargetParams then + local priorityAdvantage = bestTarget.priority - currentTargetParams.priority + if priorityAdvantage < switchThreshold then + bestTarget = currentTargetParams + bestPriority = currentTargetParams.priority + end + end + end + end + + -- MonsterAI Scenario integration: prevent illegal switches (anti-zigzag) + if currentTargetStillValid and currentTargetParams and bestTarget and bestTarget ~= currentTargetParams then + if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.shouldAllowTargetSwitch then + local okNewId, newId = pcall(function() return bestTarget.creature and bestTarget.creature:getId() end) + local okNewHp, newHp = pcall(function() return bestTarget.creature and bestTarget.creature:getHealthPercent() end) + if okNewId and newId then + local allowed = MonsterAI.Scenario.shouldAllowTargetSwitch(newId, bestTarget.priority or 0, okNewHp and newHp or nil) + if not allowed then + bestTarget = currentTargetParams + bestPriority = currentTargetParams.priority + end + end + end + end + + monsterCache.lastFullUpdate = now + + -- Update cache state + monsterCache.bestTarget = bestTarget + monsterCache.bestPriority = bestPriority + monsterCache.totalDanger = totalDanger + monsterCache.dirty = false + monsterCache.unreachableCount = unreachableCount + + return bestTarget, reachableCount, totalDanger +end + +local pendingEnable = false +local pendingEnableDesired = nil +local moduleInitialized = false + +local function performPendingEnableOnce() + if pendingEnable then return true end + if type(recalculateBestTarget) ~= 'function' or not (targetbotMacro and (type(targetbotMacro) == 'function' or type(targetbotMacro.setOn) == 'function')) then + return false + end + pendingEnable = true + if pendingEnableDesired ~= nil then + pcall(function() + if targetbotMacro and type(targetbotMacro.setOn) == 'function' then + targetbotMacro.setOn(pendingEnableDesired) + targetbotMacro.delay = nil + end + end) + pendingEnableDesired = nil + end + pcall(function() primeCreatureCache() end) + invalidateCache() + if debouncedInvalidateAndRecalc then debouncedInvalidateAndRecalc() end + schedule(10, function() pcall(function() if type(recalculateBestTarget) == 'function' then recalculateBestTarget() end end) end) + return true +end + +local function primeCreatureCache() + local pos = nil + local Client = getClient() + local p = Client and Client.getLocalPlayer and Client.getLocalPlayer() + if p then pos = p:getPosition() end + if not pos then return end + local creatures = getSpectators(pos, false, false, 8) or {} + local now = os.clock() + monsterCache.monsters = {} + monsterCache.monsterCount = 0 + local snapshotCreatures = {} + for i = 1, #creatures do + local creature = creatures[i] + if targetPathfinding.isTargetableCreature and targetPathfinding.isTargetableCreature(creature) then + local id = creature:getId() + monsterCache.monsters[id] = { + creature = creature, + path = nil, + pathTime = 0, + lastUpdate = now + } + table.insert(snapshotCreatures, { id = id, pos = creature:getPosition(), creature = creature }) + monsterCache.monsterCount = monsterCache.monsterCount + 1 + end + end + monsterCache.lastFullUpdate = now + monsterCache.dirty = false + monsterCache.primeSnapshot = { ts = now, pos = pos, creatures = snapshotCreatures } +end + +-- Schedule multiple retries with exponential backoff to cover different load timings +schedule(20, performPendingEnableOnce) +schedule(200, function() if not performPendingEnableOnce() then end end) +schedule(600, function() if not performPendingEnableOnce() then end end) +schedule(1600, function() if not performPendingEnableOnce() then warn('[TargetBot] post-init: deferred enable attempts exhausted') end end) + +-- Module load diagnostics: print whether key functions are available shortly after load +-- Module init check (silent): mark module as initialized after a short delay and attempt pending enable +schedule(1500, function() + moduleInitialized = true + pcall(function() performPendingEnableOnce() end) + -- Startup sanity log to confirm TargetBot module loaded + -- warn("[TargetBot] module initialized. TargetBot._removed=" .. tostring(TargetBot and TargetBot._removed) .. ", TargetBot.isOn=" .. tostring(TargetBot and TargetBot.isOn and TargetBot.isOn())) +end) + +-- Follow player integration state (used by target_events.lua EventBus handlers) +local followPlayerForceMode = false +local followPlayerForceExpiry = 0 + +TargetBot.isForceFollowActive = function() + if not followPlayerForceMode then return false end + if now > followPlayerForceExpiry then + followPlayerForceMode = false + return false + end + return true +end + +TargetBot.clearForceFollow = function() + followPlayerForceMode = false + followPlayerForceExpiry = 0 +end + +-- Active movement config (set by creature_attack when processing targets) +TargetBot.ActiveMovementConfig = TargetBot.ActiveMovementConfig or { + chase = false, + keepDistance = false, + keepDistanceRange = 4, + finishKillThreshold = 30, + anchor = nil, + anchorRange = 5 +} + +-- Main TargetBot loop - optimized with EventBus caching +-- PERFORMANCE: 250ms macro interval balances responsiveness and CPU usage +local lastRecalcTime = 0 +local RECALC_COOLDOWN_MS = 150 -- PERFORMANCE: Increased from 100ms +local lastPathCacheCleanup = 0 +targetbotMacro = macro(250, function() + local _msStart = os.clock() + + if not config or not config.isOn or not config.isOn() then + return + end + + -- Z-change guard: pause target processing during floor transitions + if zChanging() then + return + end + + -- CRITICAL: Respect explicit disable flag - user turned it off manually + if TargetBot and TargetBot.explicitlyDisabled then + return + end + + -- Update AttackStateMachine (only when TargetBot is ON) + if AttackStateMachine and AttackStateMachine.update then + pcall(AttackStateMachine.update) + end + + -- Prevent execution before login is complete to avoid freezing + local Client = getClient() + local isOnline = (Client and Client.isOnline) and Client.isOnline() or (g_game and g_game.isOnline and g_game.isOnline()) + if not isOnline then return end + + -- TargetBot never triggers friend-heal; keep that path dormant to save cycles + if HealEngine and HealEngine.setFriendHealingEnabled then + HealEngine.setFriendHealingEnabled(false) + end + + -- FAST PATH: If EventTargeting already has a valid target, use it + -- This skips the expensive recalculation when event-driven targeting is handling things + -- BUT we still need to run the walk/positioning logic for features like: + -- avoidAttacks, keepDistance, dynamicLure, smartPull, etc. + if EventTargeting and EventTargeting.isInCombat and EventTargeting.isInCombat() then + local eventTarget = EventTargeting.getCurrentTarget and EventTargeting.getCurrentTarget() + if eventTarget and not eventTarget:isDead() then + -- EventTargeting is handling combat - ensure we're attacking AND chase mode is set + local Client = getClient() + local currentAttack = ClientService.getAttackingCreature() + + -- CRITICAL: Chase is only active if enabled AND keepDistance is disabled + local chaseEnabled = TargetBot.ActiveMovementConfig and TargetBot.ActiveMovementConfig.chase + local keepDistanceEnabled = TargetBot.ActiveMovementConfig and TargetBot.ActiveMovementConfig.keepDistance + local useNativeChase = chaseEnabled and not keepDistanceEnabled + + if ChaseController then + ChaseController.setDesiredChase(useNativeChase) + else + if useNativeChase then + local currentMode = ClientService.getChaseMode() or 0 + if currentMode ~= 1 then + if Client and Client.setChaseMode then + Client.setChaseMode(1) + elseif g_game and g_game.setChaseMode then + g_game.setChaseMode(1) + end + if TargetBot then TargetBot.usingNativeChase = true end + end + elseif not useNativeChase then + -- Chase disabled OR keepDistance enabled - ensure Stand mode + local currentMode = ClientService.getChaseMode() or 0 + if currentMode ~= 0 then + if Client and Client.setChaseMode then + Client.setChaseMode(0) + elseif g_game and g_game.setChaseMode then + g_game.setChaseMode(0) + end + if TargetBot then TargetBot.usingNativeChase = false end + end + end + end + + if not currentAttack or currentAttack:getId() ~= eventTarget:getId() then + -- Sync our attack target with EventTargeting's choice + pcall(function() TargetBot.requestAttack(eventTarget, "event_sync") end) + end + + -- CRITICAL FIX: Still run creature_attack logic for movement features + -- (avoidAttacks, keepDistance, dynamicLure, smartPull, rePosition, etc.) + -- Get configs for this creature and build params + local configs = TargetBot.Creature.getConfigs and TargetBot.Creature.getConfigs(eventTarget) + if configs and #configs > 0 then + local config = configs[1] -- Use first matching config + local targetCount = monsterCache.monsterCount or 1 + local params = { + config = config, + creature = eventTarget, + danger = config.danger or 0, + priority = config.priority or 1 + } + -- Update MonsterAI target lock for anti-zigzag stability + if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.lockTarget then + local okId, id = pcall(function() return eventTarget:getId() end) + local okHp, hp = pcall(function() return eventTarget:getHealthPercent() end) + if okId and id then + MonsterAI.Scenario.lockTarget(id, okHp and hp or 100) + end + end + -- Run the full attack/walk logic with proper config + pcall(function() TargetBot.Creature.attack(params, targetCount, false) end) + end + + setStatusRight("Targeting (Event)") + lastAction = now + return + end + end + + local pos = player:getPosition() + if not pos then return end + + -- Periodic cache cleanup + cleanupCache() + targetPathfinding.cleanupPathCache() + + -- Handle walking if destination is set (safety check for load order) + if TargetBot.walk then + TargetBot.walk() + end + + -- Check for looting first (event-driven: only process when dirty or when actively looting) + local shouldProcessLoot = TargetBot.Looting.isDirty and TargetBot.Looting.isDirty() or (#TargetBot.Looting.list > 0) + local lootResult = false + if shouldProcessLoot then + lootResult = TargetBot.Looting.process() + TargetBot.Looting.clearDirty() + end + if lootResult then + lastAction = now + looterStatus = TargetBot.Looting.getStatus and TargetBot.Looting.getStatus() or "Looting" + return + else + looterStatus = "" + end + + -- Get best target (uses cache when possible) + local bestTarget, targetCount, totalDanger + + local Client_g = getClient() + local currentAttack_g = ClientService.getAttackingCreature() + if currentAttack_g and not currentAttack_g:isDead() and (now - lastEngagementAt) < 1500 then + bestTarget = monsterCache.bestTarget + targetCount = monsterCache.monsterCount or 0 + totalDanger = monsterCache.totalDanger or 0 + -- If cache is clean and recent and we recalculated very recently, use cached values to avoid heavy work + elseif not monsterCache.dirty and (now - (monsterCache.lastFullUpdate or 0)) < (monsterCache.FULL_UPDATE_INTERVAL or 400) and (now - lastRecalcTime) < RECALC_COOLDOWN_MS then + bestTarget = monsterCache.bestTarget + targetCount = 0 + totalDanger = monsterCache.totalDanger or 0 + else + lastRecalcTime = now + bestTarget, targetCount, totalDanger = recalculateBestTarget() + end + + -- MonsterAI scenario integration: prefer scenario optimal target when allowed + local Client = getClient() + local currentAttack = ClientService.getAttackingCreature() + if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.getOptimalTarget then + local optimal = MonsterAI.Scenario.getOptimalTarget() + if optimal and optimal.creature and not optimal.creature:isDead() then + local okPos, cpos = pcall(function() return optimal.creature:getPosition() end) + if okPos and cpos and cpos.z == pos.z then + local isCurrent = currentAttack and currentAttack:getId() == optimal.id + local params, path = processCandidate(optimal.creature, pos, isCurrent) + if params and params.config then + local okSwitch = true + if MonsterAI.Scenario.shouldAllowTargetSwitch and currentAttack and not isCurrent then + local okHp, hp = pcall(function() return optimal.creature:getHealthPercent() end) + okSwitch = MonsterAI.Scenario.shouldAllowTargetSwitch(optimal.id, params.priority or 100, okHp and hp or 100) + end + if okSwitch then + bestTarget = params + end + end + end + end + end + + -- IMPROVED: Get live monster count from EventTargeting (most accurate) + local liveMonsterCount = 0 + if EventTargeting and EventTargeting.getLiveMonsterCount then + liveMonsterCount = EventTargeting.getLiveMonsterCount() + else + liveMonsterCount = monsterCache.monsterCount or 0 + end + + -- HARD STICKY: If we already attack a valid nearby monster on screen, keep it + -- Only applies when current target is within 3 tiles (allow switching to adjacent when far) + -- Use ACL-safe client getter instead of raw g_game (respects ACL pattern) + local Client_hs = getClient() + local currentAttack = ClientService.getAttackingCreature() + if currentAttack and not currentAttack:isDead() then + local okPos, cpos = pcall(function() return currentAttack:getPosition() end) + if okPos and cpos and cpos.z == pos.z then + local dist = getDistanceBetween(pos, cpos) + if dist and dist <= 3 then + local cfgs = TargetBot.Creature.getConfigs and TargetBot.Creature.getConfigs(currentAttack) + if cfgs and cfgs[1] then + bestTarget = { + config = cfgs[1], + creature = currentAttack, + danger = cfgs[1].danger or 0, + priority = cfgs[1].priority or 1 + } + end + end + end + end + + if not bestTarget then + setWidgetTextSafe(ui.target.right, "-") + setWidgetTextSafe(ui.danger.right, "0") + setWidgetTextSafe(ui.config.right, "-") + dangerValue = 0 + + -- FIXED: Check for unreachable creatures (blocked paths) + -- If there are only unreachable creatures, allow CaveBot to proceed immediately + local unreachableCount = monsterCache.unreachableCount or 0 + local reachableOnScreen = monsterCache.monsterCount or 0 + + -- v5.0: Also check AttackStateMachine for skipped creatures + local smSkippedCount = 0 + if AttackStateMachine and AttackStateMachine.getSkippedCount then + smSkippedCount = AttackStateMachine.getSkippedCount() + end + local totalBlocked = unreachableCount + smSkippedCount + + -- Check if there are REACHABLE monsters on screen + if reachableOnScreen > 0 and reachableOnScreen > smSkippedCount then + -- There are reachable monsters but no valid target (edge case) + setStatusRight("Targeting (" .. tostring(reachableOnScreen) .. ")") + if EventTargeting and EventTargeting.refreshLiveCount then + EventTargeting.refreshLiveCount() + end + invalidateCache() + cavebotAllowance = now + 300 + return + elseif totalBlocked > 0 then + -- All creatures have blocked paths - allow CaveBot to proceed + -- Don't waste time trying to attack creatures we can't reach + setStatusRight("Blocked (" .. tostring(totalBlocked) .. ")") + cavebotAllowance = now + 100 -- Allow CaveBot immediately + + -- Emit event for CaveBot to know it can proceed + if EventBus and EventBus.emit then + pcall(function() + EventBus.emit("targetbot/all_blocked", totalBlocked) + end) + end + return + end + + cavebotAllowance = now + 100 + setStatusRight(STATUS_WAITING) + return + end + + -- Update danger value + dangerValue = totalDanger + setWidgetTextSafe(ui.danger.right, tostring(totalDanger)) + + -- PARTY HUNT: Check if force follow mode is active + -- When force follow is triggered and combat window expires, pause targeting to let follower catch up + if TargetBot.isForceFollowActive and TargetBot.isForceFollowActive() then + -- Allow CaveBot to walk (which triggers follow movement) + cavebotAllowance = now + 100 + setStatusRight("Following Leader") + -- Still show target info but don't attack + if bestTarget.creature then + pcall(function() setWidgetTextSafe(ui.target.right, bestTarget.creature:getName() .. " (paused)") end) + end + return + end + + -- Attack best target + if bestTarget.creature and bestTarget.config then + + lastAction = now + setWidgetTextSafe(ui.target.right, bestTarget.creature:getName()) + setWidgetTextSafe(ui.config.right, bestTarget.config.name or "-") + + -- KILL BEFORE WALK: Block CaveBot while we have monsters to kill + -- DynamicLure and SmartPull can bypass by calling allowCaveBot() in creature_attack.lua + -- Do NOT set cavebotAllowance here - this keeps CaveBot paused until all monsters are dead + -- IMPROVED: Use live count for accurate status + local displayCount = liveMonsterCount > 0 and liveMonsterCount or targetCount + setStatusRight("Killing (" .. tostring(displayCount) .. ")") + + -- Update MonsterAI target lock for anti-zigzag stability + if MonsterAI and MonsterAI.Scenario and MonsterAI.Scenario.lockTarget then + local okId, id = pcall(function() return bestTarget.creature:getId() end) + local okHp, hp = pcall(function() return bestTarget.creature:getHealthPercent() end) + if okId and id then + MonsterAI.Scenario.lockTarget(id, okHp and hp or 100) + end + end + + -- ═══════════════════════════════════════════════════════════════════════════ + -- LINEAR ATTACK SYSTEM: AttackStateMachine handles all attack persistence + -- Ensures continuous attacking of same target until death - no fallbacks + -- ═══════════════════════════════════════════════════════════════════════════ + local nowt = now or (os.time() * 1000) + local okId, id = pcall(function() return bestTarget.creature:getId() end) + + if okId and id then + -- Use AttackStateMachine for all attack management + local smState = AttackStateMachine.getState() + local smTargetId = AttackStateMachine.getTargetId() + local allowSync = true + + if EventTargeting and EventTargeting.isInCombat and EventTargeting.isInCombat() then + local evtTarget = EventTargeting.getCurrentTarget and EventTargeting.getCurrentTarget() + if evtTarget then + local okEvtId, evtId = pcall(function() return evtTarget:getId() end) + if okEvtId and evtId and evtId ~= id then + allowSync = false + end + end + end + + if allowSync then + local smTargetId = AttackStateMachine.getTargetId() + if not smTargetId or bestTarget.creature:getId() ~= smTargetId then + AttackStateMachine.requestAttack(bestTarget.creature, 1000) + lastEngagementAt = now + else + local gameTarget = ClientService.getAttackingCreature() + if not gameTarget then + AttackStateMachine.forceAttack(bestTarget.creature) + lastEngagementAt = now + end + end + end + + -- Update AttackController based on state machine status + if smState == "LOCKED" then + AttackController.attackState = "confirmed" + AttackController.lastConfirmedTime = nowt + AttackController.lastTargetId = id + elseif smState == "ENGAGING" then + AttackController.attackState = "pending" + AttackController.lastCommandTime = nowt + AttackController.lastTargetId = id + end + end + + -- Delegate to unified attack/walk logic from creature_attack + -- This ensures chase, positioning, avoidance and AttackBot integration run correctly + -- DynamicLure/SmartPull will call allowCaveBot() if lure conditions are met + pcall(function() TargetBot.Creature.attack(bestTarget, targetCount, false) end) + else + setWidgetTextSafe(ui.target.right, "-") + setWidgetTextSafe(ui.config.right, "-") + + -- No valid target config - check if monsters still exist + -- IMPROVED: Use live count for accuracy + if liveMonsterCount > 0 then + setStatusRight("Clearing (" .. tostring(liveMonsterCount) .. ")") + -- FIXED: Set cavebotAllowance to prevent indefinite blocking + cavebotAllowance = now + 300 + return + end + + cavebotAllowance = now + 100 + setStatusRight(STATUS_WAITING) + end + + -- Check macro execution time (throttled warning) + local _msElapsed = os.clock() - _msStart + if _msElapsed > 0.1 and (now - (_lastTargetbotSlowWarn or 0)) > 5000 then + warn("[TargetBot] Slow macro detected: " .. tostring(math.floor(_msElapsed * 1000)) .. "ms") + _lastTargetbotSlowWarn = now + end +end) + +-- Module ready: mark initialized and attempt to process pending enable immediately +moduleInitialized = true +pcall(function() performPendingEnableOnce() end) + +-- Config setup (moved here so macro/recalc are defined before callback runs) +config = Config.setup("targetbot_configs", configWidget, "json", function(name, enabled, data) + -- Track if this callback was triggered by user clicking the switch + -- The 'enabled' parameter comes from the UI switch state + local isUserToggle = (TargetBot._initialized == true) -- After init, changes are user-driven + + -- Save character's profile preference when profile changes (multi-client support) + if enabled and name and name ~= "" then + if setCharacterProfile then + setCharacterProfile("targetbotProfile", name) + end + -- Persist to UnifiedStorage for character isolation + if UnifiedStorage then + UnifiedStorage.set("targetbot.selectedConfig", name) + end + end + + if not data then + setStatusRight("Off") + if targetbotMacro and targetbotMacro.setOff then + return targetbotMacro.setOff() + end + return + end + TargetBot.Creature.resetConfigs() + for _, value in ipairs(data["targeting"] or {}) do + TargetBot.Creature.addConfig(value) + end + TargetBot.Looting.update(data["looting"] or {}) + + -- Determine final enabled state (check UnifiedStorage first for character isolation) + -- PRIORITY: stored enabled state ALWAYS takes precedence over config file's enabled state + local finalEnabled = enabled + local storedEnabled = (UnifiedStorage and UnifiedStorage.get("targetbot.enabled")) + if storedEnabled == nil then + storedEnabled = storage.targetbotEnabled + end + + -- If user explicitly set an enabled state (true or false), always respect it + if storedEnabled == true or storedEnabled == false then + finalEnabled = storedEnabled + end + + -- Track that we've initialized (for other purposes) + if not TargetBot._initialized then + TargetBot._initialized = true + end + + -- v2.4: Handle user-initiated toggle via the UI switch + -- If user clicked the switch AFTER init, update explicitlyDisabled accordingly + if isUserToggle then + if enabled == false then + -- User clicked to disable - set explicitlyDisabled + TargetBot.explicitlyDisabled = true + TargetBot._lastUserToggle = now or os.time() * 1000 + finalEnabled = false + -- v2.5: Persist explicitlyDisabled flag to storage + if UnifiedStorage then + UnifiedStorage.set("targetbot.enabled", false) + UnifiedStorage.set("targetbot.explicitlyDisabled", true) + else + storage.targetbotEnabled = false + end + storage.targetbotExplicitlyDisabled = true + elseif enabled == true then + -- User clicked to enable - clear explicitlyDisabled + TargetBot.explicitlyDisabled = false + TargetBot._lastUserToggle = now or os.time() * 1000 + finalEnabled = true + -- v2.5: Persist explicitlyDisabled flag to storage + if UnifiedStorage then + UnifiedStorage.set("targetbot.enabled", true) + UnifiedStorage.set("targetbot.explicitlyDisabled", false) + else + storage.targetbotEnabled = true + end + storage.targetbotExplicitlyDisabled = false + end + else + -- Not user-initiated - respect explicitlyDisabled flag + if TargetBot.explicitlyDisabled then + finalEnabled = false + end + end + + -- Update UI to reflect final state + if finalEnabled then + setStatusRight("On") + else + setStatusRight("Off") + end + + if targetbotMacro and targetbotMacro.setOn then + targetbotMacro.setOn(finalEnabled) + targetbotMacro.delay = nil + end + -- Force immediate cache refresh & recalc when enabling so existing monsters are picked up + if finalEnabled then + player = g_game and g_game.getLocalPlayer() or player + pcall(function() primeCreatureCache() end) + invalidateCache() + if debouncedInvalidateAndRecalc then debouncedInvalidateAndRecalc() end + schedule(50, function() pcall(function() if type(recalculateBestTarget) == 'function' then recalculateBestTarget() end end) end) + schedule(100, function() pcall(function() if targetbotMacro then pcall(targetbotMacro) end end) end) + end + lureEnabled = true +end) + +-- Stop attacking the current target +TargetBot.stopAttack = function(clearWalk) + if clearWalk then + TargetBot.clearWalk() + end + -- OTClient has a built-in autoAttackTarget() that toggles attack + -- Calling it when attacking will stop the attack + if autoAttackTarget then + autoAttackTarget(nil) + end +end + +local function removeCreatureFromCache(creature) + if not creature then return end + local id = creature:getId() or tostring(creature) + if monsterCache.monsters[id] then + monsterCache.monsters[id] = nil + monsterCache.monsterCount = monsterCache.monsterCount - 1 + local idx = monsterCache.posMap[id] + if idx then + local order = monsterCache.accessOrder + local last = #order + if idx ~= last then + local movedId = order[last] + order[idx] = movedId + monsterCache.posMap[movedId] = idx + end + order[last] = nil + monsterCache.posMap[id] = nil + end + invalidateCache() + end +end + +-- Note: Profile restoration is handled early in configs.lua +-- before Config.setup() is called, so the dropdown loads correctly + +TargetBot.isOff = function() + return not TargetBot or not TargetBot.isOn or not TargetBot.isOn() +end + +-- Export internals for target_events.lua cross-module access +TargetBot.__internals = { + invalidateCache = invalidateCache, + clearPaths = clearPaths, + recalculateBestTarget = recalculateBestTarget, + getMonsterCount = function() return monsterCache.monsterCount or 0 end, + debouncedInvalidateAndRecalc = debouncedInvalidateAndRecalc, + updateCreatureInCache = updateCreatureInCache, + removeCreatureFromCache = removeCreatureFromCache, + attackWatchdog = attackWatchdog, + reloginRecovery = reloginRecovery, + ui = ui, + setStatusRight = setStatusRight, + targetbotMacro = targetbotMacro, + setAllowance = function(v) cavebotAllowance = v end, + followPlayerForceMode = false, + followPlayerForceExpiry = 0 +} + +-- End of TargetBot module diff --git a/targetbot/target_events.lua b/targetbot/target_events.lua new file mode 100644 index 0000000..8bc0dae --- /dev/null +++ b/targetbot/target_events.lua @@ -0,0 +1,381 @@ +-- TargetBot Events Module +-- All EventBus handlers from target.lua + +local getClient = nExBot.Shared.getClient + +local SC = SafeCreature or {} + +local I = TargetBot.__internals + +if EventBus then + -- invalidate + recalc on creature changes + EventBus.on("monster:appear", function(creature) + if TargetBot.isOff() then return end + if creature then I.debouncedInvalidateAndRecalc() end + end, 20) + EventBus.on("monster:disappear", function(creature) + if TargetBot.isOff() then return end + I.debouncedInvalidateAndRecalc() + end, 20) + EventBus.on("creature:move", function(creature, oldPos) + if TargetBot.isOff() then return end + local okMonster, isMonster = pcall(function() return creature and creature:isMonster() end) + if okMonster and isMonster then I.debouncedInvalidateAndRecalc() end + end, 20) + EventBus.on("monster:health", function(creature, percent) + if TargetBot.isOff() then return end + I.debouncedInvalidateAndRecalc() + end, 20) + EventBus.on("player:move", function(newPos, oldPos) + if TargetBot.isOff() then return end + if newPos and oldPos and newPos.z ~= oldPos.z then return end + I.debouncedInvalidateAndRecalc() + end, 10) + EventBus.on("player:z_change_settled", function() + if TargetBot.isOff() then return end + if MonsterAI and MonsterAI.Reachability then + MonsterAI.Reachability.clearCache() + MonsterAI.Reachability.blockedCreatures = {} + end + I.invalidateCache() + if I.recalculateBestTarget then I.recalculateBestTarget() end + end, 5) + EventBus.on("combat:target", function(creature, oldCreature) + if TargetBot.isOff() then return end + I.debouncedInvalidateAndRecalc() + end, 20) + -- Follow player integration + EventBus.on("followplayer/force_follow", function(leaderPos, distance) + if TargetBot.isOff() then return end + pcall(function() + I.followPlayerForceMode = true + I.followPlayerForceExpiry = now + 2500 + TargetBot.allowCaveBot(100) + end) + end, 100) + EventBus.on("followplayer/enabled", function(playerName) + if TargetBot.isOff() then return end + pcall(function() I.followPlayerForceMode = false; I.followPlayerForceExpiry = 0 end) + end, 80) + EventBus.on("followplayer/disabled", function() + if TargetBot.isOff() then return end + pcall(function() I.followPlayerForceMode = false; I.followPlayerForceExpiry = 0 end) + end, 80) + -- CreatureCache handlers + EventBus.on("monster:appear", function(creature) + if TargetBot.isOff() then return end + I.updateCreatureInCache(creature) + end, 50) + EventBus.on("monster:disappear", function(creature) + if TargetBot.isOff() then return end + I.removeCreatureFromCache(creature) + end, 50) + EventBus.on("monster:health", function(creature, percent) + if TargetBot.isOff() then return end + if percent <= 0 then I.removeCreatureFromCache(creature) end + end, 80) + EventBus.on("player:move", function(newPos, oldPos) + if TargetBot.isOff() then return end + if newPos and oldPos and newPos.z ~= oldPos.z then + I.invalidateCache() + if AttackStateMachine and AttackStateMachine.clearSkipList then AttackStateMachine.clearSkipList() end + return + end + I.clearPaths() + I.invalidateCache() + end, 60) + -- combat:target with deferred combat_end + local lastCombatTargetId = nil + local _combatEndPending = nil + local COMBAT_END_GRACE_MS = 1200 + EventBus.on("combat:target", function(creature, oldCreature) + if TargetBot.isOff() then return end + I.invalidateCache() + local newId = creature and creature:getId() or nil + if newId ~= lastCombatTargetId then + if creature then + if _combatEndPending then removeEvent(_combatEndPending); _combatEndPending = nil end + if UnifiedStorage then UnifiedStorage.set("targetbot.combatActive", true) end + pcall(function() EventBus.emit("targetbot/combat_start", creature, { id = newId, pos = creature:getPosition() }) end) + lastCombatTargetId = newId + else + if _combatEndPending then return end + _combatEndPending = schedule(COMBAT_END_GRACE_MS, function() + _combatEndPending = nil + if AttackStateMachine and AttackStateMachine.isActive and AttackStateMachine.isActive() then return end + if UnifiedStorage then UnifiedStorage.set("targetbot.combatActive", false) end + pcall(function() EventBus.emit("targetbot/combat_end") end) + lastCombatTargetId = nil + end) + end + end + end, 70) + -- player:health emergency detection + EventBus.on("player:health", function(health, maxHealth, oldHealth, oldMax) + if TargetBot.isOff() then return end + local cfg = (UnifiedStorage and UnifiedStorage.get("targetbot.priority")) or (ProfileStorage and ProfileStorage.get and ProfileStorage.get('targetPriority')) or {} + local threshold = cfg and cfg.emergencyHP or 25 + local percent = 100 + if maxHealth and maxHealth > 0 then percent = math.floor(health / maxHealth * 100) end + local currentEmergency = (UnifiedStorage and UnifiedStorage.get("targetbot.emergency")) or storage.targetbotEmergency + if percent <= threshold and not currentEmergency then + if UnifiedStorage then UnifiedStorage.set("targetbot.emergency", true) else storage.targetbotEmergency = true end + pcall(function() EventBus.emit("targetbot/emergency", percent) end) + elseif percent > threshold and currentEmergency then + if UnifiedStorage then UnifiedStorage.set("targetbot.emergency", false) else storage.targetbotEmergency = false end + pcall(function() EventBus.emit("targetbot/emergency_cleared", percent) end) + end + end, 90) + -- Event-driven movement intents (keepDistance, chase) + EventBus.on("creature:move", function(creature, oldPos) + if TargetBot and TargetBot.isOn and not TargetBot.isOn() then return end + local isMonster = creature and SC.isMonster(creature) + if not isMonster then return end + local Client = getClient() + local target = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) + if not target then return end + local cid = SC.getId(creature) + local tid = SC.getId(target) + if not cid or not tid or cid ~= tid then return end + local config = TargetBot.ActiveMovementConfig + if not config then return end + local playerPos = SC.getPosition(player) + local creaturePos = SC.getPosition(creature) + if not playerPos or not creaturePos then return end + local dist = math.max(math.abs(playerPos.x - creaturePos.x), math.abs(playerPos.y - creaturePos.y)) + if config.keepDistance then + local keepRange = config.keepDistanceRange or 4 + if dist < keepRange - 1 or dist > keepRange + 2 then + local dx = creaturePos.x - playerPos.x; local dy = creaturePos.y - playerPos.y + local currentDist = math.sqrt(dx * dx + dy * dy) + if currentDist > 0 then + local ratio = keepRange / currentDist + local keepPos = { x = math.floor(creaturePos.x - dx * ratio + 0.5), y = math.floor(creaturePos.y - dy * ratio + 0.5), z = playerPos.z } + local anchorValid = true + if config.anchor then + local anchorDist = math.max(math.abs(keepPos.x - config.anchor.x), math.abs(keepPos.y - config.anchor.y)) + anchorValid = anchorDist <= (config.anchorRange or 5) + end + if anchorValid and MovementCoordinator and MovementCoordinator.Intent then + local confidence = 0.55; if dist < keepRange - 1 then confidence = 0.70 end + MovementCoordinator.Intent.register(MovementCoordinator.CONSTANTS.INTENT.KEEP_DISTANCE, keepPos, confidence, "keepdist_event", {triggered = "target_move", currentDist = dist, targetDist = keepRange}) + end + end + end + end + if config.chase and not config.keepDistance and dist > 1 then + local confidence = 0.62; if dist <= 3 then confidence = 0.68 end; if dist >= 5 then confidence = 0.75 end + local anchorValid = true + if config.anchor then + local anchorDist = math.max(math.abs(creaturePos.x - config.anchor.x), math.abs(creaturePos.y - config.anchor.y)) + anchorValid = anchorDist <= (config.anchorRange or 5) + end + if anchorValid and MovementCoordinator and MovementCoordinator.Intent then + MovementCoordinator.Intent.register(MovementCoordinator.CONSTANTS.INTENT.CHASE, creaturePos, confidence, "chase_target_move", {triggered = "target_move", dist = dist}) + end + end + end, 15) + -- Event-driven finish kill + EventBus.on("monster:health", function(creature, percent) + if TargetBot.isOff() then return end + if not creature then return end + local Client = getClient() + local target = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) + if not target then return end + local cid = SC.getId(creature) + local tid = SC.getId(target) + if not cid or not tid or cid ~= tid then return end + local config = TargetBot.ActiveMovementConfig + local threshold = config and config.finishKillThreshold or 30 + if percent and percent < threshold and percent > 0 then + local playerPos = SC.getPosition(player) + local creaturePos = SC.getPosition(creature) + if not playerPos or not creaturePos then return end + local dist = math.max(math.abs(playerPos.x - creaturePos.x), math.abs(playerPos.y - creaturePos.y)) + if dist > 1 and MovementCoordinator and MovementCoordinator.Intent then + local confidence = 0.65; if percent < 15 then confidence = 0.80 end; if percent < 10 then confidence = 0.90 end + MovementCoordinator.Intent.register(MovementCoordinator.CONSTANTS.INTENT.FINISH_KILL, creaturePos, confidence, "finish_kill_hp", {triggered = "health_change", hp = percent, dist = dist}) + end + end + end, 25) + EventBus.on("combat:target", function(creature, oldCreature) + if TargetBot.isOff() then return end + if creature and MovementCoordinator then + pcall(function() + local pos = SC.getPosition(creature) + EventBus.emit("targetbot/target_acquired", creature, pos) + end) + end + end, 65) + -- Chase mode enforcement + I.ChaseModeEnforcer = I.ChaseModeEnforcer or { + enabled = false, lastEnforcedMode = nil, lastEnforceTime = 0, enforceCooldown = 100, + shouldChase = false, keepDistance = false + } + local CME = I.ChaseModeEnforcer + I.enforceChaseModeNow = function() + if not TargetBot.isOn or not TargetBot.isOn() then CME.enabled = false; return end + local currentTime = now or (os.time() * 1000) + if (currentTime - CME.lastEnforceTime) < CME.enforceCooldown then return end + local config = TargetBot.ActiveMovementConfig + if config then CME.shouldChase = config.chase == true; CME.keepDistance = config.keepDistance == true end + local desiredMode = 0 + if CME.shouldChase and not CME.keepDistance then desiredMode = 1 end + local Client = getClient() + local isAttacking = (Client and Client.isAttacking) and Client.isAttacking() or (g_game and g_game.isAttacking and g_game.isAttacking()) + if not isAttacking then CME.enabled = false; return end + CME.enabled = true + local currentMode = (Client and Client.getChaseMode) and Client.getChaseMode() or (g_game and g_game.getChaseMode and g_game.getChaseMode()) or 0 + if currentMode ~= desiredMode then + if Client and Client.setChaseMode then Client.setChaseMode(desiredMode); CME.lastEnforcedMode = desiredMode; CME.lastEnforceTime = currentTime + if EventBus then pcall(function() EventBus.emit("targetbot/chase_mode_enforced", desiredMode, desiredMode == 1 and "chase" or "stand") end) end + elseif g_game and g_game.setChaseMode then g_game.setChaseMode(desiredMode); CME.lastEnforcedMode = desiredMode; CME.lastEnforceTime = currentTime + if EventBus then pcall(function() EventBus.emit("targetbot/chase_mode_enforced", desiredMode, desiredMode == 1 and "chase" or "stand") end) end + end + end + end + EventBus.on("targetbot/target_acquired", function(creature, creaturePos) + if TargetBot.isOff() then return end; pcall(function() I.enforceChaseModeNow() end) + end, 60) + EventBus.on("targetbot/combat_start", function(creature, data) + if TargetBot.isOff() then return end; pcall(function() I.enforceChaseModeNow() end) + end, 60) + EventBus.on("targetbot/combat_end", function() + if TargetBot.isOff() then return end; pcall(function() CME.enabled = false end) + end, 60) + EventBus.on("player:move", function(newPos, oldPos) + if TargetBot.isOff() then return end + if newPos and oldPos and newPos.z ~= oldPos.z then return end + if CME.enabled then pcall(function() I.enforceChaseModeNow() end) end + end, 5) + EventBus.on("creature:move", function(creature, oldPos) + if TargetBot.isOff() then return end; if not CME.enabled then return end + local Client = getClient() + local target = (Client and Client.getAttackingCreature) and Client.getAttackingCreature() or (g_game and g_game.getAttackingCreature and g_game.getAttackingCreature()) + if not target then return end + local cid = SC.getId(creature) + local tid = SC.getId(target) + if cid and tid and cid == tid then pcall(function() I.enforceChaseModeNow() end) end + end, 5) +end + +-- OTClient native chase mode hook +TargetBot.ChaseModeEnforcer = I.ChaseModeEnforcer +TargetBot.enforceChaseModeNow = I.enforceChaseModeNow +if g_game and type(g_game) == "table" then + pcall(function() + connect(g_game, { onChaseModeChange = function(newMode) + if not CME.enabled then return end + if not TargetBot.isOn or not TargetBot.isOn() then return end + local config = TargetBot.ActiveMovementConfig + if config then CME.shouldChase = config.chase == true; CME.keepDistance = config.keepDistance == true end + local desiredMode = 0 + if CME.shouldChase and not CME.keepDistance then desiredMode = 1 end + if newMode ~= desiredMode then + schedule(50, function() + if CME.enabled and TargetBot.isOn and TargetBot.isOn() then I.enforceChaseModeNow() end + end) + end + end}) + end) +end + +-- Non-EventBus fallbacks +if not EventBus then + if onCreatureAppear then onCreatureAppear(function(creature) + if creature then I.invalidateCache(); if I.debouncedInvalidateAndRecalc then I.debouncedInvalidateAndRecalc() end end + end) end + if onCreatureDisappear then onCreatureDisappear(function(creature) + I.invalidateCache(); if I.debouncedInvalidateAndRecalc then I.debouncedInvalidateAndRecalc() end + end) end + if onCreatureMove then onCreatureMove(function(creature, oldPos) + local okMonster, isMonster = pcall(function() return creature and creature:isMonster() end) + if okMonster and isMonster then I.invalidateCache(); if I.debouncedInvalidateAndRecalc then I.debouncedInvalidateAndRecalc() end end + end) end +end + +-- Relogin recovery +if onPlayerHealthChange then onPlayerHealthChange(function(healthPercent) + if healthPercent and healthPercent > 0 then + I.attackWatchdog.attempts = 0; I.attackWatchdog.lastForce = 0 + local storedEnabled = nil + if UnifiedStorage then storedEnabled = UnifiedStorage.get("targetbot.enabled") end + if storedEnabled == nil then storedEnabled = storage.targetbotEnabled end + if storedEnabled ~= true then return end + if TargetBot and TargetBot.explicitlyDisabled then return end + if TargetBot and TargetBot.isOn then + I.reloginRecovery.active = true; I.reloginRecovery.endTime = now + I.reloginRecovery.duration; I.reloginRecovery.lastAttempt = 0 + local Client = getClient() + player = (Client and Client.getLocalPlayer) and Client.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) or player + I.debouncedInvalidateAndRecalc() + if storedEnabled == true and not TargetBot.isOn() and not TargetBot.explicitlyDisabled then pcall(function() TargetBot.setOn() end) end + if I.ui and I.ui.status and I.ui.status.right then I.setStatusRight("Recovering...") end + if I.targetbotMacro then + local function attemptRecovery() + if TargetBot and TargetBot.explicitlyDisabled then I.reloginRecovery.active = false; return end + local storedEnabled2 = (UnifiedStorage and UnifiedStorage.get("targetbot.enabled")) + if storedEnabled2 == nil then storedEnabled2 = storage.targetbotEnabled end + if storedEnabled2 == true and TargetBot.isOn() then + pcall(I.targetbotMacro) + local ok2, best2 = pcall(function() return I.recalculateBestTarget() end) + if ok2 then + local count = I.getMonsterCount() + if I.ui and I.ui.status and I.ui.status.right then + if best2 and best2.creature then I.setStatusRight("Recovering ("..tostring(count)..") best: "..best2.creature:getName()) + else I.setStatusRight("Recovering ("..tostring(count)..")") end + end + if best2 and best2.creature then pcall(function() TargetBot.requestAttack(best2.creature, "relogin_recovery") end) end + end + end + end + schedule(200, attemptRecovery) + schedule(1000, attemptRecovery) + schedule(5000, attemptRecovery) + end + end + end +end) end + +-- EventTargeting EventBus handlers (from event_targeting.lua) +if EventBus then + EventBus.on("player:z_change_settled", function() + if TargetBot.isOff() then return end + if EventTargeting then EventTargeting.refreshLiveCount() end + end, 3) + local etDebounce = (nExBot and nExBot.EventUtil and nExBot.EventUtil.debounce) and nExBot.EventUtil.debounce(150, function() + if EventTargeting then EventTargeting.refreshLiveCount() end + end) + if etDebounce then + EventBus.on("monster:appear", function(creature) + if TargetBot.isOff() then return end; etDebounce() + end, 30) + EventBus.on("monster:disappear", function(creature) + if TargetBot.isOff() then return end; etDebounce() + end, 30) + EventBus.on("monster:health", function(creature, percent) + if TargetBot.isOff() then return end; etDebounce() + end, 35) + EventBus.on("creature:move", function(creature, oldPos) + if TargetBot.isOff() then return end + local ok, isMonster = pcall(function() return creature and creature:isMonster() end) + if ok and isMonster then etDebounce() end + end, 35) + end + EventBus.on("player:move", function(newPos, oldPos) + if TargetBot.isOff() then return end + local ppos = player and player:getPosition() + if ppos and EventTargeting then EventTargeting.refreshLiveCount() end + end, 8) + EventBus.on("combat:target", function(creature, oldCreature) + if TargetBot.isOff() then return end + if EventTargeting and EventTargeting.CombatCoordinator then + EventTargeting.CombatCoordinator.onTargetChanged(creature, oldCreature) + end + end, 30) + EventBus.on("player:health", function(health, maxHealth, oldHealth, oldMax) + if TargetBot.isOff() then return end + if EventTargeting and EventTargeting.CombatCoordinator then + EventTargeting.CombatCoordinator.onPlayerHealthChange(health, maxHealth, oldHealth, oldMax) + end + end, 30) +end diff --git a/targetbot/target_pathfinding.lua b/targetbot/target_pathfinding.lua new file mode 100644 index 0000000..aeed8cd --- /dev/null +++ b/targetbot/target_pathfinding.lua @@ -0,0 +1,147 @@ +-- TargetBot Pathfinding Module +-- Path calculation, distance management, walkability checks + +local getClient = nExBot.Shared.getClient + +local PATH_PARAMS = { + ignoreLastCreature = true, + ignoreNonPathable = true, + ignoreCost = true, + ignoreCreatures = true, + allowOnlyVisibleTiles = true, + precision = 1 +} + +local RELAXED_PATH_PARAMS = { + ignoreLastCreature = true, + ignoreNonPathable = true, + ignoreCost = true, + ignoreCreatures = true, + allowOnlyVisibleTiles = false, + precision = 1 +} + +local PathCache = { + entries = {}, + TTL = 400 +} + +local function cleanupPathCache() + local currentTime = now or (os.time() * 1000) + local cutoff = currentTime - PathCache.TTL * 2 + for id, entry in pairs(PathCache.entries) do + if entry.time < cutoff then + PathCache.entries[id] = nil + end + end +end + +local function getCachedPath(creatureId, playerPos, creaturePos) + local entry = PathCache.entries[creatureId] + local currentTime = now or (os.time() * 1000) + if entry and (currentTime - entry.time) < PathCache.TTL then + if entry.playerZ == playerPos.z and entry.creatureZ == creaturePos.z then + return entry.path + end + end + return nil +end + +local function setCachedPath(creatureId, path, playerPos, creaturePos) + PathCache.entries[creatureId] = { + path = path, + time = now or (os.time() * 1000), + playerZ = playerPos.z, + creatureZ = creaturePos.z + } +end + +local function findPathInternal(pos, cpos, maxDist, params) + return findPath(pos, cpos, maxDist, params) +end + +local function isInRange(pos1, pos2, range) + if not pos1 or not pos2 then return false end + return math.max(math.abs(pos1.x - pos2.x), math.abs(pos1.y - pos2.y)) <= range +end + +local function getWalkDirection(path) + if not path or #path == 0 then return nil end + return path[1] +end + +local function isSameFloor(pos1, pos2) + return pos1 and pos2 and pos1.z == pos2.z +end + +local function chebyshevDistance(p1, p2) + if not p1 or not p2 then return 999 end + if p1.z ~= p2.z then return 999 end + return math.max(math.abs(p1.x - p2.x), math.abs(p1.y - p2.y)) +end + +local chebyshev = chebyshevDistance + +local function getDistanceBetween(pos1, pos2) + if not pos1 or not pos2 then return nil end + return math.max(math.abs(pos1.x - pos2.x), math.abs(pos1.y - pos2.y)) +end + +local function isTargetableCreature(creature) + if not creature then return false end + local SC = SafeCreature or {} + local isDead = SC.isDead(creature) + if isDead then return false end + local isMonster = SC.isMonster(creature) + if not isMonster then return false end + local hp = SC.getHealthPercent(creature) + if hp and hp <= 0 then return false end + local Client = getClient() + local oldTibia = (Client and Client.getClientVersion) and Client.getClientVersion() < 960 + if oldTibia then return true end + local okType, creatureType = pcall(function() return creature:getType() end) + if okType and creatureType then + return creatureType < 3 + end + return true +end + +local function isTargetableMonster(creature) + if not creature then return false end + local SC = SafeCreature or {} + if SC.isDead and SC.isDead(creature) then return false end + if SC.isMonster and not SC.isMonster(creature) then return false end + if SC.getHealthPercent and SC.getHealthPercent(creature) <= 0 then return false end + local Client = getClient() + local oldTibia = (Client and Client.getClientVersion) and Client.getClientVersion() < 960 + if oldTibia then return true end + local okType, creatureType = pcall(function() return creature:getType() end) + if creatureType and creatureType >= 3 then return false end + return true +end + +local function isTileSafe(checkPos) + local Client = getClient() + local tile = (Client and Client.getTile) and Client.getTile(checkPos) or (g_map and g_map.getTile and g_map.getTile(checkPos)) + local hasCreature = tile and tile.hasCreature and tile:hasCreature() + return tile and tile:isWalkable() and not hasCreature +end + +nExBot.target_pathfinding = { + getCachedPath = getCachedPath, + setCachedPath = setCachedPath, + cleanupPathCache = cleanupPathCache, + PathCache = PathCache, + PATH_PARAMS = PATH_PARAMS, + RELAXED_PATH_PARAMS = RELAXED_PATH_PARAMS, + findPath = findPathInternal, + isInRange = isInRange, + getWalkDirection = getWalkDirection, + isSameFloor = isSameFloor, + chebyshevDistance = chebyshevDistance, + chebyshev = chebyshev, + getDistanceBetween = getDistanceBetween, + isTargetableCreature = isTargetableCreature, + isTargetableMonster = isTargetableMonster, + isTileSafe = isTileSafe +} diff --git a/targetbot/walking.lua b/targetbot/walking.lua index 783787d..b5b4670 100644 --- a/targetbot/walking.lua +++ b/targetbot/walking.lua @@ -11,18 +11,11 @@ local getClient = nExBot.Shared.getClient local getClientVersion = nExBot.Shared.getClientVersion --- Load PathUtils if available (shared module for DRY) -local PathUtils = nil +-- PathUtils is set as global by path_utils.lua (loaded in Phase 3 by _Loader) +local SharedHelpers = nExBot.SharedHelpers or {} local function ensurePathUtils() if PathUtils then return PathUtils end - -- OTClient compatible - just try dofile - local success = pcall(function() - dofile("nExBot/utils/path_utils.lua") - end) - -- After dofile, PathUtils should be global - if success then - PathUtils = PathUtils -- Re-check global - end + SharedHelpers.ensurePathUtils() return PathUtils end ensurePathUtils() @@ -43,132 +36,23 @@ local DIR_TO_OFFSET = (PathUtils and PathUtils.DIR_TO_OFFSET) or { [NorthWest] = {x = -1, y = -1} } --- Use PathUtils for floor-change colors if available -local FLOOR_CHANGE_COLORS = (PathUtils and PathUtils.FLOOR_CHANGE_COLORS) or { - [210] = true, [211] = true, [212] = true, [213] = true, -} - --- Use PathUtils for floor-change items if available -local FLOOR_CHANGE_ITEMS = (PathUtils and PathUtils.FLOOR_CHANGE_ITEMS) or { - -- Minimal fallback set - [414]=true,[415]=true,[416]=true,[417]=true, - [1956]=true,[1957]=true,[1958]=true,[1959]=true, - [1219]=true,[384]=true,[386]=true,[418]=true, -} - --- Use PathUtils for floor-change detection (DRY) +-- Use PathUtils for floor-change detection (DRY, guaranteed loaded by ensurePathUtils) local function isFloorChangeTile(pos) - if PathUtils and PathUtils.isFloorChangeTile then - return PathUtils.isFloorChangeTile(pos) - end - if TargetCore and TargetCore.PathSafety and TargetCore.PathSafety.isFloorChangeTile then - return TargetCore.PathSafety.isFloorChangeTile(pos) - end - -- Fallback implementation - if not pos then return false end - local Client = getClient() - local color = (Client and Client.getMinimapColor) and Client.getMinimapColor(pos) or (g_map and g_map.getMinimapColor and g_map.getMinimapColor(pos)) or 0 - if FLOOR_CHANGE_COLORS[color] then return true end - local tile = (Client and Client.getTile) and Client.getTile(pos) or (g_map and g_map.getTile and g_map.getTile(pos)) - if not tile then return false end - local ground = tile:getGround() - if ground and FLOOR_CHANGE_ITEMS[ground:getId()] then return true end - local topUse = tile:getTopUseThing() - if topUse and topUse:isItem() and FLOOR_CHANGE_ITEMS[topUse:getId()] then return true end - local top = tile:getTopThing() - if top and top:isItem() and FLOOR_CHANGE_ITEMS[top:getId()] then return true end - return false + return PathUtils.isFloorChangeTile(pos) end --- Use PathUtils for path validation (DRY) +-- Use TargetCore for path floor-change validation (DRY) local function pathCrossesFloorChange(path, startPos) - if PathUtils and PathUtils.pathCrossesFloorChange then - return PathUtils.pathCrossesFloorChange(path, startPos) - end - if TargetCore and TargetCore.PathSafety and TargetCore.PathSafety.pathCrossesFloorChange then - return TargetCore.PathSafety.pathCrossesFloorChange(path, startPos) - end - -- Fallback implementation - if not path or #path == 0 or not startPos then return false end - local probe = {x = startPos.x, y = startPos.y, z = startPos.z} - for i = 1, #path do - local off = DIR_TO_OFFSET[path[i]] - if off then - probe.x = probe.x + off.x - probe.y = probe.y + off.y - if isFloorChangeTile(probe) then - return true - end - end - end - return false + return TargetCore.PathSafety.pathCrossesFloorChange(path, startPos) end --- ============================================================================ --- ANTI-ZIGZAG SYSTEM --- ============================================================================ - -local AntiZigzag = { - lastDirection = nil, - lastDirectionTime = 0, - minChangeDelay = 100, -- Minimum ms between direction changes - directionHistory = {}, -- Ring buffer for last N directions - historySize = 3, -} - --- Check if two directions are similar (same or adjacent) +-- Use PathUtils for direction relationship checks (DRY) local function areSimilarDirections(dir1, dir2) - if PathUtils and PathUtils.areSimilarDirections then - return PathUtils.areSimilarDirections(dir1, dir2) - end - if dir1 == dir2 then return true end - -- Adjacent check using offsets - local off1 = DIR_TO_OFFSET[dir1] - local off2 = DIR_TO_OFFSET[dir2] - if not off1 or not off2 then return false end - return math.abs(off1.x - off2.x) <= 1 and math.abs(off1.y - off2.y) <= 1 + return PathUtils.areSimilarDirections(dir1, dir2) end --- Check if two directions are opposite local function areOppositeDirections(dir1, dir2) - if PathUtils and PathUtils.areOppositeDirections then - return PathUtils.areOppositeDirections(dir1, dir2) - end - local off1 = DIR_TO_OFFSET[dir1] - local off2 = DIR_TO_OFFSET[dir2] - if not off1 or not off2 then return false end - return off1.x == -off2.x and off1.y == -off2.y -end - --- Validate direction change to prevent zigzag -local function validateDirectionChange(newDir) - local currentTime = now - local timeSinceChange = currentTime - AntiZigzag.lastDirectionTime - - -- Allow any direction if enough time passed - if timeSinceChange >= AntiZigzag.minChangeDelay then - -- Check for oscillation pattern - if #AntiZigzag.directionHistory >= 2 then - local prevDir = AntiZigzag.directionHistory[#AntiZigzag.directionHistory] - local prevPrevDir = AntiZigzag.directionHistory[#AntiZigzag.directionHistory - 1] - - -- Detect A-B-A pattern (zigzag) - if prevPrevDir == newDir and areOppositeDirections(prevDir, newDir) then - -- Zigzag detected, dampen the change - return false - end - end - - -- Record direction - AntiZigzag.directionHistory[#AntiZigzag.directionHistory + 1] = newDir - TrimArray(AntiZigzag.directionHistory, AntiZigzag.historySize) - AntiZigzag.lastDirection = newDir - AntiZigzag.lastDirectionTime = currentTime - return true - end - - -- Too soon, only allow similar direction - return areSimilarDirections(AntiZigzag.lastDirection, newDir) + return PathUtils.areOppositeDirections(dir1, dir2) end -- Path cache for TargetBot walking @@ -321,12 +205,6 @@ TargetBot.walk = function() end end - -- ANTI-ZIGZAG: Validate direction change - if not validateDirectionChange(nextDir) then - -- Direction change too rapid, wait for next tick - return - end - -- Use cached path - take first step walk(nextDir) WalkCache.idx = WalkCache.idx + 1 @@ -359,13 +237,6 @@ TargetBot.walk = function() WalkCache.timestamp = now WalkCache.idx = 1 - -- ANTI-ZIGZAG: Validate first step direction - local firstDir = path[WalkCache.idx] - if not validateDirectionChange(firstDir) then - -- Direction change too rapid, wait for next tick - return - end - -- Take first step walk(firstDir) WalkCache.idx = WalkCache.idx + 1 @@ -380,8 +251,4 @@ TargetBot.clearWalk = function() dest = nil WalkCache.path = nil WalkCache.timestamp = 0 - -- Reset anti-zigzag state - AntiZigzag.lastDirection = nil - AntiZigzag.lastDirectionTime = 0 - AntiZigzag.directionHistory = {} end diff --git a/utils/path_strategy.lua b/utils/path_strategy.lua index 2a4cf07..cb9fe06 100644 --- a/utils/path_strategy.lua +++ b/utils/path_strategy.lua @@ -24,9 +24,7 @@ local PathStrategy = {} --- ============================================================================ -- DEPENDENCIES (resolved lazily so load order is flexible) --- ============================================================================ local _PU -- PathUtils, resolved once on first call local getClient = nExBot and nExBot.Shared and nExBot.Shared.getClient @@ -49,9 +47,7 @@ local function tick() return now or (os.clock() * 1000) end --- ============================================================================ -- CONSTANTS --- ============================================================================ -- Pathfinding flags (OTClient C++ enum Otc::PathFindFlags) local PF_ALLOW_NOT_SEEN = 1 @@ -68,32 +64,12 @@ local JITTER_MIN = -25 local JITTER_MAX = 40 local JITTER_DIAG = 15 -- extra jitter for diagonal moves --- Direction constants -local DIR_NORTH = North or 0 -local DIR_EAST = East or 1 -local DIR_SOUTH = South or 2 -local DIR_WEST = West or 3 -local DIR_NE = NorthEast or 4 -local DIR_SE = SouthEast or 5 -local DIR_SW = SouthWest or 6 -local DIR_NW = NorthWest or 7 - --- Direction tables: resolved lazily from Directions / PathUtils globals --- (may not be set yet during Phase 3 file load) -local DIR_TO_OFFSET, OPPOSITE, SIMILAR -local function _ensureDirTables() - if DIR_TO_OFFSET then return end - local D = Directions - if D then - DIR_TO_OFFSET = D.DIR_TO_OFFSET - OPPOSITE = D.OPPOSITE - SIMILAR = D.ADJACENT - end -end +-- Direction tables +local DIR_TO_OFFSET = Directions.DIR_TO_OFFSET +local OPPOSITE = Directions.OPPOSITE +local SIMILAR = Directions.ADJACENT --- ============================================================================ -- INTERNAL HELPERS --- ============================================================================ -- Lazy-resolved PathUtils helpers local _posEq, _chebyshev, _directionTo @@ -107,7 +83,6 @@ local function _ensureHelpers() end local function dirOffset(dir) - _ensureDirTables() return DIR_TO_OFFSET and DIR_TO_OFFSET[dir] end @@ -127,9 +102,7 @@ local function jitter(diagonal) return math.floor(base) end --- ============================================================================ -- PATHFINDING — ACL-UNIFIED ENTRY POINT (one-time backend detection) --- ============================================================================ --- Build native flags from human-readable option table. -- Module-level 1-entry cache to avoid recomputation without mutating caller opts. @@ -202,61 +175,7 @@ function PathStrategy.findPath(startPos, goalPos, opts) return _pathBackend(startPos, goalPos, maxSteps, flags, opts) end ---- Multi-attempt pathfinding with progressive relaxation. --- @return path, wasRelaxed -function PathStrategy.findPathRelaxed(startPos, goalPos, opts) - opts = opts or {} - local base = { - maxSteps = opts.maxSteps or DEFAULT_MAX_STEPS, - ignoreCreatures = opts.ignoreCreatures or false, - ignoreFields = opts.ignoreFields or false, - precision = opts.precision or 0, - } - - -- Attempt 1: truly strict (no ignoreNonPathable — respects PZ, invisible walls) - local path = PathStrategy.findPath(startPos, goalPos, base) - if path then return path, false end - - -- Attempt 2: allow non-pathable tiles (relaxes PZ borders, etc.) - _flagsCacheRef = nil - base.ignoreNonPathable = true - path = PathStrategy.findPath(startPos, goalPos, base) - if path then return path, false end - - -- Attempt 3: ignore creatures - _flagsCacheRef = nil - base.ignoreCreatures = true - path = PathStrategy.findPath(startPos, goalPos, base) - if path then return path, false end - - -- Early exit: for far destinations (>30 tiles), attempts 4+5 are unlikely to help - -- and just waste CPU. They only matter for close-range blocked tiles. - local dx = math.abs(goalPos.x - startPos.x) - local dy = math.abs(goalPos.y - startPos.y) - if (dx + dy) > 30 then - return nil, false - end - - -- Attempt 4: allow unseen tiles - _flagsCacheRef = nil - base.allowUnseen = true - path = PathStrategy.findPath(startPos, goalPos, base) - if path then return path, false end - - -- Attempt 5: ignore fields (relaxed) - if not opts.ignoreFields then - _flagsCacheRef = nil - base.ignoreFields = true - path = PathStrategy.findPath(startPos, goalPos, base) - if path then return path, true end - end - - return nil, false -end - --- ============================================================================ -- HUMANISED STEP TIMING (DRY: raw duration from PathUtils, jitter added here) --- ============================================================================ --- Get humanised step duration (with jitter) for the local player. function PathStrategy.stepDuration(diagonal) @@ -271,10 +190,8 @@ function PathStrategy.rawStepDuration(diagonal) return pu and pu.getStepDuration(diagonal) or (diagonal and 280 or 200) end --- ============================================================================ -- DIRECTION GUARD (sole anti-zigzag system — replaces all others) -- 3-entry ring buffer, 150ms opposite rejection, dampening after 3 rapid changes --- ============================================================================ local _dirRing = {nil, nil, nil} -- 3 most recent directions local _dirRingHead = 1 @@ -287,14 +204,11 @@ local _dirDampenUntil = 0 -- timestamp: hold direction until this time -- Lazy wrappers since PathUtils may not be loaded yet at file scope function PathStrategy.isSimilar(a, b) _ensureHelpers() - _ensureDirTables() - -- Fast inline check using SIMILAR table as fallback if SIMILAR and SIMILAR[a] then return SIMILAR[a][b] == true end local pu = PU() return pu and pu.areSimilarDirections and pu.areSimilarDirections(a, b) or false end function PathStrategy.isOpposite(a, b) - _ensureDirTables() if OPPOSITE then return OPPOSITE[a] == b end local pu = PU() return pu and pu.areOppositeDirections and pu.areOppositeDirections(a, b) or false @@ -370,9 +284,7 @@ function PathStrategy.resetDirectionState() _dirDampenUntil = 0 end --- ============================================================================ -- PATH SMOOTHING — zigzag-to-diagonal conversion --- ============================================================================ -- Lazy wrapper for directionTo local function directionTo(from, to) @@ -383,7 +295,6 @@ local function directionTo(from, to) local dy = to.y - from.y if dx ~= 0 then dx = dx > 0 and 1 or -1 end if dy ~= 0 then dy = dy > 0 and 1 or -1 end - _ensureDirTables() local key = dx .. "," .. dy local D = Directions return D and D.OFFSET_TO_DIR and D.OFFSET_TO_DIR[key] @@ -454,9 +365,7 @@ function PathStrategy.smoothPath(path, startPos) return #smoothed > 0 and smoothed or path end --- ============================================================================ -- PATH CURSOR — lightweight iterator over a direction array --- ============================================================================ local Cursor = { path = nil, @@ -503,9 +412,7 @@ function PathStrategy.advanceCursor(steps, stepDur) Cursor.ttl = math.max(1200, math.min(3000, steps * (stepDur or 200) * 1.2)) end --- ============================================================================ -- FLOOR-CHANGE SAFETY CHECK (native path verification) --- ============================================================================ local _isFC = nil -- lazy-init @@ -545,6 +452,9 @@ function PathStrategy.nativePathIsSafe(startPos, goalPos, opts) if isFC(probe) then return false, nativePath, i end + if not PU().isTileWalkable(probe) then + return false, nativePath, i + end end return true, nativePath, nil end @@ -562,9 +472,7 @@ function PathStrategy.safePrefixDest(startPos, nativePath, unsafeIdx) return dest, safeSteps end --- ============================================================================ -- FLOOR-CHANGE-AWARE CHUNKING --- ============================================================================ --- Scan a direction-array path for the number of safe steps before a floor -- change tile is reached. @@ -593,32 +501,6 @@ function PathStrategy.safeStepCount(path, startPos, fromIdx) return safe end ---- Compute optimal chunk size based on path characteristics. --- Short path → small chunk; long/straight → large chunk; zigzag → smaller. -function PathStrategy.optimalChunk(path, safeSteps, maxChunk) - maxChunk = maxChunk or 40 - local len = #path - local chunk = math.min(safeSteps, maxChunk) - - if len <= 5 then - chunk = math.min(chunk, len) - elseif len <= 15 then - chunk = math.min(chunk, 12) - end - - -- Penalise zigzag - local changes, last = 0, nil - for i = 1, math.min(chunk, len) do - if last and path[i] ~= last then changes = changes + 1 end - last = path[i] - end - if chunk >= 6 and changes > chunk * 0.6 then - chunk = math.max(4, math.floor(chunk * 0.65)) - end - - return chunk -end - --- Build the chunk destination position from cursor state. function PathStrategy.chunkDestination(path, startPos, fromIdx, steps) local dest = {x = startPos.x, y = startPos.y, z = startPos.z} @@ -630,9 +512,7 @@ function PathStrategy.chunkDestination(path, startPos, fromIdx, steps) return dest end --- ============================================================================ -- MOVEMENT DISPATCH (ACL-aware) --- ============================================================================ --- Walk a single step in the given direction. function PathStrategy.walkStep(dir) @@ -662,41 +542,8 @@ function PathStrategy.autoWalk(dest, maxSteps, opts) end end ---- Stop any ongoing autowalk. -function PathStrategy.stopAutoWalk() - local pu = PU() - if pu and pu.stopAutoWalk then - return pu.stopAutoWalk() - end - if player and player.stopAutoWalk then - pcall(player.stopAutoWalk, player) - end -end - ---- Check if player is currently autowalking. -function PathStrategy.isAutoWalking() - if player and player.isAutoWalking then - return player:isAutoWalking() - end - return false -end - ---- Check if player is walking at all. -function PathStrategy.isWalking() - if player and player.isWalking then - return player:isWalking() - end - return false -end - --- ============================================================================ -- CONVENIENCE (DRY: lazy aliases to PathUtils / Directions SSoT) -- These are resolved on first access via __index so load order doesn't matter. --- ============================================================================ - -PathStrategy.dirOffset = dirOffset -PathStrategy.applyOffset = applyOff -PathStrategy.tick = tick -- Set lazy-resolved aliases after first real call local _convenienceResolved = false @@ -704,14 +551,6 @@ local function _resolveConvenience() if _convenienceResolved then return end _convenienceResolved = true _ensureHelpers() - _ensureDirTables() - local pu = PU() - if pu then - PathStrategy.posEquals = pu.posEquals - PathStrategy.chebyshevDistance = pu.chebyshevDistance - PathStrategy.directionTo = pu.getDirectionTo - PathStrategy.DIR_TO_OFFSET = pu.DIR_TO_OFFSET - end local D = Directions if D then PathStrategy.OPPOSITE = D.OPPOSITE @@ -729,18 +568,14 @@ setmetatable(PathStrategy, { end }) --- ============================================================================ -- FULL RESET (call on CaveBot.resetWalking) --- ============================================================================ function PathStrategy.fullReset() PathStrategy.resetCursor() PathStrategy.resetDirectionState() end --- ============================================================================ -- GLOBAL EXPORT --- ============================================================================ if _G then _G.PathStrategy = PathStrategy end if nExBot then nExBot.PathStrategy = PathStrategy end diff --git a/utils/path_utils.lua b/utils/path_utils.lua index 1f7e601..466d5de 100644 --- a/utils/path_utils.lua +++ b/utils/path_utils.lua @@ -30,10 +30,8 @@ PathUtils = PathUtils or {} local PathUtils = PathUtils -- local alias for speed --- ============================================================================ -- CLIENT SERVICE ABSTRACTION (ACL Pattern) -- Resolved lazily so Phase 3 load order doesn't require nExBot.Shared yet --- ============================================================================ local _getClient -- resolved once on first call @@ -62,9 +60,7 @@ local function getPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) end --- ============================================================================ -- DIRECTION CONSTANTS (Shared, no duplication) --- ============================================================================ -- Delegate to Directions module (DRY: SSoT is constants/directions.lua) PathUtils.DIR_TO_OFFSET = Directions.DIR_TO_OFFSET @@ -75,9 +71,7 @@ PathUtils.CARDINAL_DIRS = Directions.CARDINAL PathUtils.DIAGONAL_DIRS = Directions.DIAGONAL PathUtils.ALL_DIRS = Directions.ALL --- ============================================================================ -- PATHFIND FLAGS (Native OTClientBR constants) --- ============================================================================ PathUtils.Flags = { ALLOW_NOT_SEEN = 1, @@ -111,9 +105,7 @@ function PathUtils.paramsToFlags(params) return flags end --- ============================================================================ -- FLOOR-CHANGE DETECTION (Using constants/floor_items.lua as single source) --- ============================================================================ -- Load FloorItems constants if available local FloorItems = (function() @@ -269,9 +261,7 @@ function PathUtils.isFieldTile(pos) return false end --- ============================================================================ -- TILE UTILITIES (Native API wrappers) --- ============================================================================ -- Get tile with fallback function PathUtils.getTile(pos) @@ -318,14 +308,12 @@ function PathUtils.isTileSafe(pos, allowFloorChange) return true end --- ============================================================================ -- PATHFINDING (Native API with fallback) --- ============================================================================ --- 1-entry LRU cache for findPath (avoids redundant A* on rapid retries) --- 4-entry LRU cache for findPath: walking loop typically alternates 2-3 queries --- (recovery probe + goto path + FC safety check). 4 entries avoid redundant A*. -local FINDPATH_LRU_SIZE = 4 +-- 8-entry LRU cache for findPath (avoids redundant A* on rapid retries) +-- Walking loop typically alternates 2-3 queries (recovery probe + goto + FC safety). +-- 8 entries avoid redundant A* while keeping memory footprint small. +local FINDPATH_LRU_SIZE = 8 local FINDPATH_CACHE_TTL = 200 local _fpLRU = {} -- array of {key, time, result}, newest at [1] local _fpLRUCount = 0 @@ -345,7 +333,7 @@ function PathUtils.findPath(startPos, goalPos, maxDist, params) maxDist = maxDist or 50 local flags = PathUtils.paramsToFlags(params) - -- 4-entry LRU: same start+goal+flags within TTL → reuse + -- 8-entry LRU: same start+goal+flags within TTL → reuse local key = startPos.x .. "," .. startPos.y .. "," .. startPos.z .. ">" .. goalPos.x .. "," .. goalPos.y .. "," .. goalPos.z .. ":" .. flags for i = 1, _fpLRUCount do @@ -437,33 +425,7 @@ function PathUtils.findEveryPath(startPos, maxDist, params) return map.findEveryPath(startPos, maxDist or 10, nativeParams) end --- Translate findEveryPath result to a path of directions -function PathUtils.translatePathToDirections(paths, destPosStr) - if not paths or not destPosStr then return nil end - - local predirections = {} - local currentPos = destPosStr - - while currentPos and currentPos:len() > 0 do - local node = paths[currentPos] - if not node then break end - if node[3] < 0 then break end - table.insert(predirections, node[3]) - currentPos = node[4] - end - - -- Reverse the path - local directions = {} - for i = #predirections, 1, -1 do - table.insert(directions, predirections[i]) - end - - return directions -end - --- ============================================================================ -- AUTO-WALK STATE MANAGEMENT (Native API) --- ============================================================================ -- Check if player is currently auto-walking (native API) function PathUtils.isAutoWalking() @@ -486,15 +448,6 @@ function PathUtils.stopAutoWalk() end end --- Check if player is walking (single step or auto) -function PathUtils.isWalking() - local player = getPlayer() - if not player then return false end - local isStep = player.isWalking and player:isWalking() or false - local isAuto = player.isAutoWalking and player:isAutoWalking() or false - return isStep or isAuto -end - -- Get step duration (native API with caching) local stepDurationCache = {speed = 0, cardinal = 0, diagonal = 0, time = 0} @@ -523,16 +476,7 @@ function PathUtils.getStepDuration(diagonal) return diagonal and diagonalDur or cardinalDur end --- Check if player can walk in direction (native API) -function PathUtils.canWalk(dir) - local player = getPlayer() - if not player then return false end - return player.canWalk and player:canWalk(dir) or true -end - --- ============================================================================ -- DIRECTION UTILITIES --- ============================================================================ -- Pre-built lookup tables for direction checks (DRY: SSoT is constants/directions.lua) local ADJACENT_DIRS = Directions.ADJACENT @@ -551,119 +495,7 @@ function PathUtils.areOppositeDirections(dir1, dir2) return OPPOSITE_DIRS[dir1] == dir2 end --- ============================================================================ --- POSITION UTILITIES --- ============================================================================ - --- Delegate to Directions module (SSoT — DRY) -PathUtils.getDirectionTo = Directions.getDirectionTo -PathUtils.chebyshevDistance = Directions.chebyshevDistance -PathUtils.manhattanDistance = Directions.manhattanDistance - --- Apply direction offset to position -function PathUtils.applyDirection(pos, dir) - if not pos or not dir then return nil end - local offset = PathUtils.DIR_TO_OFFSET[dir] - if not offset then return nil end - return {x = pos.x + offset.x, y = pos.y + offset.y, z = pos.z} -end - --- Check if positions are equal -function PathUtils.posEquals(pos1, pos2) - if not pos1 or not pos2 then return false end - return pos1.x == pos2.x and pos1.y == pos2.y and pos1.z == pos2.z -end - --- Check if positions are on same floor -function PathUtils.sameFloor(pos1, pos2) - if not pos1 or not pos2 then return false end - return pos1.z == pos2.z -end - --- ============================================================================ --- CREATURE UTILITIES (Optimized with single validation) --- ============================================================================ - --- Validate creature in one call (reduces pcall overhead) -function PathUtils.validateCreature(creature) - if not creature then - return false, nil, nil, nil - end - - local ok, result = pcall(function() - local isDead = creature:isDead() - local id = creature:getId() - local pos = creature:getPosition() - local hp = creature:getHealthPercent() - return {dead = isDead, id = id, pos = pos, hp = hp} - end) - - if not ok or not result then - return false, nil, nil, nil - end - - if result.dead then - return false, result.id, result.pos, result.hp - end - - return true, result.id, result.pos, result.hp -end - --- Check if creature is a valid monster target -function PathUtils.isValidMonsterTarget(creature) - if not creature then return false end - - local ok, valid = pcall(function() - if creature:isDead() then return false end - if not creature:isMonster() then return false end - -- Type 3+ = summons (not targetable) - local ctype = creature.getType and creature:getType() or 0 - if ctype >= 3 then return false end - return true - end) - - return ok and valid -end - --- ============================================================================ --- SPECTATOR UTILITIES (Native API) --- ============================================================================ - --- Get spectators with asymmetric range (native API) -function PathUtils.getSpectatorsEx(centerPos, multiFloor, minX, maxX, minY, maxY) - local map = getMap() - if not map then return {} end - - if map.getSpectatorsInRangeEx then - return map.getSpectatorsInRangeEx(centerPos, multiFloor, minX, maxX, minY, maxY) - elseif map.getSpectatorsInRange then - local range = math.max(math.abs(minX), math.abs(maxX), math.abs(minY), math.abs(maxY)) - return map.getSpectatorsInRange(centerPos, multiFloor, range, range) - end - - return {} -end - --- Get spectators in symmetric range -function PathUtils.getSpectators(centerPos, range, multiFloor) - local map = getMap() - if not map then return {} end - - range = range or 7 - multiFloor = multiFloor or false - - if map.getSpectatorsInRange then - return map.getSpectatorsInRange(centerPos, multiFloor, range, range) - elseif map.getSpectators then - return map.getSpectators(centerPos, multiFloor) - end - - return {} -end - --- ============================================================================ -- MODULE EXPORT --- ============================================================================ -- PathUtils is already global (declared at top of file) return PathUtils diff --git a/utils/safe_creature.lua b/utils/safe_creature.lua index 1a9b014..06a8de8 100644 --- a/utils/safe_creature.lua +++ b/utils/safe_creature.lua @@ -18,9 +18,7 @@ SafeCreature = SafeCreature or {} local SafeCreature = SafeCreature --- ============================================================================ -- BASIC ACCESSORS --- ============================================================================ --[[ Get creature ID safely @@ -88,9 +86,7 @@ function SafeCreature.getSpeed(creature) return ok and speed or 0 end --- ============================================================================ -- TYPE CHECKS --- ============================================================================ --[[ Check if creature is a monster safely @@ -213,9 +209,7 @@ function SafeCreature.getShield(creature) return ok and result or 0 end --- ============================================================================ -- COMBAT ACCESSORS --- ============================================================================ --[[ Get creature skull safely @@ -239,9 +233,7 @@ function SafeCreature.getOutfit(creature) return ok and outfit or nil end --- ============================================================================ -- GENERIC SAFE CALL (for methods not yet wrapped) --- ============================================================================ --[[ Call any creature method safely with a default return value @@ -258,9 +250,7 @@ function SafeCreature.call(creature, methodName, default) return ok and result or default end --- ============================================================================ -- BULK ACCESSOR (Single pcall for multiple properties) --- ============================================================================ --[[ Get multiple creature properties in a single pcall @@ -312,9 +302,7 @@ function SafeCreature.validate(creature) return true, props end --- ============================================================================ -- DISTANCE HELPERS --- ============================================================================ --[[ Calculate distance between two positions @@ -351,9 +339,7 @@ function SafeCreature.distanceBetween(creature1, creature2) return SafeCreature.distance(pos1, pos2) end --- ============================================================================ -- GLOBAL EXPORT --- ============================================================================ -- Expose as global for use by all modules (no _G in OTClient sandbox) -- SafeCreature is already global (declared without 'local') diff --git a/utils/shared.lua b/utils/shared.lua index 9c45a7f..fc5e758 100644 --- a/utils/shared.lua +++ b/utils/shared.lua @@ -19,9 +19,7 @@ local Shared = {} --------------------------------------------------------------------------------- -- CLIENT ACCESS (was duplicated in 20+ files) --------------------------------------------------------------------------------- --- Get the ClientService reference for cross-client compatibility. -- @return ClientService or nil @@ -49,9 +47,7 @@ function Shared.isOldTibia() return Shared.getClientVersion() < 960 end --------------------------------------------------------------------------------- -- TIME (was duplicated in 5+ files) --------------------------------------------------------------------------------- --- Get current time in milliseconds. -- @return number @@ -61,9 +57,7 @@ function Shared.nowMs() return os.time() * 1000 end --------------------------------------------------------------------------------- -- TABLE UTILITIES (deepClone was duplicated in 4 files) --------------------------------------------------------------------------------- --- Deep clone a table (recursive copy). -- @param t any - value to clone @@ -113,9 +107,35 @@ function Shared.properCase(str) return table.concat(words, " ") end --------------------------------------------------------------------------------- +-- CONTAINER ACCESS (DRY: was duplicated in 6+ files with Client/g_game fallback) + +--- Get open containers with cross-client fallback. + +-- @return table (array of containers, never nil) +function Shared.getContainers() + local Client = Shared.getClient() + if Client and Client.getContainers then return Client.getContainers() end + if g_game and g_game.getContainers then return g_game.getContainers() end + return {} +end + +-- TILE SAFETY (DRY: was duplicated as inline lambdas in attack_coordinator.lua) + +--- Check if a tile is walkable and has no creatures. +-- @param pos table {x, y, z} +-- @return boolean +function Shared.isTileSafe(pos) + if not pos then return false end + local Client = Shared.getClient() + local t = (Client and Client.getTile) and Client.getTile(pos) + or (g_map and g_map.getTile and g_map.getTile(pos)) + if not t then return false end + if not t:isWalkable() then return false end + if t.hasCreature and t:hasCreature() then return false end + return true +end + -- COOLDOWN UTILITIES (shared healing/potion cooldown checks) --------------------------------------------------------------------------------- --- Check if healing group cooldown is active. -- @return boolean @@ -142,9 +162,7 @@ function Shared.isPotionOnCooldown() return false end --------------------------------------------------------------------------------- -- PLAYER STAT ACCESSORS (used across heal modules) --------------------------------------------------------------------------------- --- Get player HP percent safely. -- @return number (0-100) @@ -177,9 +195,7 @@ function Shared.isInPz() return false end --------------------------------------------------------------------------------- -- SEMVER UTILITIES (used by updater) --------------------------------------------------------------------------------- --- Parse a semver string into {major, minor, patch}. -- @param str string - e.g. "3.0.0" diff --git a/utils/shared_helpers.lua b/utils/shared_helpers.lua new file mode 100644 index 0000000..e5b5d7c --- /dev/null +++ b/utils/shared_helpers.lua @@ -0,0 +1,50 @@ +local SharedHelpers = {} + +function SharedHelpers.getProfileSetting(key) + if ProfileStorage then + return ProfileStorage.get(key) + end + return storage[key] +end + +function SharedHelpers.setProfileSetting(key, value) + if ProfileStorage then + ProfileStorage.set(key, value) + else + storage[key] = value + end +end + +local _pathUtilsLoaded = false +function SharedHelpers.ensurePathUtils() + if _pathUtilsLoaded then return true end + local ok = pcall(function() dofile("nExBot/utils/path_utils.lua") end) + if ok then + _pathUtilsLoaded = true + return true + end + return false +end + +function SharedHelpers.makeDebounce(ms, fn) + if nExBot and nExBot.EventUtil and nExBot.EventUtil.debounce then + return nExBot.EventUtil.debounce(ms, fn) + end + local scheduled = false + return function(...) + if scheduled then return end + scheduled = true + local args = {...} + schedule(ms, function() + scheduled = false + if #args > 0 then + pcall(fn, table.unpack(args)) + else + pcall(fn) + end + end) + end +end + +nExBot.SharedHelpers = SharedHelpers +return SharedHelpers diff --git a/utils/storage_engine.lua b/utils/storage_engine.lua new file mode 100644 index 0000000..ae892f6 --- /dev/null +++ b/utils/storage_engine.lua @@ -0,0 +1,198 @@ +local StorageEngine = {} +local deepClone = nExBot.Shared.deepClone +local getClient = nExBot.Shared.getClient + +local function dotGet(obj, path) + if type(obj) ~= "table" or type(path) ~= "string" then return nil end + local cur = obj + for k in path:gmatch("[^%.]+") do + if type(cur) ~= "table" then return nil end + cur = cur[k] + end + return cur +end + +local function dotSet(obj, path, value) + if type(path) ~= "string" then return obj end + local r = deepClone(obj) or {} + local cur = r + local keys = {} + for k in path:gmatch("[^%.]+") do keys[#keys + 1] = k end + for i = 1, #keys - 1 do + if type(cur[keys[i]]) ~= "table" then cur[keys[i]] = {} end + cur = cur[keys[i]] + end + if #keys > 0 then cur[keys[#keys]] = value end + return r +end + +local function sanitize(data, schema) + if type(schema) ~= "table" then return data ~= nil and data or schema end + local r = {} + for k, v in pairs(schema) do + if type(v) == "table" and not (v[1] ~= nil) then + r[k] = sanitize((type(data) == "table") and data[k] or nil, v) + else + r[k] = (type(data) == "table" and data[k] ~= nil) and data[k] or deepClone(v) + end + end + if type(data) == "table" then + for k, v in pairs(data) do + if r[k] == nil then r[k] = deepClone(v) end + end + end + return r +end + +local function sanitizeName(name) + if not name then return nil end + return name:gsub("[/\\:*?\"<>|]", "_"):lower() +end + +function StorageEngine.new(opts) + local filename = opts.filename + local defaults = opts.defaults or {} + local strategy = opts.pathStrategy or "profile" + local debounceMs = opts.debounceMs or 500 + local maxFileSize = opts.maxFileSize or 10 * 1024 * 1024 + local configName = modules.game_bot.contentsPanel.config:getCurrentOption().text + + local s = { cache = nil, dirty = false, scheduled = false, init = false, char = nil, base = nil, file = nil } + + local function getChar() + if s.char then return s.char end + if player and player.getName then + local ok, n = pcall(function() return player:getName() end) + if ok and n then s.char = sanitizeName(n); return s.char end + end + local C = getClient() + local lp = (C and C.getLocalPlayer) and C.getLocalPlayer() or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) + if lp and lp:getName() then s.char = sanitizeName(lp:getName()); return s.char end + return nil + end + + local function buildPaths() + if strategy == "profile" then + local p = g_settings.getNumber('profile') or 1 + s.base = "/bot/" .. configName .. "/nExBot_configs/profile_" .. p .. "/" + s.file = s.base .. filename + return true + end + local c = getChar() + if not c then return false end + s.base = "/bot/" .. configName .. "/nExBot_configs/characters/" .. c .. "/" + s.file = s.base .. filename + return true + end + + local function ensureDir() + if not s.base then return end + if not g_resources.directoryExists(s.base) then + if strategy == "character" then + local pp = "/bot/" .. configName .. "/nExBot_configs/characters/" + if not g_resources.directoryExists(pp) then g_resources.makeDir(pp) end + end + g_resources.makeDir(s.base) + end + end + + local function readFile() + if not s.file then return nil end + if not g_resources.fileExists(s.file) then return nil end + local ok, c = pcall(function() return g_resources.readFileContents(s.file) end) + if not ok or not c then return nil end + local ok2, d = pcall(function() return json.decode(c) end) + if not ok2 or type(d) ~= "table" then return nil end + return d + end + + local function writeFile(data) + if not s.file or not data then return false end + local ok, c = pcall(function() return json.encode(data, 2) end) + if not ok or not c then return false end + if #c > maxFileSize then return false end + ensureDir() + return pcall(function() g_resources.writeFileContents(s.file, c) end) + end + + local function load() + if s.init and s.cache then return s.cache end + if not buildPaths() then return deepClone(defaults) end + s.cache = sanitize(readFile(), defaults) + s.init = true + return s.cache + end + + local function scheduleSave() + if s.scheduled then return end + s.scheduled = true + schedule(debounceMs, function() + s.scheduled = false + if s.dirty and s.cache then writeFile(s.cache); s.dirty = false end + end) + end + + local function get(path) + local d = load() + if not path then return d end + return dotGet(d, path) + end + + local function set(path, value) + if not buildPaths() then return false end + local d = load() + s.cache = dotSet(d, path, value) + s.dirty = true + scheduleSave() + return true + end + + local function isReady() + if strategy == "profile" then return s.init end + return getChar() ~= nil and s.init + end + + local function name() + return s.char or getChar() + end + + return { + load = load, + save = function() if s.cache then writeFile(s.cache); s.dirty = false end end, + get = get, + set = set, + scheduleSave = scheduleSave, + getData = function() return s.cache end, + getPath = function() return s.file end, + isReady = isReady, + getCharName = name, + getCharacterName = name, + deepClone = deepClone, + getSchema = function() return deepClone(defaults) end, + reload = function() s.init = false; s.cache = nil; s.char = nil; load() end, + getOr = function(p, d) local v = get(p); return v == nil and d or v end, + batch = function(updates) + if type(updates) ~= "table" then return end + local d = load() + for p, v in pairs(updates) do d = dotSet(d, p, v) end + s.cache = d; s.dirty = true; scheduleSave() + end, + toggle = function(p) local v = get(p); set(p, not v); return not v end, + getModule = function(m) return get(m) or deepClone(defaults[m] or {}) end, + setModule = function(m, c) return set(m, c) end, + migrateFromStorage = function(m, k) + if not storage then return false end + if not storage[k] then return false end + local e = get(m) + if e then local has = false; for _ in pairs(e) do has = true; break end; if has then return false end end + set(m, deepClone(storage[k])) + return true + end, + getStats = function() + return { charName = s.char, initialized = s.init, dirty = s.dirty, basePath = s.base } + end, + } +end + +nExBot.StorageEngine = StorageEngine +return StorageEngine diff --git a/utils/waypoint_navigator.lua b/utils/waypoint_navigator.lua index a5ee582..428c1fc 100644 --- a/utils/waypoint_navigator.lua +++ b/utils/waypoint_navigator.lua @@ -34,9 +34,7 @@ -- Module namespace (set as global by _Loader) WaypointNavigator = WaypointNavigator or {} --- ============================================================================ -- PRIVATE STATE --- ============================================================================ -- Route: ordered list of segments between consecutive goto waypoints local route = { @@ -85,9 +83,7 @@ local function getNow() return now or (os.clock() * 1000) end --- ============================================================================ -- SEGMENT PROJECTION MATH --- ============================================================================ --- Project point P onto line segment A->B using dot product. -- Returns: projectedX, projectedY, t (0-1 parameter), distance from P to projected point @@ -112,20 +108,13 @@ local function projectPointOnSegment(px, py, ax, ay, bx, by) return projX, projY, t, dist end ---- Chebyshev distance between two positions (matches CaveBot's distance metric). -local function chebyshevDist(a, b) - return math.max(math.abs(a.x - b.x), math.abs(a.y - b.y)) -end - --- Euclidean distance between two positions. local function euclideanDist(a, b) local dx, dy = a.x - b.x, a.y - b.y return math.sqrt(dx * dx + dy * dy) end --- ============================================================================ -- ROUTE BUILDING --- ============================================================================ --- Build the route from the waypointPositionCache. -- Filters to goto waypoints on the specified floor, builds segments between @@ -241,9 +230,7 @@ function WaypointNavigator.buildRoute(waypointPositionCache, playerFloor) route.built = true end --- ============================================================================ -- ROUTE PROJECTION --- ============================================================================ --- Project player position onto the nearest segment. -- Phase 1: bounding-box filter to skip far-away segments (Chebyshev, no sqrt). @@ -309,9 +296,7 @@ function WaypointNavigator.projectOntoRoute(playerPos) return 0, nil, math.huge, 0 end --- ============================================================================ -- FORWARD-ONLY WAYPOINT RESOLUTION --- ============================================================================ --- Get the correct next waypoint for the player to walk to. -- Uses distance-based advance: advances when <4 tiles from segment end, @@ -345,9 +330,7 @@ function WaypointNavigator.getNextWaypoint(playerPos) return seg.toIdx, seg.toPos end --- ============================================================================ -- PURE PURSUIT LOOKAHEAD --- ============================================================================ --- Compute a Pure Pursuit lookahead target on the route. -- Uses precomputed cumulative distances and binary search for O(log n) @@ -544,9 +527,7 @@ function WaypointNavigator.getPursuitConfig() } end --- ============================================================================ -- CORRIDOR ENFORCEMENT --- ============================================================================ --- Check if the player is within the route corridor. -- Returns a status string, distance from centerline, and recovery info if outside. @@ -641,9 +622,7 @@ function WaypointNavigator.getRecoveryTarget(playerPos) return seg.toIdx, seg.toPos, distFromRoute end --- ============================================================================ -- DRIFT CHECK (simplified interface for WaypointEngine) --- ============================================================================ --- Check if player has drifted off-route beyond the given threshold. -- @param playerPos table {x, y, z} @@ -658,9 +637,7 @@ function WaypointNavigator.checkDrift(playerPos, threshold) return distFromRoute > threshold, distFromRoute end --- ============================================================================ -- CORRIDOR CONFIGURATION --- ============================================================================ --- Set the corridor width dynamically. -- @param width number Inner corridor width (tiles from centerline) @@ -687,9 +664,7 @@ function WaypointNavigator.getCorridorConfig() } end --- ============================================================================ -- CACHE INVALIDATION --- ============================================================================ --- Invalidate the route (called when waypoint cache changes). function WaypointNavigator.invalidate() @@ -709,9 +684,7 @@ function WaypointNavigator.invalidate() tracking.softBoundaryStart = nil end --- ============================================================================ -- DEBUG / TELEMETRY --- ============================================================================ --- Get current tracking state (for debug logging). function WaypointNavigator.getCurrentSegment() diff --git a/utils/weak_cache.lua b/utils/weak_cache.lua index 41bd3b2..60fe49f 100644 --- a/utils/weak_cache.lua +++ b/utils/weak_cache.lua @@ -25,9 +25,7 @@ local WeakCache = {} WeakCache.VERSION = "1.0" --- ============================================================================ -- WEAK TABLE FACTORIES --- ============================================================================ -- Create a table with weak keys -- When the key object is garbage collected, the entry is automatically removed @@ -49,9 +47,7 @@ function WeakCache.createWeakValues() return t end --- ============================================================================ -- LRU CACHE (Least Recently Used) --- ============================================================================ -- Create an LRU cache with automatic eviction -- Uses a doubly-linked list for true O(1) touch/evict/remove. @@ -187,9 +183,7 @@ function WeakCache.createLRU(maxSize, ttl) return cache end --- ============================================================================ -- EXPORT --- ============================================================================ -- Export to global (no _G in OTClient sandbox) -- WeakCache is already global (declared without 'local') diff --git a/version b/version index b727628..fcdb2e1 100644 --- a/version +++ b/version @@ -1 +1 @@ -3.6.2 +4.0.0