diff --git a/_Loader.lua b/_Loader.lua index 868ac3d..1d8a23d 100644 --- a/_Loader.lua +++ b/_Loader.lua @@ -511,6 +511,7 @@ loadCategory("tools_legacy", { loadCategory("analytics", { "analyzer", "smart_hunt", + "hunt_context", "spy_level", "supplies", "depositer_config", diff --git a/cavebot/actions.lua b/cavebot/actions.lua index e44cfd1..8a88bb8 100644 --- a/cavebot/actions.lua +++ b/cavebot/actions.lua @@ -446,6 +446,18 @@ local function getDistanceToNextGoto(currentIdx) return 50 -- Default: no next goto found, use wide precision end +-- ============================================================================ +-- OSCILLATION / STUCK DETECTION for goto handler +-- Detects when the bot is looping 2-3 tiles without making progress toward the WP. +-- ============================================================================ +local gotoProgress = { + wpKey = nil, -- "x,y,z" of current WP (reset on WP change) + bestDist = math.huge, -- closest distance achieved to WP + staleTicks = 0, -- ticks without meaningful progress + STALE_THRESHOLD = 8, -- fast-fail after 8 non-progress ticks (~600ms) + PROGRESS_MIN = 2, -- must close ≥2 tiles to count as progress +} + CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) -- ========== PARSE POSITION ========== local posMatch = regexMatch(value, "\\s*([0-9]+)\\s*,\\s*([0-9]+)\\s*,\\s*([0-9]+),?\\s*([0-9]?)") @@ -473,19 +485,6 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) 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 ========== local Client = getClient() local minimapColor = (Client and Client.getMinimapColor) and Client.getMinimapColor(destPos) or (g_map and g_map.getMinimapColor(destPos)) or 0 @@ -530,6 +529,29 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) local distY = math.abs(destPos.y - playerPos.y) local dist = math.max(distX, distY) + -- ========== OSCILLATION / STUCK DETECTION ========== + local wpKey = destPos.x .. "," .. destPos.y .. "," .. destPos.z + if gotoProgress.wpKey ~= wpKey then + -- New waypoint: reset tracker + gotoProgress.wpKey = wpKey + gotoProgress.bestDist = dist + gotoProgress.staleTicks = 0 + else + -- Same WP: check if we've made progress + if dist <= gotoProgress.bestDist - gotoProgress.PROGRESS_MIN then + gotoProgress.bestDist = dist + gotoProgress.staleTicks = 0 + else + gotoProgress.staleTicks = gotoProgress.staleTicks + 1 + end + -- Fast-fail if stuck oscillating (only when retries > 0 — give first attempt a chance) + if gotoProgress.staleTicks >= gotoProgress.STALE_THRESHOLD and retries > 0 then + gotoProgress.staleTicks = 0 + gotoProgress.bestDist = dist -- reset for next attempt + return false -- trigger failure → recovery + end + end + -- ========== ARRIVAL CHECK ========== if distX <= precision and distY <= precision then CaveBot.clearWaypointTarget() @@ -545,6 +567,11 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) -- ========== CURRENTLY WALKING ========== if player and player:isWalking() then + -- Update progress tracker while walking (prevent false stale detection) + if dist < gotoProgress.bestDist then + gotoProgress.bestDist = dist + gotoProgress.staleTicks = 0 + end -- Check instant arrival via EventBus if CaveBot.hasArrivedAtWaypoint and CaveBot.hasArrivedAtWaypoint() then CaveBot.clearWaypointTarget() @@ -556,23 +583,19 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) -- ========== TOO FAR ========== if dist > maxDist then - -- If navigator knows the correct next WP and it's closer, advance - if WaypointNavigator and WaypointNavigator.isRouteBuilt and WaypointNavigator.isRouteBuilt() then - local nextWpIdx, nextWpPos = WaypointNavigator.getNextWaypoint(playerPos) - if nextWpIdx and nextWpPos then - local nextDist = math.max(math.abs(nextWpPos.x - playerPos.x), math.abs(nextWpPos.y - playerPos.y)) - if nextDist < dist then - CaveBot.clearWaypointTarget() - return true - end - end - end + -- Keep strict sequence: do NOT auto-advance to another WP just because it's + -- closer in geometry. Let failure/recovery handle desync states. return false, true end -- ========== MAX RETRIES ========== local maxRetries = CaveBot.Config.get("mapClick") and 4 or 8 if retries >= maxRetries then + -- skipBlocked: advance past blocked WPs instead of entering recovery + if CaveBot.Config.get("skipBlocked") then + CaveBot.clearWaypointTarget() + return true -- Complete this WP, advance to next in sequence + end return false end @@ -610,11 +633,63 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) walkParams.ignoreFields = true end + -- ========== STAIR APPROACH STABILIZATION ========== + -- When close to a FC tile, stop autoWalk to prevent overshooting. + -- walkTo's FC handler will use precise keyboard steps. + if isFloorChange and dist <= 3 then + if CaveBot.stopAutoWalk then CaveBot.stopAutoWalk() end + end + + -- ========== RESOLVE WALK TARGET ========== + -- Use Pure Pursuit lookahead when the route is built: walk to a point 10 tiles + -- ahead on the route instead of the exact waypoint position. This creates smooth + -- movement through congested WP sequences. + -- Floor-change waypoints bypass lookahead: they require exact tile precision. + -- Use Pure Pursuit lookahead only on clean (retry=0) attempts. + -- The lookahead is a geometric interpolation and may land on impassable tiles; + -- when blocked (retries > 0) fall back to destPos so progressive escalation + -- (ignoreCreatures, ignoreFields, attack blocker) works against a guaranteed- + -- walkable recorded position. + local walkTarget = destPos + if retries == 0 + and not isFloorChange + and WaypointNavigator + and type(WaypointNavigator.isRouteBuilt) == "function" + and WaypointNavigator.isRouteBuilt() + and type(WaypointNavigator.getLookaheadTarget) == "function" then + local lookahead = WaypointNavigator.getLookaheadTarget(playerPos) + if lookahead and lookahead.z == playerPos.z then + local lhDist = math.max( + math.abs(lookahead.x - playerPos.x), + math.abs(lookahead.y - playerPos.y) + ) + if lhDist >= 3 then + -- Gate 1: reject floor-change tiles (walkTo redirects to adjacent tile + -- with allowFloorChange=false, causing oscillation near the stair). + local lookaheadIsStair = (FloorItems and FloorItems.isFloorChangeTile) + and FloorItems.isFloorChangeTile(lookahead) + -- Gate 2: reject unreachable targets behind walls. The lookahead is a + -- geometric interpolation that ignores map topology; validate that A* + -- can actually find a path before committing. Uses ignoreCreatures + -- (creatures are transient) and precision=1 (don't need exact tile). + local lookaheadReachable = true + if not lookaheadIsStair then + local lhPath = findPath(playerPos, lookahead, maxDist, { + ignoreNonPathable = true, + ignoreCreatures = true, + precision = 1, + }) + lookaheadReachable = lhPath and #lhPath > 0 + end + if not lookaheadIsStair and lookaheadReachable then + walkTarget = lookahead + end + end + end + end + -- ========== ATTEMPT WALK ========== - -- Walk directly to destPos. The A* pathfinder computes optimal smooth paths - -- around obstacles. No lookahead target needed — smooth movement comes from - -- the widened arrival precision (player advances to next WP before stopping). - local walkResult = CaveBot.walkTo(destPos, maxDist, walkParams) + local walkResult = CaveBot.walkTo(walkTarget, maxDist, walkParams) if walkResult == "nudge" then -- Nudge only — count as retry so progressive strategies activate if CaveBot.setCurrentWaypointTarget then @@ -628,14 +703,29 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) CaveBot.setCurrentWaypointTarget(destPos, precision) end if CaveBot.setWalkingToWaypoint then - CaveBot.setWalkingToWaypoint(destPos) + CaveBot.setWalkingToWaypoint(walkTarget) end local walkDelay = dist <= 3 and 0 or dist <= 8 and 25 or dist <= 15 and 50 or 75 if walkDelay > 0 then CaveBot.delay(walkDelay) end return "walking" end - -- Walk failed — retry with progressive escalation + -- Walk failed — try adjacent tiles on retries > 2 (blocked WP workaround) + if retries > 2 and not isFloorChange then + local CARDINAL_OFFSETS = {{x=0,y=-1},{x=1,y=0},{x=0,y=1},{x=-1,y=0}} + for _, off in ipairs(CARDINAL_OFFSETS) do + local altDest = {x = destPos.x + off.x, y = destPos.y + off.y, z = destPos.z} + local altResult = CaveBot.walkTo(altDest, maxDist, walkParams) + if altResult and altResult ~= "nudge" then + if CaveBot.setCurrentWaypointTarget then + CaveBot.setCurrentWaypointTarget(destPos, precision) + end + CaveBot.delay(50) + return "walking" + end + end + end + if CaveBot.clearWalkingState then CaveBot.clearWalkingState() end @@ -656,12 +746,43 @@ CaveBot.registerAction("use", "#3be4d0", function(value, retries, prev) pos = {x=tonumber(pos[1][2]), y=tonumber(pos[1][3]), z=tonumber(pos[1][4])} local playerPos = player:getPosition() - if pos.z ~= playerPos.z then - return false -- different floor + + -- Floor-change awareness: if the target is a FC tile, handle approach + use + local isFC = (FloorItems and FloorItems.isFloorChangeTile) and FloorItems.isFloorChangeTile(pos) or false + + if pos.z ~= playerPos.z then + if isFC then + -- Player already changed floor after using the stair → complete + local Client = getClient() + local minimapColor = (Client and Client.getMinimapColor) and Client.getMinimapColor(pos) or (g_map and g_map.getMinimapColor(pos)) or 0 + local expectedFloor = pos.z + if minimapColor == 210 or minimapColor == 211 then + expectedFloor = pos.z - 1 + elseif minimapColor == 212 or minimapColor == 213 then + expectedFloor = pos.z + 1 + end + if playerPos.z == expectedFloor then + return true -- Arrived at expected floor + end + end + return false -- different floor, not a FC tile or wrong floor end - if math.max(math.abs(pos.x-playerPos.x), math.abs(pos.y-playerPos.y)) > 7 then - return false -- too far way + local dist = math.max(math.abs(pos.x-playerPos.x), math.abs(pos.y-playerPos.y)) + + if dist > 7 then + -- Too far: walk closer first + if isFC or dist > 10 then return false end + local maxDist = CaveBot.getMaxGotoDistance and CaveBot.getMaxGotoDistance() or 50 + local walkResult = CaveBot.walkTo(pos, maxDist, { + precision = 1, + allowFloorChange = false + }) + if walkResult then + CaveBot.delay(200) + return "retry" + end + return false end local Client = getClient() @@ -677,6 +798,12 @@ CaveBot.registerAction("use", "#3be4d0", function(value, retries, prev) use(topThing) CaveBot.delay(CaveBot.Config.get("useDelay") + CaveBot.Config.get("ping")) + + -- For FC tiles, wait for floor change instead of completing immediately + if isFC then + return "retry" + end + return true end) @@ -694,12 +821,43 @@ CaveBot.registerAction("usewith", "#3be4d0", function(value, retries, prev) local itemid = tonumber(pos[1][2]) pos = {x=tonumber(pos[1][3]), y=tonumber(pos[1][4]), z=tonumber(pos[1][5])} local playerPos = player:getPosition() - if pos.z ~= playerPos.z then + + -- Floor-change awareness: if the target is a FC tile (rope hole, shovel spot) + local isFC = (FloorItems and FloorItems.isFloorChangeTile) and FloorItems.isFloorChangeTile(pos) or false + + if pos.z ~= playerPos.z then + if isFC then + -- Player already changed floor after using item on stair → complete + local Client = getClient() + local minimapColor = (Client and Client.getMinimapColor) and Client.getMinimapColor(pos) or (g_map and g_map.getMinimapColor(pos)) or 0 + local expectedFloor = pos.z + if minimapColor == 210 or minimapColor == 211 then + expectedFloor = pos.z - 1 + elseif minimapColor == 212 or minimapColor == 213 then + expectedFloor = pos.z + 1 + end + if playerPos.z == expectedFloor then + return true -- Arrived at expected floor + end + end return false -- different floor end - if math.max(math.abs(pos.x-playerPos.x), math.abs(pos.y-playerPos.y)) > 7 then - return false -- too far way + local dist = math.max(math.abs(pos.x-playerPos.x), math.abs(pos.y-playerPos.y)) + + if dist > 7 then + -- Too far: walk closer first + if isFC or dist > 10 then return false end + local maxDist = CaveBot.getMaxGotoDistance and CaveBot.getMaxGotoDistance() or 50 + local walkResult = CaveBot.walkTo(pos, maxDist, { + precision = 1, + allowFloorChange = false + }) + if walkResult then + CaveBot.delay(200) + return "retry" + end + return false end local Client = getClient() @@ -715,6 +873,12 @@ CaveBot.registerAction("usewith", "#3be4d0", function(value, retries, prev) usewith(itemid, topThing) CaveBot.delay(CaveBot.Config.get("useDelay") + CaveBot.Config.get("ping")) + + -- For FC tiles, wait for floor change instead of completing immediately + if isFC then + return "retry" + end + return true end) diff --git a/cavebot/cavebot.lua b/cavebot/cavebot.lua index dd5bb9a..0788ff4 100644 --- a/cavebot/cavebot.lua +++ b/cavebot/cavebot.lua @@ -159,7 +159,9 @@ local function shouldSkipExecution() local elapsed = now - walkState.walkStartTime local HARD_TIMEOUT = 8000 -- 8 seconds absolute maximum local expectedDur = walkState.walkExpectedDuration or 5000 -- Fallback 5s if nil - local softTimeout = expectedDur * 1.5 + -- Pure Pursuit lookahead targets may be close in straight-line but far in actual + -- winding-corridor path length. Use a floor of 5s to avoid premature timeout. + local softTimeout = math.max(expectedDur * 2.0, 5000) if elapsed > HARD_TIMEOUT or elapsed > softTimeout then -- Walking too long — stop and let macro recompute @@ -188,8 +190,11 @@ local function shouldSkipExecution() if not walkState.minDist or curDist < walkState.minDist then walkState.minDist = curDist end - -- Scale regression tolerance: generous for U-shaped cave corridors - local tolerance = math.max(3, math.floor((walkState.walkStartDist or 20) * 0.6)) + -- Pure Pursuit lookahead can be geometrically close (small Chebyshev) but + -- require a long winding path. Tolerance = max(startDist, 8) so regression + -- only fires when the player has gone FURTHER from the lookahead than they + -- started — a reliable signal that navigation truly went backward. + local tolerance = math.max(walkState.walkStartDist or 5, 8) if walkState.minDist and curDist > walkState.minDist + tolerance then -- Getting farther from closest point — stop and recompute if player.stopAutoWalk then @@ -201,11 +206,13 @@ local function shouldSkipExecution() walkState.targetPos = nil return false end - -- Elapsed-progress check: if walking > 3s with zero distance decrease, stuck - -- Disabled for short walks (≤8 tiles) — the no-progress timer handles those + -- Elapsed-progress check: if walking > 6s with zero distance decrease, stuck. + -- 6s floor handles winding corridors where the player navigates away from the + -- lookahead before looping back around; HARD_TIMEOUT (8s) still catches true stucks. + -- Disabled for short walks (≤8 tiles) — the no-progress timer handles those. if walkState.walkStartTime and walkState.walkStartDist and (walkState.walkStartDist or 99) > 8 then local elapsed = now - walkState.walkStartTime - if elapsed > 3000 and curDist >= walkState.walkStartDist then + if elapsed > 6000 and curDist >= walkState.walkStartDist then if player.stopAutoWalk then pcall(player.stopAutoWalk, player) end @@ -321,7 +328,7 @@ WaypointEngine = { recoveryJustFocused = false, -- suppress actionRetries reset after recovery focus lastRecoverySearch = 0, -- throttle recovery searches (1/sec) recoveryStartedAt = 0, -- when current recovery session began - RECOVERY_IDLE_TIMEOUT = 300000,-- 5 min: clear blacklists if completely stuck + RECOVERY_IDLE_TIMEOUT = 12000, -- 12s: clear blacklists if all WPs exhausted -- Drift detection: proactive refocus to nearest WP when player drifts too far -- NOTE: Corridor enforcement (WaypointNavigator) is now the primary drift detector. @@ -335,6 +342,10 @@ WaypointEngine = { wasTargetBotBlocking = false, postCombatUntil = 0, -- tighter corridor check for 3s after combat ends + -- Corridor breach counter: sustained breaches in NORMAL state trigger RECOVERING + corridorBreachCount = 0, + CORRIDOR_BREACH_THRESHOLD = 3, -- 3 sustained breaches → RECOVERING + -- Performance: avoid redundant UI lookups tickCount = 0, lastTickTime = 0, @@ -640,7 +651,7 @@ local function runWaypointEngine() return false end --- Reset engine state +-- Reset engine state (clears blacklists too — matches full-restart behavior) resetWaypointEngine = function() WaypointEngine.state = "NORMAL" WaypointEngine.failureCount = 0 @@ -651,7 +662,9 @@ resetWaypointEngine = function() WaypointEngine.lastRefocusTime = 0 WaypointEngine.wasTargetBotBlocking = false WaypointEngine.postCombatUntil = 0 + WaypointEngine.corridorBreachCount = 0 lastDispatchedChild = nil + clearWaypointBlacklist() end -- Cache TargetBot function references (avoid repeated table lookups) @@ -726,22 +739,105 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking if not buildWaypointCache then return end -- Z-LEVEL CHANGE: Must run BEFORE shouldSkipExecution so stale delays - -- from the old floor can't block rescue. All Z changes handled identically - -- (no intended/accidental distinction). + -- from the old floor can't block rescue. local playerPos = player and player:getPosition() if playerPos and lastPlayerFloor and playerPos.z ~= lastPlayerFloor then - -- Clear ALL stale state from old floor + -- Determine whether this floor change was intentional (the focused WP is + -- already on the new floor, meaning goto navigated the stairs on purpose) + -- or accidental (player changed floor while targeting a different-floor WP). + local focusedChild = ui and ui.list and ui.list:getFocusedChild() + local focusedIdx = focusedChild and ui.list:getChildIndex(focusedChild) + local focusedWp = focusedIdx and waypointPositionCache[focusedIdx] + -- Case 1: WP is already on the new floor (e.g. the goto navigated to a stair + -- tile whose destination is explicitly recorded with the new floor's z value). + local intentional = focusedWp and focusedWp.isGoto and focusedWp.z == playerPos.z + + -- Case 2: Stair-triggered change — focused WP is a floor-change tile on the + -- OLD floor. The goto walked the player onto a hole/ladder/rope and the + -- server teleported them to the adjacent floor. This is intentional; using + -- findNearestSameFloorGoto here would snap to a WP *before* the stair + -- entrance and create an infinite loop (Wyrm / Banuta routes). + local stairUsed = false + if not intentional and focusedWp and focusedWp.isGoto and focusedWp.z == lastPlayerFloor then + local wpPos = { x = focusedWp.x, y = focusedWp.y, z = focusedWp.z } + if FloorItems and FloorItems.isFloorChangeTile then + stairUsed = FloorItems.isFloorChangeTile(wpPos) + end + end + + -- Always clear stale walk state regardless of intent walkState.delayUntil = 0 - cavebotMacro.delay = nil - clearWaypointBlacklist() + cavebotMacro.delay = nil safeResetWalking() - resetWaypointEngine() - -- Focus nearest same-Z goto WP (pure distance, no path validation) - local child, idx = findNearestSameFloorGoto(playerPos, playerPos.z, CaveBot.getMaxGotoDistance()) - if child then - print("[CaveBot] Z-change (" .. lastPlayerFloor .. "→" .. playerPos.z .. "): focusing WP" .. idx) - focusWaypointForRecovery(child, idx) + + if intentional then + -- Intentional stair use: the current WP is already on this floor. + -- Don't refocus — let the goto action complete normally. + -- Clear blacklists so fresh state on new floor, but keep engine in NORMAL. + clearWaypointBlacklist() + WaypointEngine.failureCount = 0 + print("[CaveBot] Z-change (" .. lastPlayerFloor .. "→" .. playerPos.z .. "): intentional, continuing WP" .. (focusedIdx or "?")) + elseif stairUsed then + -- Stair tile on the old floor caused this change (goto-driven stair use). + -- Advance to the next WP in sequence (currentIndex+1) — this preserves + -- correct route order instead of falling through to the Z-mismatch scan. + clearWaypointBlacklist() + WaypointEngine.failureCount = 0 + local actionCount = ui.list:getChildCount() + if focusedIdx and actionCount > 0 then + local nextIdx = (focusedIdx % actionCount) + 1 + local nextChild = ui.list:getChildByIndex(nextIdx) + if nextChild then + ui.list:focusChild(nextChild) + actionRetries = 0 + end + end + print("[CaveBot] Z-change (" .. lastPlayerFloor .. "→" .. playerPos.z .. "): stair at WP" .. (focusedIdx or "?") .. ", advancing to next WP") + else + -- Accidental floor change: reset fully and find nearest same-floor WP. + -- Forward-biased: scan forward from current index first, prefer closest + -- forward WP, then wrap to beginning for rescue WPs. + clearWaypointBlacklist() + resetWaypointEngine() + local maxDist = CaveBot.getMaxGotoDistance() + local bestChild, bestIdx, bestDist = nil, nil, math.huge + local actionCount = ui.list:getChildCount() + buildWaypointCache() + -- Forward scan: from focusedIdx+1 to end + if focusedIdx and actionCount > 0 then + for i = focusedIdx + 1, actionCount do + local wp = waypointPositionCache[i] + if wp and wp.isGoto and wp.z == playerPos.z then + local d = math.max(math.abs(playerPos.x - wp.x), math.abs(playerPos.y - wp.y)) + if d <= maxDist and d < bestDist then + bestChild, bestIdx, bestDist = wp.child, i, d + break -- First forward match wins (closest in sequence) + end + end + end + -- Wrap scan: from 1 to focusedIdx for rescue WPs + if not bestChild then + for i = 1, (focusedIdx or actionCount) do + local wp = waypointPositionCache[i] + if wp and wp.isGoto and wp.z == playerPos.z then + local d = math.max(math.abs(playerPos.x - wp.x), math.abs(playerPos.y - wp.y)) + if d <= maxDist and d < bestDist then + bestChild, bestIdx, bestDist = wp.child, i, d + end + end + end + end + end + -- Fallback: distance-based (if forward scan found nothing within maxDist) + if not bestChild then + bestChild, bestIdx = findNearestSameFloorGoto(playerPos, playerPos.z, maxDist) + end + if bestChild then + print("[CaveBot] Z-change (" .. lastPlayerFloor .. "→" .. playerPos.z .. "): accidental, focusing WP" .. bestIdx) + focusWaypointForRecovery(bestChild, bestIdx) + end end + lastPlayerFloor = playerPos.z return -- Consume this tick for the Z transition end @@ -845,11 +941,10 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking end -- Trigger 2: Corridor enforcement (checked every tick when not walking/in-combat) - -- During post-combat window (3s): "margin" triggers too (catch 6-15 tile drift from chase). - -- Otherwise: only hard "outside" (15+ tiles) to avoid interfering with normal A* detours. + -- NORMAL state: count sustained breaches → transition to RECOVERING after threshold. + -- RECOVERING state: corridor refocus is handled by executeRecovery(). + -- Post-combat window (3s): "margin" triggers too (catch 6-15 tile drift from chase). if WaypointNavigator and playerPos and not player:isWalking() then - -- Guard: skip if the current goto action was just dispatched recently - -- (prevents canceling a walk between A* pathfinder steps) if (now - WaypointEngine.lastRefocusTime) >= WaypointEngine.REFOCUS_COOLDOWN and type(CaveBot.ensureNavigatorRoute) == 'function' then CaveBot.ensureNavigatorRoute(playerPos.z) local status, dist, recovery @@ -859,25 +954,52 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking local inPostCombat = now < WaypointEngine.postCombatUntil local breached = status and ((inPostCombat and status ~= "inside") or (status == "outside")) - if breached and recovery then - local wp = waypointPositionCache[recovery.nextWpIdx] - if wp and wp.child and not isWaypointBlacklisted(wp.child) then - print("[CaveBot] Corridor breach: " .. math.floor(dist) .. " tiles off-route, refocusing WP" .. recovery.nextWpIdx) - focusWaypointForRecovery(wp.child, recovery.nextWpIdx) - WaypointEngine.lastRefocusTime = now - return + if breached then + if WaypointEngine.state == "RECOVERING" and recovery then + -- RECOVERING: immediate corridor refocus (bot is genuinely lost) + local wp = waypointPositionCache[recovery.nextWpIdx] + if wp and wp.child and not isWaypointBlacklisted(wp.child) then + print("[CaveBot] Corridor breach (recovery): " .. math.floor(dist) .. " tiles off-route, refocusing WP" .. recovery.nextWpIdx) + focusWaypointForRecovery(wp.child, recovery.nextWpIdx) + WaypointEngine.lastRefocusTime = now + return + end + else + -- NORMAL: count sustained breaches, don't refocus directly + WaypointEngine.corridorBreachCount = WaypointEngine.corridorBreachCount + 1 + if WaypointEngine.corridorBreachCount >= WaypointEngine.CORRIDOR_BREACH_THRESHOLD then + print("[CaveBot] Sustained corridor breach (" .. WaypointEngine.corridorBreachCount .. " checks, " .. math.floor(dist) .. " tiles off-route) — entering RECOVERING") + WaypointEngine.corridorBreachCount = 0 + transitionTo("RECOVERING") + return + end end + else + -- Inside corridor: reset breach counter + WaypointEngine.corridorBreachCount = 0 end end end -- Trigger 3: Periodic drift check (fallback when corridor is unavailable) + -- Only refocus if current WP is blacklisted or has failures — trust the sequence otherwise. if (now - WaypointEngine.lastDriftCheck) >= WaypointEngine.DRIFT_CHECK_INTERVAL then WaypointEngine.lastDriftCheck = now if not player:isWalking() then - local pp = pos() - if pp and maybeRefocusNearestWaypoint(pp) then - return + local currentAction = uiList and uiList:getFocusedChild() + local shouldRefocus = false + if currentAction then + if isWaypointBlacklisted(currentAction) then + shouldRefocus = true + elseif WaypointEngine.failureCount > 0 then + shouldRefocus = true + end + end + if shouldRefocus then + local pp = pos() + if pp and maybeRefocusNearestWaypoint(pp) then + return + end end end end @@ -894,22 +1016,43 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking if not currentAction then return end -- Z-MISMATCH GUARD: If focused WP is a goto on a different floor than player, - -- scan forward to the next same-floor goto (wraps around to WP1). - -- Prevents rapid cycling: arrive→advance→Z-mismatch→instantFail→recovery→arrive→... + -- advance sequentially (not wrapping scan) to preserve route order. + -- Only advance to the immediate next WP(s); skip non-goto actions naturally. + -- If next goto is also wrong floor → let normal failure/recovery handle it. if playerPos and currentAction.action == "goto" then buildWaypointCache() local focusedIdx = uiList:getChildIndex(currentAction) local cachedWp = waypointPositionCache[focusedIdx] if cachedWp and cachedWp.z ~= playerPos.z then local found = false + -- Try advancing sequentially: check next few WPs (up to 5 non-goto skips) + local maxSkip = 5 local scanIdx = focusedIdx - for _ = 1, actionCount do - scanIdx = (scanIdx % actionCount) + 1 + for _ = 1, maxSkip do + scanIdx = scanIdx + 1 + if scanIdx > actionCount then break end -- Don't wrap around + local nextChild = uiList:getChildByIndex(scanIdx) + if not nextChild then break end local wp = waypointPositionCache[scanIdx] - if wp and wp.isGoto and wp.z == playerPos.z then - focusWaypointForRecovery(wp.child, scanIdx) - found = true - break + if wp and wp.isGoto then + -- Found next goto: only accept if same floor + if wp.z == playerPos.z and not isWaypointBlacklisted(wp.child) then + focusWaypointForRecovery(wp.child, scanIdx) + found = true + end + break -- Stop at first goto regardless (don't skip past it) + end + -- Non-goto action: skip it (labels, use, say, etc.) + end + -- Rescue fallback: if ALL forward WPs are wrong floor, wrap to find rescue WPs + if not found then + for scanI = 1, focusedIdx - 1 do + local wp = waypointPositionCache[scanI] + if wp and wp.isGoto and wp.z == playerPos.z and not isWaypointBlacklisted(wp.child) then + focusWaypointForRecovery(wp.child, scanI) + found = true + break + end end end if found then return end @@ -1035,10 +1178,36 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking local nextChild = uiList:getChildByIndex(nextIndex) if nextChild then - -- Skip blacklisted (stuck/unreachable) waypoints - if isWaypointBlacklisted(nextChild) then + -- Skip blacklisted WPs, and floor-mismatched WPs only when they form a + -- *trailing rescue block* — i.e. from this WP to the end of the list there + -- are no same-floor WPs (Banuta-style rescue WPs appended at end of route). + -- Intentional multi-floor routes (Wyrm-style) always have same-floor WPs + -- further ahead and are therefore NOT skipped. + local function isTrailingRescueBlock(startIdx) + if not playerPos then return false end + for i = startIdx + 1, actionCount do + local wp = waypointPositionCache[i] + if wp and wp.isGoto and wp.z == playerPos.z then + return false -- same-floor WP exists ahead → intentional transition + end + end + return true -- no same-floor WP until end of route → rescue block + end + + local function shouldSkipNext(child) + if isWaypointBlacklisted(child) then return true end + if child.action == "goto" and playerPos then + local idx2 = uiList:getChildIndex(child) + local wp2 = waypointPositionCache[idx2] + if wp2 and wp2.z ~= playerPos.z then + return isTrailingRescueBlock(idx2) + end + end + return false + end + if shouldSkipNext(nextChild) then local skipped = 0 - while isWaypointBlacklisted(nextChild) and skipped < actionCount do + while shouldSkipNext(nextChild) and skipped < actionCount do nextIndex = (nextIndex % actionCount) + 1 nextChild = uiList:getChildByIndex(nextIndex) skipped = skipped + 1 @@ -1416,6 +1585,8 @@ findReachableWaypoint = function(playerPos, options) end -- Collect same-floor, non-blacklisted candidates (prefer goto WPs for recovery) + -- Forward-bias: penalize backward WPs (before currentIdx) by 2× distance + -- so the bot prefers to continue forward in the .cfg sequence. local candidates = {} for i, wp in pairs(waypointPositionCache) do if isWaypointBlacklisted(wp.child) then goto continue end @@ -1426,8 +1597,14 @@ findReachableWaypoint = function(playerPos, options) -- Include if within maxDist OR if it's one of the very closest (proximity guarantee) if dist > maxDist * 1.5 then goto continue end + -- Forward-bias: backward WPs get 2× effective distance for sorting + local sortDist = dist + if currentIdx > 0 and i < currentIdx then + sortDist = dist * 2 + end + candidates[#candidates + 1] = { - index = i, dist = dist, child = wp.child, + index = i, dist = dist, sortDist = sortDist, child = wp.child, x = wp.x, y = wp.y, z = wp.z, isGoto = wp.isGoto, withinRange = (dist <= maxDist) } @@ -1438,8 +1615,8 @@ findReachableWaypoint = function(playerPos, options) return nil, nil end - -- Sort by distance - table.sort(candidates, function(a, b) return a.dist < b.dist end) + -- Sort by effective distance (forward-biased) + table.sort(candidates, function(a, b) return a.sortDist < b.sortDist end) -- Path-validate top candidates (max 5 strict A* calls, bounded cost) -- This prevents selecting WPs behind walls during recovery. diff --git a/cavebot/walking.lua b/cavebot/walking.lua index b72cd45..31eecb5 100644 --- a/cavebot/walking.lua +++ b/cavebot/walking.lua @@ -122,7 +122,11 @@ end local lastNudgeDir = nil local lastNudgeTime = 0 +--- All 8 directions for expanded nudge +local ALL_DIRS = {0, 1, 2, 3, 4, 5, 6, 7} + --- Try a single keyboard step toward dest. Returns "nudge" or false. +--- Tries all 8 directions: primary first, then adjacent, then remaining. local function tryKeyboardNudge(playerPos, dest) if not playerPos or not dest then return false end if player:isWalking() then return false end @@ -130,13 +134,36 @@ local function tryKeyboardNudge(playerPos, dest) local dir = getDirectionTo(playerPos, dest) if dir == nil then return false end - local candidates = { dir } + -- Build priority-ordered candidate list: primary → adjacent → remaining + local used = {} + local candidates = {} + + -- Primary direction + candidates[#candidates + 1] = dir + used[dir] = true + + -- Adjacent directions local adj = ADJACENT_DIRS[dir] - if adj then candidates[2] = adj[1]; candidates[3] = adj[2] end + if adj then + for _, d in ipairs(adj) do + if not used[d] then + candidates[#candidates + 1] = d + used[d] = true + end + end + end - -- Anti-oscillation - if dir == lastNudgeDir and now - lastNudgeTime < 500 and adj then - candidates = { adj[1], adj[2], dir } + -- Remaining directions (perpendicular and backward) + for _, d in ipairs(ALL_DIRS) do + if not used[d] then + candidates[#candidates + 1] = d + end + end + + -- Anti-oscillation: rotate primary to end if same direction nudge recently + if dir == lastNudgeDir and now - lastNudgeTime < 500 then + table.remove(candidates, 1) + candidates[#candidates + 1] = dir end for _, d in ipairs(candidates) do @@ -228,7 +255,11 @@ local function findWalkablePath(playerPos, dest, opts) return path, false end - -- 3) RELAXED pathfinding (last resort, includes ignoreNonPathable) + -- 3) RELAXED pathfinding (narrow passages near trees/objects). + -- ignoreNonPathable lets A* route through tiles flagged non-pathable + -- (common near trees, objects) that are actually walkable. + -- Multi-step validation: check first 3 steps against REAL tile walkability + -- to reject paths that go through actual walls. local relaxedPath, wasRelaxed = PS().findPathRelaxed(playerPos, dest, { maxSteps = maxSteps, ignoreCreatures = opts.ignoreCreatures or false, @@ -236,15 +267,42 @@ local function findWalkablePath(playerPos, dest, opts) precision = opts.precision or 0, }) - if relaxedPath and #relaxedPath > 0 and resolveWalkableDir(relaxedPath[1]) then - PS().setCursor(relaxedPath, dest) - local sm = PS().smoothPath(relaxedPath, playerPos) - if sm and #sm > 0 and #sm <= #relaxedPath then - relaxedPath = sm - local cur = PS().getCursor() - if cur then cur.path = relaxedPath end + if relaxedPath and #relaxedPath > 0 then + local VALIDATE_STEPS = math.min(3, #relaxedPath) + local probe = {x = playerPos.x, y = playerPos.y, z = playerPos.z} + local validSteps = 0 + + for i = 1, VALIDATE_STEPS do + local dir = relaxedPath[i] + if i == 1 then + -- First step: canWalkDirection (most reliable for current position) + if not resolveWalkableDir(dir) then break end + else + -- Steps 2+: check actual tile walkability + local off = DIR_TO_OFFSET[dir] + if not off then break end + local nextPos = {x = probe.x + off.x, y = probe.y + off.y, z = probe.z} + if PathUtils and PathUtils.isTileWalkable then + if not PathUtils.isTileWalkable(nextPos, true) then break end + end + end + validSteps = validSteps + 1 + local off = DIR_TO_OFFSET[relaxedPath[i]] + if off then + probe = {x = probe.x + off.x, y = probe.y + off.y, z = probe.z} + end + end + + if validSteps >= VALIDATE_STEPS then + PS().setCursor(relaxedPath, dest) + local sm = PS().smoothPath(relaxedPath, playerPos) + if sm and #sm > 0 and #sm <= #relaxedPath then + relaxedPath = sm + local cur = PS().getCursor() + if cur then cur.path = relaxedPath end + end + return relaxedPath, true end - return relaxedPath, wasRelaxed end -- No walkable path found @@ -255,7 +313,7 @@ end -- DISPATCH: KEYBOARD STEP vs AUTOWALK -- ============================================================================ -local KEYBOARD_THRESHOLD = 12 +local KEYBOARD_THRESHOLD = 3 --- Walk a single keyboard step along the path. Returns true on success. local function keyboardStep(path, playerPos, curIdx) @@ -353,30 +411,53 @@ CaveBot.walkTo = function(dest, maxDist, params) local manhattan = distX + distY if manhattan <= 3 then - -- Close: precise keyboard steps + -- Direct step when adjacent (no pathfinding needed) + if manhattan == 1 then + local dir = getDirectionTo(playerPos, dest) + if dir and canWalkDirection(dir) then + PS().walkStep(dir) + return true + end + end + + -- Close: precise keyboard steps. Prefer the raw pathfinder direction + -- (precision=0 must land on the exact tile); fall back to smoothed only + -- when the raw direction is blocked (creature, pushable). local fcPath = PS().findPath(playerPos, dest, {ignoreNonPathable = true, precision = 0}) + -- Fallback: relaxed pathfinding (stair tiles often flagged non-pathable) + if (not fcPath or #fcPath == 0) and PS().findPathRelaxed then + fcPath = PS().findPathRelaxed(playerPos, dest, { + ignoreNonPathable = true, precision = 0, + }) + end if fcPath and #fcPath > 0 then local dir = fcPath[1] + if canWalkDirection(dir) then + PS().walkStep(dir) + return true + end local smoothed = PS().smoothDirection(dir, true) or dir - if canWalkDirection(smoothed) then + if smoothed ~= dir and canWalkDirection(smoothed) then PS().walkStep(smoothed) - elseif canWalkDirection(dir) then - PS().walkStep(dir) + return true end end - return true + -- No path or step blocked → signal failure so retries accumulate + return false else -- Far: guarded autoWalk local isSafe = PS().nativePathIsSafe(playerPos, dest, {ignoreNonPathable = true}) if isSafe then PS().autoWalk(dest, maxDist, {ignoreNonPathable = true, precision = precision}) - else - local dirToDest = getDirectionTo(playerPos, dest) - if dirToDest and canWalkDirection(dirToDest) then - PS().walkStep(dirToDest) - end + return true + end + local dirToDest = getDirectionTo(playerPos, dest) + if dirToDest and canWalkDirection(dirToDest) then + PS().walkStep(dirToDest) + return true end - return true + -- Can't reach FC tile from here → signal failure + return false end end @@ -404,6 +485,15 @@ CaveBot.walkTo = function(dest, maxDist, params) }) if not path then + -- mapClick fallback: use native autoWalk (game's own pathfinding) + -- which can sometimes route around obstacles our A* can't handle + if CaveBot.Config and CaveBot.Config.get and CaveBot.Config.get("mapClick") then + local distToDest = math.max(math.abs(dest.x - playerPos.x), math.abs(dest.y - playerPos.y)) + if distToDest > 1 then + PS().autoWalk(dest, maxDist, {ignoreNonPathable = true, precision = precision}) + return true + end + end return tryKeyboardNudge(playerPos, dest) end diff --git a/core/AttackBot.lua b/core/AttackBot.lua index e543e20..685e365 100644 --- a/core/AttackBot.lua +++ b/core/AttackBot.lua @@ -15,12 +15,13 @@ setDefaultTab("Main") -- locales local panelName = "AttackBot" local currentSettings -local showSettings = false local showItem = false local category = 1 local patternCategory = 1 local pattern = 1 local mainWindow +local attackBotKeyboardBound = false +local attackEntryList -- ============================================================================ -- BOTCORE INTEGRATION @@ -725,12 +726,42 @@ end mainWindow:hide() local panel = mainWindow.mainPanel - local settingsUI = mainWindow.settingsPanel + local function rw(id) + return mainWindow:recursiveGetChildById(id) + end + + local uiFormPane = rw("formPane") + local uiEntryList = rw("entryList") + local uiUp = rw("up") + local uiDown = rw("down") + local uiMonsters = rw("monsters") + local uiSpellName = rw("spellName") + local uiItemId = rw("itemId") + local uiCategory = rw("category") + local uiRange = rw("range") + local uiSelectorHint = rw("selectorHint") + local uiPreviousCategory = rw("previousCategory") + local uiNextCategory = rw("nextCategory") + local uiPreviousRange = rw("previousRange") + local uiNextRange = rw("nextRange") + local uiManaPercent = rw("manaPercent") + local uiCreatures = rw("creatures") + local uiMinHp = rw("minHp") + local uiMaxHp = rw("maxHp") + local uiCooldown = rw("cooldown") + local uiOrMore = rw("orMore") + local uiAddEntry = rw("addEntry") + + if not uiEntryList or not uiMonsters or not uiSpellName or not uiItemId then + warn("[AttackBot] Failed to bind AttackBotWindow controls") + return + end + attackEntryList = uiEntryList mainWindow.onVisibilityChange = function(widget, visible) if not visible then currentSettings.attackTable = {} - for i, child in ipairs(panel.entryList:getChildren()) do + for i, child in ipairs(uiEntryList:getChildren()) do table.insert(currentSettings.attackTable, child.params) end nExBotConfigSave("atk") @@ -739,40 +770,55 @@ end -- main panel - -- functions - function toggleSettings() - panel:setVisible(not showSettings) - mainWindow.shooterLabel:setVisible(not showSettings) - settingsUI:setVisible(showSettings) - mainWindow.settingsLabel:setVisible(showSettings) - mainWindow.settings:setText(showSettings and "Back" or "Settings") + local selectorHints = { + [1] = "Spell mode: type spell name, then press Enter to add.", + [2] = "Rune mode: drag a rune into the item slot, then press Enter.", + [3] = "Rune mode: drag a rune into the item slot, then press Enter.", + [4] = "Empowered spell: set conditions and add to queue.", + [5] = "Directional spell: choose pattern/range and add." + } + + local function focusPrimaryInput() + if showItem then + return + end + if uiSpellName and uiSpellName:isVisible() then + uiSpellName:focus() + end end - toggleSettings() - mainWindow.settings.onClick = function() - showSettings = not showSettings - toggleSettings() + local function updateMonstersWidth() + local baseWidth = (uiFormPane and uiFormPane:getWidth()) or (panel:getWidth() or 500) + local reserved = showItem and 90 or 200 + uiMonsters:setWidth(math.max(170, baseWidth - reserved)) end function toggleItem() - panel.monsters:setWidth(showItem and 405 or 341) - panel.itemId:setVisible(showItem) - panel.spellName:setVisible(not showItem) + updateMonstersWidth() + uiItemId:setVisible(showItem) + uiSpellName:setVisible(not showItem) end toggleItem() + panel.onGeometryChange = function() + updateMonstersWidth() + end + function setCategoryText() - panel.category.description:setText(categories[category]) + uiCategory.description:setText(categories[category]) + if uiSelectorHint then + uiSelectorHint:setText(selectorHints[category] or selectorHints[1]) + end end setCategoryText() function setPatternText() - panel.range.description:setText(patterns[patternCategory][pattern]) + uiRange.description:setText(patterns[patternCategory][pattern]) end setPatternText() -- in/de/crementation buttons - panel.previousCategory.onClick = function() + uiPreviousCategory.onClick = function() if category == 1 then category = #categories else @@ -785,8 +831,9 @@ end toggleItem() setPatternText() setCategoryText() + focusPrimaryInput() end - panel.nextCategory.onClick = function() + uiNextCategory.onClick = function() if category == #categories then category = 1 else @@ -799,14 +846,9 @@ end toggleItem() setPatternText() setCategoryText() + focusPrimaryInput() 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.previousRange.onClick = function() + uiPreviousRange.onClick = function() local t = patterns[patternCategory] if pattern == 1 then pattern = #t @@ -815,7 +857,7 @@ end end setPatternText() end - panel.nextRange.onClick = function() + uiNextRange.onClick = function() local t = patterns[patternCategory] if pattern == #t then pattern = 1 @@ -832,14 +874,27 @@ end widget:setText(params.description) if params.itemId > 0 then - widget.spell:setVisible(false) - widget.id:setVisible(true) - widget.id:setItemId(params.itemId) + if widget.spell then + widget.spell:setVisible(false) + end + if widget.id then + widget.id:setVisible(true) + widget.id:setItemId(params.itemId) + pcall(function() widget.id:setItemCount(0) end) + pcall(function() widget.id:setItemSubType(0) end) + end + else + if widget.id then + widget.id:setVisible(false) + end + if widget.spell then + widget.spell:setVisible(true) + end end widget:setTooltip(params.tooltip) widget.remove.onClick = function() - panel.up:setEnabled(false) - panel.down:setEnabled(false) + uiUp:setEnabled(false) + uiDown:setEnabled(false) widget:destroy() end widget.enabled:setChecked(params.enabled) @@ -849,15 +904,15 @@ end end -- will serve as edit widget.onDoubleClick = function(widget) - panel.manaPercent:setValue(params.mana) - panel.creatures:setValue(params.count) - panel.minHp:setValue(params.minHp) - panel.maxHp:setValue(params.maxHp) - panel.cooldown:setValue(params.cooldown) + uiManaPercent:setValue(params.mana) + uiCreatures:setValue(params.count) + uiMinHp:setValue(params.minHp) + uiMaxHp:setValue(params.maxHp) + uiCooldown:setValue(params.cooldown) showItem = params.itemId > 100 and true or false - panel.itemId:setItemId(params.itemId) - panel.spellName:setText(params.spell or "") - panel.orMore:setChecked(params.orMore) + uiItemId:setItemId(params.itemId) + uiSpellName:setText(params.spell or "") + uiOrMore:setChecked(params.orMore) toggleItem() category = params.category patternCategory = params.patternCategory @@ -867,18 +922,18 @@ end widget:destroy() end widget.onClick = function(widget) - if #panel.entryList:getChildren() == 1 then - panel.up:setEnabled(false) - panel.down:setEnabled(false) - elseif panel.entryList:getChildIndex(widget) == 1 then - panel.up:setEnabled(false) - panel.down:setEnabled(true) - elseif panel.entryList:getChildIndex(widget) == panel.entryList:getChildCount() then - panel.up:setEnabled(true) - panel.down:setEnabled(false) + if #uiEntryList:getChildren() == 1 then + uiUp:setEnabled(false) + uiDown:setEnabled(false) + elseif uiEntryList:getChildIndex(widget) == 1 then + uiUp:setEnabled(false) + uiDown:setEnabled(true) + elseif uiEntryList:getChildIndex(widget) == uiEntryList:getChildCount() then + uiUp:setEnabled(true) + uiDown:setEnabled(false) else - panel.up:setEnabled(true) - panel.down:setEnabled(true) + uiUp:setEnabled(true) + uiDown:setEnabled(true) end end end @@ -888,31 +943,31 @@ end function refreshAttacks() if not currentSettings.attackTable then return end - panel.entryList:destroyChildren() + uiEntryList:destroyChildren() for i, entry in pairs(currentSettings.attackTable) do - local label = UI.createWidget("AttackEntry", panel.entryList) + local label = UI.createWidget("AttackEntry", uiEntryList) label.params = entry setupWidget(label) end end refreshAttacks() - panel.up:setEnabled(false) - panel.down:setEnabled(false) + uiUp:setEnabled(false) + uiDown:setEnabled(false) -- adding values - panel.addEntry.onClick = function(wdiget) + uiAddEntry.onClick = function(wdiget) -- first variables - local creatures = panel.monsters:getText():lower() + local creatures = uiMonsters:getText():lower() local monsters = (creatures:len() == 0 or creatures == "*" or creatures == "monster names") and true or string.split(creatures, ",") - local mana = panel.manaPercent:getValue() - local count = panel.creatures:getValue() - local minHp = panel.minHp:getValue() - local maxHp = panel.maxHp:getValue() - local cooldown = panel.cooldown:getValue() - local itemId = panel.itemId:getItemId() - local spell = panel.spellName:getText() + local mana = uiManaPercent:getValue() + local count = uiCreatures:getValue() + local minHp = uiMinHp:getValue() + local maxHp = uiMaxHp:getValue() + local cooldown = uiCooldown:getValue() + local itemId = uiItemId:getItemId() + local spell = uiSpellName:getText() local tooltip = monsters ~= true and creatures - local orMore = panel.orMore:isChecked() + local orMore = uiOrMore:isChecked() -- validation if showItem and itemId < 100 then @@ -951,7 +1006,7 @@ end description = '['..type..'] '..countDescription.. ' '..specificMonsters..': '..attackType..', '..categoryName..' ('..minHp..'%-'..maxHp..'%)' } - local label = UI.createWidget("AttackEntry", panel.entryList) + local label = UI.createWidget("AttackEntry", uiEntryList) label.params = params setupWidget(label) resetFields() @@ -959,87 +1014,57 @@ end -- moving values -- up - panel.up.onClick = function(widget) - local focused = panel.entryList:getFocusedChild() - local n = panel.entryList:getChildIndex(focused) + uiUp.onClick = function(widget) + local focused = uiEntryList:getFocusedChild() + local n = uiEntryList:getChildIndex(focused) if n-1 == 1 then widget:setEnabled(false) end - panel.down:setEnabled(true) - panel.entryList:moveChildToIndex(focused, n-1) - panel.entryList:ensureChildVisible(focused) + uiDown:setEnabled(true) + uiEntryList:moveChildToIndex(focused, n-1) + uiEntryList:ensureChildVisible(focused) end -- down - panel.down.onClick = function(widget) - local focused = panel.entryList:getFocusedChild() - local n = panel.entryList:getChildIndex(focused) + uiDown.onClick = function(widget) + local focused = uiEntryList:getFocusedChild() + local n = uiEntryList:getChildIndex(focused) - if n + 1 == panel.entryList:getChildCount() then + if n + 1 == uiEntryList:getChildCount() then widget:setEnabled(false) end - panel.up:setEnabled(true) - panel.entryList:moveChildToIndex(focused, n+1) - panel.entryList:ensureChildVisible(focused) + uiUp:setEnabled(true) + uiEntryList:moveChildToIndex(focused, n+1) + uiEntryList:ensureChildVisible(focused) end - -- [[settings panel]] -- - settingsUI.profileName.onTextChange = function(widget, text) - currentSettings.name = text - setProfileName() - end - settingsUI.IgnoreMana.onClick = function(widget) - currentSettings.ignoreMana = not currentSettings.ignoreMana - settingsUI.IgnoreMana:setChecked(currentSettings.ignoreMana) - end - settingsUI.Rotate.onClick = function(widget) - currentSettings.Rotate = not currentSettings.Rotate - settingsUI.Rotate:setChecked(currentSettings.Rotate) - end - settingsUI.Kills.onClick = function(widget) - currentSettings.Kills = not currentSettings.Kills - settingsUI.Kills:setChecked(currentSettings.Kills) - end - settingsUI.Cooldown.onClick = function(widget) - currentSettings.Cooldown = not currentSettings.Cooldown - settingsUI.Cooldown:setChecked(currentSettings.Cooldown) - end - settingsUI.Visible.onClick = function(widget) - currentSettings.Visible = not currentSettings.Visible - settingsUI.Visible:setChecked(currentSettings.Visible) - end - settingsUI.PvpMode.onClick = function(widget) - currentSettings.pvpMode = not currentSettings.pvpMode - settingsUI.PvpMode:setChecked(currentSettings.pvpMode) - end - settingsUI.PvpSafe.onClick = function(widget) - currentSettings.PvpSafe = not currentSettings.PvpSafe - settingsUI.PvpSafe:setChecked(currentSettings.PvpSafe) - end - settingsUI.Training.onClick = function(widget) - currentSettings.Training = not currentSettings.Training - settingsUI.Training:setChecked(currentSettings.Training) - end - settingsUI.BlackListSafe.onClick = function(widget) - currentSettings.BlackListSafe = not currentSettings.BlackListSafe - settingsUI.BlackListSafe:setChecked(currentSettings.BlackListSafe) - end - settingsUI.KillsAmount.onValueChange = function(widget, value) - currentSettings.KillsAmount = value - end - settingsUI.AntiRsRange.onValueChange = function(widget, value) - currentSettings.AntiRsRange = value - end - - -- window elements mainWindow.closeButton.onClick = function() - showSettings = false - toggleSettings() resetFields() mainWindow:hide() end + if not attackBotKeyboardBound then + attackBotKeyboardBound = true + onKeyPress(function(keys) + if not mainWindow or not mainWindow:isVisible() then + return + end + + if keys == "Escape" then + resetFields() + focusPrimaryInput() + return + end + + if keys == "Enter" then + if uiAddEntry and uiAddEntry.onClick then + uiAddEntry.onClick(uiAddEntry) + end + end + end) + end + -- core functions function resetFields() showItem = false @@ -1049,15 +1074,16 @@ end category = 1 setPatternText() setCategoryText() - panel.manaPercent:setText(1) - panel.creatures:setText(1) - panel.minHp:setValue(0) - panel.maxHp:setValue(100) - panel.cooldown:setText(1) - panel.monsters:setText("monster names") - panel.itemId:setItemId(0) - panel.spellName:setText("spell name") - panel.orMore:setChecked(false) + uiManaPercent:setText(1) + uiCreatures:setText(1) + uiMinHp:setValue(0) + uiMaxHp:setValue(100) + uiCooldown:setText(1) + uiMonsters:setText("monster names") + uiItemId:setItemId(0) + uiSpellName:setText("spell name") + uiOrMore:setChecked(false) + focusPrimaryInput() end resetFields() @@ -1067,19 +1093,6 @@ end setProfileName() -- main panel refreshAttacks() - -- settings - settingsUI.profileName:setText(currentSettings.name) - settingsUI.Visible:setChecked(currentSettings.Visible) - settingsUI.Cooldown:setChecked(currentSettings.Cooldown) - settingsUI.PvpMode:setChecked(currentSettings.pvpMode) - settingsUI.PvpSafe:setChecked(currentSettings.PvpSafe) - settingsUI.BlackListSafe:setChecked(currentSettings.BlackListSafe) - settingsUI.AntiRsRange:setValue(currentSettings.AntiRsRange) - settingsUI.IgnoreMana:setChecked(currentSettings.ignoreMana) - settingsUI.Rotate:setChecked(currentSettings.Rotate) - settingsUI.Kills:setChecked(currentSettings.Kills) - settingsUI.KillsAmount:setValue(currentSettings.KillsAmount) - settingsUI.Training:setChecked(currentSettings.Training) end loadSettings() @@ -1742,7 +1755,7 @@ function attackBotMain() -- Global guards (cannot attack at all) if not currentSettings or not currentSettings.enabled then return end - if not panel or not panel.entryList then return end + if not attackEntryList then return end if not target() then return end if SafeCall.isInPz() then return end if isGlobalBackoffActive() then return end @@ -1768,7 +1781,7 @@ function attackBotMain() -- Resource availability cache (items/spells checked once per item/spell key) local availableItems = {} local canCastCaller = SafeCall.getCachedCaller("canCast") - local entries = panel.entryList:getChildren() + local entries = attackEntryList:getChildren() -- ========== ACT: Find highest-priority valid entry and execute ========== diff --git a/core/AttackBot.otui b/core/AttackBot.otui index 3f366b7..89dfe85 100644 --- a/core/AttackBot.otui +++ b/core/AttackBot.otui @@ -1,5 +1,14 @@ AttackEntry < NxListEntryCheckable text-offset: 38 0 + + NxItem + id: id + anchors.left: enabled.right + anchors.verticalCenter: parent.verticalCenter + size: 16 16 + margin-left: 2 + visible: false + UIWidget id: spell anchors.left: enabled.right @@ -16,16 +25,16 @@ AttackBotBotPanel < NxBotSection id: title anchors.top: parent.top anchors.left: parent.left - text-align: center anchors.right: parent.right - margin-right: 50 + margin-right: 56 + text-align: center !text: tr('AttackBot') NxButton id: setup anchors.top: prev.top anchors.right: parent.right - width: 46 + width: 52 height: 20 text: Setup @@ -33,507 +42,406 @@ AttackBotBotPanel < NxBotSection id: 1 anchors.top: title.bottom anchors.left: parent.left - text: 1 - margin-right: 2 margin-top: 6 - size: 17 17 + size: 18 17 + text: 1 NxButton id: 2 - anchors.verticalCenter: prev.verticalCenter anchors.left: prev.right - text: 2 + anchors.verticalCenter: prev.verticalCenter margin-left: 4 - size: 17 17 + size: 18 17 + text: 2 NxButton id: 3 - anchors.verticalCenter: prev.verticalCenter anchors.left: prev.right - text: 3 + anchors.verticalCenter: prev.verticalCenter margin-left: 4 - size: 17 17 + size: 18 17 + text: 3 NxButton id: 4 - anchors.verticalCenter: prev.verticalCenter anchors.left: prev.right - text: 4 + anchors.verticalCenter: prev.verticalCenter margin-left: 4 - size: 17 17 + size: 18 17 + text: 4 NxButton id: 5 - anchors.verticalCenter: prev.verticalCenter anchors.left: prev.right - text: 5 + anchors.verticalCenter: prev.verticalCenter margin-left: 4 - size: 17 17 + size: 18 17 + text: 5 NxLabel id: name - anchors.verticalCenter: prev.verticalCenter anchors.left: prev.right anchors.right: parent.right - text-align: center - margin-left: 4 + anchors.verticalCenter: prev.verticalCenter + margin-left: 6 height: 17 + text-align: center text: Profile #1 AttackBotPanel < Panel - size: 500 200 + anchors.fill: parent image-source: /images/ui/panel_flat image-border: 5 - padding: 5 + padding: 8 - TextList - id: entryList + Panel + id: listPane anchors.left: parent.left - anchors.right: parent.right anchors.top: parent.top - margin-top: 3 - size: 430 100 - vertical-scrollbar: entryListScrollBar - - VerticalScrollBar - id: entryListScrollBar - anchors.top: entryList.top - anchors.bottom: entryList.bottom - anchors.right: entryList.right - step: 14 - pixels-scroll: true - - NxNavPrevButton - id: previousCategory - anchors.left: entryList.left - anchors.top: entryList.bottom - margin-top: 8 - - NxCategoryBar - id: category - anchors.top: entryList.bottom - anchors.left: previousCategory.right - anchors.verticalCenter: previousCategory.verticalCenter - margin-left: 3 - width: 315 - - NxNavNextButton - id: nextCategory - anchors.left: category.right - anchors.top: entryList.bottom - margin-top: 8 - margin-left: 2 - - NxNavPrevButton - id: previousSource - anchors.left: entryList.left - anchors.top: category.bottom - margin-top: 8 - - NxCategoryBar - id: source - anchors.top: category.bottom - anchors.left: previousSource.right - anchors.verticalCenter: previousSource.verticalCenter - margin-left: 3 - width: 105 - - NxNavNextButton - id: nextSource - anchors.left: source.right - anchors.top: category.bottom - margin-top: 8 - margin-left: 2 - - NxNavPrevButton - id: previousRange - anchors.left: nextSource.right - anchors.verticalCenter: nextSource.verticalCenter - margin-left: 8 - - NxCategoryBar - id: range - anchors.left: previousRange.right - anchors.verticalCenter: previousRange.verticalCenter - margin-left: 3 - width: 323 - - NxNavNextButton - id: nextRange - anchors.left: range.right - anchors.verticalCenter: range.verticalCenter - margin-left: 2 - - NxTextInput - id: monsters - anchors.left: entryList.left - anchors.top: range.bottom - margin-top: 5 - size: 405 15 - text: monster names - font: cipsoftFont - - NxLabel - anchors.left: prev.left - anchors.top: prev.bottom - margin-top: 6 - margin-left: 3 - text-align: center - text: Mana%: - font: verdana-11px-rounded - - NxSpinBox - id: manaPercent - anchors.verticalCenter: prev.verticalCenter - anchors.left: prev.right - margin-left: 4 - size: 30 20 - minimum: 0 - maximum: 99 - step: 1 - editable: true - focusable: true - - NxLabel - anchors.left: prev.right - margin-left: 7 - anchors.verticalCenter: prev.verticalCenter - text: Creatures: - font: verdana-11px-rounded - - NxSpinBox - id: creatures - anchors.verticalCenter: prev.verticalCenter - anchors.left: prev.right - margin-left: 4 - size: 30 20 - minimum: 1 - maximum: 99 - step: 1 - editable: true - focusable: true - - NxCheckBox - id: orMore - anchors.verticalCenter: prev.verticalCenter - anchors.left: prev.right - margin-left: 3 - tooltip: or more creatures - - NxLabel - anchors.left: prev.right - margin-left: 7 - anchors.verticalCenter: prev.verticalCenter - text: HP: - font: verdana-11px-rounded - - NxSpinBox - id: minHp - anchors.verticalCenter: prev.verticalCenter - anchors.left: prev.right - margin-left: 4 - size: 40 20 - minimum: 0 - maximum: 99 - value: 0 - editable: true - focusable: true - - NxLabel - anchors.left: prev.right - margin-left: 4 - anchors.verticalCenter: prev.verticalCenter - text: - - font: verdana-11px-rounded - - NxSpinBox - id: maxHp - anchors.verticalCenter: prev.verticalCenter - anchors.left: prev.right - margin-left: 4 - size: 40 20 - minimum: 1 - maximum: 100 - value: 100 - editable: true - focusable: true - - NxLabel - anchors.left: prev.right - margin-left: 7 - anchors.verticalCenter: prev.verticalCenter - text: CD (s): - font: verdana-11px-rounded - - NxSpinBox - id: cooldown - anchors.verticalCenter: prev.verticalCenter - anchors.left: prev.right - margin-left: 4 - size: 60 20 - minimum: 0 - maximum: 999999 - step: 1 - value: 0 - editable: true - focusable: true - - NxButton - id: up - anchors.right: parent.right - anchors.top: entryList.bottom - size: 60 17 - text: Move Up - text-align: center - font: cipsoftFont - margin-top: 7 - margin-right: 8 - - NxButton - id: down - anchors.right: prev.left - anchors.verticalCenter: prev.verticalCenter - size: 60 17 - margin-right: 5 - text: Move Down - text-align: center - font: cipsoftFont - - NxButton - id: addEntry - anchors.right: parent.right anchors.bottom: parent.bottom - size: 40 19 - text-align: center - text: New - font: cipsoftFont - - NxItem - id: itemId - anchors.right: addEntry.left - margin-right: 5 - anchors.bottom: parent.bottom - margin-bottom: 2 - tooltip: drag item here on press to open window - - NxTextInput - id: spellName - anchors.top: monsters.top - anchors.left: monsters.right + width: 338 + image-source: /images/ui/panel_flat + image-border: 3 + padding: 5 + + NxLabel + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + text: Priority Queue + text-align: center + color: #ff4b81 + font: verdana-11px-rounded + + TextList + id: entryList + anchors.left: parent.left + anchors.right: parent.right + anchors.top: prev.bottom + anchors.bottom: controls.top + margin-top: 4 + margin-bottom: 5 + margin-right: 18 + vertical-scrollbar: entryListScrollBar + + VerticalScrollBar + id: entryListScrollBar + anchors.top: entryList.top + anchors.bottom: entryList.bottom + anchors.right: parent.right + step: 14 + pixels-scroll: true + + Panel + id: controls + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 20 + + NxButton + id: down + anchors.right: up.left + anchors.verticalCenter: parent.verticalCenter + margin-right: 5 + size: 74 18 + text: Move Down + text-align: center + font: cipsoftFont + + NxButton + id: up + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + size: 70 18 + text: Move Up + text-align: center + font: cipsoftFont + + Panel + id: formPane + anchors.left: listPane.right anchors.right: parent.right - margin-left: 5 - height: 15 - text: spell name - font: cipsoftFont - visible: false - -SettingsPanel < Panel - size: 500 200 - image-source: /images/ui/panel_flat - image-border: 5 - padding: 10 - - VerticalSeparator - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.left: Visible.right - margin-left: 10 - margin-top: 5 - margin-bottom: 5 - - NxLabel anchors.top: parent.top - anchors.left: prev.right - anchors.right: parent.right - margin-left: 10 - text-align: center - font: verdana-11px-rounded - text: Profile: - - NxTextInput - id: profileName - anchors.top: prev.bottom - margin-top: 3 - anchors.left: prev.left - anchors.right: prev.right - margin-left: 20 - margin-right: 20 - - NxButton - id: resetSettings - anchors.right: parent.right anchors.bottom: parent.bottom - text-align: center - text: Reset Settings - - NxCheckBox - id: IgnoreMana - anchors.top: parent.top - anchors.left: parent.left - margin-top: 5 - width: 200 - text: Check RL Tibia conditions - - NxCheckBox - id: Kills - anchors.top: prev.bottom - anchors.left: prev.left - margin-top: 8 - width: 200 - height: 22 - text: Don't use area attacks if less than kills to red skull - text-wrap: true - text-align: left - - NxSpinBox - id: KillsAmount - anchors.top: prev.top - anchors.bottom: prev.bottom - anchors.left: prev.right - text-align: left - width: 30 - minimum: 1 - maximum: 10 - focusable: true - margin-left: 5 - - NxCheckBox - id: Rotate - anchors.top: Kills.bottom - anchors.left: Kills.left - margin-top: 8 - width: 220 - text: Turn to side with most monsters - - NxCheckBox - id: Cooldown - anchors.top: prev.bottom - anchors.left: prev.left - margin-top: 8 - width: 220 - text: Check spell cooldowns - - NxCheckBox - id: Visible - anchors.top: prev.bottom - anchors.left: prev.left - margin-top: 8 - width: 245 - text: Items must be visible (recommended) - - NxCheckBox - id: PvpMode - anchors.top: prev.bottom - anchors.left: prev.left - margin-top: 8 - width: 245 - text: PVP mode - - NxCheckBox - id: PvpSafe - anchors.top: prev.bottom - anchors.left: prev.left - margin-top: 8 - width: 245 - text: PVP safe - - NxCheckBox - id: Training - anchors.top: prev.bottom - anchors.left: prev.left - margin-top: 8 - width: 245 - text: Stop when attacking trainers - - NxCheckBox - id: BlackListSafe - anchors.top: prev.bottom - anchors.left: prev.left - margin-top: 8 - width: 200 - height: 18 - text: Stop if Anti-RS player in range - - NxSpinBox - id: AntiRsRange - anchors.top: prev.top - anchors.bottom: prev.bottom - anchors.left: prev.right - text-align: center - width: 50 - minimum: 1 - maximum: 10 - focusable: true - margin-left: 5 + margin-left: 8 + image-source: /images/ui/panel_flat + image-border: 3 + padding: 9 + + NxLabel + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + text: New Rule + text-align: center + color: #ff4b81 + font: verdana-11px-rounded + + HorizontalSeparator + anchors.left: parent.left + anchors.right: parent.right + anchors.top: prev.bottom + margin-top: 4 + + NxNavPrevButton + id: previousCategory + anchors.left: parent.left + anchors.top: prev.bottom + margin-top: 7 + + NxNavNextButton + id: nextCategory + anchors.right: parent.right + anchors.verticalCenter: previousCategory.verticalCenter + + NxCategoryBar + id: category + anchors.left: previousCategory.right + anchors.right: nextCategory.left + anchors.verticalCenter: previousCategory.verticalCenter + margin-left: 3 + margin-right: 2 + tooltip: Attack type. Change this first. + + NxNavPrevButton + id: previousRange + anchors.left: parent.left + anchors.top: category.bottom + margin-top: 7 + + NxNavNextButton + id: nextRange + anchors.right: parent.right + anchors.verticalCenter: previousRange.verticalCenter + + NxCategoryBar + id: range + anchors.left: previousRange.right + anchors.right: nextRange.left + anchors.verticalCenter: previousRange.verticalCenter + margin-left: 3 + margin-right: 2 + tooltip: Attack pattern and distance. + + NxLabel + id: selectorHint + anchors.left: parent.left + anchors.right: parent.right + anchors.top: range.bottom + margin-top: 7 + text: Spell mode: type spell name, then press Enter to add. + color: #a4aece + text-align: center + font: verdana-11px-rounded + + NxLabel + anchors.left: parent.left + anchors.top: selectorHint.bottom + margin-top: 8 + text: Targets: + font: verdana-11px-rounded + + NxTextInput + id: monsters + anchors.left: prev.right + anchors.verticalCenter: prev.verticalCenter + margin-left: 6 + width: 250 + height: 20 + text: monster names + font: cipsoftFont + tooltip: Use * for any creature or comma-separated names. + + NxTextInput + id: spellName + anchors.top: monsters.top + anchors.left: monsters.right + anchors.right: parent.right + margin-left: 6 + height: 20 + text: spell name + font: cipsoftFont + visible: false + tooltip: Spell text to cast when conditions match. + + NxLabel + anchors.left: parent.left + anchors.top: monsters.bottom + margin-top: 10 + text: Mana%: + font: verdana-11px-rounded + + NxSpinBox + id: manaPercent + anchors.left: prev.right + anchors.verticalCenter: prev.verticalCenter + margin-left: 6 + size: 44 20 + minimum: 0 + maximum: 99 + step: 1 + editable: true + focusable: true + + NxLabel + anchors.left: prev.right + anchors.verticalCenter: prev.verticalCenter + margin-left: 10 + text: Creatures: + font: verdana-11px-rounded + + NxSpinBox + id: creatures + anchors.left: prev.right + anchors.verticalCenter: prev.verticalCenter + margin-left: 6 + size: 44 20 + minimum: 1 + maximum: 99 + step: 1 + editable: true + focusable: true + + NxCheckBox + id: orMore + anchors.left: prev.right + anchors.verticalCenter: prev.verticalCenter + margin-left: 6 + tooltip: or more creatures + + NxLabel + anchors.left: parent.left + anchors.top: prev.bottom + margin-top: 10 + text: HP: + font: verdana-11px-rounded + + NxSpinBox + id: minHp + anchors.left: prev.right + anchors.verticalCenter: prev.verticalCenter + margin-left: 6 + size: 50 20 + minimum: 0 + maximum: 99 + value: 0 + editable: true + focusable: true + + NxLabel + anchors.left: prev.right + anchors.verticalCenter: prev.verticalCenter + margin-left: 6 + text: - + font: verdana-11px-rounded + + NxSpinBox + id: maxHp + anchors.left: prev.right + anchors.verticalCenter: prev.verticalCenter + margin-left: 6 + size: 50 20 + minimum: 1 + maximum: 100 + value: 100 + editable: true + focusable: true + + NxLabel + anchors.left: prev.right + anchors.verticalCenter: prev.verticalCenter + margin-left: 10 + text: CD (s): + font: verdana-11px-rounded + + NxSpinBox + id: cooldown + anchors.left: prev.right + anchors.verticalCenter: prev.verticalCenter + margin-left: 6 + size: 60 20 + minimum: 0 + maximum: 999999 + step: 1 + value: 0 + editable: true + focusable: true + + NxItem + id: itemId + anchors.left: parent.left + anchors.bottom: addEntry.bottom + margin-bottom: 1 + tooltip: Drag rune item here when category is rune-based. + + NxButton + id: addEntry + anchors.right: parent.right + anchors.bottom: parent.bottom + size: 88 22 + text-align: center + text: Add Rule + font: cipsoftFont + tooltip: Enter also adds a new rule. + + NxLabel + id: keyboardHint + anchors.left: itemId.right + anchors.right: addEntry.left + anchors.verticalCenter: addEntry.verticalCenter + margin-left: 8 + margin-right: 8 + text: Enter: Add | Esc: Clear + color: #a4aece + text-align: center + font: verdana-11px-rounded AttackBotWindow < NxWindow - size: 535 300 - padding: 15 + size: 760 420 + minimum-size: 640 360 + padding: 12 text: AttackBot - @onEscape: self:hide() NxLabel id: mainLabel anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - margin-top: 10 - margin-left: 2 - !text: tr('More important methods come first (Example: Exori gran above Exori)') - text-align: left + margin-top: 15 + text: Priority matters. Put high-impact spells first. + text-align: center font: verdana-11px-rounded color: #a4aece - SettingsPanel - id: settingsPanel - anchors.top: prev.bottom - margin-top: 10 - anchors.left: parent.left - margin-left: 2 - NxLabel - id: settingsLabel - anchors.verticalCenter: prev.top - anchors.left: prev.left - margin-left: 3 - text: Settings - color: #ff4b81 + id: shooterLabel + anchors.left: parent.left + anchors.right: parent.right + anchors.top: mainLabel.bottom + margin-top: 5 + text: Spell Shooter + text-align: left font: verdana-11px-rounded + color: #ff4b81 AttackBotPanel id: mainPanel - anchors.top: mainLabel.bottom - margin-top: 10 anchors.left: parent.left - margin-left: 2 - visible: false - - NxLabel - id: shooterLabel - anchors.verticalCenter: prev.top - anchors.left: prev.left - margin-left: 3 - text: Spell Shooter - color: #ff4b81 - font: verdana-11px-rounded - visible: false + anchors.right: parent.right + anchors.top: shooterLabel.bottom + anchors.bottom: closeButton.top + margin-top: 5 + margin-bottom: 7 NxButton id: closeButton anchors.right: parent.right anchors.bottom: parent.bottom - size: 45 21 + size: 62 21 text: Close font: cipsoftFont - NxButton - id: settings - anchors.left: parent.left - anchors.verticalCenter: prev.verticalCenter - size: 50 21 - font: cipsoftFont - text: Settings - HorizontalSeparator anchors.left: parent.left anchors.right: parent.right diff --git a/core/HealBot.lua b/core/HealBot.lua index 998d4e2..c82130c 100644 --- a/core/HealBot.lua +++ b/core/HealBot.lua @@ -38,6 +38,7 @@ local function ensureCurrentSettings() end local standBySpells, standByItems = false, false +local healKeyboardBound = false -- Load heal modules using simple dofile; they set globals directly -- Try multiple paths in order of likelihood @@ -402,6 +403,38 @@ if rootWidget then local refreshSpells local refreshItems + local activeHealForm = "spell" + + local function setActiveHealForm(form) + activeHealForm = form == "item" and "item" or "spell" + end + + local function clearSpellForm() + healWindow.healer.spells.spellFormula:setText('') + healWindow.healer.spells.spellValue:setText('') + healWindow.healer.spells.manaCost:setText('') + healWindow.healer.spells.spellFormula:focus() + end + + local function clearItemForm() + healWindow.healer.items.itemId:setItemId(0) + healWindow.healer.items.itemValue:setText('') + healWindow.healer.items.itemValue:focus() + end + + local function refreshSpellHint() + local src = healWindow.healer.spells.spellSource:getCurrentOption().text + local eq = healWindow.healer.spells.spellCondition:getCurrentOption().text + local hint = "Cast spell when " .. src .. " is " .. eq:lower() .. " the trigger value." + healWindow.healer.spells.spellHint:setText(hint) + end + + local function refreshItemHint() + local src = healWindow.healer.items.itemSource:getCurrentOption().text + local eq = healWindow.healer.items.itemCondition:getCurrentOption().text + local hint = "Use item when " .. src .. " is " .. eq:lower() .. " the trigger value." + healWindow.healer.items.itemHint:setText(hint) + end local loadSettings = function() ui.title:setOn(currentSettings.enabled) @@ -409,13 +442,10 @@ if rootWidget then setProfileName() refreshSpells() refreshItems() + refreshSpellHint() + refreshItemHint() applyHealEngineToggles() - healWindow.settings.list.Visible:setChecked(currentSettings.Visible) - healWindow.settings.list.Cooldown:setChecked(currentSettings.Cooldown) - healWindow.settings.list.Delay:setChecked(currentSettings.Delay) - healWindow.settings.list.MessageDelay:setChecked(currentSettings.MessageDelay) - healWindow.settings.list.Interval:setChecked(currentSettings.Interval) - healWindow.settings.list.Conditions:setChecked(currentSettings.Conditions) + end refreshSpells = function() @@ -514,6 +544,42 @@ if rootWidget then saveHeal() end + healWindow.healer.spells.spellSource.onOptionChange = function(widget) + setActiveHealForm("spell") + refreshSpellHint() + end + + healWindow.healer.spells.spellCondition.onOptionChange = function(widget) + setActiveHealForm("spell") + refreshSpellHint() + end + + healWindow.healer.items.itemSource.onOptionChange = function(widget) + setActiveHealForm("item") + refreshItemHint() + end + + healWindow.healer.items.itemCondition.onOptionChange = function(widget) + setActiveHealForm("item") + refreshItemHint() + end + + healWindow.healer.spells.spellFormula.onTextChange = function(widget) + setActiveHealForm("spell") + end + healWindow.healer.spells.spellValue.onTextChange = function(widget) + setActiveHealForm("spell") + end + healWindow.healer.spells.manaCost.onTextChange = function(widget) + setActiveHealForm("spell") + end + healWindow.healer.items.itemValue.onTextChange = function(widget) + setActiveHealForm("item") + end + healWindow.healer.items.itemId.onItemChange = function(widget) + setActiveHealForm("item") + end + healWindow.healer.spells.addSpell.onClick = function() ensureCurrentSettings() if not currentSettings then @@ -529,9 +595,7 @@ if rootWidget then local origin = (src == "Current Mana" and "MP") or (src == "Current Health" and "HP") or (src == "Mana Percent" and "MP%") or (src == "Health Percent" and "HP%") or "burst" local sign = (eq == "Above" and ">") or (eq == "Below" and "<") or "=" table.insert(currentSettings.spellTable, {index = #currentSettings.spellTable+1, spell = spellFormula, sign = sign, origin = origin, cost = manaCost, value = trigger, enabled = true}) - healWindow.healer.spells.spellFormula:setText('') - healWindow.healer.spells.spellValue:setText('') - healWindow.healer.spells.manaCost:setText('') + clearSpellForm() refreshSpells() applyHealEngineToggles() saveHeal() @@ -546,8 +610,7 @@ if rootWidget then local origin = (src == "Current Mana" and "MP") or (src == "Current Health" and "HP") or (src == "Mana Percent" and "MP%") or (src == "Health Percent" and "HP%") or "burst" local sign = (eq == "Above" and ">") or (eq == "Below" and "<") or "=" table.insert(currentSettings.itemTable, {index = #currentSettings.itemTable+1, item = id, sign = sign, origin = origin, value = trigger, enabled = true}) - healWindow.healer.items.itemId:setItemId(0) - healWindow.healer.items.itemValue:setText('') + clearItemForm() refreshItems() applyHealEngineToggles() saveHeal() @@ -584,11 +647,24 @@ if rootWidget then end end - healWindow.settings.profiles.ResetSettings.onClick = function() - resetSettings() - loadSettings() - end + if not healKeyboardBound then + healKeyboardBound = true + onKeyPress(function(keys) + if not healWindow or not healWindow:isVisible() then return end + if keys == "Escape" then + if activeHealForm == "item" then clearItemForm() else clearSpellForm() end + return + end + if keys == "Enter" then + if activeHealForm == "item" then + healWindow.healer.items.addItem.onClick() + else + healWindow.healer.spells.addSpell.onClick() + end + end + end) + end -- public functions HealBot = {} -- global table diff --git a/core/HealBot.otui b/core/HealBot.otui index 347fac2..0641d46 100644 --- a/core/HealBot.otui +++ b/core/HealBot.otui @@ -19,24 +19,44 @@ SpellConditionBox < NxComboBox SpellEntry < NxListEntryCheckable ItemEntry < NxListEntryCheckable + text-offset: 58 0 + + NxItem + id: id + anchors.left: enabled.right + anchors.verticalCenter: parent.verticalCenter + margin-left: 2 SpellHealing < NxPanel - size: 490 130 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 188 + image-source: /images/ui/panel_flat + image-border: 3 + padding: 7 NxLabel id: title - anchors.verticalCenter: parent.top + anchors.top: parent.top anchors.left: parent.left - margin-left: 5 + anchors.right: parent.right text: Spell Healing + text-align: center color: #46e6a6 + HorizontalSeparator + anchors.top: title.bottom + anchors.left: parent.left + anchors.right: parent.right + margin-top: 4 + SpellSourceBox id: spellSource - anchors.top: spellList.top - anchors.left: spellList.right - margin-left: 80 - width: 125 + anchors.top: prev.bottom + anchors.right: parent.right + margin-top: 7 + width: 128 font: verdana-11px-rounded NxLabel @@ -44,21 +64,20 @@ SpellHealing < NxPanel anchors.left: spellList.right anchors.verticalCenter: prev.verticalCenter text: When - margin-left: 7 + margin-left: 10 NxLabel id: isSpell - anchors.left: spellList.right + anchors.left: whenSpell.left anchors.top: whenSpell.bottom text: Is - margin-top: 9 - margin-left: 7 + margin-top: 11 SpellConditionBox id: spellCondition anchors.left: spellSource.left anchors.top: spellSource.bottom - margin-top: 15 + margin-top: 10 width: 80 font: verdana-11px-rounded @@ -74,7 +93,7 @@ SpellHealing < NxPanel anchors.left: isSpell.left anchors.top: isSpell.bottom text: Cast - margin-top: 9 + margin-top: 11 NxTextInput id: spellFormula @@ -87,25 +106,38 @@ SpellHealing < NxPanel anchors.left: castSpell.left anchors.top: castSpell.bottom text: Mana Cost: - margin-top: 8 + margin-top: 10 NxTextInput id: manaCost anchors.left: spellFormula.left anchors.top: spellFormula.bottom - width: 40 + margin-top: 2 + width: 46 + + NxLabel + id: spellHint + anchors.left: castSpell.left + anchors.right: spellSource.right + anchors.top: manaSpell.bottom + anchors.bottom: controls.top + margin-top: 8 + margin-bottom: 4 + text: Trigger hint + color: #a4aece + text-wrap: true TextList id: spellList anchors.left: parent.left - anchors.bottom: parent.bottom - anchors.top: parent.top + anchors.bottom: controls.top + anchors.top: spellSource.top padding: 1 padding-top: 2 - width: 270 - margin-bottom: 7 - margin-left: 7 - margin-top: 10 + padding-right: 16 + width: 320 + margin-bottom: 6 + margin-left: 4 vertical-scrollbar: spellListScrollBar VerticalScrollBar @@ -116,48 +148,70 @@ SpellHealing < NxPanel step: 14 pixels-scroll: true + Panel + id: controls + anchors.left: parent.left + margin-left: 4 + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 21 + NxButton id: addSpell - anchors.right: spellFormula.right - anchors.bottom: spellList.bottom - text: Add - size: 40 17 + anchors.right: controls.right + anchors.verticalCenter: controls.verticalCenter + text: Add Spell + size: 74 18 font: cipsoftFont NxButton id: MoveUp anchors.right: prev.left - anchors.bottom: prev.bottom + anchors.verticalCenter: prev.verticalCenter margin-right: 5 text: Move Up - size: 55 17 + size: 62 18 font: cipsoftFont NxButton id: MoveDown anchors.right: prev.left - anchors.bottom: prev.bottom + anchors.verticalCenter: prev.verticalCenter margin-right: 5 text: Move Down - size: 55 17 + size: 72 18 font: cipsoftFont ItemHealing < NxPanel - size: 490 120 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: prev.bottom + anchors.bottom: parent.bottom + margin-top: 10 + image-source: /images/ui/panel_flat + image-border: 3 + padding: 7 NxLabel id: title - anchors.verticalCenter: parent.top + anchors.top: parent.top anchors.left: parent.left - margin-left: 5 + anchors.right: parent.right text: Item Healing + text-align: center color: #ff4b81 + HorizontalSeparator + anchors.top: title.bottom + anchors.left: parent.left + anchors.right: parent.right + margin-top: 4 + SpellSourceBox id: itemSource - anchors.top: itemList.top + anchors.top: prev.bottom anchors.right: parent.right - margin-right: 10 + margin-top: 7 width: 128 font: verdana-11px-rounded @@ -166,21 +220,20 @@ ItemHealing < NxPanel anchors.left: itemList.right anchors.verticalCenter: prev.verticalCenter text: When - margin-left: 7 + margin-left: 10 NxLabel id: isItem - anchors.left: itemList.right + anchors.left: whenItem.left anchors.top: whenItem.bottom text: Is - margin-top: 9 - margin-left: 7 + margin-top: 11 SpellConditionBox id: itemCondition anchors.left: itemSource.left anchors.top: itemSource.bottom - margin-top: 15 + margin-top: 10 width: 80 font: verdana-11px-rounded @@ -196,24 +249,37 @@ ItemHealing < NxPanel anchors.left: isItem.left anchors.top: isItem.bottom text: Use - margin-top: 15 + margin-top: 11 NxItem id: itemId anchors.left: itemCondition.left anchors.top: itemCondition.bottom + margin-top: 2 + + NxLabel + id: itemHint + anchors.left: useItem.left + anchors.right: itemSource.right + anchors.top: useItem.bottom + anchors.bottom: controls.top + margin-top: 8 + margin-bottom: 4 + text: Trigger hint + color: #a4aece + text-wrap: true TextList id: itemList anchors.left: parent.left - anchors.bottom: parent.bottom - anchors.top: parent.top + anchors.bottom: controls.top + anchors.top: itemSource.top padding: 1 padding-top: 2 - width: 270 - margin-top: 10 - margin-bottom: 7 - margin-left: 8 + padding-right: 16 + width: 320 + margin-bottom: 6 + margin-left: 4 vertical-scrollbar: itemListScrollBar VerticalScrollBar @@ -224,57 +290,77 @@ ItemHealing < NxPanel step: 14 pixels-scroll: true + Panel + id: controls + anchors.left: parent.left + margin-left: 4 + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 21 + NxButton id: addItem - anchors.right: itemValue.right - anchors.bottom: itemList.bottom - text: Add - size: 40 17 + anchors.right: controls.right + anchors.verticalCenter: controls.verticalCenter + text: Add Item + size: 72 18 font: cipsoftFont NxButton id: MoveUp anchors.right: prev.left - anchors.bottom: prev.bottom + anchors.verticalCenter: prev.verticalCenter margin-right: 5 text: Move Up - size: 55 17 + size: 62 18 font: cipsoftFont NxButton id: MoveDown anchors.right: prev.left - anchors.bottom: prev.bottom + anchors.verticalCenter: prev.verticalCenter margin-right: 5 text: Move Down - size: 55 17 + size: 72 18 font: cipsoftFont HealerPanel < Panel - size: 510 275 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom SpellHealing id: spells anchors.top: parent.top - margin-top: 8 anchors.left: parent.left + anchors.right: parent.right + height: 188 ItemHealing id: items anchors.top: prev.bottom anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom margin-top: 10 HealBotSettingsPanel < Panel - size: 500 267 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom padding-top: 8 + image-source: /images/ui/panel_flat + image-border: 3 + padding: 8 Panel id: list anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom - margin-right: 240 + width: 215 padding-left: 6 padding-right: 6 padding-top: 6 @@ -322,14 +408,15 @@ HealBotSettingsPanel < Panel anchors.top: prev.top anchors.bottom: prev.bottom anchors.left: prev.right - margin-left: 8 + margin-left: 10 NxPanel id: profiles - anchors.fill: parent - anchors.left: prev.left - margin-left: 8 - margin-right: 8 + anchors.left: prev.right + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + margin-left: 10 padding: 8 NxLabel @@ -369,28 +456,27 @@ HealBotSettingsPanel < Panel HealWindow < NxWindow !text: tr('Self Healer') - size: 520 360 - @onEscape: self:hide() + size: 720 500 + minimum-size: 620 440 NxLabel id: title anchors.left: parent.left + anchors.right: parent.right anchors.top: parent.top - margin-left: 2 + margin-top: 3 !text: tr('More important methods come first (Example: Exura gran above Exura)') - text-align: left + text-align: center color: #a4aece HealerPanel id: healer anchors.top: prev.bottom anchors.left: parent.left - - HealBotSettingsPanel - id: settings - anchors.top: title.bottom - anchors.left: parent.left - visible: false + anchors.right: parent.right + anchors.bottom: closeButton.top + margin-top: 6 + margin-bottom: 8 NxButton id: closeButton @@ -398,16 +484,9 @@ HealWindow < NxWindow font: cipsoftFont anchors.right: parent.right anchors.bottom: parent.bottom - size: 45 21 + size: 58 21 margin-right: 5 - NxButton - id: settingsButton - !text: tr('Settings') - font: cipsoftFont - anchors.left: parent.left - anchors.bottom: parent.bottom - HorizontalSeparator id: separator anchors.right: parent.right diff --git a/core/hunt_context.lua b/core/hunt_context.lua new file mode 100644 index 0000000..04aa99d --- /dev/null +++ b/core/hunt_context.lua @@ -0,0 +1,187 @@ +--[[ + HuntContext Module v1.0 + + Bridge between Hunt Analyzer (smart_hunt.lua) and PriorityEngine. + Provides a lazy-cached signal struct consumed by PriorityEngine.huntScore(). + + SRP — owns only the translation of hunt metrics → targeting signal. + DRY — single source of truth for the hunt→targeting bridge. + KISS — flat struct, no nested logic, O(1) read in hot path. + SOLID — open for new signal fields, closed for modification of callers. + + API: + HuntContext.getSignal() → { survivability, manaStress, efficiency, threatBias } + All values: 0.0–1.0 (normalized). Always returns the cached struct, + never nil — safe to read from every PriorityEngine scoring cycle. + + Cache policy: + - Recompute when any input metric changes by ≥ CHANGE_THRESHOLD (5%). + - Force recompute after CACHE_MAX_AGE_MS (30 s) regardless. + - Guard: if HuntAnalytics is absent or session inactive, returns neutral signal. +]] + +HuntContext = HuntContext or {} +HuntContext.VERSION = "1.0" + +-- ============================================================================ +-- DEPENDENCIES +-- ============================================================================ + +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 + +-- ============================================================================ +-- CONSTANTS +-- ============================================================================ + +local CACHE_MAX_AGE_MS = 30000 -- force recompute every 30 s +local CHANGE_THRESHOLD = 0.05 -- 5% drift in any input triggers recompute + +-- Normalisation baselines (tunable via EventBus recalibrate event) +local BASELINE = { + killsPerHour_max = 200, -- 200 kills/hr → efficiency = 1.0 + manaPotions_stress = 60, -- 60 potions/hr → manaStress = 1.0 +} + +-- ============================================================================ +-- PRIVATE STATE +-- ============================================================================ + +-- Neutral signal returned when no session data is available +local _signal = { + survivability = 1.0, + manaStress = 0.0, + efficiency = 1.0, + threatBias = 0.0, +} + +local _lastComputed = 0 +local _lastRaw = {} + +-- ============================================================================ +-- PURE HELPERS +-- ============================================================================ + +local function clamp01(v) + return math.max(0.0, math.min(1.0, v or 0.0)) +end + +-- Returns true when at least one raw value drifted beyond CHANGE_THRESHOLD +local function hasChanged(raw) + for k, v in pairs(raw) do + local prev = _lastRaw[k] or 0 + if prev == 0 then + if v ~= 0 then return true end + elseif math.abs((v - prev) / prev) >= CHANGE_THRESHOLD then + return true + end + end + return false +end + +-- ============================================================================ +-- SIGNAL COMPUTATION +-- ============================================================================ + +local function computeSignal() + if not (HuntAnalytics and HuntAnalytics.getMetrics) then return end + + local ok, metrics = pcall(HuntAnalytics.getMetrics) + if not ok or not metrics then return end + + local raw = { + survivabilityIndex = metrics.survivabilityIndex or 100, + damageRatio = metrics.damageRatio or 0, + potionsPerHour = metrics.potionsPerHour or 0, + efficiency = metrics.efficiency or 0, + killsPerHour = metrics.killsPerHour or 0, + nearDeathPerHour = metrics.nearDeathPerHour or 0, + } + + if not hasChanged(raw) then return end + _lastRaw = raw + + -- survivability: survivabilityIndex is 0–100; normalize to 0–1 + local surv = clamp01(raw.survivabilityIndex / 100) + + -- manaStress: potionsPerHour proxy; 60+/hr = full stress + local manaStress = clamp01(raw.potionsPerHour / BASELINE.manaPotions_stress) + + -- efficiency: killsPerHour normalized; 200+/hr = optimal + local eff = clamp01(raw.killsPerHour / BASELINE.killsPerHour_max) + + -- threatBias: composite push signal — high when survivability is low AND mana stressed + local threatBias = clamp01((1 - surv) * 0.6 + manaStress * 0.4) + + _signal.survivability = surv + _signal.manaStress = manaStress + _signal.efficiency = eff + _signal.threatBias = threatBias + _lastComputed = nowMs() +end + +-- ============================================================================ +-- PUBLIC API +-- ============================================================================ + +--- Returns the hunt signal struct. Always O(1) — recomputes lazily only when +--- input metrics drift beyond threshold or cache expires. +--- Never nil. Safe to call from every PriorityEngine scoring cycle. +---@return table { survivability, manaStress, efficiency, threatBias } +function HuntContext.getSignal() + local t = nowMs() + if (t - _lastComputed) >= CACHE_MAX_AGE_MS then + -- Cache expired: force recompute + pcall(computeSignal) + -- Bump timestamp even on failure so we don't hammer a missing HuntAnalytics + _lastComputed = t + else + -- Within cache window: only recompute if inputs drifted + pcall(computeSignal) + end + return _signal +end + +--- Reset signal to neutral defaults (call on session start or stop). +function HuntContext.reset() + _signal = { survivability = 1.0, manaStress = 0.0, efficiency = 1.0, threatBias = 0.0 } + _lastComputed = 0 + _lastRaw = {} +end + +--- Recalibrate normalisation baselines (e.g. for different vocation/spawn). +---@param overrides table { killsPerHour_max?, manaPotions_stress? } +function HuntContext.recalibrate(overrides) + if type(overrides) ~= "table" then return end + for k, v in pairs(overrides) do + if BASELINE[k] ~= nil and type(v) == "number" and v > 0 then + BASELINE[k] = v + end + end + -- Invalidate cache so next getSignal() recomputes with new baselines + _lastComputed = 0 + _lastRaw = {} +end + +-- ============================================================================ +-- EVENTBUS WIRING +-- ============================================================================ + +if EventBus and EventBus.on then + -- Reset on each hunt session start + EventBus.on("analytics:session:start", function() + HuntContext.reset() + end, 0) + + -- Allow runtime recalibration + EventBus.on("hunt_context:recalibrate", function(overrides) + HuntContext.recalibrate(overrides) + end, 0) +end + +if MonsterAI and MonsterAI.DEBUG then + print("[HuntContext] v" .. HuntContext.VERSION .. " loaded") +end diff --git a/core/smart_hunt.lua b/core/smart_hunt.lua index 915c8b4..86cccd9 100644 --- a/core/smart_hunt.lua +++ b/core/smart_hunt.lua @@ -987,6 +987,11 @@ local function calculateMetrics() return metrics end +-- Expose metrics to external modules (e.g. HuntContext for PriorityEngine bridge) +function Analytics.getMetrics() + return calculateMetrics() +end + -- ============================================================================ -- INSIGHTS ANALYSIS -- ============================================================================ @@ -1674,15 +1679,283 @@ local function buildSummary() return table.concat(lines, "\n") end +-- ============================================================================ +-- TAB BUILDERS +-- ============================================================================ + +local function buildSessionTab() + local lines = {} + local m = analytics.metrics + local elapsed = getElapsed() + local metrics = calculateMetrics() + local levelInfo = Player.levelProgress() + local stamInfo = Player.staminaInfo() + + table.insert(lines, string.format("Hunt Analyzer — %s", isSessionActive() and "ACTIVE" or "STOPPED")) + table.insert(lines, "") + + addSection(lines, "SESSION", { + "Duration: " .. formatDuration(elapsed), + "Level: " .. levelInfo.level .. " (" .. string.format("%.1f%%", levelInfo.percent) .. ")" + }) + + local xpLines = { + "XP Gained: " .. formatNum(metrics.xpGained), + "XP/Hour: " .. formatNum(math.floor(metrics.xpPerHour)), + "Progress/Hour: " .. string.format("%.2f%%", metrics.levelPercentPerHour) + } + local hoursToLevel = metrics.xpPerHour > 0 and levelInfo.xpRemaining / metrics.xpPerHour or 0 + if hoursToLevel > 0 and hoursToLevel < 10000 then + table.insert(xpLines, "Time to Level: " .. string.format("%.1fh", hoursToLevel)) + end + addSection(lines, "EXPERIENCE", xpLines) + + addSection(lines, "COMBAT", { + "Kills: " .. formatNum(m.kills) .. " (" .. formatNum(math.floor(metrics.killsPerHour)) .. "/h)", + "Damage Taken: " .. formatNum(m.damageTaken), + "Healing Done: " .. formatNum(m.healingDone), + "Deaths: " .. m.deathCount .. " Near-Death: " .. m.nearDeathCount + }) + + addSection(lines, "STAMINA", (function() + local startStaminaMins = analytics.session and analytics.session.startStamina or 0 + local staminaUsedMins = math.max(0, startStaminaMins - stamInfo.minutes) + local usedStr + if staminaUsedMins > 0 then + local h = math.floor(staminaUsedMins / 60) + local mn = staminaUsedMins % 60 + usedStr = h > 0 and string.format("%dh %dm", h, mn) or string.format("%dm", mn) + end + local sl = { + "Current: " .. string.format("%.2fh (%s)", stamInfo.hours, stamInfo.status), + "Session Start: " .. string.format("%.2fh", startStaminaMins / 60), + } + if usedStr then sl[#sl+1] = "Spent: " .. usedStr end + if stamInfo.greenRemaining > 0 then + sl[#sl+1] = "Green Left: " .. string.format("%.1fh", stamInfo.greenRemaining) + end + return sl + end)()) + + addSection(lines, "PLAYER", { + "Magic Level: " .. Player.mlevel(), + "Speed: " .. Player.speed() + }) + + return table.concat(lines, "\n") +end + +local function buildConsumptionTab() + local lines = {} + local m = analytics.metrics + local elapsed = getElapsed() + local metrics = calculateMetrics() + + table.insert(lines, "Consumption — " .. formatDuration(elapsed)) + table.insert(lines, "") + + -- Spells + local spellList = {} + for name, data in pairs(analytics.spellsUsed or {}) do + spellList[#spellList+1] = {name=name, count=data.count or 0, mana=data.mana or 0, type=data.type or "other"} + end + table.sort(spellList, function(a,b) return a.count > b.count end) + local spellLines = {} + local totalSpells = (m.healSpellsCast or 0) + (m.attackSpellsCast or 0) + (m.supportSpellsCast or 0) + if totalSpells > 0 then + spellLines[1] = string.format("Total: %d (%.0f/h) Mana: %s", totalSpells, perHour(totalSpells, elapsed), formatNum(m.manaSpent or 0)) + end + for i = 1, math.min(10, #spellList) do + local sp = spellList[i] + local icon = sp.type == "heal" and "[H]" or sp.type == "attack" and "[A]" or "[S]" + spellLines[#spellLines+1] = string.format("%s %dx %s", icon, sp.count, sp.name) + end + if #spellList > 10 then spellLines[#spellLines+1] = string.format("... and %d more", #spellList - 10) end + if #spellLines == 0 then spellLines[1] = "No spells tracked yet" end + addSection(lines, "SPELLS USED", spellLines) + + -- Potions + local potionList = {} + for name, count in pairs(analytics.potionsUsed or {}) do potionList[#potionList+1] = {name=name, count=count} end + table.sort(potionList, function(a,b) return a.count > b.count end) + local potionLines = {} + local totalPotions = m.potionsUsed or 0 + if totalPotions > 0 then + potionLines[1] = string.format("Total: %d (%.0f/h) HP: %d MP: %d", + totalPotions, metrics.potionsPerHour or 0, m.healPotionsUsed or 0, m.manaPotionsUsed or 0) + end + for i = 1, math.min(8, #potionList) do + potionLines[#potionLines+1] = string.format("%dx %s", potionList[i].count, potionList[i].name) + end + if #potionList > 8 then potionLines[#potionLines+1] = string.format("... and %d more", #potionList - 8) end + if #potionLines == 0 then potionLines[1] = "No potions tracked yet" end + addSection(lines, "POTIONS USED", potionLines) + + -- Runes + local runeList = {} + for name, count in pairs(analytics.runesUsed or {}) do + if count and count > 0 then runeList[#runeList+1] = {name=name, count=count} end + end + table.sort(runeList, function(a,b) return a.count > b.count end) + local runeLines = {} + local totalRunes = m.runesUsed or 0 + if totalRunes > 0 then + runeLines[1] = string.format("Total: %d (%.0f/h) Attack: %d Heal: %d", + totalRunes, metrics.runesPerHour or 0, m.attackRunesUsed or 0, m.healRunesUsed or 0) + end + for i = 1, math.min(8, #runeList) do + runeLines[#runeLines+1] = string.format("%dx %s", runeList[i].count, runeList[i].name) + end + if #runeList > 8 then runeLines[#runeLines+1] = string.format("... and %d more", #runeList - 8) end + if #runeLines == 0 then runeLines[1] = "No runes tracked yet" end + addSection(lines, "RUNES USED", runeLines) + + return table.concat(lines, "\n") +end + +local function buildLootCombatTab() + local lines = {} + local m = analytics.metrics + local metrics = calculateMetrics() + + table.insert(lines, "Loot & Combat") + table.insert(lines, "") + + -- Monsters killed + local monsterList = {} + for name, count in pairs(analytics.monsters or {}) do monsterList[#monsterList+1] = {name=name, count=count} end + table.sort(monsterList, function(a,b) return a.count > b.count end) + local monLines = {} + for i = 1, math.min(10, #monsterList) do + monLines[#monLines+1] = string.format("%dx %s", monsterList[i].count, monsterList[i].name) + end + if #monsterList > 10 then monLines[#monLines+1] = string.format("... and %d more types", #monsterList - 10) end + if #monLines == 0 then monLines[1] = "No monsters killed yet" end + addSection(lines, "MONSTERS KILLED", monLines) + + -- Loot + local lootLines = { + "Total Value: " .. formatNum(m.lootValue) .. " gp (" .. formatNum(math.floor(metrics.lootValuePerHour)) .. "/h)", + "Gold Coins: " .. formatNum(m.lootGold) .. " (" .. formatNum(math.floor(metrics.lootGoldPerHour)) .. "/h)", + "Drops Parsed: " .. formatNum(m.lootDrops), + "Avg/Kill: " .. formatNum(math.floor(metrics.lootPerKill)) .. " gp" + } + local topItems = {} + for name, data in pairs(analytics.lootItems or {}) do + topItems[#topItems+1] = {name=name, count=data.count or 0, value=data.value or 0} + end + table.sort(topItems, function(a,b) return a.value > b.value end) + for i = 1, math.min(5, #topItems) do + local itm = topItems[i] + lootLines[#lootLines+1] = string.format("%d) %s x%d (%s gp)", i, itm.name, itm.count, formatNum(math.floor(itm.value))) + end + addSection(lines, "LOOT", lootLines) + + -- Combat detail + addSection(lines, "COMBAT DETAIL", { + "Damage Taken: " .. formatNum(m.damageTaken), + "Healing Done: " .. formatNum(m.healingDone), + "Damage Ratio: " .. string.format("%.2f", metrics.damageRatio), + "Near-Deaths: " .. m.nearDeathCount, + "Deaths: " .. m.deathCount, + }) + + return table.concat(lines, "\n") +end + +local function buildInsightsTab() + local lines = {} + local score = Insights.calculateScore() + addSection(lines, "HUNT SCORE", { Insights.scoreBar(score) }) + + local insightsList = Insights.analyze() + table.insert(lines, "[INSIGHTS]") + table.insert(lines, string.rep("-", 46)) + local insightLines = Insights.format(insightsList) + if #insightLines > 0 then + for _, line in ipairs(insightLines) do table.insert(lines, line) end + else + table.insert(lines, " No insights yet — hunt for a few minutes first.") + end + table.insert(lines, "") + table.insert(lines, " [!]=Critical [*]=Warning [>]=Tip [i]=Info") + return table.concat(lines, "\n") +end + +local HA_BUILDERS = { + buildSessionTab, + buildConsumptionTab, + buildLootCombatTab, + buildInsightsTab, +} + -- ============================================================================ -- UI -- ============================================================================ -local analyticsWindow = nil +local COLOR_ACTIVE = "#3be4d0" +local COLOR_INACTIVE = "#a4aece" +local BG_ACTIVE = "#3be4d01a" +local BG_INACTIVE = "#1b2235" +local BORDER_ACTIVE = "#3be4d088" +local BORDER_INACTIVE = "#050712" + +local analyticsWindow = nil +local haActiveTab = 1 +local haTabPanels = {} +local haTabBtns = {} +local liveUpdatesActive = false + +local function haFindChild(parent, id) + if not parent or not id then return nil end + local ok, w = pcall(function() return parent[id] end) + if ok and w and type(w) ~= "string" and type(w) ~= "number" then return w end + ok, w = pcall(function() return parent:getChildById(id) end) + if ok and w then return w end + return nil +end --- Live update flag for analytics window (must be defined before showAnalytics) -local liveUpdatesActive = false -local lastSummaryText = "" +local function haUpdateWidgetRefs() + if not analyticsWindow then haTabPanels = {}; haTabBtns = {}; return end + local tabBar = haFindChild(analyticsWindow, "tabBar") + for i = 1, 4 do + haTabBtns[i] = tabBar and haFindChild(tabBar, "tab" .. i .. "btn") or nil + haTabPanels[i] = haFindChild(analyticsWindow, "tab" .. i) or nil + end +end + +local function haApplyTabStyle(idx, isActive) + local btn = haTabBtns[idx] + if not btn then return end + pcall(function() + btn:setColor(isActive and COLOR_ACTIVE or COLOR_INACTIVE) + btn:setBackgroundColor(isActive and BG_ACTIVE or BG_INACTIVE) + btn:setBorderColor(isActive and BORDER_ACTIVE or BORDER_INACTIVE) + end) +end + +local function haSwitchTab(idx) + haActiveTab = idx + for i = 1, 4 do + local panel = haTabPanels[i] + if panel then pcall(function() if i == idx then panel:show() else panel:hide() end end) end + haApplyTabStyle(i, i == idx) + if analyticsWindow then + local sb = haFindChild(analyticsWindow, "tab" .. i .. "Scroll") + if sb then pcall(function() if i == idx then sb:show() else sb:hide() end end) end + end + end +end + +local function haRefreshActiveTab(force) + if not analyticsWindow or not analyticsWindow:isVisible() then return end + local panel = haTabPanels[haActiveTab] + if not panel then return end + local label = haFindChild(panel, "text") + if not label then return end + local ok, txt = pcall(HA_BUILDERS[haActiveTab]) + pcall(function() label:setText(ok and txt or ("Error: " .. tostring(txt))) end) +end local function stopLiveUpdates() liveUpdatesActive = false @@ -1690,86 +1963,89 @@ end local function doLiveUpdate() if not liveUpdatesActive then return end - - if analyticsWindow and analyticsWindow.content and analyticsWindow.content.textContent then - pcall(function() - local newText = buildSummary() - if newText ~= lastSummaryText then - analyticsWindow.content.textContent:setText(newText) - lastSummaryText = newText - end - end) - -- Schedule next update - schedule(1000, doLiveUpdate) - else - -- Window closed, stop live updates + if not analyticsWindow or not analyticsWindow:isVisible() then liveUpdatesActive = false + return end + haRefreshActiveTab() + schedule(2000, doLiveUpdate) end local function startLiveUpdates() - if liveUpdatesActive then return end -- Already running + if liveUpdatesActive then return end liveUpdatesActive = true - -- Start the update loop - schedule(1000, doLiveUpdate) + schedule(2000, doLiveUpdate) end local function showAnalytics() - if analyticsWindow then - stopLiveUpdates() -- Stop any existing live updates + if analyticsWindow then + stopLiveUpdates() pcall(function() analyticsWindow:destroy() end) - analyticsWindow = nil + analyticsWindow = nil end - - -- Auto-start session if not active - if not isSessionActive() then - startSession() - end - - -- Try to create window, fall back to console output - local ok, win = pcall(function() return UI.createWindow('HuntAnalyzerWindow') end) - if not ok or not win then - print(buildSummary()) - return + + if not isSessionActive() then startSession() end + + -- Import OTUI style + pcall(function() + local path = "/core/smart_hunt.otui" + if g_resources and g_resources.fileExists and g_resources.fileExists(path) then + g_ui.importStyle(path) + end + end) + + local ok, win = pcall(function() return UI.createWindow("HuntAnalyzerWindow") end) + if not ok or not win then + print(buildSummary()) + return end - + analyticsWindow = win - - -- Safely access window elements - if analyticsWindow.content and analyticsWindow.content.textContent then - analyticsWindow.content.textContent:setText(buildSummary()) - end - - if analyticsWindow.buttons then - if analyticsWindow.buttons.refreshButton then - -- Keep refresh button for manual refresh, but it's less needed now - analyticsWindow.buttons.refreshButton.onClick = function() - if analyticsWindow and analyticsWindow.content and analyticsWindow.content.textContent then - analyticsWindow.content.textContent:setText(buildSummary()) + haUpdateWidgetRefs() + + -- Wire tab buttons + local tabBar = haFindChild(win, "tabBar") + if tabBar then + for i = 1, 4 do + local btn = haFindChild(tabBar, "tab" .. i .. "btn") + if btn then + local idx = i + btn.onClick = function() + haSwitchTab(idx) + haRefreshActiveTab() end end end - if analyticsWindow.buttons.closeButton then - analyticsWindow.buttons.closeButton.onClick = function() - stopLiveUpdates() -- Stop live updates when closing - if analyticsWindow then pcall(function() analyticsWindow:destroy() end) end - analyticsWindow = nil + end + + -- Wire action buttons + local buttons = haFindChild(win, "buttons") + if buttons then + local refreshBtn = haFindChild(buttons, "refresh") + local resetBtn = haFindChild(buttons, "reset") + local closeBtn = haFindChild(buttons, "close") + + if refreshBtn then + refreshBtn.onClick = function() haRefreshActiveTab() end + end + if resetBtn then + resetBtn.onClick = function() + startSession() + haRefreshActiveTab() end end - if analyticsWindow.buttons.resetButton then - analyticsWindow.buttons.resetButton.onClick = function() - startSession() - if analyticsWindow and analyticsWindow.content and analyticsWindow.content.textContent then - analyticsWindow.content.textContent:setText(buildSummary()) - end + if closeBtn then + closeBtn.onClick = function() + stopLiveUpdates() + if analyticsWindow then pcall(function() analyticsWindow:destroy() end) end + analyticsWindow = nil end end end - - -- Safely show window + + haSwitchTab(haActiveTab) + haRefreshActiveTab() pcall(function() analyticsWindow:show():raise():focus() end) - - -- Start live updates startLiveUpdates() end @@ -1806,26 +2082,8 @@ if btn then btn:setTooltip("View hunting analytics") end -- Monster Insights button below Hunt Analyzer local monsterBtn = UI.Button("Monster Insights", function() - -- Ensure monster inspector is loaded and window exists - if not MonsterInspectorWindow then - if nExBot and nExBot.MonsterInspector and nExBot.MonsterInspector.showWindow then - nExBot.MonsterInspector.showWindow() - else - -- Try to load it manually - pcall(function() dofile("/targetbot/monster_inspector.lua") end) - if nExBot and nExBot.MonsterInspector and nExBot.MonsterInspector.showWindow then - nExBot.MonsterInspector.showWindow() - end - end - else - MonsterInspectorWindow:setVisible(not MonsterInspectorWindow:isVisible()) - if MonsterInspectorWindow:isVisible() then - if nExBot and nExBot.MonsterInspector and nExBot.MonsterInspector.refreshPatterns then - nExBot.MonsterInspector.refreshPatterns() - elseif refreshPatterns then - refreshPatterns() - end - end + if nExBot and nExBot.MonsterInspector and nExBot.MonsterInspector.toggleWindow then + nExBot.MonsterInspector.toggleWindow() end end) if monsterBtn then monsterBtn:setTooltip("View learned monster patterns and samples") end diff --git a/core/smart_hunt.otui b/core/smart_hunt.otui index 9484b88..ab2e075 100644 --- a/core/smart_hunt.otui +++ b/core/smart_hunt.otui @@ -1,38 +1,175 @@ HuntAnalyzerWindow < NxWindow text: Hunt Analyzer - width: 420 + width: 520 height: 480 - @onEscape: self:destroy() + @onEscape: self:hide() - VerticalScrollBar - id: contentScroll + Panel + id: tabBar anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 26 + background-color: #0b0f1e + + NxButton + id: tab1btn + text: Session + anchors.top: parent.top + anchors.left: parent.left + anchors.bottom: parent.bottom + width: 80 + + NxButton + id: tab2btn + text: Consumption + anchors.top: parent.top + anchors.left: tab1btn.right + anchors.bottom: parent.bottom + width: 110 + margin-left: 2 + + NxButton + id: tab3btn + text: Loot & Combat + anchors.top: parent.top + anchors.left: tab2btn.right + anchors.bottom: parent.bottom + width: 115 + margin-left: 2 + + NxButton + id: tab4btn + text: Insights + anchors.top: parent.top + anchors.left: tab3btn.right + anchors.bottom: parent.bottom + width: 85 + margin-left: 2 + + VerticalScrollBar + id: tab1Scroll + anchors.top: tabBar.bottom anchors.bottom: buttons.top anchors.right: parent.right - margin-top: 5 - margin-bottom: 10 + margin-top: 4 + margin-bottom: 8 step: 24 pixels-scroll: true ScrollablePanel - id: content - anchors.top: parent.top + id: tab1 + anchors.top: tabBar.bottom + anchors.left: parent.left + anchors.right: tab1Scroll.left + anchors.bottom: buttons.top + margin-top: 4 + margin-bottom: 8 + margin-right: 4 + vertical-scrollbar: tab1Scroll + + NxLabel + id: text + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + text-wrap: true + text-auto-resize: true + font: verdana-11px-monochrome + color: #f5f7ff + + VerticalScrollBar + id: tab2Scroll + anchors.top: tabBar.bottom + anchors.bottom: buttons.top + anchors.right: parent.right + margin-top: 4 + margin-bottom: 8 + step: 24 + pixels-scroll: true + + ScrollablePanel + id: tab2 + anchors.top: tabBar.bottom + anchors.left: parent.left + anchors.right: tab2Scroll.left + anchors.bottom: buttons.top + margin-top: 4 + margin-bottom: 8 + margin-right: 4 + vertical-scrollbar: tab2Scroll + + NxLabel + id: text + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + text-wrap: true + text-auto-resize: true + font: verdana-11px-monochrome + color: #f5f7ff + + VerticalScrollBar + id: tab3Scroll + anchors.top: tabBar.bottom + anchors.bottom: buttons.top + anchors.right: parent.right + margin-top: 4 + margin-bottom: 8 + step: 24 + pixels-scroll: true + + ScrollablePanel + id: tab3 + anchors.top: tabBar.bottom + anchors.left: parent.left + anchors.right: tab3Scroll.left + anchors.bottom: buttons.top + margin-top: 4 + margin-bottom: 8 + margin-right: 4 + vertical-scrollbar: tab3Scroll + + NxLabel + id: text + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + text-wrap: true + text-auto-resize: true + font: verdana-11px-monochrome + color: #f5f7ff + + VerticalScrollBar + id: tab4Scroll + anchors.top: tabBar.bottom + anchors.bottom: buttons.top + anchors.right: parent.right + margin-top: 4 + margin-bottom: 8 + step: 24 + pixels-scroll: true + + ScrollablePanel + id: tab4 + anchors.top: tabBar.bottom anchors.left: parent.left - anchors.right: contentScroll.left + anchors.right: tab4Scroll.left anchors.bottom: buttons.top - margin-top: 5 - margin-bottom: 10 - margin-right: 5 - vertical-scrollbar: contentScroll - + margin-top: 4 + margin-bottom: 8 + margin-right: 4 + vertical-scrollbar: tab4Scroll + NxLabel - id: textContent + id: text anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right text-wrap: true text-auto-resize: true font: verdana-11px-monochrome + color: #f5f7ff Panel id: buttons @@ -40,24 +177,25 @@ HuntAnalyzerWindow < NxWindow anchors.left: parent.left anchors.right: parent.right height: 30 - + NxButton - id: refreshButton + id: refresh text: Refresh anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter width: 80 - + NxButton - id: closeButton - text: Close - anchors.right: parent.right + id: reset + text: Reset Session + anchors.left: refresh.right anchors.verticalCenter: parent.verticalCenter - width: 80 + width: 110 + margin-left: 8 NxButton - id: resetButton - text: Reset Data - anchors.horizontalCenter: parent.horizontalCenter + id: close + text: Close + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - width: 90 + width: 80 diff --git a/targetbot/attack_state_machine.lua b/targetbot/attack_state_machine.lua index 59ce567..ce1e985 100644 --- a/targetbot/attack_state_machine.lua +++ b/targetbot/attack_state_machine.lua @@ -80,8 +80,8 @@ local function ensureDeps() CC = { TICK_INTERVAL = 100, COMMAND_COOLDOWN = 350, CONFIRM_TIMEOUT = 1200, GRACE_PERIOD = 1500, KEEPALIVE_INTERVAL = 2000, STOP_DEBOUNCE = 150, - REAFFIRM_RETRY_MAX = 5, ENGAGE_BACKOFF_BASE = 1500, - ENGAGE_BACKOFF_GROWTH = 1.5, SWITCH_COOLDOWN = 2500, + REAFFIRM_RETRY_MAX = 3, ENGAGE_BACKOFF_BASE = 1000, + ENGAGE_BACKOFF_GROWTH = 1.5, ENGAGE_BACKOFF_CAP = 3000, SWITCH_COOLDOWN = 2500, CONFIG_SWITCH_COOLDOWN = 400, CRITICAL_HP = 25, PATH_SKIP_DURATION = 10000, } @@ -541,7 +541,7 @@ local function handleEngaging() -- Grow timeout for next attempt state.currentTimeout = math.min( state.currentTimeout * CC.ENGAGE_BACKOFF_GROWTH, - 5000 -- hard cap 5s + CC.ENGAGE_BACKOFF_CAP or 3000 -- use constant cap ) state.enteredAt = nowMs() log("Retry " .. state.retries .. "/" .. CC.REAFFIRM_RETRY_MAX .. diff --git a/targetbot/combat_constants.lua b/targetbot/combat_constants.lua index a1dab15..96e7c7c 100644 --- a/targetbot/combat_constants.lua +++ b/targetbot/combat_constants.lua @@ -26,9 +26,10 @@ 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) +CC.REAFFIRM_RETRY_MAX = 3 -- Max retries before forfeit +CC.ENGAGE_BACKOFF_BASE = 1000 -- First retry timeout (ms) CC.ENGAGE_BACKOFF_GROWTH = 1.5 -- Exponential backoff multiplier +CC.ENGAGE_BACKOFF_CAP = 3000 -- Max backoff cap (ms) -- Target switching CC.SWITCH_COOLDOWN = 2500 -- Min between target switches (ms) diff --git a/targetbot/creature_attack.lua b/targetbot/creature_attack.lua index ddd53e9..003c776 100644 --- a/targetbot/creature_attack.lua +++ b/targetbot/creature_attack.lua @@ -1561,7 +1561,7 @@ TargetBot.Creature.attack = function(params, targets, isLooting) if TargetBot then TargetBot.UnreachableTracker = TargetBot.UnreachableTracker or { entries = {}, - ttl = 800, + ttl = 300, lastCleanup = 0, cleanupInterval = 2000 } diff --git a/targetbot/monster_ai.lua b/targetbot/monster_ai.lua index 6feecb3..a339fe2 100644 --- a/targetbot/monster_ai.lua +++ b/targetbot/monster_ai.lua @@ -60,103 +60,15 @@ end -- 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 +-- Delegate all safe-creature helpers to monster_ai_core (single source of truth, DRY) +local _H = MonsterAI._helpers +local isCreatureValid = _H.isCreatureValid +local safeCreatureCall = _H.safeCreatureCall +local safeGetId = _H.safeGetId +local safeIsDead = _H.safeIsDead +local safeIsMonster = _H.safeIsMonster +local safeIsRemoved = _H.safeIsRemoved +local isValidAliveMonster = _H.isValidAliveMonster -- Extended telemetry defaults MonsterAI.COLLECT_EXTENDED = (MonsterAI.COLLECT_EXTENDED == nil) and true or MonsterAI.COLLECT_EXTENDED @@ -1425,6 +1337,13 @@ if EventBus then if score and score > bestScore then bestScore, bestData, bestMonster = score, data, m end end + -- Track the best monster if it wasn't already tracked (monster:appear may have been missed) + if bestScore and bestScore > CONST.DAMAGE.CORRELATION_THRESHOLD and bestMonster and not bestData then + MonsterAI.Tracker.track(bestMonster) + local bid = safeGetId(bestMonster) + bestData = bid and MonsterAI.Tracker.monsters[bid] + end + if bestScore and bestScore > CONST.DAMAGE.CORRELATION_THRESHOLD and bestData then -- Attribute this damage bestData.lastDamageTime = nowt @@ -1489,24 +1408,38 @@ if EventBus then if not srcPos or not destPos then return end - -- Get the source tile and find creatures on it + -- Find the monster that fired (check source tile first, then nearby tiles as fallback) local Client = getClient() local srcTile = (Client and Client.getTile) and Client.getTile(srcPos) or (g_map and g_map.getTile and g_map.getTile(srcPos)) - if not srcTile then return end - - local creatures = srcTile:getCreatures() - if not creatures or #creatures == 0 then return end - - -- Find a monster on the source tile (the caster) local src = nil - for i = 1, #creatures do - local c = creatures[i] - if c and safeIsMonster(c) and not safeIsDead(c) then - src = c - break + + if srcTile then + local creatures = srcTile:getCreatures() + if creatures then + for i = 1, #creatures do + local c = creatures[i] + if c and safeIsMonster(c) and not safeIsDead(c) then + src = c; break + end + end end end - + + -- Fallback: monster may have moved off the source tile between firing and callback + if not src then + local specs = g_map and g_map.getSpectatorsInRange and g_map.getSpectatorsInRange(srcPos, false, 2, 2) or {} + local bestDist = math.huge + for _, c in ipairs(specs) do + if safeIsMonster(c) and not safeIsDead(c) then + local cpos = safeCreatureCall(c, "getPosition", nil) + if cpos then + local d = math.max(math.abs(cpos.x - srcPos.x), math.abs(cpos.y - srcPos.y)) + if d < bestDist then bestDist = d; src = c end + end + end + end + end + if not src then return end local id = safeGetId(src) @@ -2028,7 +1961,27 @@ function MonsterAI.updateAll() pcall(function() MonsterAI.CombatFeedback.checkTimeouts() end) end - MonsterAI.lastUpdate = nowMs() + -- Checksum guard: emit monsterai:state_updated only when tracked state changes. + -- Prevents Monster Inspector (and any other subscriber) from rebuilding on silent ticks. + local nowt = nowMs() + local chk = 0 + if MonsterAI.Tracker and MonsterAI.Tracker.monsters then + for id, d in pairs(MonsterAI.Tracker.monsters) do + -- Cheap XOR-style accumulation — avoids heavy string hashing + chk = (chk + (id % 997) + ((d.lastAttackTime or 0) % 997)) % 65521 + end + end + if MonsterAI.RealTime and MonsterAI.RealTime.threatCache then + chk = (chk + math.floor((MonsterAI.RealTime.threatCache.totalThreat or 0) * 100) % 997) % 65521 + end + if chk ~= MonsterAI._stateChecksum then + MonsterAI._stateChecksum = chk + if EventBus then + pcall(function() EventBus.emit("monsterai:state_updated") end) + end + end + + MonsterAI.lastUpdate = nowt end -- ============================================================================ diff --git a/targetbot/monster_inspector.lua b/targetbot/monster_inspector.lua index 8c56c03..91fdffc 100644 --- a/targetbot/monster_inspector.lua +++ b/targetbot/monster_inspector.lua @@ -1,9 +1,10 @@ --- Monster Insights UI +-- Monster Insights UI — v3.0 (Tabbed) +-- Tabs: 1=Live Monsters 2=Patterns 3=Combat Stats 4=Scenario --- Toggleable debug for this module (set MONSTER_INSPECTOR_DEBUG = true in console to enable) MONSTER_INSPECTOR_DEBUG = (type(MONSTER_INSPECTOR_DEBUG) == "boolean" and MONSTER_INSPECTOR_DEBUG) or false --- Safe wrapper for UnifiedStorage.get that checks isReady() first +-- ── Helpers ────────────────────────────────────────────────────────────────── + local function safeUnifiedGet(key, default) if not UnifiedStorage or not UnifiedStorage.get then return default end if not UnifiedStorage.isReady or not UnifiedStorage.isReady() then return default end @@ -12,835 +13,623 @@ local function safeUnifiedGet(key, default) return default end --- Import the style first (try multiple paths to be robust across environments) +-- Merge in-memory patterns (primary) with stored patterns (secondary/cross-session) +local function getPatterns() + local mem = (MonsterAI and MonsterAI.Patterns and MonsterAI.Patterns.knownMonsters) or {} + local stored = safeUnifiedGet("targetbot.monsterPatterns", {}) + local merged = {} + for k, v in pairs(stored) do merged[k] = v end + for k, v in pairs(mem) do merged[k] = v end -- memory wins on conflict + return merged +end + +local function isTableEmpty(tbl) + if not tbl then return true end + for _ in pairs(tbl) do return false end + return true +end + +local function fmtTime(ms) + if not ms or (type(ms) == "number" and ms <= 0) then return "-" end + return os.date("%Y-%m-%d %H:%M:%S", math.floor(ms / 1000)) +end + +-- ── Style constants ─────────────────────────────────────────────────────────── + +local COLOR_ACTIVE = "#3be4d0" +local COLOR_INACTIVE = "#a4aece" +local BG_ACTIVE = "#3be4d01a" +local BG_INACTIVE = "#1b2235" +local BORDER_ACTIVE = "#3be4d088" +local BORDER_INACTIVE = "#050712" + +-- ── Module state ────────────────────────────────────────────────────────────── + +nExBot = nExBot or {} +nExBot.MonsterInspector = nExBot.MonsterInspector or {} + +local activeTab = 1 +local tabPanels = {} -- [1..4] ScrollablePanel widgets +local tabBtns = {} -- [1..4] NxButton widgets +local refreshInProgress = false +local lastRefreshMs = 0 +local MIN_REFRESH_MS = 2500 +local liveUpdateActive = false + +-- ── Style import ────────────────────────────────────────────────────────────── + local function tryImportStyle() - local candidates = {} - -- Common relative paths - candidates[1] = "/targetbot/monster_inspector.otui" - candidates[2] = "targetbot/monster_inspector.otui" - -- Fully-qualified path using centralized paths (cache-aware) + local candidates = { + "/targetbot/monster_inspector.otui", + "targetbot/monster_inspector.otui", + } if nExBot and nExBot.paths then candidates[#candidates + 1] = nExBot.paths.base .. "/targetbot/monster_inspector.otui" elseif BotConfigName then candidates[#candidates + 1] = "/bot/" .. BotConfigName .. "/targetbot/monster_inspector.otui" - else - local ok, cfg = pcall(function() return modules.game_bot.contentsPanel.config:getCurrentOption().text end) - if ok and cfg then - candidates[#candidates + 1] = "/bot/" .. cfg .. "/targetbot/monster_inspector.otui" - end end - for i = 1, #candidates do local path = candidates[i] if g_resources and g_resources.fileExists and g_resources.fileExists(path) then pcall(function() g_ui.importStyle(path) end) - return true end end - - -- Last resort: try the default import and let underlying API log the reason pcall(function() g_ui.importStyle("/targetbot/monster_inspector.otui") end) - warn("[MonsterInspector] Failed to locate '/targetbot/monster_inspector.otui' via tested paths. UI may be missing or path differs from expected.") return false end tryImportStyle() --- Create window from style and keep it hidden by default. Provide a helper to (re)create on demand. -local function createWindowIfMissing() - if MonsterInspectorWindow and MonsterInspectorWindow:isVisible() then return MonsterInspectorWindow end - -- Try import and create window - tryImportStyle() - local ok, win = pcall(function() return UI.createWindow("MonsterInspectorWindow") end) - if not ok or not win then - warn("[MonsterInspector] Failed to create MonsterInspectorWindow - style may be missing or invalid") - MonsterInspectorWindow = nil - return nil +-- ── Widget binding ──────────────────────────────────────────────────────────── + +local function findChild(parent, id) + if not parent or not id then return nil end + local ok, w = pcall(function() return parent[id] end) + if ok and w then return w end + ok, w = pcall(function() return parent:getChildById(id) end) + if ok and w then return w end + return nil +end + +local function updateWidgetRefs() + if not MonsterInspectorWindow then + tabPanels = {}; tabBtns = {}; return end + local tabBar = findChild(MonsterInspectorWindow, "tabBar") + for i = 1, 4 do + tabBtns[i] = tabBar and findChild(tabBar, "tab" .. i .. "btn") or nil + tabPanels[i] = findChild(MonsterInspectorWindow, "tab" .. i) or nil + end +end - MonsterInspectorWindow = win - -- Ensure it's hidden initially - pcall(function() MonsterInspectorWindow:hide() end) +-- ── Tab switching ───────────────────────────────────────────────────────────── +local function applyTabStyle(idx, isActive) + local btn = tabBtns[idx] + if not btn then return end + pcall(function() + btn:setColor(isActive and COLOR_ACTIVE or COLOR_INACTIVE) + btn:setBackgroundColor(isActive and BG_ACTIVE or BG_INACTIVE) + btn:setBorderColor(isActive and BORDER_ACTIVE or BORDER_INACTIVE) + end) +end - -- Rebind buttons and visibility handlers (same logic as below) - -- Setup actual buttons if present - use direct property access (OTClient pattern) - local function bindButtons() - local buttonsPanel = win.buttons - if not buttonsPanel then - pcall(function() buttonsPanel = win:getChildById("buttons") end) - end - - if not buttonsPanel then - if MONSTER_INSPECTOR_DEBUG then print("[MonsterInspector] Buttons panel not found during window creation") end - return +local function switchTab(idx) + activeTab = idx + for i = 1, 4 do + local panel = tabPanels[i] + if panel then + pcall(function() + if i == idx then panel:show() else panel:hide() end + end) end - - local refreshBtn = buttonsPanel.refresh - local exportBtn = buttonsPanel.export -- Note: export button may not exist in current OTUI - local clearBtn = buttonsPanel.clear - local closeBtn = buttonsPanel.close - - if refreshBtn then refreshBtn.onClick = function() refreshPatterns() end end - if exportBtn then exportBtn.onClick = function() exportPatterns() end end - if clearBtn then clearBtn.onClick = function() clearPatterns() end end - if closeBtn then closeBtn.onClick = function() win:hide() end end - - win.onVisibilityChange = function(widget, visible) - if visible then - updateWidgetRefs() - refreshPatterns() + applyTabStyle(i, i == idx) + -- Show/hide matching scrollbar + if MonsterInspectorWindow then + local sb = findChild(MonsterInspectorWindow, "tab" .. i .. "Scroll") + if sb then + pcall(function() + if i == idx then sb:show() else sb:hide() end + end) end end end - pcall(bindButtons) - - -- Initialize content - pcall(function() updateWidgetRefs() end) - pcall(function() refreshPatterns() end) - - return MonsterInspectorWindow end --- Ensure window exists at load time if possible -createWindowIfMissing() +-- ── Tab content builders ────────────────────────────────────────────────────── --- Ensure global namespace for inspector exists to avoid nil indexing during early calls -nExBot = nExBot or {} -nExBot.MonsterInspector = nExBot.MonsterInspector or {} +local function buildLiveTab() + local lines = {} + local live = (MonsterAI and MonsterAI.Tracker and MonsterAI.Tracker.monsters) or {} + local count = 0 + for _ in pairs(live) do count = count + 1 end -local patternList, dmgLabel, waveLabel, areaLabel = nil, nil, nil, nil + table.insert(lines, string.format("Live Tracker - %d creature(s)", count)) + table.insert(lines, "") --- Robust recursive lookup for widgets (tries direct property, getChildById, and recursive search) -local function findChildRecursive(parent, id) - if not parent or not id then return nil end - local ok, child = pcall(function() return parent[id] end) - if ok and child then return child end - ok, child = pcall(function() return parent:getChildById(id) end) - if ok and child then return child end - -- Depth-first search of children - ok, child = pcall(function() - local children = parent.getChildren and parent:getChildren() or {} - for i = 1, #children do - local found = findChildRecursive(children[i], id) - if found then return found end + if count == 0 then + -- Fallback: enumerate spectators directly so the tab is never blank + local nearby = {} + local p = player and player:getPosition() + if p then + pcall(function() + local specs = (g_map and g_map.getSpectatorsInRange + and g_map.getSpectatorsInRange(p, false, 8, 8)) or {} + for _, c in ipairs(specs) do + local ok2, valid = pcall(function() + return c:isMonster() and not c:isDead() and not c:isRemoved() + end) + if ok2 and valid then + local name = "?" + pcall(function() name = c:getName() end) + table.insert(nearby, name) + end + end + end) end - return nil - end) - if ok and child then return child end - return nil -end - -local function updateWidgetRefs() - -- Robustly bind important widgets (content -> textContent) using recursive lookup - if not MonsterInspectorWindow then - patternList, dmgLabel, waveLabel, areaLabel = nil, nil, nil, nil - -- MonsterInspectorWindow missing (silent) - return + if #nearby > 0 then + table.insert(lines, string.format(" %d nearby (TargetBot off - enable for full tracking):", #nearby)) + table.insert(lines, "") + local seen = {} + for _, name in ipairs(nearby) do + seen[name] = (seen[name] or 0) + 1 + end + local sorted = {} + for name, cnt in pairs(seen) do sorted[#sorted+1] = {name=name, cnt=cnt} end + table.sort(sorted, function(a,b) return a.cnt > b.cnt end) + for _, e in ipairs(sorted) do + table.insert(lines, string.format(" %dx %s", e.cnt, e.name)) + end + else + table.insert(lines, " No creatures currently tracked.") + table.insert(lines, " Enable TargetBot for live tracking data.") + end + return table.concat(lines, "\n") end - -- Try direct properties first (common when otui sets ids as fields) - local content = nil - local ok, cont = pcall(function() return MonsterInspectorWindow.content end) - if ok and cont then content = cont end - - -- Fallback to recursive search - if not content then content = findChildRecursive(MonsterInspectorWindow, 'content') end + table.insert(lines, string.format(" %-18s %6s %5s %7s %6s %7s %5s %6s", + "Name", "Samps", "Conf", "CD(ms)", "DPS", "Missiles", "Speed", "Facing")) + table.insert(lines, string.rep("-", 76)) - -- Find the textual content label - local textContent = nil - if content then - local ok2, tc = pcall(function() return content.textContent end) - if ok2 and tc then textContent = tc end - if not textContent then textContent = findChildRecursive(content, 'textContent') end - else - -- As a last resort, search the entire window for the label - textContent = findChildRecursive(MonsterInspectorWindow, 'textContent') + local tbl = {} + for id, d in pairs(live) do + local facing = false + if MonsterAI and MonsterAI.RealTime and MonsterAI.RealTime.directions then + local rt = MonsterAI.RealTime.directions[id] + facing = rt and rt.facingPlayerSince ~= nil + end + local dps = 0 + if MonsterAI and MonsterAI.Tracker and MonsterAI.Tracker.getDPS then + local ok, val = pcall(MonsterAI.Tracker.getDPS, id) + if ok and val then dps = val end + end + table.insert(tbl, { + name = d.name or "unknown", + samples = d.samples and #d.samples or 0, + conf = d.confidence or 0, + cd = d.ewmaCooldown or d.predictedWaveCooldown, + dps = dps, + missiles = d.missileCount or 0, + speed = d.avgSpeed or 0, + facing = facing, + }) end + table.sort(tbl, function(a, b) return (a.conf or 0) > (b.conf or 0) end) - if textContent then - patternList = textContent - -- Ensure window references are set so other code can access them directly - if content and (not MonsterInspectorWindow.content) then MonsterInspectorWindow.content = content end - if MonsterInspectorWindow.content and (not MonsterInspectorWindow.content.textContent) then MonsterInspectorWindow.content.textContent = textContent end + for i = 1, math.min(#tbl, 20) do + local e = tbl[i] + local confs = string.format("%.2f", e.conf) + local cdStr = (type(e.cd) == "number" and string.format("%d", math.floor(e.cd))) or "-" + local faceStr= e.facing and "YES" or "no" + table.insert(lines, string.format(" %-18s %6d %5s %7s %6.1f %7d %5.2f %6s", + e.name:sub(1, 18), e.samples, confs, cdStr, e.dps or 0, e.missiles, e.speed, faceStr)) + end - else - patternList = nil - warn("[MonsterInspector] Failed to bind textContent widget; UI may not be loaded or style import failed") + if #tbl > 20 then + table.insert(lines, string.format(" ... and %d more", #tbl - 20)) end + return table.concat(lines, "\n") end +local function buildPatternsTab() + local lines = {} + local patterns = getPatterns() + local count = 0 + for _ in pairs(patterns) do count = count + 1 end + table.insert(lines, string.format("Learned Patterns - %d monster type(s)", count)) + table.insert(lines, "") --- Populate refs now (also called again on visibility change) -updateWidgetRefs() + if count == 0 then + table.insert(lines, " No patterns yet.") + table.insert(lines, "") + -- Diagnostic hints + local trackerCount = 0 + if MonsterAI and MonsterAI.Tracker and MonsterAI.Tracker.monsters then + for _ in pairs(MonsterAI.Tracker.monsters) do trackerCount = trackerCount + 1 end + end + local tbOn = TargetBot and TargetBot.isOn and TargetBot.isOn() + table.insert(lines, string.format(" Tracker: %d monster(s) TargetBot: %s", + trackerCount, tbOn and "ON" or "OFF")) + table.insert(lines, " Patterns are learned from missile attacks after 2+") + table.insert(lines, " observations per monster type.") + return table.concat(lines, "\n") + end -local refreshTimerActive = false -local refreshInProgress = false -local lastPatternsChecksum = nil -local lastRefreshMs = 0 -local MIN_REFRESH_MS = 2500 -- don't refresh more often than this (ms) -local lastLabelUpdateMs = 0 -local MIN_LABEL_UPDATE_MS = 1000 -- don't update labels more often than this (ms) + table.insert(lines, string.format(" %-20s %8s %6s %5s %s", + "Name", "CD(ms)", "Var", "Conf", "Last Seen")) + table.insert(lines, string.rep("-", 68)) --- Helper function to check if table is empty (since 'next' is not available) -local function isTableEmpty(tbl) - if not tbl then return true end - for _ in pairs(tbl) do - return false + local sorted = {} + for name, p in pairs(patterns) do + table.insert(sorted, { name = name, p = p }) end - return true -end + table.sort(sorted, function(a, b) + return (a.p.confidence or 0) > (b.p.confidence or 0) + end) -local function fmtTime(ms) - if not ms or (type(ms) == 'number' and ms <= 0) then return "-" end - return os.date('%Y-%m-%d %H:%M:%S', math.floor(ms / 1000)) -end + for _, item in ipairs(sorted) do + local p = item.p + local cd = p.waveCooldown and string.format("%d", math.floor(p.waveCooldown)) or "-" + local var = p.waveVariance and string.format("%.1f", p.waveVariance) or "-" + local conf = p.confidence and string.format("%.2f", p.confidence) or "-" + local last = p.lastSeen and fmtTime(p.lastSeen) or "-" + table.insert(lines, string.format(" %-20s %8s %6s %5s %s", + item.name:sub(1, 20), cd, var, conf, last)) + end --- Build a compact human-friendly string for a single pattern -local function formatPatternLine(name, p) - local cooldown = p and p.waveCooldown and string.format("%dms", math.floor(p.waveCooldown)) or "-" - local variance = p and p.waveVariance and string.format("%.1f", p.waveVariance) or "-" - local conf = p and p.confidence and string.format("%.2f", p.confidence) or "-" - local last = p and p.lastSeen and fmtTime(p.lastSeen) or "-" - return string.format("%s — cd:%s var:%s conf:%s last:%s", name, cooldown, variance, conf, last) + return table.concat(lines, "\n") end --- Build a textual summary (smart_hunt style) for quick rendering in a scrollable content label -local function buildSummary() +local function buildStatsTab() local lines = {} - local stats = (MonsterAI and MonsterAI.Tracker and MonsterAI.Tracker.stats) or { waveAttacksObserved = 0, areaAttacksObserved = 0, totalDamageReceived = 0 } - - -- Header with version - table.insert(lines, string.format("Monster AI v%s", MonsterAI and MonsterAI.VERSION or "?")) - table.insert(lines, string.format("Stats: Damage=%s Waves=%s Area=%s", stats.totalDamageReceived or 0, stats.waveAttacksObserved or 0, stats.areaAttacksObserved or 0)) - - -- Session stats (new in v2.0) + local stats = (MonsterAI and MonsterAI.Tracker and MonsterAI.Tracker.stats) + or { waveAttacksObserved = 0, areaAttacksObserved = 0, totalDamageReceived = 0 } + + table.insert(lines, string.format("Monster AI v%s", MonsterAI and MonsterAI.VERSION or "?")) + table.insert(lines, "") + if MonsterAI and MonsterAI.Telemetry and MonsterAI.Telemetry.session then - local session = MonsterAI.Telemetry.session - local sessionDuration = ((now or 0) - (session.startTime or 0)) / 1000 - table.insert(lines, string.format("Session: Kills=%d Deaths=%d Duration=%.0fs Tracked=%d", - session.killCount or 0, - session.deathCount or 0, - sessionDuration, - session.totalMonstersTracked or 0 - )) - end - - -- Metrics Aggregator Summary (NEW in v2.2) + local s = MonsterAI.Telemetry.session + local dur = ((now or 0) - (s.startTime or 0)) / 1000 + table.insert(lines, "-- Session " .. string.rep("-", 36)) + table.insert(lines, string.format(" Kills: %d Deaths: %d Duration: %.0fs Tracked: %d", + s.killCount or 0, s.deathCount or 0, dur, s.totalMonstersTracked or 0)) + end + + table.insert(lines, "") + table.insert(lines, "-- Combat " .. string.rep("-", 37)) + table.insert(lines, string.format(" Damage Received: %d Waves: %d Area: %d", + stats.totalDamageReceived or 0, stats.waveAttacksObserved or 0, stats.areaAttacksObserved or 0)) + if MonsterAI and MonsterAI.Metrics and MonsterAI.Metrics.getSummary then - local summary = MonsterAI.Metrics.getSummary() - - -- Combat metrics - if summary.combat then - local c = summary.combat - table.insert(lines, string.format("Combat: DPS Received=%.1f KDR=%.1f", - c.dpsReceived or 0, - c.kdr or 0 - )) - end - - -- Performance metrics - if summary.performance and summary.performance.cyclesSaved > 0 then - local p = summary.performance - table.insert(lines, string.format("Performance: Cycles=%d Saved=%d Mode=%s", - p.updateCycles or 0, - p.cyclesSaved or 0, - (p.volume or "normal"):upper() - )) + local ok, s = pcall(MonsterAI.Metrics.getSummary) + if ok and s and s.combat then + table.insert(lines, string.format(" DPS Received: %.1f KDR: %.2f", + s.combat.dpsReceived or 0, s.combat.kdr or 0)) end end - - -- Real-time prediction stats + if MonsterAI and MonsterAI.getPredictionStats then - local predStats = MonsterAI.getPredictionStats() - table.insert(lines, string.format("Predictions: Events=%d Correct=%d Missed=%d Accuracy=%.1f%%", - predStats.eventsProcessed or 0, - predStats.predictionsCorrect or 0, - predStats.predictionsMissed or 0, - (predStats.accuracy or 0) * 100 - )) - - -- WavePredictor stats if available - if predStats.wavePredictor then - local wp = predStats.wavePredictor - table.insert(lines, string.format("WavePredictor: Total=%d Correct=%d FalsePos=%d Acc=%.1f%%", - wp.total or 0, - wp.correct or 0, - wp.falsePositive or 0, - (wp.accuracy or 0) * 100 - )) - end - end - - -- Real-time threat status - if MonsterAI and MonsterAI.getImmediateThreat then - local threat = MonsterAI.getImmediateThreat() - local threatStatus = threat.immediateThreat and "DANGER!" or "Safe" - table.insert(lines, string.format("Threat: %s Level=%.1f HighThreat=%d", - threatStatus, - threat.totalThreat or 0, - threat.highThreatCount or 0 - )) - end - - -- Auto-Tuner Status (new in v2.0) - if MonsterAI and MonsterAI.AutoTuner then - local autoTuneStatus = MonsterAI.AUTO_TUNE_ENABLED and "ON" or "OFF" - local adjustments = MonsterAI.RealTime and MonsterAI.RealTime.metrics and MonsterAI.RealTime.metrics.autoTuneAdjustments or 0 - local pendingSuggestions = 0 - if MonsterAI.AutoTuner.suggestions then - for _ in pairs(MonsterAI.AutoTuner.suggestions) do pendingSuggestions = pendingSuggestions + 1 end - end - table.insert(lines, string.format("AutoTuner: %s Adjustments=%d Pending=%d", - autoTuneStatus, adjustments, pendingSuggestions)) - end - - -- Classification Stats (new in v2.0) - if MonsterAI and MonsterAI.Classifier and MonsterAI.Classifier.cache then - local classifiedCount = 0 - for _ in pairs(MonsterAI.Classifier.cache) do classifiedCount = classifiedCount + 1 end - table.insert(lines, string.format("Classifications: %d monster types analyzed", classifiedCount)) - end - - -- Telemetry Stats (new in v2.0) - if MonsterAI and MonsterAI.RealTime and MonsterAI.RealTime.metrics then - local telemetrySamples = MonsterAI.RealTime.metrics.telemetrySamples or 0 - table.insert(lines, string.format("Telemetry: %d samples collected", telemetrySamples)) - end - - -- Combat Feedback Stats (NEW in v2.0 - 30% accuracy improvement) - if MonsterAI and MonsterAI.CombatFeedback then - local cf = MonsterAI.CombatFeedback - if cf.getStats then - local cfStats = cf.getStats() - local accuracy = cfStats.accuracy or 0 - local predictions = cfStats.totalPredictions or 0 - local hits = cfStats.hits or 0 - local misses = cfStats.misses or 0 - local adaptiveWeights = cfStats.adaptiveWeightsCount or 0 - - table.insert(lines, string.format("CombatFeedback: Predictions=%d Hits=%d Misses=%d Acc=%.1f%% Weights=%d", - predictions, hits, misses, accuracy * 100, adaptiveWeights)) + local ok, ps = pcall(MonsterAI.getPredictionStats) + if ok and ps then + table.insert(lines, "") + table.insert(lines, "-- Predictions " .. string.rep("-", 32)) + table.insert(lines, string.format(" Events: %d Correct: %d Missed: %d Acc: %.1f%%", + ps.eventsProcessed or 0, ps.predictionsCorrect or 0, + ps.predictionsMissed or 0, (ps.accuracy or 0) * 100)) + if ps.wavePredictor then + local wp = ps.wavePredictor + table.insert(lines, string.format(" WavePredictor: Total=%d Correct=%d FalsePos=%d Acc=%.1f%%", + wp.total or 0, wp.correct or 0, wp.falsePositive or 0, (wp.accuracy or 0) * 100)) + end end end - - -- Spell Tracker Stats (NEW in v2.2 - Monster spell analysis) + if MonsterAI and MonsterAI.SpellTracker then - local st = MonsterAI.SpellTracker - local stats = st.getStats and st.getStats() or {} - local reactivity = st.analyzeReactivity and st.analyzeReactivity() or {} - - table.insert(lines, string.format("SpellTracker: Total=%d /min=%.1f Types=%d", - stats.totalSpellsCast or 0, - stats.spellsPerMinute or 0, - stats.uniqueMissileTypes or 0 - )) - - -- Reactivity analysis - local reactivityStatus = "Normal" - if reactivity.spellBurstDetected then - reactivityStatus = "BURST!" - elseif reactivity.highVolumeThreshold then - reactivityStatus = "High Volume" - elseif reactivity.lowVolumeThreshold then - reactivityStatus = "Low Volume" - end - - table.insert(lines, string.format(" Reactivity: %s Active=%d AvgInterval=%dms", - reactivityStatus, - reactivity.activeMonsterCount or 0, - math.floor(reactivity.avgTimeBetweenSpells or 0) - )) - - -- Show top spell casters - local topCasters = {} + local st = MonsterAI.SpellTracker + local ok, sts = pcall(function() return st.getStats and st.getStats() or {} end) + sts = ok and sts or {} + table.insert(lines, "") + table.insert(lines, "-- SpellTracker " .. string.rep("-", 31)) + table.insert(lines, string.format(" Total: %d /min: %.1f Types: %d", + sts.totalSpellsCast or 0, sts.spellsPerMinute or 0, sts.uniqueMissileTypes or 0)) + + local casters = {} if st.monsterSpells then - for id, data in pairs(st.monsterSpells) do - if data.totalSpellsCast and data.totalSpellsCast > 0 then - table.insert(topCasters, { - name = data.name or "Unknown", - spells = data.totalSpellsCast, - cooldown = data.ewmaSpellCooldown, - frequency = data.castFrequency or 0 - }) + for _, d in pairs(st.monsterSpells) do + if (d.totalSpellsCast or 0) > 0 then + table.insert(casters, { name = d.name or "?", spells = d.totalSpellsCast, + cd = d.ewmaSpellCooldown }) end end - table.sort(topCasters, function(a, b) return a.spells > b.spells end) - end - - if #topCasters > 0 then - table.insert(lines, " Top Casters:") - for i = 1, math.min(3, #topCasters) do - local c = topCasters[i] - local cdStr = c.cooldown and string.format("%dms", math.floor(c.cooldown)) or "-" - table.insert(lines, string.format(" %s: %d spells cd=%s freq=%d/min", - c.name:sub(1, 15), c.spells, cdStr, c.frequency)) + table.sort(casters, function(a, b) return a.spells > b.spells end) + end + if #casters > 0 then + table.insert(lines, " Top casters:") + for i = 1, math.min(5, #casters) do + local c = casters[i] + local cdStr = c.cd and string.format("%dms", math.floor(c.cd)) or "-" + table.insert(lines, string.format(" %-18s %d spells cd=%s", + c.name:sub(1, 18), c.spells, cdStr)) end end end - - -- Scenario Manager Stats (NEW in v2.1 - Anti-Zigzag) - if MonsterAI and MonsterAI.Scenario then - local scn = MonsterAI.Scenario - local scnStats = scn.getStats and scn.getStats() or {} - - local scenarioType = scnStats.currentScenario or "unknown" - local monsterCount = scnStats.monsterCount or 0 - local isZigzag = scnStats.isZigzagging and "YES!" or "No" - local switches = scnStats.consecutiveSwitches or 0 - local clusterType = scnStats.clusterType or "none" - - -- Scenario type with description - local scenarioDesc = "" - if scnStats.config and scnStats.config.description then - scenarioDesc = " (" .. scnStats.config.description .. ")" + + if MonsterAI and MonsterAI.getImmediateThreat then + local ok, t = pcall(MonsterAI.getImmediateThreat) + if ok and t then + table.insert(lines, "") + table.insert(lines, "-- Threat " .. string.rep("-", 37)) + table.insert(lines, string.format(" Status: %s Level: %.1f High-Threat: %d", + t.immediateThreat and "DANGER!" or "Safe", + t.totalThreat or 0, t.highThreatCount or 0)) end - - table.insert(lines, string.format("Scenario: %s%s", scenarioType:upper(), scenarioDesc)) - table.insert(lines, string.format(" Monsters: %d Cluster: %s Zigzag: %s Switches: %d", - monsterCount, clusterType, isZigzag, switches)) - - -- Target lock info - if scnStats.targetLockId then - local lockData = MonsterAI.Tracker and MonsterAI.Tracker.monsters[scnStats.targetLockId] - local lockName = lockData and lockData.name or "Unknown" - local lockHealth = lockData and lockData.creature and lockData.creature:getHealthPercent() or 0 - table.insert(lines, string.format(" Target Lock: %s (%d%% HP)", lockName, lockHealth)) + end + + return table.concat(lines, "\n") +end + +local function buildScenarioTab() + local lines = {} + + if MonsterAI and MonsterAI.Scenario then + local ok, sc = pcall(function() + return MonsterAI.Scenario.getStats and MonsterAI.Scenario.getStats() or {} + end) + sc = ok and sc or {} + local cfg = sc.config or {} + table.insert(lines, "-- Scenario " .. string.rep("-", 35)) + local desc = cfg.description and (" (" .. cfg.description .. ")") or "" + table.insert(lines, string.format(" Type: %s%s", + (sc.currentScenario or "unknown"):upper(), desc)) + table.insert(lines, string.format(" Monsters: %d Cluster: %s Zigzag: %s Switches: %d", + sc.monsterCount or 0, + sc.clusterType or "none", + sc.isZigzagging and "YES!" or "No", + sc.consecutiveSwitches or 0)) + if sc.targetLockId then + local ld = MonsterAI.Tracker and MonsterAI.Tracker.monsters and MonsterAI.Tracker.monsters[sc.targetLockId] + local lname = ld and ld.name or "Unknown" + table.insert(lines, string.format(" Target Lock: %s", lname)) end - - -- Anti-zigzag status - local cfg = scnStats.config or {} if cfg.switchCooldownMs then - table.insert(lines, string.format(" Anti-Zigzag: Cooldown=%dms Stickiness=%d MaxSwitches/min=%s", - cfg.switchCooldownMs, - cfg.targetStickiness or 0, - cfg.maxSwitchesPerMinute and tostring(cfg.maxSwitchesPerMinute) or "∞")) + table.insert(lines, string.format(" Anti-Zigzag: Cooldown=%dms Stickiness=%d", + cfg.switchCooldownMs, cfg.targetStickiness or 0)) end end - - -- Volume Adaptation Stats (NEW in v2.2 - Dynamic reactivity) - if MonsterAI and MonsterAI.VolumeAdaptation then - local va = MonsterAI.VolumeAdaptation - local vaStats = va.getStats and va.getStats() or {} - local params = vaStats.params or {} - local metrics = vaStats.metrics or {} - - local volumeDisplay = (vaStats.currentVolume or "normal"):upper() - local desc = params.description or "" - - table.insert(lines, string.format("VolumeAdaptation: %s", volumeDisplay)) - if desc ~= "" then - table.insert(lines, string.format(" Mode: %s", desc)) - end - table.insert(lines, string.format(" Telemetry=%dms CacheTTL=%dms EWMA=%.2f", - params.telemetryInterval or 200, - params.threatCacheTTL or 100, - params.ewmaAlpha or 0.25 - )) - table.insert(lines, string.format(" Avg Monsters=%.1f Peak=%d Adaptations=%d Saved=%d", - metrics.avgMonsterCount or 0, - metrics.peakMonsterCount or 0, - metrics.volumeChanges or 0, - metrics.adaptationsSaved or 0 - )) - end - - -- Reachability Stats (NEW in v2.1 - Prevents "Creature not reachable") + if MonsterAI and MonsterAI.Reachability then - local reach = MonsterAI.Reachability - local reachStats = reach.getStats and reach.getStats() or {} - - local blockedCount = reachStats.blockedCount or 0 - local checksPerformed = reachStats.checksPerformed or 0 - local cacheHits = reachStats.cacheHits or 0 - local reachableCount = reachStats.reachable or 0 - local blockedTotal = reachStats.blocked or 0 - - local hitRate = checksPerformed > 0 and (cacheHits / (checksPerformed + cacheHits)) * 100 or 0 - - table.insert(lines, string.format("Reachability: Checks=%d CacheHit=%.0f%% Blocked=%d Reachable=%d", - checksPerformed, hitRate, blockedTotal, reachableCount)) - - -- Show blocked reasons breakdown - if reachStats.byReason then - local reasons = reachStats.byReason - if (reasons.no_path or 0) > 0 or (reasons.blocked_tile or 0) > 0 then - table.insert(lines, string.format(" Blocked: NoPath=%d Tile=%d Elevation=%d TooFar=%d", - reasons.no_path or 0, - reasons.blocked_tile or 0, - reasons.elevation or 0, - reasons.too_far or 0)) + local ok, rs = pcall(function() + return MonsterAI.Reachability.getStats and MonsterAI.Reachability.getStats() or {} + end) + rs = ok and rs or {} + table.insert(lines, "") + table.insert(lines, "-- Reachability " .. string.rep("-", 31)) + local hitRate = (rs.checksPerformed or 0) > 0 + and (rs.cacheHits or 0) / ((rs.checksPerformed or 0) + (rs.cacheHits or 0)) * 100 or 0 + table.insert(lines, string.format(" Checks: %d Cache Hit: %.0f%% Blocked: %d Reachable: %d", + rs.checksPerformed or 0, hitRate, rs.blocked or 0, rs.reachable or 0)) + if rs.byReason then + local r = rs.byReason + if (r.no_path or 0) > 0 or (r.blocked_tile or 0) > 0 then + table.insert(lines, string.format(" NoPath: %d Tile: %d Elevation: %d TooFar: %d", + r.no_path or 0, r.blocked_tile or 0, r.elevation or 0, r.too_far or 0)) end end - - -- Show currently blocked creatures - if blockedCount > 0 then - table.insert(lines, string.format(" Currently Blocked: %d creatures (cooldown active)", blockedCount)) - end end - - -- TargetBot Integration Stats (NEW in v2.0) - if MonsterAI and MonsterAI.TargetBot then - local tbi = MonsterAI.TargetBot - local tbiStats = tbi.getStats and tbi.getStats() or {} - - local status = "Active" - if tbiStats.feedbackActive and tbiStats.trackerActive and tbiStats.realTimeActive then - status = "Full Integration" - elseif tbiStats.trackerActive then - status = "Partial Integration" - end - - table.insert(lines, string.format("TargetBot Integration: %s", status)) - - -- Show danger level - if tbi.getDangerLevel then - local dangerLevel, threats = tbi.getDangerLevel() - local threatCount = #threats - table.insert(lines, string.format(" Danger Level: %.1f/10 Active Threats: %d", dangerLevel, threatCount)) - - -- List top 3 threats - for i = 1, math.min(3, threatCount) do - local t = threats[i] - local imminentStr = t.imminent and " [IMMINENT]" or "" - table.insert(lines, string.format(" %d. %s (level %.1f)%s", i, t.name, t.level, imminentStr)) - end - end - end - - table.insert(lines, "") - - -- Show Classifications section (new in v2.0) - if MonsterAI and MonsterAI.Classifier and MonsterAI.Classifier.cache then - local classCount = 0 - for _ in pairs(MonsterAI.Classifier.cache) do classCount = classCount + 1 end - - if classCount > 0 then - table.insert(lines, "Classifications:") - table.insert(lines, string.format(" %-18s %6s %6s %8s %6s %6s", "name", "danger", "conf", "type", "dist", "cd")) - - -- Sort by confidence - local classItems = {} - for name, c in pairs(MonsterAI.Classifier.cache) do - table.insert(classItems, {name = name, class = c}) - end - table.sort(classItems, function(a, b) return (a.class.confidence or 0) > (b.class.confidence or 0) end) - - for i = 1, math.min(#classItems, 10) do - local item = classItems[i] - local c = item.class - local typeStr = "" - if c.isRanged then typeStr = "Ranged" - elseif c.isMelee then typeStr = "Melee" end - if c.isWaveAttacker then typeStr = typeStr .. "+Wave" end - if c.isFast then typeStr = typeStr .. "+Fast" end - - table.insert(lines, string.format(" %-18s %6d %6.2f %8s %6d %6s", - item.name:sub(1, 18), - c.estimatedDanger or 0, - c.confidence or 0, - typeStr:sub(1, 8), - c.preferredDistance or 0, - c.attackCooldown and string.format("%dms", math.floor(c.attackCooldown)) or "-" - )) - end + + if MonsterAI and MonsterAI.TargetBot and MonsterAI.TargetBot.getDangerLevel then + local ok, danger, threats = pcall(MonsterAI.TargetBot.getDangerLevel) + if ok and danger then + threats = threats or {} table.insert(lines, "") - end - end - - -- Show Pending Suggestions (new in v2.0) - if MonsterAI and MonsterAI.AutoTuner and MonsterAI.AutoTuner.suggestions then - local hasSignificantSuggestions = false - for name, s in pairs(MonsterAI.AutoTuner.suggestions) do - if math.abs((s.suggestedDanger or 0) - (s.currentDanger or 0)) >= 1 then - hasSignificantSuggestions = true - break - end - end - - if hasSignificantSuggestions then - table.insert(lines, "Danger Suggestions:") - for name, s in pairs(MonsterAI.AutoTuner.suggestions) do - local change = (s.suggestedDanger or 0) - (s.currentDanger or 0) - if math.abs(change) >= 1 then - local changeStr = change > 0 and "+" .. tostring(change) or tostring(change) - table.insert(lines, string.format(" %s: %d -> %d (%s) [%.0f%% conf]", - name, - s.currentDanger or 0, - s.suggestedDanger or 0, - changeStr, - (s.confidence or 0) * 100 - )) - if s.reasons and #s.reasons > 0 then - table.insert(lines, " Reasons: " .. table.concat(s.reasons, ", ")) - end - end + table.insert(lines, "-- Danger " .. string.rep("-", 37)) + table.insert(lines, string.format(" Level: %.1f/10 Active Threats: %d", danger, #threats)) + for i = 1, math.min(5, #threats) do + local t = threats[i] + table.insert(lines, string.format(" %d. %s (%.1f)%s", + i, t.name, t.level, t.imminent and " [IMMINENT]" or "")) end - table.insert(lines, "") end end - - table.insert(lines, "Patterns:") - local patterns = safeUnifiedGet("targetbot.monsterPatterns", {}) - if isTableEmpty(patterns) then - -- If no persisted patterns, try to show live tracking info (useful while hunting) - local live = (MonsterAI and MonsterAI.Tracker and MonsterAI.Tracker.monsters) or {} - local liveCount = 0 - for _ in pairs(live) do liveCount = liveCount + 1 end - - if liveCount == 0 then - table.insert(lines, " None") - else - table.insert(lines, string.format(" (Live tracking: %d monsters)", liveCount)) - -- Header (columns) - added facing column - table.insert(lines, string.format(" %-18s %6s %5s %6s %6s %7s %6s %6s", "name","samps","conf","cd","dps","missiles","spd","facing")) - - -- show up to 20 tracked monsters sorted by confidence (descending) - local tbl = {} - for id, d in pairs(live) do - local name = d.name or "unknown" - local samples = d.samples and #d.samples or 0 - local conf = d.confidence or 0 - local cooldown = d.ewmaCooldown or d.predictedWaveCooldown or "-" - -- Check if facing player from RealTime data - local facing = false - if MonsterAI and MonsterAI.RealTime and MonsterAI.RealTime.directions[id] then - local rt = MonsterAI.RealTime.directions[id] - facing = rt.facingPlayerSince ~= nil - end - table.insert(tbl, { id = id, name = name, samples = samples, conf = conf, cooldown = cooldown, facing = facing }) - end - table.sort(tbl, function(a, b) return (a.conf or 0) > (b.conf or 0) end) - for i = 1, math.min(#tbl, 20) do - local e = tbl[i] - local confs = e.conf and string.format("%.2f", e.conf) or "-" - local cd = (type(e.cooldown) == 'number' and string.format("%dms", math.floor(e.cooldown))) or tostring(e.cooldown) - local d = MonsterAI and MonsterAI.Tracker and MonsterAI.Tracker.monsters and MonsterAI.Tracker.monsters[e.id] or {} - local dps = MonsterAI and MonsterAI.Tracker and MonsterAI.Tracker.getDPS and MonsterAI.Tracker.getDPS(e.id) or 0 - local missiles = d.missileCount or 0 - local spd = d.avgSpeed or 0 - local facingStr = e.facing and "YES" or "no" - table.insert(lines, string.format(" %-18s %6d %5s %6s %6.2f %7d %6.2f %6s", e.name, e.samples, confs, cd, (dps or 0), missiles, spd, facingStr)) - end - table.insert(lines, " (Note: live tracker data and patterns persist after observed attacks)") - end - else - for name, p in pairs(patterns) do - local cooldown = p and p.waveCooldown and string.format("%dms", math.floor(p.waveCooldown)) or "-" - local variance = p and p.waveVariance and string.format("%.1f", p.waveVariance) or "-" - local conf = p and p.confidence and string.format("%.2f", p.confidence) or "-" - local last = p and p.lastSeen and fmtTime(p.lastSeen) or "-" - table.insert(lines, string.format(" %s cd:%s var:%s conf:%s last:%s", name, cooldown, variance, conf, last)) - end + if isTableEmpty(lines) then + table.insert(lines, " No scenario data available.") end + return table.concat(lines, "\n") end -function refreshPatterns() - if not MonsterInspectorWindow or not MonsterInspectorWindow:isVisible() then return end +-- ── Builders dispatch ───────────────────────────────────────────────────────── - -- Ensure we have the latest widget refs; try again if not bound - if not MonsterInspectorWindow.content or not MonsterInspectorWindow.content.textContent then - updateWidgetRefs() - end +local BUILDERS = { + buildLiveTab, + buildPatternsTab, + buildStatsTab, + buildScenarioTab, +} - if not MonsterInspectorWindow.content or not MonsterInspectorWindow.content.textContent then - warn("[MonsterInspector] refreshPatterns: textContent widget missing after updateWidgetRefs; aborting refresh.") - -- Diagnostic dump to help root-cause: storage and tracker stats - local count = 0 - local patterns = safeUnifiedGet("targetbot.monsterPatterns", {}) - for _ in pairs(patterns) do count = count + 1 end - print(string.format("[MonsterInspector][DIAG] monsterPatterns count=%d", count)) - if MonsterAI and MonsterAI.Tracker and MonsterAI.Tracker.stats then - local s = MonsterAI.Tracker.stats - print(string.format("[MonsterInspector][DIAG] MonsterAI stats: damage=%d waves=%d area=%d", s.totalDamageReceived or 0, s.waveAttacksObserved or 0, s.areaAttacksObserved or 0)) - end - return - end +-- ── Live update loop ────────────────────────────────────────────────────────── - if refreshInProgress then return end +local refreshActiveTab -- forward declaration; defined below - -- Throttle frequent calls - if now and (now - lastRefreshMs) < MIN_REFRESH_MS then +local function doLiveUpdate() + if not liveUpdateActive then return end + if not MonsterInspectorWindow or not MonsterInspectorWindow:isVisible() then + liveUpdateActive = false return end + refreshInProgress = false + refreshActiveTab() + schedule(3000, doLiveUpdate) +end + +local function startLiveUpdate() + if liveUpdateActive then return end + liveUpdateActive = true + schedule(3000, doLiveUpdate) +end + +local function stopLiveUpdate() + liveUpdateActive = false +end + +-- ── Refresh ─────────────────────────────────────────────────────────────────── + +refreshActiveTab = function() + if not MonsterInspectorWindow or not MonsterInspectorWindow:isVisible() then return end + if refreshInProgress then return end + if now and (now - lastRefreshMs) < MIN_REFRESH_MS then return end refreshInProgress = true - lastRefreshMs = now + if now then lastRefreshMs = now end - -- Set the content text (simplified like Hunt Analyzer) - MonsterInspectorWindow.content.textContent:setText(buildSummary()) + local panel = tabPanels[activeTab] + if panel then + local textLabel = findChild(panel, "text") + if textLabel then + local ok, txt = pcall(BUILDERS[activeTab]) + pcall(function() textLabel:setText(ok and txt or ("Error: " .. tostring(txt))) end) + end + end refreshInProgress = false end --- Export all patterns to clipboard as CSV-like text -local function exportPatterns() - local lines = {} - table.insert(lines, "name,cooldown_ms,variance,confidence,last_seen") - local patterns = safeUnifiedGet("targetbot.monsterPatterns", {}) - for name, p in pairs(patterns) do - local cd = p.waveCooldown and tostring(math.floor(p.waveCooldown)) or "" - local var = p.waveVariance and tostring(p.waveVariance) or "" - local conf = p.confidence and tostring(p.confidence) or "" - local last = p.lastSeen and tostring(math.floor(p.lastSeen / 1000)) or "" - table.insert(lines, string.format('%s,%s,%s,%s,%s', name, cd, var, conf, last)) - end - local out = table.concat(lines, "\n") - if g_window and g_window.setClipboardText then - g_window.setClipboardText(out) - print("[MonsterInspector] Patterns exported to clipboard") - end +-- Public alias kept for backward compatibility with external callers +function refreshPatterns() + refreshActiveTab() end --- Clear persisted patterns and in-memory knownMonsters -local function clearPatterns() - if UnifiedStorage then - UnifiedStorage.set("targetbot.monsterPatterns", {}) - end - if MonsterAI and MonsterAI.Patterns and MonsterAI.Patterns.knownMonsters then - MonsterAI.Patterns.knownMonsters = {} - end - refreshPatterns() - print("[MonsterInspector] Cleared stored monster patterns") -end +-- ── Window lifecycle ────────────────────────────────────────────────────────── + +local function bindButtons(win) + if not win then return end + local buttons = findChild(win, "buttons") + if not buttons then return end + + local refreshBtn = findChild(buttons, "refresh") + local clearBtn = findChild(buttons, "clear") + local closeBtn = findChild(buttons, "close") --- Buttons - use direct property access (standard OTClient pattern) -local function bindInspectorButtons() - if not MonsterInspectorWindow then return end - - -- Access buttons panel directly as property (standard OTClient widget hierarchy) - local buttonsPanel = MonsterInspectorWindow.buttons - - if not buttonsPanel then - -- Fallback: try getChildById if direct access fails - pcall(function() buttonsPanel = MonsterInspectorWindow:getChildById("buttons") end) - end - - if not buttonsPanel then - warn("[MonsterInspector] Could not find buttons panel - window may not be fully loaded") - return - end - - -- Access buttons directly as properties (OTClient creates child widgets as properties) - local refreshBtn = buttonsPanel.refresh - local clearBtn = buttonsPanel.clear - local closeBtn = buttonsPanel.close - - -- Fallback to getChildById if direct access returns nil - if not refreshBtn then - pcall(function() refreshBtn = buttonsPanel:getChildById("refresh") end) - end - if not clearBtn then - pcall(function() clearBtn = buttonsPanel:getChildById("clear") end) - end - if not closeBtn then - pcall(function() closeBtn = buttonsPanel:getChildById("close") end) - end - - -- Bind click handlers if refreshBtn then - refreshBtn.onClick = function() - if MONSTER_INSPECTOR_DEBUG then print("[MonsterInspector] Refresh button clicked") end - refreshPatterns() + refreshBtn.onClick = function() + refreshInProgress = false + lastRefreshMs = 0 -- bypass throttle on manual refresh + refreshActiveTab() end - if MONSTER_INSPECTOR_DEBUG then print("[MonsterInspector] Bound refresh button") end - else - warn("[MonsterInspector] Could not find refresh button") end - + if clearBtn then clearBtn.onClick = function() - if MONSTER_INSPECTOR_DEBUG then print("[MonsterInspector] Clear button clicked") end - clearPatterns() + if UnifiedStorage then UnifiedStorage.set("targetbot.monsterPatterns", {}) end + if MonsterAI and MonsterAI.Patterns then MonsterAI.Patterns.knownMonsters = {} end + refreshInProgress = false + refreshActiveTab() + print("[MonsterInspector] Cleared stored monster patterns") end - if MONSTER_INSPECTOR_DEBUG then print("[MonsterInspector] Bound clear button") end - else - warn("[MonsterInspector] Could not find clear button") end - + if closeBtn then - closeBtn.onClick = function() MonsterInspectorWindow:hide() end - if MONSTER_INSPECTOR_DEBUG then print("[MonsterInspector] Bound close button") end - else - warn("[MonsterInspector] Could not find close button") + closeBtn.onClick = function() win:hide() end + end + + local tabBar = findChild(win, "tabBar") + if tabBar then + for i = 1, 4 do + local btn = findChild(tabBar, "tab" .. i .. "btn") + if btn then + local idx = i + btn.onClick = function() + switchTab(idx) + refreshInProgress = false + refreshActiveTab() + end + end + end end - -- Auto-refresh while visible (guarded to avoid duplicate schedule chains) - MonsterInspectorWindow.onVisibilityChange = function(widget, visible) + win.onVisibilityChange = function(widget, visible) if visible then - -- re-resolve widgets in case UI was reloaded or nested updateWidgetRefs() - -- Rebind buttons when window becomes visible (in case they weren't bound initially) - if not buttonsPanel or not buttonsPanel.refresh then - bindInspectorButtons() - end - refreshPatterns() + switchTab(activeTab) + refreshInProgress = false + lastRefreshMs = 0 + refreshActiveTab() + startLiveUpdate() + else + stopLiveUpdate() end end end --- Bind buttons on load -bindInspectorButtons() +local function createWindowIfMissing() + if MonsterInspectorWindow and MonsterInspectorWindow:isVisible() then + return MonsterInspectorWindow + end + tryImportStyle() + local ok, win = pcall(function() return UI.createWindow("MonsterInspectorWindow") end) + if not ok or not win then + warn("[MonsterInspector] Failed to create MonsterInspectorWindow") + MonsterInspectorWindow = nil + return nil + end + MonsterInspectorWindow = win + pcall(function() MonsterInspectorWindow:hide() end) + pcall(function() updateWidgetRefs() end) + pcall(function() bindButtons(win) end) + pcall(function() switchTab(1) end) + return MonsterInspectorWindow +end + +createWindowIfMissing() +updateWidgetRefs() +if MonsterInspectorWindow then + pcall(function() bindButtons(MonsterInspectorWindow) end) +end + +-- ── Public API ──────────────────────────────────────────────────────────────── --- Initialize (load current data) -refreshPatterns() +nExBot.MonsterInspector.refresh = refreshActiveTab +nExBot.MonsterInspector.rebindButtons = function() bindButtons(MonsterInspectorWindow) end +nExBot.MonsterInspector.refreshPatterns = refreshPatterns -nExBot.MonsterInspector = { - refresh = refreshPatterns, - clear = clearPatterns, - rebindButtons = bindInspectorButtons -} +nExBot.MonsterInspector.clear = function() + if UnifiedStorage then UnifiedStorage.set("targetbot.monsterPatterns", {}) end + if MonsterAI and MonsterAI.Patterns then MonsterAI.Patterns.knownMonsters = {} end + refreshInProgress = false + refreshActiveTab() +end --- Convenience helpers to show/toggle the inspector from console or other modules nExBot.MonsterInspector.showWindow = function() - if not MonsterInspectorWindow then - createWindowIfMissing() - end + if not MonsterInspectorWindow then createWindowIfMissing() end if MonsterInspectorWindow then MonsterInspectorWindow:show() updateWidgetRefs() - - -- Ensure tracker runs to populate initial samples (no console required) - if MonsterAI and MonsterAI.updateAll then pcall(function() MonsterAI.updateAll() end) end - refreshPatterns() - - -- 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 - if not hasPatterns then - schedule(500, function() - if MonsterAI and MonsterAI.updateAll then pcall(function() MonsterAI.updateAll() end) end - refreshPatterns() - end) - end + switchTab(activeTab) + if MonsterAI and MonsterAI.updateAll then pcall(MonsterAI.updateAll) end + refreshInProgress = false + refreshActiveTab() end end nExBot.MonsterInspector.toggleWindow = function() - if not MonsterInspectorWindow then - createWindowIfMissing() - end + if not MonsterInspectorWindow then createWindowIfMissing() end if MonsterInspectorWindow then if MonsterInspectorWindow:isVisible() then MonsterInspectorWindow:hide() else - MonsterInspectorWindow:show() - updateWidgetRefs() - if MonsterAI and MonsterAI.updateAll then pcall(function() MonsterAI.updateAll() end) end - refreshPatterns() - -- Retry shortly if no patterns yet - local patterns2 = safeUnifiedGet("targetbot.monsterPatterns", {}) - if not (patterns2 and next(patterns2) ~= nil) then - schedule(500, function() if MonsterAI and MonsterAI.updateAll then pcall(function() MonsterAI.updateAll() end) end; refreshPatterns() end) - end + nExBot.MonsterInspector.showWindow() end end end --- Expose refreshPatterns function -nExBot.MonsterInspector.refreshPatterns = refreshPatterns - - +-- EventBus: auto-refresh on MonsterAI state changes +if EventBus and EventBus.on then + EventBus.on("monsterai:state_updated", function() + if MonsterInspectorWindow and MonsterInspectorWindow:isVisible() then + refreshActiveTab() + end + end, 0) +end diff --git a/targetbot/monster_inspector.otui b/targetbot/monster_inspector.otui index 2fca879..e9d729e 100644 --- a/targetbot/monster_inspector.otui +++ b/targetbot/monster_inspector.otui @@ -1,12 +1,55 @@ MonsterInspectorWindow < NxWindow text: Monster Insights - width: 520 - height: 480 + width: 560 + height: 500 @onEscape: self:hide() - VerticalScrollBar - id: contentScroll + Panel + id: tabBar anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 26 + background-color: #0b0f1e + + NxButton + id: tab1btn + text: Live Monsters + anchors.top: parent.top + anchors.left: parent.left + anchors.bottom: parent.bottom + width: 130 + + NxButton + id: tab2btn + text: Patterns + anchors.top: parent.top + anchors.left: tab1btn.right + anchors.bottom: parent.bottom + width: 110 + margin-left: 2 + + NxButton + id: tab3btn + text: Combat Stats + anchors.top: parent.top + anchors.left: tab2btn.right + anchors.bottom: parent.bottom + width: 100 + margin-left: 2 + + NxButton + id: tab4btn + text: Scenario + anchors.top: parent.top + anchors.left: tab3btn.right + anchors.bottom: parent.bottom + width: 100 + margin-left: 2 + + VerticalScrollBar + id: tab1Scroll + anchors.top: tabBar.bottom anchors.bottom: buttons.top anchors.right: parent.right margin-top: 4 @@ -15,18 +58,111 @@ MonsterInspectorWindow < NxWindow pixels-scroll: true ScrollablePanel - id: content - anchors.top: parent.top + id: tab1 + anchors.top: tabBar.bottom + anchors.left: parent.left + anchors.right: tab1Scroll.left + anchors.bottom: buttons.top + margin-top: 4 + margin-bottom: 8 + margin-right: 4 + vertical-scrollbar: tab1Scroll + + NxLabel + id: text + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + text-wrap: true + text-auto-resize: true + font: verdana-11px-monochrome + color: #f5f7ff + + VerticalScrollBar + id: tab2Scroll + anchors.top: tabBar.bottom + anchors.bottom: buttons.top + anchors.right: parent.right + margin-top: 4 + margin-bottom: 8 + step: 24 + pixels-scroll: true + + ScrollablePanel + id: tab2 + anchors.top: tabBar.bottom + anchors.left: parent.left + anchors.right: tab2Scroll.left + anchors.bottom: buttons.top + margin-top: 4 + margin-bottom: 8 + margin-right: 4 + vertical-scrollbar: tab2Scroll + + NxLabel + id: text + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + text-wrap: true + text-auto-resize: true + font: verdana-11px-monochrome + color: #f5f7ff + + VerticalScrollBar + id: tab3Scroll + anchors.top: tabBar.bottom + anchors.bottom: buttons.top + anchors.right: parent.right + margin-top: 4 + margin-bottom: 8 + step: 24 + pixels-scroll: true + + ScrollablePanel + id: tab3 + anchors.top: tabBar.bottom + anchors.left: parent.left + anchors.right: tab3Scroll.left + anchors.bottom: buttons.top + margin-top: 4 + margin-bottom: 8 + margin-right: 4 + vertical-scrollbar: tab3Scroll + + NxLabel + id: text + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + text-wrap: true + text-auto-resize: true + font: verdana-11px-monochrome + color: #f5f7ff + + VerticalScrollBar + id: tab4Scroll + anchors.top: tabBar.bottom + anchors.bottom: buttons.top + anchors.right: parent.right + margin-top: 4 + margin-bottom: 8 + step: 24 + pixels-scroll: true + + ScrollablePanel + id: tab4 + anchors.top: tabBar.bottom anchors.left: parent.left - anchors.right: contentScroll.left + anchors.right: tab4Scroll.left anchors.bottom: buttons.top margin-top: 4 margin-bottom: 8 margin-right: 4 - vertical-scrollbar: contentScroll + vertical-scrollbar: tab4Scroll NxLabel - id: textContent + id: text anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right @@ -45,7 +181,6 @@ MonsterInspectorWindow < NxWindow NxButton id: refresh text: Refresh - !tooltip: tr('Refresh monster data') anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter width: 80 @@ -53,7 +188,6 @@ MonsterInspectorWindow < NxWindow NxButton id: clear text: Clear Patterns - !tooltip: tr('Clear all learned patterns') anchors.left: refresh.right anchors.verticalCenter: parent.verticalCenter width: 120 @@ -62,7 +196,6 @@ MonsterInspectorWindow < NxWindow NxButton id: close text: Close - !tooltip: tr('Close this window') anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: 80 diff --git a/targetbot/monster_patterns.lua b/targetbot/monster_patterns.lua index 51d8beb..8c8aba4 100644 --- a/targetbot/monster_patterns.lua +++ b/targetbot/monster_patterns.lua @@ -81,8 +81,9 @@ end -- Persist partial updates to a known monster pattern -- Also runs decay at persist-time for patterns older than 7 days function MonsterAI.Patterns.persist(monsterName, updates) - if not monsterName then return end + if not monsterName or monsterName == "" then return end local name = monsterName:lower() + if name == "" or name == "unknown" then return end MonsterAI.Patterns.knownMonsters[name] = MonsterAI.Patterns.knownMonsters[name] or {} for k, v in pairs(updates) do MonsterAI.Patterns.knownMonsters[name][k] = v diff --git a/targetbot/monster_reachability.lua b/targetbot/monster_reachability.lua index f231f7e..1cc6761 100644 --- a/targetbot/monster_reachability.lua +++ b/targetbot/monster_reachability.lua @@ -32,7 +32,9 @@ local R = MonsterAI.Reachability R.cache = {} R.cacheTime = {} R.CACHE_TTL = 1500 -R.BLOCKED_COOLDOWN = 5000 +R.BLOCKED_COOLDOWN_STATIC = 15000 -- Never-reachable: 15s (wall-blocked from first check) +R.BLOCKED_COOLDOWN_DYNAMIC = 5000 -- Previously-reachable: 5s (walked behind wall) +R.BLOCKED_COOLDOWN_MAX = 30000 -- Cap for escalating cooldown R.blockedCreatures = {} R.stats = { @@ -66,9 +68,12 @@ function R.isReachable(creature, forceRecheck) return cr.reachable, cr.reason, cr.path end local bl = R.blockedCreatures[id] - if bl and (nowt - bl.blockedTime) < R.BLOCKED_COOLDOWN then - if bl.attempts < 3 then bl.attempts = bl.attempts + 1 - else R.stats.cacheHits = R.stats.cacheHits + 1; return false, bl.reason, nil end + if bl then + local cooldown = bl.cooldown or (bl.wasEverReachable and R.BLOCKED_COOLDOWN_DYNAMIC or R.BLOCKED_COOLDOWN_STATIC) + if (nowt - bl.blockedTime) < cooldown then + if bl.attempts < 3 then bl.attempts = bl.attempts + 1 + else R.stats.cacheHits = R.stats.cacheHits + 1; return false, bl.reason, nil end + end end end @@ -143,7 +148,17 @@ function R.isReachable(creature, forceRecheck) if ok2 then hasLOS = los end end + -- LoS check is soft: path exists so melee can still reach (around corners) + -- Note: no_path creatures never reach here (bailed out above), so the + -- no_path + no_los hard-block is naturally enforced. + if not hasLOS then + R.stats.byReason.no_los = (R.stats.byReason.no_los or 0) + 1 + end + R.stats.reachable = R.stats.reachable + 1 + -- Mark as ever-reachable for dynamic cooldown + local existing = R.blockedCreatures[id] + if existing then existing.wasEverReachable = true end R.clearBlocked(id) return R.cacheResult(id, true, hasLOS and "clear" or "no_los_melee_ok", result) end @@ -161,8 +176,21 @@ end function R.markBlocked(id, reason) local e = R.blockedCreatures[id] - if e then e.attempts = e.attempts + 1; e.reason = reason - else R.blockedCreatures[id] = { blockedTime = nowMs(), attempts = 1, reason = reason } end + if e then + e.attempts = e.attempts + 1 + e.reason = reason + -- Escalate cooldown on repeated blocks (doubled, capped) + if e.attempts > 1 then + local baseCooldown = e.wasEverReachable and R.BLOCKED_COOLDOWN_DYNAMIC or R.BLOCKED_COOLDOWN_STATIC + e.cooldown = math.min(baseCooldown * e.attempts, R.BLOCKED_COOLDOWN_MAX) + end + else + R.blockedCreatures[id] = { + blockedTime = nowMs(), attempts = 1, reason = reason, + wasEverReachable = false, + cooldown = R.BLOCKED_COOLDOWN_STATIC -- Default to static until proven reachable + } + end end function R.clearBlocked(id) R.blockedCreatures[id] = nil end @@ -170,7 +198,7 @@ function R.clearCache() R.cache = {}; R.cacheTime = {} end function R.cleanup() local nowt = nowMs() - local expiry = R.BLOCKED_COOLDOWN * 2 + local expiry = R.BLOCKED_COOLDOWN_MAX * 2 for id, d in pairs(R.blockedCreatures) do if (nowt - d.blockedTime) > expiry then R.blockedCreatures[id] = nil end end @@ -197,7 +225,8 @@ function R.getCachedPath(cid) local c = R.cache[cid]; return c and c.path or nil function R.isBlocked(cid) local b = R.blockedCreatures[cid] if not b then return false end - if (nowMs() - b.blockedTime) > R.BLOCKED_COOLDOWN then R.blockedCreatures[cid] = nil; return false end + local cooldown = b.cooldown or (b.wasEverReachable and R.BLOCKED_COOLDOWN_DYNAMIC or R.BLOCKED_COOLDOWN_STATIC) + if (nowMs() - b.blockedTime) > cooldown then R.blockedCreatures[cid] = nil; return false end return true, b.reason, b.attempts end diff --git a/targetbot/monster_tbi.lua b/targetbot/monster_tbi.lua index 5d37423..d691c28 100644 --- a/targetbot/monster_tbi.lua +++ b/targetbot/monster_tbi.lua @@ -1,202 +1,55 @@ --[[ - Monster TargetBot Integration (TBI) Module v3.0 - - Single Responsibility: Enhanced priority calculation for targeting, - 9-stage scoring, sorted target lists, and danger assessment. - - Depends on: monster_ai_core.lua, monster_tracking.lua, - monster_patterns.lua, monster_prediction.lua, - monster_combat_feedback.lua - Populates: MonsterAI.TargetBot (TBI) + Monster TargetBot Integration (TBI) Module v4.0 + + Single Responsibility: Danger assessment, debug helpers, and EventBus wiring + for the TargetBot subsystem. Priority calculation is fully delegated to + PriorityEngine (single source of truth — see priority_engine.lua). + + REMOVED in v4.0 (consolidated into PriorityEngine): + - TBI.calculatePriority() → PriorityEngine.calculate() + - TBI.getSortedTargets() → PriorityEngine handles per-creature scoring + - TBI.getBestTarget() → use PriorityEngine directly + - schedule() emit loop → no more unconditional targetbot:ai_recommendation flood + + KEPT / REFACTORED: + - TBI.getDangerLevel() → uses PriorityEngine.calculate() for consistency + - TBI.getStats() → subsystem health summary + - TBI.debugCreature() → delegates to PriorityEngine for breakdown + - TBI.isCreatureFacingPosition() / TBI.predictPosition() → pure geometry helpers + + Depends on: monster_ai_core.lua, PriorityEngine (priority_engine.lua) + Populates: MonsterAI.TargetBot (TBI) ]] -local H = MonsterAI._helpers -local nowMs = H.nowMs -local safeGetId = H.safeGetId -local safeIsDead = H.safeIsDead +local H = MonsterAI._helpers +local nowMs = H.nowMs +local safeGetId = H.safeGetId +local safeIsDead = H.safeIsDead +local safeIsRemoved = H.safeIsRemoved +local safeCreatureCall = H.safeCreatureCall +local getClient = H.getClient +local isValidAliveMonster = H.isValidAliveMonster -- 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 +-- STATE -- ============================================================================ MonsterAI.TargetBot = MonsterAI.TargetBot or {} local TBI = MonsterAI.TargetBot -TBI.config = { - baseWeight = 1.0, - distanceWeight = 0.8, - healthWeight = 0.7, - dangerWeight = 1.5, - waveWeight = 2.0, - imminentWeight = 3.0, - imminentThresholdMs = 600, - dangerousCooldownRatio = 0.7, - lowHealthThreshold = 30, - criticalHealthThreshold = 15, - meleeRange = 1, - closeRange = 3, - mediumRange = 6, - fastMonsterThreshold = 250, - slowMonsterThreshold = 100 +-- Default config used when building a minimal config for PriorityEngine calls +TBI._defaultConfig = { + priority = 1, + maxDistance = 8, + chase = false, + danger = 0, } -- ============================================================================ --- PRIORITY CALCULATION (9-STAGE) --- ============================================================================ - -function TBI.calculatePriority(creature, options) - if not creature then return 0, {} end - if safeIsDead(creature) or safeIsRemoved(creature) then return 0, {} end - - options = options or {} - local cfg = TBI.config - local bk = {} - - 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)() - if not ppos or not cpos then return 0, bk end - - local priority = 100 * cfg.baseWeight - bk.base = priority - - -- 1. DISTANCE - local dx = math.abs(cpos.x - ppos.x) - local dy = math.abs(cpos.y - ppos.y) - local dist = math.max(dx, dy) - local ds = 0 - if dist <= cfg.meleeRange then ds = 50 - elseif dist <= cfg.closeRange then ds = 35 - elseif dist <= cfg.mediumRange then ds = 20 - else ds = math.max(0, 15 - (dist - cfg.mediumRange) * 2) end - ds = ds * cfg.distanceWeight; priority = priority + ds; bk.distance = ds - - -- 2. HEALTH - local hp = safeCreatureCall(creature, "getHealthPercent", 100) - local hs = 0 - if hp <= cfg.criticalHealthThreshold then hs = 30 - elseif hp <= cfg.lowHealthThreshold then hs = 20 - elseif hp <= 50 then hs = 10 end - hs = hs * cfg.healthWeight; priority = priority + hs; bk.health = hs - - -- 3. TRACKER DATA - local td = MonsterAI.Tracker and MonsterAI.Tracker.monsters[cid] - local ts = 0 - if td then - local dps = td.ewmaDps or 0 - if dps >= 80 then ts = ts + 40 elseif dps >= 40 then ts = ts + 25 elseif dps >= 20 then ts = ts + 10 end - bk.dps = dps - local hc = td.hitCount or 0 - if hc >= 10 then ts = ts + 15 elseif hc >= 5 then ts = ts + 8 elseif hc >= 2 then ts = ts + 3 end - local rd = td.recentDamage or 0 - if rd > 0 then ts = ts + math.min(30, rd / 5); bk.recentDamage = rd end - if (td.waveCount or 0) >= 3 then ts = ts + 20 elseif (td.waveCount or 0) >= 1 then ts = ts + 10 end - local la = td.lastAttackTime or td.firstSeen or 0 - local tsa = nowMs() - la - if tsa < 2000 then ts = ts + 20 elseif tsa < 5000 then ts = ts + 10 end - end - ts = ts * cfg.dangerWeight; priority = priority + ts; bk.tracker = ts - - -- 4. WAVE PREDICTION - local ws = 0 - if MonsterAI.RealTime and MonsterAI.RealTime.directions then - local rt = MonsterAI.RealTime.directions[cid] - if rt then - local pat = MonsterAI.Patterns and MonsterAI.Patterns.get(cname) or {} - local wCd = pat.waveCooldown or 2000 - local lw = td and (td.lastWaveTime or td.lastAttackTime) or 0 - local el = nowMs() - lw - local rem = math.max(0, wCd - el) - local ratio = el / wCd - if rem <= cfg.imminentThresholdMs and ratio >= cfg.dangerousCooldownRatio then - ws = 60 * cfg.imminentWeight; bk.imminent = true - elseif rem <= 1500 then ws = 40 * cfg.waveWeight - elseif rem <= 2500 then ws = 20 * cfg.waveWeight end - - if rt.dir and ppos then - if TBI.isCreatureFacingPosition(cpos, rt.dir, ppos) then ws = ws + 15; bk.facing = true end - if MonsterAI.Predictor and MonsterAI.Predictor.isPositionInWavePath then - if MonsterAI.Predictor.isPositionInWavePath(ppos, cpos, rt.dir, pat.waveRange or 5, pat.waveWidth or 3) then - ws = ws + 25; bk.inWavePath = true - end - end - end - end - end - priority = priority + ws; bk.wave = ws - - -- 5. CLASSIFICATION - local cs = 0 - if MonsterAI.Classifier then - local cl = MonsterAI.Classifier.get(cname) - if cl then - if cl.dangerLevel == "critical" then cs = 50 - elseif cl.dangerLevel == "high" then cs = 30 - elseif cl.dangerLevel == "medium" then cs = 15 end - if cl.isWaveCaster then cs = cs + 20 end - if cl.isRanged then cs = cs + 10 end - bk.classification = cl.dangerLevel - end - end - priority = priority + cs; bk.class = cs - - -- 6. MOVEMENT / TRAJECTORY - local ms = 0 - local iw = safeCreatureCall(creature, "isWalking", false) - if iw then - local wd = safeCreatureCall(creature, "getWalkDirection", nil) - if wd then - local pp = TBI.predictPosition(cpos, wd, 1) - if pp then - local fd = math.max(math.abs(pp.x - ppos.x), math.abs(pp.y - ppos.y)) - if fd < dist then ms = 15; bk.approaching = true - elseif fd > dist then ms = -5; bk.fleeing = true end - end - end - local spd = safeCreatureCall(creature, "getSpeed", 100) - if spd >= cfg.fastMonsterThreshold then ms = ms + 10; bk.fast = true end - end - priority = priority + ms; bk.movement = ms - - -- 7. ADAPTIVE WEIGHTS (CombatFeedback) - local fs = 0 - if MonsterAI.CombatFeedback and MonsterAI.CombatFeedback.getWeights then - local w = MonsterAI.CombatFeedback.getWeights(cname) - if w then - local am = w.overall or 1.0; priority = priority * am; bk.adaptiveMultiplier = am - if w.wave and w.wave > 1.1 then fs = fs + 15 end - if w.melee and w.melee > 1.1 then fs = fs + 10 end - end - end - priority = priority + fs; bk.feedback = fs - - -- 8. TELEMETRY BONUSES - local tels = 0 - if MonsterAI.Telemetry and MonsterAI.Telemetry.get then - local tel = MonsterAI.Telemetry.get(cid) - if tel then - if (tel.damageVariance or 0) > 50 then tels = tels + 10 end - if (tel.stepConsistency or 0) < 0.5 then tels = tels + 5 end - end - end - priority = priority + tels; bk.telemetry = tels - - -- 9. CLAMP - priority = math.max(0, math.min(1000, priority)) - bk.final = priority - return priority, bk -end - --- ============================================================================ --- HELPERS +-- GEOMETRY HELPERS (pure — unchanged from v3.0) -- ============================================================================ function TBI.isCreatureFacingPosition(cpos, dir, tpos) @@ -222,15 +75,22 @@ function TBI.predictPosition(pos, dir, steps) end -- ============================================================================ --- SORTED TARGETS +-- DANGER LEVEL (delegates scoring to PriorityEngine) -- ============================================================================ -function TBI.getSortedTargets(options) - options = options or {} - local targets = {} +--- Compute overall danger level and active threat list using PriorityEngine. +--- @param maxRange number optional search radius (default 8) +--- @return number (0–10), table threats +function TBI.getDangerLevel(maxRange) + maxRange = maxRange or 8 local ppos = player and player:getPosition() - if not ppos then return targets end - local maxR = options.maxRange or 10 + if not ppos then return 0, {} end + if not (PriorityEngine and PriorityEngine.calculate) then return 0, {} end + + local level = 0 + local threats = {} + local cfg = TBI._defaultConfig + 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 {} @@ -240,36 +100,24 @@ function TBI.getSortedTargets(options) local cp = safeCreatureCall(cr, "getPosition", nil) if cp and cp.z == ppos.z then local d = math.max(math.abs(cp.x - ppos.x), math.abs(cp.y - ppos.y)) - if d <= maxR then - local pri, bk = TBI.calculatePriority(cr, options) - targets[#targets+1] = { creature = cr, priority = pri, distance = d, - breakdown = bk, id = safeGetId(cr), name = safeCreatureCall(cr, "getName", "unknown") } + if d <= maxRange then + local pri = PriorityEngine.calculate(cr, cfg, nil) + local tl = pri / 200 + level = level + tl + if tl >= 1.0 then + local id = safeGetId(cr) + local td = MonsterAI.Tracker and id and MonsterAI.Tracker.monsters[id] + threats[#threats+1] = { + name = safeCreatureCall(cr, "getName", "unknown"), + level = tl, + imminent = td and td.wavePredicted or false, + } + end end end end end - table.sort(targets, function(a,b) return a.priority > b.priority end) - return targets -end - -function TBI.getBestTarget(options) - local t = TBI.getSortedTargets(options) - return t[1] -end --- ============================================================================ --- DANGER LEVEL --- ============================================================================ - -function TBI.getDangerLevel() - local ppos = player and player:getPosition() - if not ppos then return 0, {} end - local level, threats = 0, {} - for _, t in ipairs(TBI.getSortedTargets({maxRange = 8})) do - local tl = t.priority / 200 - level = level + tl - if tl >= 1.0 then threats[#threats+1] = { name = t.name, level = tl, imminent = t.breakdown and t.breakdown.imminent } end - end return math.min(10, level), threats end @@ -278,22 +126,52 @@ end -- ============================================================================ function TBI.getStats() - local s = { config = TBI.config, + return { feedbackActive = MonsterAI.CombatFeedback ~= nil, - trackerActive = MonsterAI.Tracker ~= nil, - realTimeActive = MonsterAI.RealTime ~= nil } - if MonsterAI.CombatFeedback and MonsterAI.CombatFeedback.getStats then - s.feedback = MonsterAI.CombatFeedback.getStats() - end - return s + trackerActive = MonsterAI.Tracker ~= nil, + realTimeActive = MonsterAI.RealTime ~= nil, + priorityEngine = PriorityEngine ~= nil, + feedback = MonsterAI.CombatFeedback and MonsterAI.CombatFeedback.getStats + and MonsterAI.CombatFeedback.getStats() or nil, + } end +--- Print a full PriorityEngine breakdown for a specific creature to console. function TBI.debugCreature(creature) if not creature then print("[TBI] No creature specified"); return end - local pri, bk = TBI.calculatePriority(creature) - print("[TBI] Priority breakdown for " .. (creature:getName() or "unknown") .. ":") - print(" Final Priority: " .. pri) - for k, v in pairs(bk) do print(" " .. k .. ": " .. tostring(v)) end + if not (PriorityEngine and PriorityEngine.calculate) then + print("[TBI] PriorityEngine not loaded"); return + end + local cfg = TBI._defaultConfig + -- Build a minimal path estimate using Chebyshev distance + local ppos = player and player:getPosition() + local cpos = safeCreatureCall(creature, "getPosition", nil) + local path = nil + if ppos and cpos then + local d = math.max(math.abs(cpos.x - ppos.x), math.abs(cpos.y - ppos.y)) + -- Fake a path table of length d so distanceScore behaves correctly + path = {} + for i = 1, d do path[i] = 0 end + end + local pri = PriorityEngine.calculate(creature, cfg, path) + local name = safeCreatureCall(creature, "getName", "unknown") + print(string.format("[TBI] PriorityEngine score for '%s': %d", name, pri)) + -- Dump MonsterAI tracker data if available + local id = safeGetId(creature) + if id and MonsterAI.Tracker and MonsterAI.Tracker.monsters then + local td = MonsterAI.Tracker.monsters[id] + if td then + print(string.format(" DPS=%.1f waveCount=%d confidence=%.2f ewmaCooldown=%s", + td.ewmaDps or 0, td.waveCount or 0, td.confidence or 0, + td.ewmaCooldown and string.format("%dms", math.floor(td.ewmaCooldown)) or "-")) + end + end + -- Dump HuntContext signal + if HuntContext and HuntContext.getSignal then + local sig = HuntContext.getSignal() + print(string.format(" HuntContext: surv=%.2f manaStress=%.2f eff=%.2f threatBias=%.2f", + sig.survivability, sig.manaStress, sig.efficiency, sig.threatBias)) + end end -- ============================================================================ @@ -301,27 +179,15 @@ end -- ============================================================================ if EventBus and EventBus.on then + -- Respond to direct priority requests from other modules EventBus.on("targetbot:request_priority", function(creature, callback) if tbOff() then return end if creature and callback then - local p, bk = TBI.calculatePriority(creature) - callback(p, bk) - end - end) - - -- Canonical emitBestTarget chain (gated by TargetBot state to prevent CPU waste) - schedule(2000, function() - local function emit() - if TargetBot and TargetBot.isOn and TargetBot.isOn() then - if EventBus and EventBus.emit then - local best = TBI.getBestTarget() - if best then EventBus.emit("targetbot:ai_recommendation", best.creature, best.priority, best.breakdown) end - end - end - schedule(1000, emit) + local cfg = TBI._defaultConfig + local pri = PriorityEngine and PriorityEngine.calculate(creature, cfg, nil) or 0 + callback(pri) end - emit() end) end -if MonsterAI.DEBUG then print("[MonsterAI] TBI module v3.0 loaded") end +if MonsterAI.DEBUG then print("[MonsterAI] TBI module v4.0 loaded (delegates to PriorityEngine)") end diff --git a/targetbot/priority_engine.lua b/targetbot/priority_engine.lua index 399c2a1..c33a38e 100644 --- a/targetbot/priority_engine.lua +++ b/targetbot/priority_engine.lua @@ -558,6 +558,50 @@ local function mobilityScore(creature, config) return s end +-- 8. Hunt context score (HuntContext bridge — reads lazy-cached signal, O(1)) +-- Contributes at most +60 so it never overrides config.priority tier differences. +local function huntScore(creature, hp) + if not (HuntContext and HuntContext.getSignal) then return 0 end + local ok, sig = pcall(HuntContext.getSignal) + if not ok or not sig then return 0 end + + local s = 0 + + -- Low survivability → prioritize wave-casting threats to eliminate them faster + if sig.survivability < 0.4 then + local name = cName(creature) + if MonsterAI and MonsterAI.Classifier and MonsterAI.Classifier.get then + local cl = MonsterAI.Classifier.get(name) + if cl and (cl.isWaveAttacker or cl.isWaveCaster) then + s = s + 15 + end + end + end + + -- High mana stress → prefer the closest target to minimize time-to-kill + if sig.manaStress > 0.7 then + local p = cPos(creature) + local pp = getPlayer() and cPos(getPlayer()) + if p and pp then + local d = math.max(math.abs(p.x - pp.x), math.abs(p.y - pp.y)) + if d <= 2 then s = s + 10 end + end + end + + -- Low hunt efficiency → push near-dead targets to ensure kills complete + if sig.efficiency < 0.6 and hp <= 25 then + s = s + 8 + end + + -- High composite threat bias → flat additive pressure proportional to danger + if sig.threatBias > 0.6 then + s = s + math.floor(sig.threatBias * 12) + end + + -- Hard cap: hunt signal never overrides config.priority tier differences (1000 per tier) + return math.min(s, 60) +end + -- ============================================================================ -- MAIN ENTRY POINT -- ============================================================================ @@ -590,7 +634,7 @@ function PriorityEngine.calculate(creature, config, path) return 0 end - -- Aggregate all sub-scores + -- Aggregate all sub-scores (single source of truth) local total = baseScore(config) + healthScore(hp, config) + distanceScore(pathLen) @@ -598,6 +642,7 @@ function PriorityEngine.calculate(creature, config, path) + threatScore(creature) + scenarioScore(creature, hp) + mobilityScore(creature, config) + + huntScore(creature, hp) -- Ensure non-negative return math.max(0, total) diff --git a/utils/waypoint_navigator.lua b/utils/waypoint_navigator.lua index a5ee582..4d606a8 100644 --- a/utils/waypoint_navigator.lua +++ b/utils/waypoint_navigator.lua @@ -185,6 +185,9 @@ function WaypointNavigator.buildRoute(waypointPositionCache, playerFloor) end -- Build segments between consecutive gotos (reference waypointPositionCache directly) + -- IMPORTANT: Never drop consecutive user-defined segments by distance. + -- Large/open-area routes can legitimately have long links; skipping them + -- truncates the route and causes early wrap loops (WP1..WP4 repeating). for i = 1, #gotos - 1 do local from = gotos[i] local to = gotos[i + 1] @@ -192,15 +195,15 @@ function WaypointNavigator.buildRoute(waypointPositionCache, playerFloor) local dy = to.pos.y - from.pos.y local length = math.sqrt(dx * dx + dy * dy) - if length <= maxSegmentLength then + if length > 0 then route.segments[#route.segments + 1] = { fromPos = from.pos, -- reference, not copy toPos = to.pos, -- reference, not copy fromIdx = from.idx, toIdx = to.idx, length = length, - dirX = length > 0 and dx / length or 0, - dirY = length > 0 and dy / length or 0, + dirX = dx / length, + dirY = dy / length, cumulativeDist = 0, -- filled below midX = (from.pos.x + to.pos.x) * 0.5, -- for spatial pruning midY = (from.pos.y + to.pos.y) * 0.5, @@ -208,13 +211,19 @@ function WaypointNavigator.buildRoute(waypointPositionCache, playerFloor) end end - -- Wrap-around segment (last -> first) if close enough + -- Wrap-around segment (last -> first) if close enough. + -- Skipped when the last goto is a floor-change tile: those routes are meant to + -- exit the floor via stairs/holes, not loop back. Adding a wrap-around in + -- that case makes Pure Pursuit aim backwards (toward WP1) instead of forward + -- to the stair tile, causing the bot to spin on the current floor indefinitely. local last = gotos[#gotos] local first = gotos[1] + local lastIsStair = (FloorItems and FloorItems.isFloorChangeTile) + and FloorItems.isFloorChangeTile({ x = last.pos.x, y = last.pos.y, z = last.pos.z }) local wrapDx = first.pos.x - last.pos.x local wrapDy = first.pos.y - last.pos.y local wrapLength = math.sqrt(wrapDx * wrapDx + wrapDy * wrapDy) - if wrapLength <= maxSegmentLength and wrapLength > 0 then + if not lastIsStair and wrapLength <= maxSegmentLength and wrapLength > 0 then route.segments[#route.segments + 1] = { fromPos = last.pos, toPos = first.pos, @@ -314,8 +323,10 @@ end -- ============================================================================ --- Get the correct next waypoint for the player to walk to. --- Uses distance-based advance: advances when <4 tiles from segment end, --- regardless of segment length (consistent behavior). +-- Advisory only: the goto action's distance≤precision arrival check is the +-- authoritative WP completion gate. This function should NOT trigger early +-- advance; it returns the segment endpoint so callers know which WP the +-- player is heading toward. -- @param playerPos table {x, y, z} -- @return waypointIndex (or nil), waypointPos (or nil) function WaypointNavigator.getNextWaypoint(playerPos) @@ -334,9 +345,10 @@ function WaypointNavigator.getNextWaypoint(playerPos) local seg = route.segments[segIdx] - -- Distance-based advance: advance when <4 tiles from segment end + -- Advance to next segment only when effectively at the endpoint (<1 tile). + -- The goto action handles WP completion via its own arrival precision check. local remainingDist = (1 - progress) * seg.length - if remainingDist < 4 and segIdx < #route.segments then + if remainingDist < 1 and segIdx < #route.segments then local nextSeg = route.segments[segIdx + 1] return nextSeg.toIdx, nextSeg.toPos end