From bdab104f9fca873f9e6c194f240f6653a9b9974c Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 10 Mar 2026 16:51:40 -0300 Subject: [PATCH 01/21] feat(targetbot): integrate HuntContext for enhanced targeting prioritization --- _Loader.lua | 1 + core/hunt_context.lua | 187 +++++++++++++++++ core/smart_hunt.lua | 5 + targetbot/monster_ai.lua | 128 +++--------- targetbot/monster_inspector.lua | 46 ++--- targetbot/monster_tbi.lua | 350 ++++++++++---------------------- targetbot/priority_engine.lua | 47 ++++- 7 files changed, 393 insertions(+), 371 deletions(-) create mode 100644 core/hunt_context.lua 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/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..6047081 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 -- ============================================================================ diff --git a/targetbot/monster_ai.lua b/targetbot/monster_ai.lua index 6feecb3..3b383e7 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 @@ -2028,7 +1940,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..ee16fbb 100644 --- a/targetbot/monster_inspector.lua +++ b/targetbot/monster_inspector.lua @@ -175,13 +175,9 @@ end -- Populate refs now (also called again on visibility change) updateWidgetRefs() -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) +local MIN_REFRESH_MS = 2500 -- floor: never rebuild more often than this (ms) -- Helper function to check if table is empty (since 'next' is not available) local function isTableEmpty(tbl) @@ -796,50 +792,40 @@ nExBot.MonsterInspector = { -- 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 + -- Trigger one MonsterAI tick so the inspector has data immediately on open + if MonsterAI and MonsterAI.updateAll then pcall(MonsterAI.updateAll) 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 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 + if MonsterAI and MonsterAI.updateAll then pcall(MonsterAI.updateAll) 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 end end end +-- Push-based auto-refresh: subscribe to MonsterAI state changes. +-- refreshPatterns() is already guarded by MIN_REFRESH_MS so it won't flood. +if EventBus and EventBus.on then + EventBus.on("monsterai:state_updated", function() + if MonsterInspectorWindow and MonsterInspectorWindow:isVisible() then + refreshPatterns() + end + end, 0) +end + -- Expose refreshPatterns function nExBot.MonsterInspector.refreshPatterns = refreshPatterns 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) From b71bba37a3b8c52c94a9ad54e074d7cabdc92fd1 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 10 Mar 2026 17:42:28 -0300 Subject: [PATCH 02/21] fix(walking): reduce keyboard step threshold for improved responsiveness feat(actions): implement Pure Pursuit lookahead for smoother waypoint navigation --- cavebot/actions.lua | 23 +++++++++++++++++++---- cavebot/walking.lua | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/cavebot/actions.lua b/cavebot/actions.lua index e44cfd1..aee8a47 100644 --- a/cavebot/actions.lua +++ b/cavebot/actions.lua @@ -610,11 +610,26 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) walkParams.ignoreFields = true 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 carries the + -- player through waypoints without stopping — arrival is detected by the + -- hasPassedWaypoint() check above (fires every 150ms during walk). + -- Floor-change waypoints bypass lookahead: they require exact tile precision. + local walkTarget = destPos + if 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 + walkTarget = lookahead + 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 diff --git a/cavebot/walking.lua b/cavebot/walking.lua index b72cd45..678c5b5 100644 --- a/cavebot/walking.lua +++ b/cavebot/walking.lua @@ -255,7 +255,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) From f75b2c62d87fa119b7416d5ff9a2876b9985a8fb Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 10 Mar 2026 18:13:47 -0300 Subject: [PATCH 03/21] fix(pathfinding): enhance lookahead logic for goto action to improve navigation accuracy --- cavebot/actions.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cavebot/actions.lua b/cavebot/actions.lua index aee8a47..bb97f2f 100644 --- a/cavebot/actions.lua +++ b/cavebot/actions.lua @@ -616,8 +616,14 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) -- player through waypoints without stopping — arrival is detected by the -- hasPassedWaypoint() check above (fires every 150ms during walk). -- 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 not isFloorChange + if retries == 0 + and not isFloorChange and WaypointNavigator and type(WaypointNavigator.isRouteBuilt) == "function" and WaypointNavigator.isRouteBuilt() From 517b9c7d701b502671a8584f679cdee9eed8662e Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 10 Mar 2026 20:26:36 -0300 Subject: [PATCH 04/21] feat(inspector): tabbed UI revamp + fix patterns always showing None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace single-panel text dump with 4-tab layout (Live Monsters, Patterns, Combat Stats, Scenario) — 560x500 window - Fix root cause of Patterns always showing None: inspector now reads MonsterAI.Patterns.knownMonsters (in-memory, always populated by persist()) as primary source, merging UnifiedStorage for cross-session entries. Storage-not-ready no longer silently drops all pattern data. - Add switchTab() with teal highlight on active button, hides inactive panels and scrollbars - refreshActiveTab() dispatches to the correct tab builder, throttled at 2500ms, reset correctly on manual Refresh and tab switch - EventBus monsterai:state_updated auto-refresh retained --- targetbot/monster_inspector.lua | 1103 +++++++++++------------------- targetbot/monster_inspector.otui | 157 ++++- 2 files changed, 561 insertions(+), 699 deletions(-) diff --git a/targetbot/monster_inspector.lua b/targetbot/monster_inspector.lua index ee16fbb..112b600 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,793 +13,530 @@ 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 + +-- ── 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 - end - - MonsterInspectorWindow = win - -- Ensure it's hidden initially - pcall(function() MonsterInspectorWindow:hide() end) +-- ── 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 - -- 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 - 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() - end - 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 - pcall(bindButtons) - - -- Initialize content - pcall(function() updateWidgetRefs() end) - pcall(function() refreshPatterns() end) - - return MonsterInspectorWindow end --- Ensure window exists at load time if possible -createWindowIfMissing() - --- Ensure global namespace for inspector exists to avoid nil indexing during early calls -nExBot = nExBot or {} -nExBot.MonsterInspector = nExBot.MonsterInspector or {} +-- ── Tab switching ───────────────────────────────────────────────────────────── -local patternList, dmgLabel, waveLabel, areaLabel = nil, nil, nil, nil - --- 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 - end - return nil +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) - 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 +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 + 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 +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 - - -- 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') - end +-- ── Tab content builders ────────────────────────────────────────────────────── - 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 +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 - else - patternList = nil - warn("[MonsterInspector] Failed to bind textContent widget; UI may not be loaded or style import failed") + table.insert(lines, string.format("Live Tracker — %d creature(s)", count)) + table.insert(lines, "") + + if count == 0 then + table.insert(lines, " No creatures currently tracked.") + table.insert(lines, " (Tracker populates during combat)") + return table.concat(lines, "\n") + 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)) + + 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) + + 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 + + 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, " Patterns are learned after observing 2+ wave attacks") + table.insert(lines, " from the same monster type.") + return table.concat(lines, "\n") + end -local refreshInProgress = false -local lastRefreshMs = 0 -local MIN_REFRESH_MS = 2500 -- floor: never rebuild 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 - )) + local s = MonsterAI.Telemetry.session + local dur = ((now or 0) - (s.startTime or 0)) / 1000 + table.insert(lines, "── Session ─────────────────────────────────────") + 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 - - -- Metrics Aggregator Summary (NEW in v2.2) + + table.insert(lines, "") + table.insert(lines, "── Combat ──────────────────────────────────────") + 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 ─────────────────────────────────") + 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 ─────────────────────────────────") + 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 ───────────────────────────────────────") + 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 ─────────────────────────────────────") + 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)) - 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)) + 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 ─────────────────────────────────") + 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 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 ───────────────────────────────────────") + 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 +-- ── Refresh ─────────────────────────────────────────────────────────────────── +local function refreshActiveTab() + if not MonsterInspectorWindow or not MonsterInspectorWindow:isVisible() then return end if refreshInProgress then return end - - -- Throttle frequent calls - if now and (now - lastRefreshMs) < MIN_REFRESH_MS 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 + 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 + refreshActiveTab() 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 --- Initialize (load current data) -refreshPatterns() +-- ── Public API ──────────────────────────────────────────────────────────────── -nExBot.MonsterInspector = { - refresh = refreshPatterns, - clear = clearPatterns, - rebindButtons = bindInspectorButtons -} +nExBot.MonsterInspector.refresh = refreshActiveTab +nExBot.MonsterInspector.rebindButtons = function() bindButtons(MonsterInspectorWindow) end +nExBot.MonsterInspector.refreshPatterns = refreshPatterns + +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 MonsterInspectorWindow then MonsterInspectorWindow:show() updateWidgetRefs() - -- Trigger one MonsterAI tick so the inspector has data immediately on open + switchTab(activeTab) if MonsterAI and MonsterAI.updateAll then pcall(MonsterAI.updateAll) end - refreshPatterns() + refreshInProgress = false + refreshActiveTab() end end @@ -808,25 +546,16 @@ nExBot.MonsterInspector.toggleWindow = function() if MonsterInspectorWindow:isVisible() then MonsterInspectorWindow:hide() else - MonsterInspectorWindow:show() - updateWidgetRefs() - if MonsterAI and MonsterAI.updateAll then pcall(MonsterAI.updateAll) end - refreshPatterns() + nExBot.MonsterInspector.showWindow() end end end --- Push-based auto-refresh: subscribe to MonsterAI state changes. --- refreshPatterns() is already guarded by MIN_REFRESH_MS so it won't flood. +-- 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 - refreshPatterns() + refreshActiveTab() end end, 0) end - --- Expose refreshPatterns function -nExBot.MonsterInspector.refreshPatterns = refreshPatterns - - 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 From e04a01e2fae2bb1ec9689f3f8ac39b971997d5c7 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 10 Mar 2026 20:59:48 -0300 Subject: [PATCH 05/21] fix(cavebot): track lookahead target in regression detector to prevent mid-route stops When Pure Pursuit selects a lookahead waypoint, the regression detector was tracking progress toward the original destPos instead of the actual walk target. As the player moved past destPos toward the lookahead, curDist increased and triggered the stuck-detection logic, stopping autoWalk mid-route. Pass walkTarget (lookahead or destPos when fallback) to setWalkingToWaypoint so the detector measures movement toward where the walk is actually headed. --- cavebot/actions.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cavebot/actions.lua b/cavebot/actions.lua index bb97f2f..57ae13e 100644 --- a/cavebot/actions.lua +++ b/cavebot/actions.lua @@ -649,7 +649,7 @@ 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 From 58795aa68a465f2d5286d184dac6cbb131016f4d Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 10 Mar 2026 21:35:59 -0300 Subject: [PATCH 06/21] feat(inspector): fix live tracking display + add Hunt Analyzer tabbed layout monster_inspector.lua: - Add 3s schedule-based live update loop (startLiveUpdate/stopLiveUpdate) so the window refreshes even when monsterai:state_updated never fires (e.g. TargetBot is off or no wave attacks occur) - Add direct g_map.getSpectatorsInRange fallback in buildLiveTab() so Tab 1 always shows nearby creatures even when MonsterAI.Tracker is inactive - Reset lastRefreshMs=0 on manual Refresh click and on window show so the 2500ms throttle never silently blocks the first visible refresh - startLiveUpdate on window show, stopLiveUpdate on hide smart_hunt.otui: - Replace single-panel HuntAnalyzerWindow with 4-tab layout mirroring monster_inspector.otui: Session / Consumption / Loot & Combat / Insights smart_hunt.lua: - Add buildSessionTab / buildConsumptionTab / buildLootCombatTab / buildInsightsTab extracting content from buildSummary() per tab - Rewrite showAnalytics() / doLiveUpdate() to drive the new tabbed window using haFindChild / haUpdateWidgetRefs / haSwitchTab pattern - Simplify Monster Insights button to delegate to toggleWindow() --- core/smart_hunt.lua | 417 +++++++++++++++++++++++++------- core/smart_hunt.otui | 190 +++++++++++++-- targetbot/monster_inspector.lua | 67 ++++- 3 files changed, 564 insertions(+), 110 deletions(-) diff --git a/core/smart_hunt.lua b/core/smart_hunt.lua index 6047081..86cccd9 100644 --- a/core/smart_hunt.lua +++ b/core/smart_hunt.lua @@ -1679,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 + +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 --- Live update flag for analytics window (must be defined before showAnalytics) -local liveUpdatesActive = false -local lastSummaryText = "" +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 @@ -1695,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 @@ -1811,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/monster_inspector.lua b/targetbot/monster_inspector.lua index 112b600..569b036 100644 --- a/targetbot/monster_inspector.lua +++ b/targetbot/monster_inspector.lua @@ -54,6 +54,7 @@ local tabBtns = {} -- [1..4] NxButton widgets local refreshInProgress = false local lastRefreshMs = 0 local MIN_REFRESH_MS = 2500 +local liveUpdateActive = false -- ── Style import ────────────────────────────────────────────────────────────── @@ -147,8 +148,42 @@ local function buildLiveTab() table.insert(lines, "") if count == 0 then - table.insert(lines, " No creatures currently tracked.") - table.insert(lines, " (Tracker populates during combat)") + -- 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 + 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 @@ -406,6 +441,29 @@ local BUILDERS = { buildScenarioTab, } +-- ── Live update loop ────────────────────────────────────────────────────────── + +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 ─────────────────────────────────────────────────────────────────── local function refreshActiveTab() @@ -447,6 +505,7 @@ local function bindButtons(win) if refreshBtn then refreshBtn.onClick = function() refreshInProgress = false + lastRefreshMs = 0 -- bypass throttle on manual refresh refreshActiveTab() end end @@ -485,7 +544,11 @@ local function bindButtons(win) updateWidgetRefs() switchTab(activeTab) refreshInProgress = false + lastRefreshMs = 0 refreshActiveTab() + startLiveUpdate() + else + stopLiveUpdate() end end end From c7033aa72288ac03b5a0e8134fa06a0282e996a3 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 10 Mar 2026 21:51:20 -0300 Subject: [PATCH 07/21] fix(cavebot): improve timeout and regression tolerance logic for navigation accuracy --- cavebot/cavebot.lua | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/cavebot/cavebot.lua b/cavebot/cavebot.lua index dd5bb9a..53d1524 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 From 3ca030a7c99badb77d494e30dc4d45c8e1b9a6a4 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 10 Mar 2026 22:08:52 -0300 Subject: [PATCH 08/21] fix(AttackBot, monster_inspector): add nil check for widget.id and forward declare refreshActiveTab function --- core/AttackBot.lua | 6 ++++-- targetbot/monster_inspector.lua | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/core/AttackBot.lua b/core/AttackBot.lua index e543e20..c50ba92 100644 --- a/core/AttackBot.lua +++ b/core/AttackBot.lua @@ -833,8 +833,10 @@ 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.id then + widget.id:setVisible(true) + widget.id:setItemId(params.itemId) + end end widget:setTooltip(params.tooltip) widget.remove.onClick = function() diff --git a/targetbot/monster_inspector.lua b/targetbot/monster_inspector.lua index 569b036..6637172 100644 --- a/targetbot/monster_inspector.lua +++ b/targetbot/monster_inspector.lua @@ -443,6 +443,8 @@ local BUILDERS = { -- ── Live update loop ────────────────────────────────────────────────────────── +local refreshActiveTab -- forward declaration; defined below + local function doLiveUpdate() if not liveUpdateActive then return end if not MonsterInspectorWindow or not MonsterInspectorWindow:isVisible() then @@ -466,7 +468,7 @@ end -- ── Refresh ─────────────────────────────────────────────────────────────────── -local function refreshActiveTab() +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 From 02cfc4474ef6ab90d93c9b8b7358ffa48ce2df2f Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 10 Mar 2026 22:23:07 -0300 Subject: [PATCH 09/21] fix(cavebot): reduce RECOVERY_IDLE_TIMEOUT from 5 min to 12s for quicker blacklist clearance --- cavebot/cavebot.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cavebot/cavebot.lua b/cavebot/cavebot.lua index 53d1524..8bafce6 100644 --- a/cavebot/cavebot.lua +++ b/cavebot/cavebot.lua @@ -328,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. From 8ad9d8c846520803a0982356186b11925841bc3f Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Wed, 11 Mar 2026 11:22:26 -0300 Subject: [PATCH 10/21] fix(cavebot): enhance resetWaypointEngine to clear blacklists and prevent blacklisted waypoints from being processed --- cavebot/cavebot.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cavebot/cavebot.lua b/cavebot/cavebot.lua index 8bafce6..3a413e8 100644 --- a/cavebot/cavebot.lua +++ b/cavebot/cavebot.lua @@ -647,7 +647,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 @@ -659,6 +659,7 @@ resetWaypointEngine = function() WaypointEngine.wasTargetBotBlocking = false WaypointEngine.postCombatUntil = 0 lastDispatchedChild = nil + clearWaypointBlacklist() end -- Cache TargetBot function references (avoid repeated table lookups) @@ -913,7 +914,7 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking for _ = 1, actionCount do scanIdx = (scanIdx % actionCount) + 1 local wp = waypointPositionCache[scanIdx] - if wp and wp.isGoto and wp.z == playerPos.z then + if wp and wp.isGoto and wp.z == playerPos.z and not isWaypointBlacklisted(wp.child) then focusWaypointForRecovery(wp.child, scanIdx) found = true break From 9eb566c6b9f6dedb18b05eed474a762db4ad2f90 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Wed, 11 Mar 2026 11:45:17 -0300 Subject: [PATCH 11/21] fix(cavebot): enhance waypoint processing to skip blacklisted and floor-mismatched waypoints --- cavebot/actions.lua | 12 +++++++++++- cavebot/cavebot.lua | 17 ++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/cavebot/actions.lua b/cavebot/actions.lua index 57ae13e..e611112 100644 --- a/cavebot/actions.lua +++ b/cavebot/actions.lua @@ -630,7 +630,17 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) and type(WaypointNavigator.getLookaheadTarget) == "function" then local lookahead = WaypointNavigator.getLookaheadTarget(playerPos) if lookahead and lookahead.z == playerPos.z then - walkTarget = lookahead + -- Reject degenerate lookahead: at route wrap-around the navigator can return + -- the last WP on the route (e.g. the start of the loop) which is already + -- behind the player, causing walkTo to return "arrived" immediately and + -- the bot to spin forever without actually walking to destPos. + local lhDist = math.max( + math.abs(lookahead.x - playerPos.x), + math.abs(lookahead.y - playerPos.y) + ) + if lhDist >= 3 then + walkTarget = lookahead + end end end diff --git a/cavebot/cavebot.lua b/cavebot/cavebot.lua index 3a413e8..a0dd569 100644 --- a/cavebot/cavebot.lua +++ b/cavebot/cavebot.lua @@ -1043,10 +1043,21 @@ 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 waypoints AND floor-mismatched goto WPs (e.g. rescue + -- waypoints on a different floor that should only activate if the player + -- accidentally changes floors — never advance to them during normal routing). + 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 true 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 From 27e7643f509503406699dea30e77f13352cbe355 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Wed, 11 Mar 2026 13:39:01 -0300 Subject: [PATCH 12/21] fix(monster_ai, monster_inspector, monster_patterns): enhance tracking and diagnostics for monsters and patterns --- targetbot/monster_ai.lua | 47 ++++++++++++++++++++++++--------- targetbot/monster_inspector.lua | 29 +++++++++++++------- targetbot/monster_patterns.lua | 3 ++- 3 files changed, 55 insertions(+), 24 deletions(-) diff --git a/targetbot/monster_ai.lua b/targetbot/monster_ai.lua index 3b383e7..a339fe2 100644 --- a/targetbot/monster_ai.lua +++ b/targetbot/monster_ai.lua @@ -1337,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 @@ -1401,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) diff --git a/targetbot/monster_inspector.lua b/targetbot/monster_inspector.lua index 6637172..19cfb0a 100644 --- a/targetbot/monster_inspector.lua +++ b/targetbot/monster_inspector.lua @@ -242,8 +242,17 @@ local function buildPatternsTab() if count == 0 then table.insert(lines, " No patterns yet.") - table.insert(lines, " Patterns are learned after observing 2+ wave attacks") - table.insert(lines, " from the same monster type.") + 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 @@ -283,13 +292,13 @@ local function buildStatsTab() if MonsterAI and MonsterAI.Telemetry and MonsterAI.Telemetry.session then local s = MonsterAI.Telemetry.session local dur = ((now or 0) - (s.startTime or 0)) / 1000 - table.insert(lines, "── Session ─────────────────────────────────────") + 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 ──────────────────────────────────────") + 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)) @@ -305,7 +314,7 @@ local function buildStatsTab() local ok, ps = pcall(MonsterAI.getPredictionStats) if ok and ps then table.insert(lines, "") - table.insert(lines, "── Predictions ─────────────────────────────────") + 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)) @@ -322,7 +331,7 @@ local function buildStatsTab() 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 ─────────────────────────────────") + 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)) @@ -351,7 +360,7 @@ local function buildStatsTab() local ok, t = pcall(MonsterAI.getImmediateThreat) if ok and t then table.insert(lines, "") - table.insert(lines, "── Threat ───────────────────────────────────────") + 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)) @@ -370,7 +379,7 @@ local function buildScenarioTab() end) sc = ok and sc or {} local cfg = sc.config or {} - table.insert(lines, "── Scenario ─────────────────────────────────────") + 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)) @@ -396,7 +405,7 @@ local function buildScenarioTab() end) rs = ok and rs or {} table.insert(lines, "") - table.insert(lines, "── Reachability ─────────────────────────────────") + 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", @@ -415,7 +424,7 @@ local function buildScenarioTab() if ok and danger then threats = threats or {} table.insert(lines, "") - table.insert(lines, "── Danger ───────────────────────────────────────") + 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] 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 From 1ad4b6b076946f65fc2ed05db6d1d9bc0c8b2547 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Wed, 11 Mar 2026 13:57:44 -0300 Subject: [PATCH 13/21] fix(cavebot): improve handling of Z-level changes and waypoint processing for intentional and accidental floor transitions --- cavebot/cavebot.lua | 63 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/cavebot/cavebot.lua b/cavebot/cavebot.lua index a0dd569..ecbdf0a 100644 --- a/cavebot/cavebot.lua +++ b/cavebot/cavebot.lua @@ -734,22 +734,40 @@ 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] + local intentional = focusedWp and focusedWp.isGoto and focusedWp.z == playerPos.z + + -- 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 "?")) + else + -- Accidental floor change: reset fully and snap to nearest same-floor WP. + clearWaypointBlacklist() + resetWaypointEngine() + local child, idx = findNearestSameFloorGoto(playerPos, playerPos.z, CaveBot.getMaxGotoDistance()) + if child then + print("[CaveBot] Z-change (" .. lastPlayerFloor .. "→" .. playerPos.z .. "): accidental, focusing WP" .. idx) + focusWaypointForRecovery(child, idx) + end end + lastPlayerFloor = playerPos.z return -- Consume this tick for the Z transition end @@ -1043,15 +1061,30 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking local nextChild = uiList:getChildByIndex(nextIndex) if nextChild then - -- Skip blacklisted waypoints AND floor-mismatched goto WPs (e.g. rescue - -- waypoints on a different floor that should only activate if the player - -- accidentally changes floors — never advance to them during normal routing). + -- 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 true end + if wp2 and wp2.z ~= playerPos.z then + return isTrailingRescueBlock(idx2) + end end return false end From d9d2c661387f369c952e052955fd99151dd2429a Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Wed, 11 Mar 2026 14:01:08 -0300 Subject: [PATCH 14/21] fix(monster_inspector): update formatting in live and patterns tab messages for consistency --- targetbot/monster_inspector.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/targetbot/monster_inspector.lua b/targetbot/monster_inspector.lua index 19cfb0a..91fdffc 100644 --- a/targetbot/monster_inspector.lua +++ b/targetbot/monster_inspector.lua @@ -144,7 +144,7 @@ local function buildLiveTab() local count = 0 for _ in pairs(live) do count = count + 1 end - table.insert(lines, string.format("Live Tracker — %d creature(s)", count)) + table.insert(lines, string.format("Live Tracker - %d creature(s)", count)) table.insert(lines, "") if count == 0 then @@ -168,7 +168,7 @@ local function buildLiveTab() end) end if #nearby > 0 then - table.insert(lines, string.format(" %d nearby (TargetBot off — enable for full tracking):", #nearby)) + 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 @@ -237,7 +237,7 @@ local function buildPatternsTab() 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, string.format("Learned Patterns - %d monster type(s)", count)) table.insert(lines, "") if count == 0 then From d99a73b5b0c6b8cafd9465b5d68b217f566c9153 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Wed, 11 Mar 2026 15:18:07 -0300 Subject: [PATCH 15/21] fix(cavebot, waypoint_navigator): improve handling of floor changes and wrap-around logic for waypoints --- cavebot/cavebot.lua | 23 +++++++++++++++++++++++ utils/waypoint_navigator.lua | 10 ++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/cavebot/cavebot.lua b/cavebot/cavebot.lua index ecbdf0a..fd34f12 100644 --- a/cavebot/cavebot.lua +++ b/cavebot/cavebot.lua @@ -743,8 +743,23 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking 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 @@ -757,6 +772,14 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking 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). + -- Don't snap to nearest — the goto for this WP will instantFail (floor + -- mismatch) and the Z-mismatch guard will then advance to the next + -- same-floor goto naturally, preserving correct route order. + clearWaypointBlacklist() + WaypointEngine.failureCount = 0 + print("[CaveBot] Z-change (" .. lastPlayerFloor .. "→" .. playerPos.z .. "): stair at WP" .. (focusedIdx or "?") .. ", advancing via Z-mismatch guard") else -- Accidental floor change: reset fully and snap to nearest same-floor WP. clearWaypointBlacklist() diff --git a/utils/waypoint_navigator.lua b/utils/waypoint_navigator.lua index a5ee582..00ec668 100644 --- a/utils/waypoint_navigator.lua +++ b/utils/waypoint_navigator.lua @@ -208,13 +208,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, From c33ab661963221a236e20fa89f525d579ec64529 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Wed, 11 Mar 2026 15:32:08 -0300 Subject: [PATCH 16/21] fix(cavebot): prevent oscillation near stairs by rejecting floor-change tiles in lookahead --- cavebot/actions.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cavebot/actions.lua b/cavebot/actions.lua index e611112..2bf18a4 100644 --- a/cavebot/actions.lua +++ b/cavebot/actions.lua @@ -638,7 +638,13 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) math.abs(lookahead.x - playerPos.x), math.abs(lookahead.y - playerPos.y) ) - if lhDist >= 3 then + -- Reject floor-change tile as lookahead when the current WP is not a stair. + -- walkTo with allowFloorChange=false redirects away from stair tiles to an + -- adjacent tile, causing the bot to oscillate near the stair indefinitely + -- instead of advancing to the stair WP and using it properly. + local lookaheadIsStair = (FloorItems and FloorItems.isFloorChangeTile) + and FloorItems.isFloorChangeTile(lookahead) + if lhDist >= 3 and not lookaheadIsStair then walkTarget = lookahead end end From 72f15f37a9f406bdaa171d4e749e38ab351f49ee Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Wed, 11 Mar 2026 15:41:53 -0300 Subject: [PATCH 17/21] fix(cavebot): enhance walking logic to prefer raw pathfinder direction and improve fallback to smoothed direction --- cavebot/walking.lua | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/cavebot/walking.lua b/cavebot/walking.lua index 678c5b5..72391e6 100644 --- a/cavebot/walking.lua +++ b/cavebot/walking.lua @@ -353,15 +353,19 @@ CaveBot.walkTo = function(dest, maxDist, params) local manhattan = distX + distY if manhattan <= 3 then - -- Close: precise keyboard steps + -- 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}) if fcPath and #fcPath > 0 then local dir = fcPath[1] - local smoothed = PS().smoothDirection(dir, true) or dir - if canWalkDirection(smoothed) then - PS().walkStep(smoothed) - elseif canWalkDirection(dir) then + if canWalkDirection(dir) then PS().walkStep(dir) + else + local smoothed = PS().smoothDirection(dir, true) or dir + if smoothed ~= dir and canWalkDirection(smoothed) then + PS().walkStep(smoothed) + end end end return true From d2cdf931ba4b8dba564a74cc6905b7d8d2a993a4 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Wed, 11 Mar 2026 15:59:25 -0300 Subject: [PATCH 18/21] fix(cavebot): enhance lookahead logic to reject floor-change tiles and unreachable targets --- cavebot/actions.lua | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/cavebot/actions.lua b/cavebot/actions.lua index 2bf18a4..94c01a8 100644 --- a/cavebot/actions.lua +++ b/cavebot/actions.lua @@ -630,22 +630,31 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) and type(WaypointNavigator.getLookaheadTarget) == "function" then local lookahead = WaypointNavigator.getLookaheadTarget(playerPos) if lookahead and lookahead.z == playerPos.z then - -- Reject degenerate lookahead: at route wrap-around the navigator can return - -- the last WP on the route (e.g. the start of the loop) which is already - -- behind the player, causing walkTo to return "arrived" immediately and - -- the bot to spin forever without actually walking to destPos. local lhDist = math.max( math.abs(lookahead.x - playerPos.x), math.abs(lookahead.y - playerPos.y) ) - -- Reject floor-change tile as lookahead when the current WP is not a stair. - -- walkTo with allowFloorChange=false redirects away from stair tiles to an - -- adjacent tile, causing the bot to oscillate near the stair indefinitely - -- instead of advancing to the stair WP and using it properly. - local lookaheadIsStair = (FloorItems and FloorItems.isFloorChangeTile) - and FloorItems.isFloorChangeTile(lookahead) - if lhDist >= 3 and not lookaheadIsStair then - walkTarget = lookahead + 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 From d00445db81793fbd7289f136b4494ae2fc698137 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Fri, 13 Mar 2026 08:58:29 -0300 Subject: [PATCH 19/21] refactor(AttackBot): improve UI structure and enhance control bindings for better usability --- core/AttackBot.lua | 317 +++++++++-------- core/AttackBot.otui | 807 ++++++++++++++++++++------------------------ 2 files changed, 534 insertions(+), 590 deletions(-) diff --git a/core/AttackBot.lua b/core/AttackBot.lua index c50ba92..5e2d84c 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,44 @@ 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 uiPreviousSource = rw("previousSource") + local uiNextSource = rw("nextSource") + 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 +772,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 +833,9 @@ end toggleItem() setPatternText() setCategoryText() + focusPrimaryInput() end - panel.nextCategory.onClick = function() + uiNextCategory.onClick = function() if category == #categories then category = 1 else @@ -799,14 +848,15 @@ end toggleItem() setPatternText() setCategoryText() + focusPrimaryInput() end - panel.previousSource.onClick = function() + uiPreviousSource.onClick = function() warn("[AttackBot] TODO, reserved for future use.") end - panel.nextSource.onClick = function() + uiNextSource.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 +865,7 @@ end end setPatternText() end - panel.nextRange.onClick = function() + uiNextRange.onClick = function() local t = patterns[patternCategory] if pattern == #t then pattern = 1 @@ -832,16 +882,25 @@ end widget:setText(params.description) if params.itemId > 0 then - widget.spell:setVisible(false) + if widget.spell then + widget.spell:setVisible(false) + end if widget.id then widget.id:setVisible(true) widget.id:setItemId(params.itemId) 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) @@ -851,15 +910,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 @@ -869,18 +928,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 @@ -890,31 +949,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 @@ -953,7 +1012,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() @@ -961,87 +1020,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 @@ -1051,15 +1080,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() @@ -1069,19 +1099,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() @@ -1744,7 +1761,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 @@ -1770,7 +1787,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..e52e96e 100644 --- a/core/AttackBot.otui +++ b/core/AttackBot.otui @@ -1,5 +1,13 @@ AttackEntry < NxListEntryCheckable text-offset: 38 0 + + NxItem + id: id + anchors.left: enabled.right + anchors.verticalCenter: parent.verticalCenter + margin-left: 2 + visible: false + UIWidget id: spell anchors.left: enabled.right @@ -16,524 +24,443 @@ 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 + text: Manage NxButton 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: entryList.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: previousSource + anchors.left: parent.left + anchors.top: category.bottom + margin-top: 7 + + NxCategoryBar + id: source + anchors.left: previousSource.right + anchors.verticalCenter: previousSource.verticalCenter + margin-left: 3 + width: 110 + tooltip: Reserved source selector. + + NxNavNextButton + id: nextSource + anchors.left: source.right + anchors.verticalCenter: previousSource.verticalCenter + margin-left: 2 + + NxNavPrevButton + id: previousRange + anchors.left: nextSource.right + anchors.verticalCenter: nextSource.verticalCenter + margin-left: 8 + + 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 From f5ed455ae27604e4a5d61ac0d8f4b318aabb45ba Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Fri, 13 Mar 2026 10:38:37 -0300 Subject: [PATCH 20/21] refactor(AttackBot, HealBot): streamline UI elements and enhance layout for improved usability --- core/AttackBot.lua | 10 +- core/AttackBot.otui | 27 +---- core/HealBot.lua | 106 ++++++++++++++++--- core/HealBot.otui | 241 +++++++++++++++++++++++++++++--------------- 4 files changed, 257 insertions(+), 127 deletions(-) diff --git a/core/AttackBot.lua b/core/AttackBot.lua index 5e2d84c..685e365 100644 --- a/core/AttackBot.lua +++ b/core/AttackBot.lua @@ -742,8 +742,6 @@ end local uiSelectorHint = rw("selectorHint") local uiPreviousCategory = rw("previousCategory") local uiNextCategory = rw("nextCategory") - local uiPreviousSource = rw("previousSource") - local uiNextSource = rw("nextSource") local uiPreviousRange = rw("previousRange") local uiNextRange = rw("nextRange") local uiManaPercent = rw("manaPercent") @@ -850,12 +848,6 @@ end setCategoryText() focusPrimaryInput() end - uiPreviousSource.onClick = function() - warn("[AttackBot] TODO, reserved for future use.") - end - uiNextSource.onClick = function() - warn("[AttackBot] TODO, reserved for future use.") - end uiPreviousRange.onClick = function() local t = patterns[patternCategory] if pattern == 1 then @@ -888,6 +880,8 @@ 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 diff --git a/core/AttackBot.otui b/core/AttackBot.otui index e52e96e..89dfe85 100644 --- a/core/AttackBot.otui +++ b/core/AttackBot.otui @@ -5,6 +5,7 @@ AttackEntry < NxListEntryCheckable id: id anchors.left: enabled.right anchors.verticalCenter: parent.verticalCenter + size: 16 16 margin-left: 2 visible: false @@ -35,7 +36,7 @@ AttackBotBotPanel < NxBotSection anchors.right: parent.right width: 52 height: 20 - text: Manage + text: Setup NxButton id: 1 @@ -127,7 +128,7 @@ AttackBotPanel < Panel id: entryListScrollBar anchors.top: entryList.top anchors.bottom: entryList.bottom - anchors.right: entryList.right + anchors.right: parent.right step: 14 pixels-scroll: true @@ -204,31 +205,11 @@ AttackBotPanel < Panel tooltip: Attack type. Change this first. NxNavPrevButton - id: previousSource + id: previousRange anchors.left: parent.left anchors.top: category.bottom margin-top: 7 - NxCategoryBar - id: source - anchors.left: previousSource.right - anchors.verticalCenter: previousSource.verticalCenter - margin-left: 3 - width: 110 - tooltip: Reserved source selector. - - NxNavNextButton - id: nextSource - anchors.left: source.right - anchors.verticalCenter: previousSource.verticalCenter - margin-left: 2 - - NxNavPrevButton - id: previousRange - anchors.left: nextSource.right - anchors.verticalCenter: nextSource.verticalCenter - margin-left: 8 - NxNavNextButton id: nextRange 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 From 2928048939cdc7209b3eb02ec988841c426c9745 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Sun, 15 Mar 2026 17:11:12 -0300 Subject: [PATCH 21/21] Enhance CaveBot navigation and combat mechanics - Implement oscillation and stuck detection in the goto action to prevent looping without progress. - Refactor waypoint handling to improve recovery from accidental floor changes and corridor breaches. - Adjust combat constants for reaffirmation retries and engagement backoff to optimize attack behavior. - Improve reachability checks for creatures, including dynamic cooldown adjustments based on previous reachability. - Enhance pathfinding logic to ensure smoother navigation through obstacles and better handling of floor changes. --- cavebot/actions.lua | 188 +++++++++++++++++++++++------ cavebot/cavebot.lua | 170 ++++++++++++++++++++------ cavebot/walking.lua | 138 +++++++++++++++++---- targetbot/attack_state_machine.lua | 6 +- targetbot/combat_constants.lua | 5 +- targetbot/creature_attack.lua | 2 +- targetbot/monster_reachability.lua | 45 +++++-- utils/waypoint_navigator.lua | 20 +-- 8 files changed, 458 insertions(+), 116 deletions(-) diff --git a/cavebot/actions.lua b/cavebot/actions.lua index 94c01a8..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,17 @@ 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 carries the - -- player through waypoints without stopping — arrival is detected by the - -- hasPassedWaypoint() check above (fires every 150ms during walk). + -- 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; @@ -681,7 +710,22 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) 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 @@ -702,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() @@ -723,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) @@ -740,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() @@ -761,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 fd34f12..0788ff4 100644 --- a/cavebot/cavebot.lua +++ b/cavebot/cavebot.lua @@ -342,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, @@ -658,6 +662,7 @@ resetWaypointEngine = function() WaypointEngine.lastRefocusTime = 0 WaypointEngine.wasTargetBotBlocking = false WaypointEngine.postCombatUntil = 0 + WaypointEngine.corridorBreachCount = 0 lastDispatchedChild = nil clearWaypointBlacklist() end @@ -774,20 +779,62 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking 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). - -- Don't snap to nearest — the goto for this WP will instantFail (floor - -- mismatch) and the Z-mismatch guard will then advance to the next - -- same-floor goto naturally, preserving correct route order. + -- 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 - print("[CaveBot] Z-change (" .. lastPlayerFloor .. "→" .. playerPos.z .. "): stair at WP" .. (focusedIdx or "?") .. ", advancing via Z-mismatch guard") + 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 snap to nearest same-floor WP. + -- 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 child, idx = findNearestSameFloorGoto(playerPos, playerPos.z, CaveBot.getMaxGotoDistance()) - if child then - print("[CaveBot] Z-change (" .. lastPlayerFloor .. "→" .. playerPos.z .. "): accidental, focusing WP" .. idx) - focusWaypointForRecovery(child, idx) + 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 @@ -894,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 @@ -908,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 @@ -943,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 and not isWaypointBlacklisted(wp.child) 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 @@ -1491,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 @@ -1501,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) } @@ -1513,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 72391e6..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 + + -- 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 - if dir == lastNudgeDir and now - lastNudgeTime < 500 and adj then - candidates = { adj[1], adj[2], dir } + -- 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 @@ -353,34 +411,53 @@ CaveBot.walkTo = function(dest, maxDist, params) local manhattan = distX + distY if manhattan <= 3 then + -- 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) - else - local smoothed = PS().smoothDirection(dir, true) or dir - if smoothed ~= dir and canWalkDirection(smoothed) then - PS().walkStep(smoothed) - end + return true + end + local smoothed = PS().smoothDirection(dir, true) or dir + if smoothed ~= dir and canWalkDirection(smoothed) then + PS().walkStep(smoothed) + 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 @@ -408,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/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_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/utils/waypoint_navigator.lua b/utils/waypoint_navigator.lua index 00ec668..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, @@ -320,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) @@ -340,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