From bdab104f9fca873f9e6c194f240f6653a9b9974c Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 10 Mar 2026 16:51:40 -0300 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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 From a1b116fb383df0f5701d066706ebed689f8605f2 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 17 Mar 2026 20:05:38 -0300 Subject: [PATCH 22/22] waypoint improvement --- cavebot/actions.lua | 97 ++++++-- cavebot/cavebot.lua | 211 +++++++++-------- cavebot/config.lua | 2 - cavebot/config_loader.lua | 51 ++++ cavebot/doors.lua | 37 +++ cavebot/loop_guard_v2.lua | 76 ++++++ cavebot/navigation_v2.lua | 360 +++++++++++++++++++++++++++++ cavebot/recovery_planner_v2.lua | 159 +++++++++++++ cavebot/route_compiler.lua | 91 ++++++++ cavebot/route_validator.lua | 81 +++++++ cavebot/walking.lua | 42 ++-- cavebot/waypoint_schema.lua | 130 +++++++++++ core/cavebot.lua | 7 + core/hold_target.lua | 54 +++++ targetbot/attack_state_machine.lua | 79 +++++++ targetbot/event_targeting.lua | 33 ++- targetbot/monster_reachability.lua | 37 ++- targetbot/target.lua | 196 +++++++++------- 18 files changed, 1514 insertions(+), 229 deletions(-) create mode 100644 cavebot/config_loader.lua create mode 100644 cavebot/loop_guard_v2.lua create mode 100644 cavebot/navigation_v2.lua create mode 100644 cavebot/recovery_planner_v2.lua create mode 100644 cavebot/route_compiler.lua create mode 100644 cavebot/route_validator.lua create mode 100644 cavebot/waypoint_schema.lua diff --git a/cavebot/actions.lua b/cavebot/actions.lua index 8a88bb8..ebbce08 100644 --- a/cavebot/actions.lua +++ b/cavebot/actions.lua @@ -458,6 +458,64 @@ local gotoProgress = { PROGRESS_MIN = 2, -- must close ≥2 tiles to count as progress } +local function isNearFloorChangePos(p) + if not p or not FloorItems or not FloorItems.isFloorChangeTile then return false end + if FloorItems.isFloorChangeTile(p) then return true end + local adj = { + {x=0,y=-1},{x=1,y=0},{x=0,y=1},{x=-1,y=0}, + {x=1,y=-1},{x=1,y=1},{x=-1,y=1},{x=-1,y=-1} + } + for i = 1, #adj do + local q = {x = p.x + adj[i].x, y = p.y + adj[i].y, z = p.z} + if FloorItems.isFloorChangeTile(q) then + return true + end + end + return false +end + +local function getAdjacentApproachPos(playerPos, targetPos) + if not playerPos or not targetPos then return targetPos end + local bestPos, bestDist = nil, math.huge + local adj = { + {x=0,y=-1},{x=1,y=0},{x=0,y=1},{x=-1,y=0}, + {x=1,y=-1},{x=1,y=1},{x=-1,y=1},{x=-1,y=-1} + } + for i = 1, #adj do + local alt = {x = targetPos.x + adj[i].x, y = targetPos.y + adj[i].y, z = targetPos.z} + if not isNearFloorChangePos(alt) then + local d = math.max(math.abs(playerPos.x - alt.x), math.abs(playerPos.y - alt.y)) + if d < bestDist then + bestPos = alt + bestDist = d + end + end + end + return bestPos or targetPos +end + +local function classifyGotoBlock(playerPos, destPos, isFloorChange, maxDist) + if not playerPos or not destPos then return "unknown" end + if playerPos.z ~= destPos.z then return "floor" end + + if not isFloorChange and FloorItems and FloorItems.isFieldTile and FloorItems.isFieldTile(destPos) then + return "field" + end + + local blocker = getBlockingMonster(playerPos, destPos, maxDist) + if blocker then + return "creature" + end + + local Client = getClient() + local tile = (Client and Client.getTile and Client.getTile(destPos)) or (g_map and g_map.getTile and g_map.getTile(destPos)) + if tile and tile.isWalkable and not tile:isWalkable() then + return "wall" + end + + return "unknown" +end + 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]?)") @@ -553,7 +611,7 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) end -- ========== ARRIVAL CHECK ========== - if distX <= precision and distY <= precision then + if dist <= precision then CaveBot.clearWaypointTarget() if isFloorChange then if playerPos.z == expectedFloorAfterChange then @@ -589,12 +647,14 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) end -- ========== MAX RETRIES ========== - local maxRetries = CaveBot.Config.get("mapClick") and 4 or 8 + local maxRetries = 8 + local hardRetryLimit = 14 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 + local reason = classifyGotoBlock(playerPos, destPos, isFloorChange, maxDist) + if (reason == "creature" or reason == "field") and retries < hardRetryLimit then + -- Transient blockers get bounded extra retries before recovery. + CaveBot.delay(120) + return "retry" end return false end @@ -651,8 +711,11 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) -- (ignoreCreatures, ignoreFields, attack blocker) works against a guaranteed- -- walkable recorded position. local walkTarget = destPos + local nearTransition = isNearFloorChangePos(playerPos) or isNearFloorChangePos(destPos) if retries == 0 and not isFloorChange + and not nearTransition + and dist > 6 and WaypointNavigator and type(WaypointNavigator.isRouteBuilt) == "function" and WaypointNavigator.isRouteBuilt() @@ -663,7 +726,7 @@ CaveBot.registerAction("goto", "#46e6a6", function(value, retries, prev) math.abs(lookahead.x - playerPos.x), math.abs(lookahead.y - playerPos.y) ) - if lhDist >= 3 then + if lhDist >= 5 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) @@ -770,16 +833,16 @@ CaveBot.registerAction("use", "#3be4d0", function(value, retries, prev) 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 + if dist > 1 then + -- Walk to an adjacent usable approach tile for reliable interaction. local maxDist = CaveBot.getMaxGotoDistance and CaveBot.getMaxGotoDistance() or 50 - local walkResult = CaveBot.walkTo(pos, maxDist, { + local approachPos = getAdjacentApproachPos(playerPos, pos) + local walkResult = CaveBot.walkTo(approachPos, maxDist, { precision = 1, allowFloorChange = false }) if walkResult then - CaveBot.delay(200) + CaveBot.delay(100) return "retry" end return false @@ -845,16 +908,16 @@ CaveBot.registerAction("usewith", "#3be4d0", function(value, retries, prev) 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 + if dist > 1 then + -- Walk to an adjacent usable approach tile for reliable interaction. local maxDist = CaveBot.getMaxGotoDistance and CaveBot.getMaxGotoDistance() or 50 - local walkResult = CaveBot.walkTo(pos, maxDist, { + local approachPos = getAdjacentApproachPos(playerPos, pos) + local walkResult = CaveBot.walkTo(approachPos, maxDist, { precision = 1, allowFloorChange = false }) if walkResult then - CaveBot.delay(200) + CaveBot.delay(100) return "retry" end return false diff --git a/cavebot/cavebot.lua b/cavebot/cavebot.lua index 0788ff4..0aecafd 100644 --- a/cavebot/cavebot.lua +++ b/cavebot/cavebot.lua @@ -296,9 +296,16 @@ local chebyshevDist = Directions.chebyshevDistance -- SSoT: constants/direction local waypointPositionCache = {} -- Waypoint position cache table local waypointCacheValid = false local waypointCacheFloors = {} +local routeGraphCache = nil +local routeValidationReport = nil +local routeValidationPrintedFingerprint = nil local startupWaypointFound = false local startupCheckTime = nil -- Set on first check to enforce 500ms delay +local WaypointSchema = CaveBot and CaveBot.WaypointSchema +local RouteCompiler = CaveBot and CaveBot.RouteCompiler +local RouteValidator = CaveBot and CaveBot.RouteValidator + --[[ WAYPOINT ENGINE @@ -665,6 +672,7 @@ resetWaypointEngine = function() WaypointEngine.corridorBreachCount = 0 lastDispatchedChild = nil clearWaypointBlacklist() + if CaveBot.NavigationV2 then CaveBot.NavigationV2.reset() end end -- Cache TargetBot function references (avoid repeated table lookups) @@ -734,114 +742,62 @@ if EventBus then end, 5) -- High priority end +-- ============================================================================ +-- NAVIGATION V2 CONTEXT +-- Shared context object passed to NavigationV2.handleFloorChange / +-- NavigationV2.handleRecovery on every macro tick. +-- Callbacks are closures over cavebot.lua locals — valid once macro runs. +-- Data fields are refreshed by updateNavCtx() each tick. +-- ============================================================================ +local navCtx = { + -- Stable callbacks (upvalue closures; all locals fully assigned by macro time). + setLastFloor = function(z) lastPlayerFloor = z end, + setActionRetries = function(n) actionRetries = n end, + clearDelays = function() walkState.delayUntil = 0; cavebotMacro.delay = nil end, + safeResetWalking = function() safeResetWalking() end, + focusWaypoint = function(c, i) focusWaypointForRecovery(c, i) end, + clearBlacklist = function() clearWaypointBlacklist() end, + resetEngine = function() resetWaypointEngine() end, + transitionTo = function(s) transitionTo(s) end, + isBlacklisted = function(c) return isWaypointBlacklisted(c) end, + blacklistWaypoint = function(c) blacklistWaypoint(c) end, + buildCache = function() buildWaypointCache() end, + getWaypointCache = function() return waypointPositionCache end, + getActionCount = function() return ui.list and ui.list:getChildCount() or 0 end, + findNearestSameFloor = function(pp, z, d) return findNearestSameFloorGoto(pp, z, d) end, + getMaxGotoDist = CaveBot.getMaxGotoDistance, + -- Data fields (refreshed by updateNavCtx each tick). + playerPos = nil, + lastPlayerFloor = nil, + focusedChild = nil, + focusedIdx = nil, + waypointCache = nil, + actionCount = 0, + uiList = nil, + engine = WaypointEngine, -- stable table reference +} + +local function updateNavCtx(playerPos) + navCtx.playerPos = playerPos + navCtx.lastPlayerFloor = lastPlayerFloor + navCtx.uiList = ui.list + navCtx.waypointCache = waypointPositionCache + navCtx.actionCount = ui.list:getChildCount() + local fc = ui.list:getFocusedChild() + navCtx.focusedChild = fc + navCtx.focusedIdx = fc and ui.list:getChildIndex(fc) or nil +end + cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking -- Guard: forward-declared functions may not be assigned yet during reload if not buildWaypointCache then return end - -- Z-LEVEL CHANGE: Must run BEFORE shouldSkipExecution so stale delays - -- from the old floor can't block rescue. local playerPos = player and player:getPosition() - if playerPos and lastPlayerFloor and playerPos.z ~= lastPlayerFloor then - -- Determine whether this floor change was intentional (the focused WP is - -- already on the new floor, meaning goto navigated the stairs on purpose) - -- or accidental (player changed floor while targeting a different-floor WP). - local focusedChild = ui and ui.list and ui.list:getFocusedChild() - local focusedIdx = focusedChild and ui.list:getChildIndex(focusedChild) - local focusedWp = focusedIdx and waypointPositionCache[focusedIdx] - -- Case 1: WP is already on the new floor (e.g. the goto navigated to a stair - -- tile whose destination is explicitly recorded with the new floor's z value). - local intentional = focusedWp and focusedWp.isGoto and focusedWp.z == playerPos.z - - -- Case 2: Stair-triggered change — focused WP is a floor-change tile on the - -- OLD floor. The goto walked the player onto a hole/ladder/rope and the - -- server teleported them to the adjacent floor. This is intentional; using - -- findNearestSameFloorGoto here would snap to a WP *before* the stair - -- entrance and create an infinite loop (Wyrm / Banuta routes). - local stairUsed = false - if not intentional and focusedWp and focusedWp.isGoto and focusedWp.z == lastPlayerFloor then - local wpPos = { x = focusedWp.x, y = focusedWp.y, z = focusedWp.z } - if FloorItems and FloorItems.isFloorChangeTile then - stairUsed = FloorItems.isFloorChangeTile(wpPos) - end - end - - -- Always clear stale walk state regardless of intent - walkState.delayUntil = 0 - cavebotMacro.delay = nil - safeResetWalking() - - if intentional then - -- Intentional stair use: the current WP is already on this floor. - -- Don't refocus — let the goto action complete normally. - -- Clear blacklists so fresh state on new floor, but keep engine in NORMAL. - clearWaypointBlacklist() - WaypointEngine.failureCount = 0 - print("[CaveBot] Z-change (" .. lastPlayerFloor .. "→" .. playerPos.z .. "): intentional, continuing WP" .. (focusedIdx or "?")) - elseif stairUsed then - -- Stair tile on the old floor caused this change (goto-driven stair use). - -- Advance to the next WP in sequence (currentIndex+1) — this preserves - -- correct route order instead of falling through to the Z-mismatch scan. - clearWaypointBlacklist() - WaypointEngine.failureCount = 0 - local actionCount = ui.list:getChildCount() - if focusedIdx and actionCount > 0 then - local nextIdx = (focusedIdx % actionCount) + 1 - local nextChild = ui.list:getChildByIndex(nextIdx) - if nextChild then - ui.list:focusChild(nextChild) - actionRetries = 0 - end - end - print("[CaveBot] Z-change (" .. lastPlayerFloor .. "→" .. playerPos.z .. "): stair at WP" .. (focusedIdx or "?") .. ", advancing to next WP") - else - -- Accidental floor change: reset fully and find nearest same-floor WP. - -- Forward-biased: scan forward from current index first, prefer closest - -- forward WP, then wrap to beginning for rescue WPs. - clearWaypointBlacklist() - resetWaypointEngine() - local maxDist = CaveBot.getMaxGotoDistance() - local bestChild, bestIdx, bestDist = nil, nil, math.huge - local actionCount = ui.list:getChildCount() - buildWaypointCache() - -- Forward scan: from focusedIdx+1 to end - if focusedIdx and actionCount > 0 then - for i = focusedIdx + 1, actionCount do - local wp = waypointPositionCache[i] - if wp and wp.isGoto and wp.z == playerPos.z then - local d = math.max(math.abs(playerPos.x - wp.x), math.abs(playerPos.y - wp.y)) - if d <= maxDist and d < bestDist then - bestChild, bestIdx, bestDist = wp.child, i, d - break -- First forward match wins (closest in sequence) - end - end - end - -- Wrap scan: from 1 to focusedIdx for rescue WPs - if not bestChild then - for i = 1, (focusedIdx or actionCount) do - local wp = waypointPositionCache[i] - if wp and wp.isGoto and wp.z == playerPos.z then - local d = math.max(math.abs(playerPos.x - wp.x), math.abs(playerPos.y - wp.y)) - if d <= maxDist and d < bestDist then - bestChild, bestIdx, bestDist = wp.child, i, d - end - end - end - end - end - -- Fallback: distance-based (if forward scan found nothing within maxDist) - if not bestChild then - bestChild, bestIdx = findNearestSameFloorGoto(playerPos, playerPos.z, maxDist) - end - if bestChild then - print("[CaveBot] Z-change (" .. lastPlayerFloor .. "→" .. playerPos.z .. "): accidental, focusing WP" .. bestIdx) - focusWaypointForRecovery(bestChild, bestIdx) - end - end - - lastPlayerFloor = playerPos.z - return -- Consume this tick for the Z transition + -- FLOOR TRACKING + Z-LEVEL CHANGE: delegated to NavigationV2. + updateNavCtx(playerPos) + if CaveBot.NavigationV2 and CaveBot.NavigationV2.handleFloorChange(navCtx) then + return end - if playerPos then lastPlayerFloor = playerPos.z end -- SMART EXECUTION: Skip if we shouldn't execute this tick if shouldSkipExecution() then return end @@ -858,9 +814,9 @@ cavebotMacro = macro(75, function() -- 75ms for smooth, responsive walking checkStartupWaypoint() end - -- WAYPOINT ENGINE: High-performance stuck detection and recovery - if runWaypointEngine() then - return -- Engine handled recovery, skip normal processing + -- NAVIGATION V2: Recovery engine (handles RECOVERING/HARD_STUCK states). + if CaveBot.NavigationV2 and CaveBot.NavigationV2.handleRecovery(navCtx) then + return end -- Lazy-init TargetBot cache @@ -1386,10 +1342,15 @@ end -- Get waypoint engine statistics CaveBot.getWaypointStats = function() + local navV2State = nil + if CaveBot.NavigationV2 and CaveBot.NavigationV2.getState then + navV2State = CaveBot.NavigationV2.getState() + end return { state = WaypointEngine.state, failureCount = WaypointEngine.failureCount, - isRecovering = (WaypointEngine.state == "RECOVERING") + isRecovering = (WaypointEngine.state == "RECOVERING"), + navV2State = navV2State } end @@ -1422,6 +1383,10 @@ end -- @return table {x, y, z} or nil local function parseWaypointPosition(text) if not text then return nil end + if WaypointSchema and WaypointSchema.parsePositionFromText then + local parsed = WaypointSchema.parsePositionFromText(text) + if parsed then return parsed end + end -- Detect usewith prefix: format is "usewith:itemid,x,y,z" — skip itemid local prefix = text:match("^(%w+):") if prefix and prefix:lower() == "usewith" then @@ -1463,6 +1428,8 @@ invalidateWaypointCache = function() waypointPositionCache = {} waypointCacheValid = false waypointCacheFloors = {} + routeGraphCache = nil + routeValidationReport = nil -- Invalidate WaypointNavigator route (segment cache is stale) if WaypointNavigator and WaypointNavigator.invalidate then WaypointNavigator.invalidate() @@ -1487,9 +1454,17 @@ buildWaypointCache = function() waypointPositionCache = {} waypointCacheFloors = {} local actions = ui.list:getChildren() + local routeNodes = {} for i, child in ipairs(actions) do local text = child:getText() + local parsed = WaypointSchema and WaypointSchema.parseLine and WaypointSchema.parseLine(text, i) or nil + if parsed then + parsed.child = child + parsed.action = child.action or parsed.action + routeNodes[#routeNodes + 1] = parsed + end + local pos = parseWaypointPosition(text) if pos then waypointPositionCache[i] = { @@ -1503,10 +1478,34 @@ buildWaypointCache = function() waypointCacheFloors[pos.z] = true end end + + if #routeNodes > 0 and RouteCompiler and RouteCompiler.compile then + routeGraphCache = RouteCompiler.compile(routeNodes) + if RouteValidator and RouteValidator.validate then + routeValidationReport = RouteValidator.validate(routeGraphCache) + if routeValidationReport and not routeValidationReport.ok then + local fingerprint = tostring(routeValidationReport.errors) .. ":" .. tostring(routeValidationReport.warnings) + if fingerprint ~= routeValidationPrintedFingerprint then + routeValidationPrintedFingerprint = fingerprint + warn("[CaveBot] Route validation found " .. routeValidationReport.errors .. " error(s) and " .. routeValidationReport.warnings .. " warning(s)") + end + end + end + end waypointCacheValid = true end +CaveBot.getRouteGraph = function() + buildWaypointCache() + return routeGraphCache +end + +CaveBot.getRouteValidationReport = function() + buildWaypointCache() + return routeValidationReport +end + -- Lightweight distance-only search for the nearest goto WP on a given floor. -- No path validation — the goto callback's walkTo handles pathfinding. -- Used for immediate rescue WP focus after accidental floor changes. diff --git a/cavebot/config.lua b/cavebot/config.lua index eee5b3b..33ddbb3 100644 --- a/cavebot/config.lua +++ b/cavebot/config.lua @@ -19,8 +19,6 @@ CaveBot.Config.setup = function() add("walkDelay", "Walk delay", 10) add("ignoreFields", "Ignore fields", true) add("walkingDebug", "Walking debug", false) -- Disabled by default for performance - add("skipBlocked", "Skip blocked path", false) - add("mapClick", "Map click walking", true) add("useDelay", "Delay after use", 400) add("autoUseTools", "Auto use tools", true) add("autoOpenDoors", "Auto open doors", true) diff --git a/cavebot/config_loader.lua b/cavebot/config_loader.lua new file mode 100644 index 0000000..27f4b04 --- /dev/null +++ b/cavebot/config_loader.lua @@ -0,0 +1,51 @@ +-- config_loader.lua +-- Utilities to load and inspect cfg text using the v2 route pipeline. + +CaveBot = CaveBot or {} + +local ConfigLoader = {} + +local WaypointSchema = CaveBot.WaypointSchema +local RouteCompiler = CaveBot.RouteCompiler +local RouteValidator = CaveBot.RouteValidator + +local function trim(s) + if not s then return "" end + return (s:gsub("^%s+", ""):gsub("%s+$", "")) +end + +function ConfigLoader.parseText(cfgText) + local nodes = {} + if type(cfgText) ~= "string" then + return nodes + end + + local idx = 0 + for line in cfgText:gmatch("[^\r\n]+") do + local raw = trim(line) + if raw ~= "" and not raw:match("^config:%s*") and not raw:match("^extensions:%s*") then + idx = idx + 1 + local parsed = WaypointSchema and WaypointSchema.parseLine and WaypointSchema.parseLine(raw, idx) or nil + if parsed then + nodes[#nodes + 1] = parsed + end + end + end + + return nodes +end + +function ConfigLoader.loadFromText(cfgText) + local nodes = ConfigLoader.parseText(cfgText) + local route = RouteCompiler and RouteCompiler.compile and RouteCompiler.compile(nodes) or nil + local report = RouteValidator and RouteValidator.validate and RouteValidator.validate(route) or nil + + return { + nodes = nodes, + route = route, + report = report, + } +end + +CaveBot.ConfigLoader = ConfigLoader +return ConfigLoader diff --git a/cavebot/doors.lua b/cavebot/doors.lua index 23f9489..c832633 100644 --- a/cavebot/doors.lua +++ b/cavebot/doors.lua @@ -2,6 +2,27 @@ CaveBot.Extensions.OpenDoors = {} local getClient = nExBot.Shared.getClient +local function chebyshev(a, b) + return math.max(math.abs(a.x - b.x), math.abs(a.y - b.y)) +end + +local function getDoorApproachPos(doorPos, playerPos) + local offsets = { + {x=0,y=-1},{x=1,y=0},{x=0,y=1},{x=-1,y=0}, + {x=1,y=-1},{x=1,y=1},{x=-1,y=1},{x=-1,y=-1} + } + local best, bestDist = nil, math.huge + for i = 1, #offsets do + local p = {x = doorPos.x + offsets[i].x, y = doorPos.y + offsets[i].y, z = doorPos.z} + local d = chebyshev(playerPos, p) + if d < bestDist then + best = p + bestDist = d + end + end + return best +end + CaveBot.Extensions.OpenDoors.setup = function() CaveBot.registerAction("OpenDoors", "#6be8e0", function(value, retries) local pos = string.split(value, ",") @@ -20,6 +41,22 @@ CaveBot.Extensions.OpenDoors.setup = function() end pos = {x=tonumber(pos[1]), y=tonumber(pos[2]), z=tonumber(pos[3])} + local playerPos = player:getPosition() + if not playerPos then return false end + + if chebyshev(playerPos, pos) > 1 then + local maxDist = CaveBot.getMaxGotoDistance and CaveBot.getMaxGotoDistance() or 50 + local approachPos = getDoorApproachPos(pos, playerPos) + local walkResult = CaveBot.walkTo(approachPos, maxDist, { + precision = 1, + allowFloorChange = false, + }) + if walkResult then + CaveBot.delay(100) + return "retry" + end + return false + end local Client = getClient() local doorTile = (Client and Client.getTile) and Client.getTile(pos) or (g_map and g_map.getTile(pos)) diff --git a/cavebot/loop_guard_v2.lua b/cavebot/loop_guard_v2.lua new file mode 100644 index 0000000..a40de9a --- /dev/null +++ b/cavebot/loop_guard_v2.lua @@ -0,0 +1,76 @@ +-- loop_guard_v2.lua +-- Ring-buffer waypoint-cycle detector. +-- Records each recovery-focus event and detects oscillation (A→B→A cycling). +-- Integrated into NavigationV2's recovery path to break infinite blacklist loops. + +CaveBot = CaveBot or {} + +local RING_SIZE = 8 -- slots in the circular buffer +local CYCLE_THRESHOLD = 3 -- same WP index seen >= this many times in window → cycling +local WINDOW_MS = 30000 -- 30-second detection window + +local LoopGuard = { + _ring = {}, -- circular buffer: each slot = { idx=int, t=ms } + _head = 0, -- index of last-written slot (1-based, wraps mod RING_SIZE) + _size = 0, -- number of filled slots (up to RING_SIZE) + activations = 0, -- cumulative count of cycle-break escalations + lastActivation = 0, -- `now` timestamp of the last escalation + COOLDOWN = 5000, -- ms minimum between consecutive escalations +} + +-- Record that NavigationV2 recovery just focused WP at `idx`. +function LoopGuard.recordFocus(idx) + if not idx then return end + LoopGuard._head = (LoopGuard._head % RING_SIZE) + 1 + LoopGuard._ring[LoopGuard._head] = { idx = idx, t = now } + if LoopGuard._size < RING_SIZE then + LoopGuard._size = LoopGuard._size + 1 + end +end + +-- Count appearances of `idx` in the ring within the last WINDOW_MS. +local function countInWindow(idx) + local cutoff = now - WINDOW_MS + local n = 0 + for i = 1, LoopGuard._size do + local slot = LoopGuard._ring[((LoopGuard._head - i) % RING_SIZE) + 1] + if slot and slot.t >= cutoff and slot.idx == idx then + n = n + 1 + end + end + return n +end + +-- Returns true if focusing `idx` again would indicate a repeating cycle. +function LoopGuard.isCycling(idx) + if not idx then return false end + return countInWindow(idx) >= CYCLE_THRESHOLD +end + +-- Clear the ring buffer (call on config change or confirmed route advance). +function LoopGuard.reset() + LoopGuard._ring = {} + LoopGuard._head = 0 + LoopGuard._size = 0 +end + +-- Record that a cycle was broken via blacklist escalation (for cooldown tracking). +function LoopGuard.markActivation() + LoopGuard.activations = LoopGuard.activations + 1 + LoopGuard.lastActivation = now +end + +-- True if still within cooldown period after the last escalation. +function LoopGuard.isCoolingDown() + return (now - LoopGuard.lastActivation) < LoopGuard.COOLDOWN +end + +function LoopGuard.getMetrics() + return { + activations = LoopGuard.activations, + lastActivation = LoopGuard.lastActivation, + } +end + +CaveBot.LoopGuard = LoopGuard +return LoopGuard diff --git a/cavebot/navigation_v2.lua b/cavebot/navigation_v2.lua new file mode 100644 index 0000000..ffa86f0 --- /dev/null +++ b/cavebot/navigation_v2.lua @@ -0,0 +1,360 @@ +-- navigation_v2.lua +-- Floor-change handler and enhanced recovery engine for CaveBot. +-- Called from the macro via CaveBot.NavigationV2.handleFloorChange(navCtx) +-- and CaveBot.NavigationV2.handleRecovery(navCtx). +-- +-- Context object (navCtx) fields — see cavebot.lua for builder: +-- playerPos, lastPlayerFloor, focusedChild, focusedIdx +-- waypointCache, actionCount, uiList, engine (WaypointEngine table) +-- setLastFloor(z), setActionRetries(n), clearDelays() +-- safeResetWalking(), focusWaypoint(child,idx) +-- clearBlacklist(), resetEngine(), transitionTo(state) +-- isBlacklisted(child), blacklistWaypoint(child) +-- buildCache(), findNearestSameFloor(pp,z,maxDist), getMaxGotoDist() + +CaveBot = CaveBot or {} + +local STATE = { + TRACKING = "TRACKING", + RECOVERING = "RECOVERING", + HARD_STUCK = "HARD_STUCK", +} + +local NAV = { + STATE = STATE, + -- Internal state + _hardStuck = false, + _lastRecoveryAt = 0, + -- Metrics + _metrics = { + zIntentional = 0, + zStair = 0, + zAccidental = 0, + recoveryRuns = 0, + hardStuckCount = 0, + loopGuardBreaks = 0, + }, +} + +-- ── FLOOR-CHANGE HANDLER ───────────────────────────────────────────────────── +-- Called every macro tick. Updates lastPlayerFloor and, when a Z-change is +-- detected, classifies it as intentional / stair-triggered / accidental and +-- takes the appropriate recovery action. +-- Returns true when the tick was consumed (caller should `return` immediately). +function NAV.handleFloorChange(ctx) + local playerPos = ctx.playerPos + + -- Always keep lastPlayerFloor current (also handles non-Z-change ticks). + if playerPos then + ctx.setLastFloor(playerPos.z) + end + + local lastFloor = ctx.lastPlayerFloor + if not (playerPos and lastFloor and playerPos.z ~= lastFloor) then + return false -- no floor change this tick + end + + -- Floor changed: clear stale delays and walk state unconditionally. + ctx.clearDelays() + ctx.safeResetWalking() + + local focusedChild = ctx.focusedChild + local focusedIdx = ctx.focusedIdx + local wp = focusedIdx and ctx.waypointCache[focusedIdx] + + -- ── Case 1: Intentional ──────────────────────────────────────────────────── + -- The focused WP is a goto already on the new floor (the goto action chose + -- this floor; the WP was recorded with the destination floor's z value). + local intentional = wp and wp.isGoto and (wp.z == playerPos.z) + if intentional then + ctx.clearBlacklist() + ctx.engine.failureCount = 0 + NAV._metrics.zIntentional = NAV._metrics.zIntentional + 1 + print(("[CaveBot] Z-change (%d→%d): intentional, continuing WP%s"):format( + lastFloor, playerPos.z, tostring(focusedIdx or "?"))) + return true + end + + -- ── Case 2: Stair-triggered ──────────────────────────────────────────────── + -- The focused WP is a floor-change tile on the OLD floor (goto walked the + -- player onto a hole/ladder/rope and the server teleported them down/up). + local stairUsed = false + if wp and wp.isGoto and (wp.z == lastFloor) then + local wpPos = { x = wp.x, y = wp.y, z = wp.z } + if FloorItems and FloorItems.isFloorChangeTile then + stairUsed = FloorItems.isFloorChangeTile(wpPos) + end + end + + if stairUsed then + ctx.clearBlacklist() + ctx.engine.failureCount = 0 + NAV._metrics.zStair = NAV._metrics.zStair + 1 + -- Advance to the next WP in sequence to preserve route order. + if focusedIdx and ctx.actionCount > 0 then + local nextIdx = (focusedIdx % ctx.actionCount) + 1 + local nextChild = ctx.uiList:getChildByIndex(nextIdx) + if nextChild then + ctx.uiList:focusChild(nextChild) + ctx.setActionRetries(0) + end + end + print(("[CaveBot] Z-change (%d→%d): stair at WP%s, advancing"):format( + lastFloor, playerPos.z, tostring(focusedIdx or "?"))) + return true + end + + -- ── Case 3: Accidental ──────────────────────────────────────────────────── + -- Player ended up on a different floor without intent — reset and refocus. + NAV._metrics.zAccidental = NAV._metrics.zAccidental + 1 + ctx.clearBlacklist() + ctx.resetEngine() + ctx.buildCache() + local wc = ctx.getWaypointCache and ctx.getWaypointCache() or ctx.waypointCache + local actionCount = ctx.getActionCount and ctx.getActionCount() or ctx.actionCount + + local maxDist = ctx.getMaxGotoDist() + local curIdx = focusedIdx or 0 + local bestChild, bestIdx, bestDist = nil, nil, math.huge + + -- Forward scan from curIdx+1 → end (first in-sequence match wins). + for i = curIdx + 1, actionCount do + local wpi = wc[i] + if wpi and wpi.isGoto and wpi.z == playerPos.z then + local d = math.max(math.abs(playerPos.x - wpi.x), math.abs(playerPos.y - wpi.y)) + if d <= maxDist then + bestChild, bestIdx = wpi.child, i + break + end + end + end + + -- Wrap scan from 1 → curIdx-1 (rescue WPs at start of route). + if not bestChild then + for i = 1, math.max(1, curIdx - 1) do + local wpi = wc[i] + if wpi and wpi.isGoto and wpi.z == playerPos.z then + local d = math.max(math.abs(playerPos.x - wpi.x), math.abs(playerPos.y - wpi.y)) + if d <= maxDist and d < bestDist then + bestChild, bestIdx, bestDist = wpi.child, i, d + end + end + end + end + + -- Distance fallback (searches all same-floor WPs by Chebyshev). + if not bestChild and ctx.findNearestSameFloor then + bestChild, bestIdx = ctx.findNearestSameFloor(playerPos, playerPos.z, maxDist) + end + + -- Exhaustive fallback: scan all goto WPs and pick the best rescue candidate + -- by floor proximity first, then distance. This prevents idle stops when all + -- nearby same-floor WPs are out of maxDist during accidental Z changes. + if not bestChild and wc and actionCount > 0 then + local bestScore = math.huge + for i = 1, actionCount do + local wpi = wc[i] + if wpi and wpi.isGoto and not ctx.isBlacklisted(wpi.child) then + local floorPenalty = math.abs((wpi.z or playerPos.z) - playerPos.z) * 1000 + local d = math.max(math.abs(playerPos.x - wpi.x), math.abs(playerPos.y - wpi.y)) + local score = floorPenalty + d + if score < bestScore then + bestScore = score + bestChild = wpi.child + bestIdx = i + end + end + end + end + + if bestChild then + print(("[CaveBot] Z-change (%d→%d): accidental, focusing WP%d"):format( + lastFloor, playerPos.z, bestIdx)) + ctx.focusWaypoint(bestChild, bestIdx) + else + print(("[CaveBot] Z-change (%d→%d): accidental, no same-floor WP in range"):format( + lastFloor, playerPos.z)) + end + + return true -- tick consumed +end + +-- ── INTERNAL: Enhanced recovery execution ──────────────────────────────────── +-- WaypointNavigator primary → RecoveryPlanner 4-tier fallback → hard-stuck valve. +local function executeEnhancedRecovery(ctx) + -- Throttle: at most once per second to avoid hammering PathStrategy. + if (now - NAV._lastRecoveryAt) < 1000 then return end + NAV._lastRecoveryAt = now + NAV._metrics.recoveryRuns = NAV._metrics.recoveryRuns + 1 + + local playerPos = ctx.playerPos + if not playerPos then return end + + -- Always work on fresh cache snapshots after editor/config/runtime updates. + ctx.buildCache() + local waypointCache = ctx.getWaypointCache and ctx.getWaypointCache() or ctx.waypointCache + local actionCount = ctx.getActionCount and ctx.getActionCount() or ctx.actionCount + + local engine = ctx.engine + + -- Hard-stuck safety valve: if recovery has been running for too long, + -- clear all blacklists and flag hard-stuck for expanded-radius search. + local idleTimeout = engine.RECOVERY_IDLE_TIMEOUT or 12000 + if engine.recoveryStartedAt > 0 and (now - engine.recoveryStartedAt) > idleTimeout then + warn(("[CaveBot] Recovery idle %ds — clearing blacklists"):format( + math.floor(idleTimeout / 1000))) + ctx.clearBlacklist() + engine.recoveryStartedAt = now + NAV._metrics.hardStuckCount = NAV._metrics.hardStuckCount + 1 + NAV._hardStuck = true + end + + -- Helper: attempt to focus a recovery WP with loop-guard protection. + -- Returns true if focus was accepted; false if cycling detected (WP blacklisted). + local function tryFocus(child, idx, label) + if not child then return false end + local lg = CaveBot.LoopGuard + if lg and lg.isCycling(idx) and not lg.isCoolingDown() then + NAV._metrics.loopGuardBreaks = NAV._metrics.loopGuardBreaks + 1 + lg.markActivation() + ctx.blacklistWaypoint(child) + print(("[CaveBot] LoopGuard: WP%d cycling, extending blacklist"):format(idx)) + return false + end + if lg then lg.recordFocus(idx) end + ctx.focusWaypoint(child, idx) + ctx.transitionTo("NORMAL") + return true + end + + -- ── PRIMARY: WaypointNavigator segment-aware forward recovery ───────────── + if WaypointNavigator and type(CaveBot.ensureNavigatorRoute) == "function" then + CaveBot.ensureNavigatorRoute(playerPos.z) + local wpIdx + if type(WaypointNavigator.getNextWaypoint) == "function" then + wpIdx = WaypointNavigator.getNextWaypoint(playerPos) + end + if wpIdx then + local wp = waypointCache[wpIdx] + -- If the suggested WP is blacklisted, walk forward through gotoIndices. + if wp and wp.child and ctx.isBlacklisted(wp.child) then + local gotoIndices = (WaypointNavigator.getGotoIndices and WaypointNavigator.getGotoIndices()) or {} + local originalIdx = wpIdx + local found = false + for pass = 1, 2 do + local pastOriginal = (pass == 2) + for _, gIdx in ipairs(gotoIndices) do + if pass == 1 then + if gIdx == originalIdx then + pastOriginal = true + elseif pastOriginal then + local fwdWp = waypointCache[gIdx] + if fwdWp and fwdWp.child and not ctx.isBlacklisted(fwdWp.child) and fwdWp.z == playerPos.z then + wp = fwdWp; wpIdx = gIdx; found = true; break + end + end + else -- pass 2: wrap from start to originalIdx + if gIdx == originalIdx then break end + local fwdWp = waypointCache[gIdx] + if fwdWp and fwdWp.child and not ctx.isBlacklisted(fwdWp.child) and fwdWp.z == playerPos.z then + wp = fwdWp; wpIdx = gIdx; found = true; break + end + end + end + if found then break end + end + end + if wp and wp.child and not ctx.isBlacklisted(wp.child) then + local d = math.max(math.abs(playerPos.x - wp.x), math.abs(playerPos.y - wp.y)) + if d <= 3 then + -- Already within arrival distance: snap focus and return to NORMAL. + ctx.uiList:focusChild(wp.child) + ctx.setActionRetries(0) + ctx.transitionTo("NORMAL") + return + end + if tryFocus(wp.child, wpIdx, "navigator") then return end + -- Loop-guard blocked this WP; fall through to RecoveryPlanner. + end + end + end + + -- ── FALLBACK: RecoveryPlanner 4-tier ladder ──────────────────────────────── + -- Reload focusedIdx in case earlier tick logic changed focus. + local fc = ctx.uiList:getFocusedChild() + local curFocusedIdx = fc and ctx.uiList:getChildIndex(fc) or ctx.focusedIdx + + local rp = CaveBot.RecoveryPlanner + if rp then + local child, idx, tier = rp.findTarget( + playerPos, ctx.isBlacklisted, waypointCache, curFocusedIdx, actionCount, NAV._hardStuck) + if child then + if tryFocus(child, idx, "planner_t" .. tostring(tier)) then + print(("[CaveBot] Recovery (tier %d): WP%d"):format(tier, idx)) + return + end + -- Cycling: blacklist extended, retry next tick. + return + end + end + + -- All recovery options exhausted: idle in RECOVERING. + -- The safety valve above will clear blacklists after idleTimeout. +end + +-- ── RECOVERY HANDLER ───────────────────────────────────────────────────────── +-- Called every macro tick (after shouldSkipExecution). +-- Returns true when this tick was consumed by recovery logic. +function NAV.handleRecovery(ctx) + local engine = ctx.engine + if not engine then return false end + + local state = engine.state + + if state == "NORMAL" then + if engine.failureCount >= (engine.FAILURE_THRESHOLD or 3) then + -- Threshold crossed: transition to RECOVERING (transitionTo sets recoveryStartedAt). + ctx.transitionTo("RECOVERING") + return true + end + -- Engine healthy: clear hard-stuck flag. + NAV._hardStuck = false + return false + + elseif state == "RECOVERING" then + -- Ensure recoveryStartedAt is set (transitionTo may have been called externally). + if engine.recoveryStartedAt == 0 then + engine.recoveryStartedAt = now + end + executeEnhancedRecovery(ctx) + return true + end + + return false +end + +-- ── PUBLIC INTERFACE ────────────────────────────────────────────────────────── + +function NAV.getState() + if NAV._hardStuck then return STATE.HARD_STUCK end + return STATE.TRACKING +end + +function NAV.getMetrics() + return { + state = NAV.getState(), + hardStuck = NAV._hardStuck, + metrics = NAV._metrics, + recovery = CaveBot.RecoveryPlanner and CaveBot.RecoveryPlanner.getMetrics() or nil, + loopGuard = CaveBot.LoopGuard and CaveBot.LoopGuard.getMetrics() or nil, + } +end + +function NAV.reset() + NAV._hardStuck = false + NAV._lastRecoveryAt = 0 + if CaveBot.LoopGuard then CaveBot.LoopGuard.reset() end +end + +CaveBot.NavigationV2 = NAV +return NAV diff --git a/cavebot/recovery_planner_v2.lua b/cavebot/recovery_planner_v2.lua new file mode 100644 index 0000000..91d0934 --- /dev/null +++ b/cavebot/recovery_planner_v2.lua @@ -0,0 +1,159 @@ +-- recovery_planner_v2.lua +-- 5-tier sequence-aware recovery ladder. +-- Used by NavigationV2 as fallback when WaypointNavigator cannot find a target. +-- +-- Tier 1 (A): Forward-in-sequence, same floor, within maxGotoDist +-- Tier 2 (B): Nearest valid same-floor WP (distance-sorted + strict path check) +-- Tier 3 (C): Adjacent-floor (±1) rescue +-- Tier 4 (D): Backtrack window (up to 10 WPs behind, ignores blacklist) +-- Tier 5 (E): Global all-floor rescue sweep (intelligent loop through all WPs) + +CaveBot = CaveBot or {} + +local RecoveryPlanner = { + lastTier = nil, + tierHits = { 0, 0, 0, 0, 0 }, +} + +-- Chebyshev distance (matches OTClient diagonal-movement semantics). +local function cdist(a, b) + if not a or not b then return math.huge end + return math.max(math.abs(a.x - b.x), math.abs(a.y - b.y)) +end + +-- Strict path-existence check: no ignoreNonPathable so walls block correctly. +-- Falls back optimistic when PathStrategy is unavailable. +local function hasPath(fromPos, toWp) + if not fromPos or not toWp then return false end + if fromPos.z ~= toWp.z then return true end -- cross-floor: accepted, goto handles it + local ps = (nExBot and nExBot.PathStrategy) or PathStrategy + if not (ps and ps.findPath) then return true end + local path = ps.findPath(fromPos, { x = toWp.x, y = toWp.y, z = toWp.z }, + { ignoreCreatures = true, maxDist = 70 }) + return path ~= nil and #path > 0 +end + +-- Record a tier hit and return the result triple for callers. +local function hitTier(n, child, idx) + RecoveryPlanner.lastTier = n + RecoveryPlanner.tierHits[n] = (RecoveryPlanner.tierHits[n] or 0) + 1 + return child, idx, n +end + +-- findTarget(playerPos, isBlacklisted, waypointCache, focusedIdx, actionCount, hardStuck) +-- Returns: child, idx, tier OR nil, nil, nil +function RecoveryPlanner.findTarget(playerPos, isBlacklisted, waypointCache, focusedIdx, actionCount, hardStuck) + if not playerPos or not waypointCache or actionCount == 0 then + return nil, nil, nil + end + + local maxDist = (CaveBot.getMaxGotoDistance and CaveBot.getMaxGotoDistance()) or 50 + local curIdx = focusedIdx or 0 + + -- ── TIER A: Forward-in-sequence, same floor ──────────────────────────────── + -- Pass 1: curIdx+1 → end. Pass 2: wrap 1 → curIdx-1. + for pass = 1, 2 do + local iStart = (pass == 1) and (curIdx + 1) or 1 + local iEnd = (pass == 1) and actionCount or math.max(1, curIdx - 1) + for i = iStart, iEnd do + local wp = waypointCache[i] + if wp and wp.isGoto and wp.z == playerPos.z then + if not isBlacklisted(wp.child) and cdist(playerPos, wp) <= maxDist then + return hitTier(1, wp.child, i) + end + end + end + end + + -- ── TIER B: Nearest valid same-floor (distance-sorted, path-checked) ──────── + local sameFloor = {} + for i = 1, actionCount do + local wp = waypointCache[i] + if wp and wp.isGoto and wp.z == playerPos.z and (hardStuck or not isBlacklisted(wp.child)) then + sameFloor[#sameFloor + 1] = { child = wp.child, idx = i, dist = cdist(playerPos, wp), wp = wp } + end + end + table.sort(sameFloor, function(a, b) return a.dist < b.dist end) + -- Prefer a candidate with a confirmed path. + for _, c in ipairs(sameFloor) do + if hasPath(playerPos, c.wp) then + return hitTier(2, c.child, c.idx) + end + end + -- Accept nearest even without confirmed path (goto callback will handle failure). + if sameFloor[1] then + return hitTier(2, sameFloor[1].child, sameFloor[1].idx) + end + + -- ── TIER C: Adjacent floor ±1 rescue ────────────────────────────────────── + local cross = {} + for i = 1, actionCount do + local wp = waypointCache[i] + if wp and wp.isGoto and math.abs(wp.z - playerPos.z) == 1 and (hardStuck or not isBlacklisted(wp.child)) then + cross[#cross + 1] = { child = wp.child, idx = i, dist = cdist(playerPos, wp) } + end + end + table.sort(cross, function(a, b) return a.dist < b.dist end) + if cross[1] then + return hitTier(3, cross[1].child, cross[1].idx) + end + + -- ── TIER D: Backtrack window (last 10 WPs, ignores blacklist for rescue) ──── + local back = {} + local backStart = math.max(1, curIdx - 10) + local backEnd = math.max(1, curIdx - 1) + for i = backEnd, backStart, -1 do + local wp = waypointCache[i] + if wp and wp.isGoto and wp.z == playerPos.z then + back[#back + 1] = { child = wp.child, idx = i, dist = cdist(playerPos, wp) } + end + end + table.sort(back, function(a, b) return a.dist < b.dist end) + if back[1] then + return hitTier(4, back[1].child, back[1].idx) + end + + -- ── TIER E: Global all-floor rescue sweep ───────────────────────────────── + -- Intelligent full-loop over every goto waypoint. Scores by floor proximity + -- then distance, preferring path-confirmed same-floor candidates. + local global = {} + for i = 1, actionCount do + local wp = waypointCache[i] + if wp and wp.isGoto and (hardStuck or not isBlacklisted(wp.child)) then + local floorDiff = math.abs((wp.z or playerPos.z) - playerPos.z) + local dist = cdist(playerPos, wp) + local score = (floorDiff * 1000) + dist + global[#global + 1] = { child = wp.child, idx = i, wp = wp, score = score, floorDiff = floorDiff } + end + end + + table.sort(global, function(a, b) return a.score < b.score end) + + for _, g in ipairs(global) do + if g.floorDiff > 0 or hasPath(playerPos, g.wp) then + return hitTier(5, g.child, g.idx) + end + end + + if global[1] then + return hitTier(5, global[1].child, global[1].idx) + end + + return nil, nil, nil +end + +function RecoveryPlanner.getMetrics() + return { + lastTier = RecoveryPlanner.lastTier, + tierHits = { + RecoveryPlanner.tierHits[1], + RecoveryPlanner.tierHits[2], + RecoveryPlanner.tierHits[3], + RecoveryPlanner.tierHits[4], + RecoveryPlanner.tierHits[5], + }, + } +end + +CaveBot.RecoveryPlanner = RecoveryPlanner +return RecoveryPlanner diff --git a/cavebot/route_compiler.lua b/cavebot/route_compiler.lua new file mode 100644 index 0000000..ddd5f0c --- /dev/null +++ b/cavebot/route_compiler.lua @@ -0,0 +1,91 @@ +-- route_compiler.lua +-- Compiles parsed waypoint nodes into a deterministic route graph. + +CaveBot = CaveBot or {} + +local RouteCompiler = {} + +local function positionKey(p) + if not p then return nil end + return table.concat({ tostring(p.x), tostring(p.y), tostring(p.z) }, ":") +end + +local function addFloorIndex(byFloor, z, idx) + if not z then return end + local list = byFloor[z] + if not list then + list = {} + byFloor[z] = list + end + list[#list + 1] = idx +end + +function RouteCompiler.compile(nodes) + local route = { + nodes = nodes or {}, + nodeCount = 0, + gotoIndices = {}, + byFloor = {}, + sequentialEdges = {}, + gotoEdges = {}, + floorTransitions = {}, + positionHistogram = {}, + anchorByFloor = {}, + } + + route.nodeCount = #route.nodes + if route.nodeCount == 0 then + return route + end + + for i, node in ipairs(route.nodes) do + if node.pos then + addFloorIndex(route.byFloor, node.pos.z, i) + local k = positionKey(node.pos) + if k then + route.positionHistogram[k] = (route.positionHistogram[k] or 0) + 1 + end + end + + if node.isGoto then + route.gotoIndices[#route.gotoIndices + 1] = i + if node.pos and not route.anchorByFloor[node.pos.z] then + route.anchorByFloor[node.pos.z] = i + end + end + + local nextIdx = (i % route.nodeCount) + 1 + route.sequentialEdges[#route.sequentialEdges + 1] = { from = i, to = nextIdx } + end + + for i = 1, #route.gotoIndices do + local fromIdx = route.gotoIndices[i] + local toIdx = route.gotoIndices[(i % #route.gotoIndices) + 1] + local from = route.nodes[fromIdx] + local to = route.nodes[toIdx] + local dz = nil + if from and from.pos and to and to.pos then + dz = to.pos.z - from.pos.z + if dz ~= 0 then + route.floorTransitions[#route.floorTransitions + 1] = { + from = fromIdx, + to = toIdx, + fromZ = from.pos.z, + toZ = to.pos.z, + dz = dz, + } + end + end + + route.gotoEdges[#route.gotoEdges + 1] = { + from = fromIdx, + to = toIdx, + dz = dz, + } + end + + return route +end + +CaveBot.RouteCompiler = RouteCompiler +return RouteCompiler diff --git a/cavebot/route_validator.lua b/cavebot/route_validator.lua new file mode 100644 index 0000000..8f4b7f9 --- /dev/null +++ b/cavebot/route_validator.lua @@ -0,0 +1,81 @@ +-- route_validator.lua +-- Validates compiled routes and emits actionable diagnostics. + +CaveBot = CaveBot or {} + +local RouteValidator = {} + +local function push(list, severity, code, message, index) + list[#list + 1] = { + severity = severity, + code = code, + message = message, + index = index, + } +end + +local function countKeys(t) + local n = 0 + for _ in pairs(t or {}) do + n = n + 1 + end + return n +end + +function RouteValidator.validate(route) + local issues = {} + route = route or {} + local nodes = route.nodes or {} + local gotoIndices = route.gotoIndices or {} + + if #nodes == 0 then + push(issues, "error", "empty-route", "Route has no waypoint nodes") + end + + if #gotoIndices == 0 then + push(issues, "error", "missing-goto", "Route has no goto waypoints") + end + + for _, node in ipairs(nodes) do + if node.parseError then + push(issues, "error", "parse-error", "Waypoint parse failed: " .. tostring(node.parseError), node.index) + end + end + + for _, edge in ipairs(route.gotoEdges or {}) do + if edge.dz and math.abs(edge.dz) > 1 then + push(issues, "error", "invalid-floor-jump", "Goto floor jump larger than 1 level", edge.from) + end + end + + local floorCount = countKeys(route.byFloor) + if floorCount > 1 and #route.floorTransitions == 0 then + push(issues, "warning", "multi-floor-without-transitions", "Route spans multiple floors but has no consecutive goto floor transitions") + end + + for key, count in pairs(route.positionHistogram or {}) do + if count >= 4 then + push(issues, "warning", "high-duplicate-waypoints", "Waypoint position repeated many times: " .. key .. " x" .. count) + end + end + + local errors = 0 + local warnings = 0 + for _, issue in ipairs(issues) do + if issue.severity == "error" then + errors = errors + 1 + elseif issue.severity == "warning" then + warnings = warnings + 1 + end + end + + return { + ok = errors == 0, + errors = errors, + warnings = warnings, + issues = issues, + } +end + +CaveBot.RouteValidator = RouteValidator +return RouteValidator diff --git a/cavebot/walking.lua b/cavebot/walking.lua index 31eecb5..15bee47 100644 --- a/cavebot/walking.lua +++ b/cavebot/walking.lua @@ -100,6 +100,26 @@ local function isNearFloorChangeTile(tilePos) return false end +local function getFloorChangeApproachTile(playerPos, dest) + if not playerPos or not dest then return nil end + local bestTile, bestLen = nil, math.huge + for _, off in ipairs(Dirs.ADJACENT_OFFSETS or {}) do + local alt = applyOffset(dest, off) + if not isFloorChangeTile(alt) then + local path = PS().findPath(playerPos, alt, { + ignoreNonPathable = true, + ignoreCreatures = true, + precision = 0, + }) + if path and #path > 0 and #path < bestLen then + bestTile = alt + bestLen = #path + end + end + end + return bestTile +end + local function stopAutoWalk() if PathUtils and PathUtils.stopAutoWalk then PathUtils.stopAutoWalk(); return end if player and player.stopAutoWalk then player:stopAutoWalk() end @@ -409,8 +429,9 @@ CaveBot.walkTo = function(dest, maxDist, params) if allowFloorChange then if player:isWalking() then return true end local manhattan = distX + distY + local approachDest = getFloorChangeApproachTile(playerPos, dest) - if manhattan <= 3 then + if manhattan <= 2 or (approachDest and posEquals(playerPos, approachDest)) then -- Direct step when adjacent (no pathfinding needed) if manhattan == 1 then local dir = getDirectionTo(playerPos, dest) @@ -445,13 +466,15 @@ CaveBot.walkTo = function(dest, maxDist, params) -- No path or step blocked → signal failure so retries accumulate return false else - -- Far: guarded autoWalk - local isSafe = PS().nativePathIsSafe(playerPos, dest, {ignoreNonPathable = true}) + -- Far: walk to a non-FC adjacent approach tile first, then enter the + -- FC tile with precise keyboard steps when close. + local walkDest = approachDest or dest + local isSafe = PS().nativePathIsSafe(playerPos, walkDest, {ignoreNonPathable = true}) if isSafe then - PS().autoWalk(dest, maxDist, {ignoreNonPathable = true, precision = precision}) + PS().autoWalk(walkDest, maxDist, {ignoreNonPathable = true, precision = 0}) return true end - local dirToDest = getDirectionTo(playerPos, dest) + local dirToDest = getDirectionTo(playerPos, walkDest) if dirToDest and canWalkDirection(dirToDest) then PS().walkStep(dirToDest) return true @@ -485,15 +508,6 @@ 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/cavebot/waypoint_schema.lua b/cavebot/waypoint_schema.lua new file mode 100644 index 0000000..8f51c38 --- /dev/null +++ b/cavebot/waypoint_schema.lua @@ -0,0 +1,130 @@ +-- waypoint_schema.lua +-- Canonical parser for CaveBot waypoint lines. + +CaveBot = CaveBot or {} + +local WaypointSchema = {} + +local function trim(s) + if not s then return "" end + return (s:gsub("^%s+", ""):gsub("%s+$", "")) +end + +local function splitCsv(raw) + local out = {} + if not raw or raw == "" then return out end + for token in raw:gmatch("[^,]+") do + out[#out + 1] = trim(token) + end + return out +end + +local function toNumber(v) + if v == nil then return nil end + return tonumber(trim(tostring(v))) +end + +local function parsePosition(tokens, startAt) + local x = toNumber(tokens[startAt]) + local y = toNumber(tokens[startAt + 1]) + local z = toNumber(tokens[startAt + 2]) + if not x or not y or not z then + return nil + end + return { x = x, y = y, z = z } +end + +function WaypointSchema.parseLine(text, index) + local line = trim(text) + local node = { + index = index, + text = line, + action = nil, + value = nil, + parseError = nil, + pos = nil, + isGoto = false, + precision = nil, + } + + if line == "" then + node.parseError = "empty-line" + return node + end + + local action, rawValue = line:match("^(%w+)%s*:%s*(.*)$") + if not action then + node.parseError = "missing-action-prefix" + return node + end + + action = action:lower() + rawValue = trim(rawValue) + node.action = action + node.value = rawValue + + if action == "goto" then + local tokens = splitCsv(rawValue) + node.pos = parsePosition(tokens, 1) + if not node.pos then + node.parseError = "goto-invalid-position" + return node + end + node.isGoto = true + node.precision = toNumber(tokens[4]) + if node.precision and node.precision < 0 then + node.parseError = "goto-invalid-precision" + return node + end + return node + end + + if action == "usewith" then + local tokens = splitCsv(rawValue) + node.itemId = toNumber(tokens[1]) + node.pos = parsePosition(tokens, 2) + if not node.itemId or not node.pos then + node.parseError = "usewith-invalid-payload" + end + return node + end + + if action == "use" then + local tokens = splitCsv(rawValue) + if #tokens == 1 then + node.itemId = toNumber(tokens[1]) + if not node.itemId then + node.parseError = "use-invalid-itemid" + end + return node + end + + node.pos = parsePosition(tokens, 1) + if not node.pos then + node.parseError = "use-invalid-position" + end + return node + end + + if action == "stand" or action == "lure" or action == "standlure" then + local tokens = splitCsv(rawValue) + node.pos = parsePosition(tokens, 1) + if not node.pos then + node.parseError = action .. "-invalid-position" + end + return node + end + + return node +end + +function WaypointSchema.parsePositionFromText(text) + local node = WaypointSchema.parseLine(text, 0) + if node and node.pos then + return { x = node.pos.x, y = node.pos.y, z = node.pos.z } + end + return nil +end + +CaveBot.WaypointSchema = WaypointSchema +return WaypointSchema diff --git a/core/cavebot.lua b/core/cavebot.lua index 05048e8..5e156a8 100644 --- a/core/cavebot.lua +++ b/core/cavebot.lua @@ -29,6 +29,13 @@ safeDofile("/cavebot/editor.lua") safeDofile("/cavebot/recorder.lua") safeDofile("/cavebot/tools.lua") safeDofile("/cavebot/walking.lua") +safeDofile("/cavebot/waypoint_schema.lua") +safeDofile("/cavebot/route_compiler.lua") +safeDofile("/cavebot/route_validator.lua") +safeDofile("/cavebot/loop_guard_v2.lua") +safeDofile("/cavebot/recovery_planner_v2.lua") +safeDofile("/cavebot/navigation_v2.lua") +safeDofile("/cavebot/config_loader.lua") safeDofile("/cavebot/minimap.lua") diff --git a/core/hold_target.lua b/core/hold_target.lua index 8b65fd8..4c87a3a 100644 --- a/core/hold_target.lua +++ b/core/hold_target.lua @@ -2,6 +2,23 @@ setDefaultTab("Tools") local targetID = nil +local UNREACHABLE_TEXT_PATTERNS = { + "sorry, not possible", + "there is no way", + "creature is not reachable", +} + +local function isUnreachableMessage(text) + if type(text) ~= "string" then return false end + local t = text:lower() + for i = 1, #UNREACHABLE_TEXT_PATTERNS do + if t:find(UNREACHABLE_TEXT_PATTERNS[i], 1, true) then + return true + end + end + return false +end + -- escape when attacking will reset hold target onKeyPress(function(keys) if keys == "Escape" and targetID then @@ -9,6 +26,23 @@ onKeyPress(function(keys) end end) +-- If the client reports unreachable while Hold Target is active, clear the +-- remembered target so this module doesn't keep forcing the same stale target. +onTextMessage(function(mode, text) + if not targetID then return end + if not isUnreachableMessage(text) then return end + + if MonsterAI and MonsterAI.Reachability and MonsterAI.Reachability.markBlocked then + pcall(function() MonsterAI.Reachability.markBlocked(targetID, "not_possible") end) + end + + if AttackStateMachine and AttackStateMachine.skipCreature then + pcall(function() AttackStateMachine.skipCreature(targetID, 15000) end) + end + + targetID = nil +end) + -- Hold Target handler function (shared by UnifiedTick and fallback macro) local function holdTargetHandler() -- if attacking then save it as target, but check pos z in case of marking by mistake on other floor @@ -28,6 +62,26 @@ local function holdTargetHandler() local oldTarget = spec:getId() == targetID if sameFloor and oldTarget then + -- Respect ASM skip-list: do not re-force recently blocked targets. + if AttackStateMachine and AttackStateMachine.isSkipped and AttackStateMachine.isSkipped(targetID) then + targetID = nil + return + end + + -- Respect reachability: if blocked/unreachable, clear hold lock. + if MonsterAI and MonsterAI.Reachability and MonsterAI.Reachability.isReachable then + local reachable = false + local okReach, rr = pcall(function() return MonsterAI.Reachability.isReachable(spec) end) + if okReach and rr == true then reachable = true end + if not reachable then + targetID = nil + if AttackStateMachine and AttackStateMachine.skipCreature then + pcall(function() AttackStateMachine.skipCreature(spec:getId(), 15000) end) + end + return + end + end + -- Route through ASM to prevent competing attack commands if AttackStateMachine and AttackStateMachine.forceAttack then AttackStateMachine.forceAttack(spec) diff --git a/targetbot/attack_state_machine.lua b/targetbot/attack_state_machine.lua index ce1e985..2590475 100644 --- a/targetbot/attack_state_machine.lua +++ b/targetbot/attack_state_machine.lua @@ -129,6 +129,49 @@ local function cName(c) return ok and v or "?" end +local function canEngageCreature(creature) + if not creature or cDead(creature) then return false end + + local id = cId(creature) + if id and AttackStateMachine.isSkipped and AttackStateMachine.isSkipped(id) then + return false + end + + if not (MonsterAI and MonsterAI.Reachability) then + return true + end + + if id and MonsterAI.Reachability.isBlocked then + local isBlocked, reason = MonsterAI.Reachability.isBlocked(id) + if isBlocked and (reason == "no_path" or reason == "blocked_tile" or reason == "not_possible") then + return false + end + end + + local C = getClient() + local lp = (C and C.getLocalPlayer and C.getLocalPlayer()) + or (g_game and g_game.getLocalPlayer and g_game.getLocalPlayer()) + local okP, pPos = pcall(function() return lp and lp:getPosition() end) + local okC, cPos = pcall(function() return creature:getPosition() end) + pPos = okP and pPos or nil + cPos = okC and cPos or nil + if not pPos or not cPos then return false end + + local dist = math.max(math.abs(cPos.x - pPos.x), math.abs(cPos.y - pPos.y)) + if dist <= 1 then + return true + end + + if MonsterAI.Reachability.isReachable then + local ok, reachable = pcall(function() + return MonsterAI.Reachability.isReachable(creature) + end) + return ok and reachable == true + end + + return true +end + -- ============================================================================ -- INTERNAL STATE -- ============================================================================ @@ -464,6 +507,28 @@ local function handleIdle() end for _, spec in ipairs(specs) do if cId(spec) == state.holdTargetId and not cDead(spec) then + -- Don't re-lock recently skipped targets. + if AttackStateMachine.isSkipped and AttackStateMachine.isSkipped(state.holdTargetId) then + state.holdTargetId = nil + state.holdTargetName = nil + return + end + + -- Respect strict reachability before hold-target re-acquire. + if MonsterAI and MonsterAI.Reachability and MonsterAI.Reachability.isReachable then + local okReach, reachable = pcall(function() + return MonsterAI.Reachability.isReachable(spec) + end) + if not okReach or not reachable then + if AttackStateMachine.skipCreature then + AttackStateMachine.skipCreature(state.holdTargetId, CC.PATH_SKIP_DURATION) + end + state.holdTargetId = nil + state.holdTargetName = nil + return + end + end + log("Hold-target re-acquired: " .. cName(spec)) setTarget(spec, state.priority, "hold_reacquire") return @@ -679,7 +744,14 @@ function AttackStateMachine.requestAttack(creature, priority) ensureDeps() if (nowMs() - state.lastStopAt) < CC.STOP_DEBOUNCE then return false end + if not canEngageCreature(creature) then + return false + end + local id = cId(creature) + if id and AttackStateMachine.isSkipped and AttackStateMachine.isSkipped(id) then + return false + end -- REAFFIRM path: same target in ENGAGING → reset retries, keep going if id == state.targetId then @@ -723,7 +795,14 @@ end function AttackStateMachine.forceAttack(creature) if not creature or cDead(creature) then return false end + if not canEngageCreature(creature) then + return false + end + local id = cId(creature) + if id and AttackStateMachine.isSkipped and AttackStateMachine.isSkipped(id) then + return false + end if id == state.targetId and state.current ~= STATE.IDLE then -- Already targeting — just refresh state.creature = creature diff --git a/targetbot/event_targeting.lua b/targetbot/event_targeting.lua index 0fcd576..3d7a685 100644 --- a/targetbot/event_targeting.lua +++ b/targetbot/event_targeting.lua @@ -358,8 +358,20 @@ function EventTargeting.getLiveMonsterCount() if isTargetableMonster(creature) then local okPos, cpos = pcall(function() return creature:getPosition() end) if okPos and cpos and cpos.z == playerZ then - count = count + 1 - monsters[#monsters + 1] = creature + -- Skip confirmed-blocked (wall-blocked) creatures so they are not + -- counted as live monsters requiring attention. + local skip = false + if MonsterAI and MonsterAI.Reachability then + local okId, cid = pcall(function() return creature:getId() end) + if okId and cid then + local isBlocked, reason = MonsterAI.Reachability.isBlocked(cid) + skip = isBlocked and (reason == "no_path" or reason == "blocked_tile" or reason == "not_possible") + end + end + if not skip then + count = count + 1 + monsters[#monsters + 1] = creature + end end end end @@ -936,14 +948,25 @@ function EventTargeting.TargetAcquisition.acquireTarget(creature, path, priority -- Verify path exists before attacking (prevents attacking through walls, etc.) -- ═══════════════════════════════════════════════════════════════════════════ if dist > 1 then - -- Re-validate path if not provided or stale - if not path then + -- Always run strict reachability gate here (even when `path` is already + -- provided from cache/caller) so stale/lenient paths never bypass wall checks. + if MonsterAI and MonsterAI.Reachability then + local reachable, reason = MonsterAI.Reachability.isReachable(creature) + if not reachable then + if EventTargeting.DEBUG then + print("[EventTargeting] BLOCKED: " .. creature:getName() .. " unreachable: " .. tostring(reason)) + end + return + end + path = MonsterAI.Reachability.getCachedPath(id) or path + elseif not path then + -- Fallback when Reachability module is unavailable. local validatedPath, pathLen, reachable = EventTargeting.PathValidator.validate(playerPos, creaturePos) if not reachable then if EventTargeting.DEBUG then print("[EventTargeting] BLOCKED: " .. creature:getName() .. " is unreachable (no path)") end - return -- Do NOT attack unreachable targets + return end path = validatedPath end diff --git a/targetbot/monster_reachability.lua b/targetbot/monster_reachability.lua index 1cc6761..67cf4a6 100644 --- a/targetbot/monster_reachability.lua +++ b/targetbot/monster_reachability.lua @@ -104,7 +104,7 @@ function R.isReachable(creature, forceRecheck) end) if not ok or not result or #result == 0 then R.stats.byReason.no_path = R.stats.byReason.no_path + 1 - R.markBlocked(id, "no_path") + R.markBlocked(id, "no_path", creature) return R.cacheResult(id, false, "no_path", nil) end @@ -119,7 +119,7 @@ function R.isReachable(creature, forceRecheck) if tile then if tile.isWalkable and not tile:isWalkable() then R.stats.byReason.blocked_tile = R.stats.byReason.blocked_tile + 1 - R.markBlocked(id, "blocked_tile") + R.markBlocked(id, "blocked_tile", creature) return R.cacheResult(id, false, "blocked_tile", nil) end local items = tile.getItems and tile:getItems() @@ -127,7 +127,7 @@ function R.isReachable(creature, forceRecheck) for _, item in ipairs(items) do if item.isNotWalkable and item:isNotWalkable() then R.stats.byReason.blocked_tile = R.stats.byReason.blocked_tile + 1 - R.markBlocked(id, "blocked_tile") + R.markBlocked(id, "blocked_tile", creature) return R.cacheResult(id, false, "blocked_tile", nil) end end @@ -174,21 +174,30 @@ function R.cacheResult(id, reachable, reason, path) return reachable, reason, path end -function R.markBlocked(id, reason) +function R.markBlocked(id, reason, creature) local e = R.blockedCreatures[id] if e then e.attempts = e.attempts + 1 e.reason = reason + e.blockedTime = nowMs() + local c = creature or e.creature + if c then + local cpos = safeCreatureCall(c, "getPosition", nil) + if cpos then e.lastPos = {x = cpos.x, y = cpos.y, z = cpos.z} end + end -- 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 + local cpos = creature and safeCreatureCall(creature, "getPosition", nil) or nil R.blockedCreatures[id] = { blockedTime = nowMs(), attempts = 1, reason = reason, wasEverReachable = false, - cooldown = R.BLOCKED_COOLDOWN_STATIC -- Default to static until proven reachable + cooldown = R.BLOCKED_COOLDOWN_STATIC, -- Default to static until proven reachable + creature = creature, + lastPos = cpos and {x = cpos.x, y = cpos.y, z = cpos.z} or nil, } end end @@ -276,7 +285,23 @@ if EventBus and EventBus.on then if tbOff() then return end if creature and safeIsMonster(creature) then local id = safeGetId(creature) - if id and R.blockedCreatures[id] then R.clearBlocked(id); R.cache[id] = nil; R.cacheTime[id] = nil end + if id and R.blockedCreatures[id] then + local entry = R.blockedCreatures[id] + local cpos = safeCreatureCall(creature, "getPosition", nil) + if cpos and entry.lastPos then + local moved = math.max(math.abs(cpos.x - entry.lastPos.x), math.abs(cpos.y - entry.lastPos.y)) + -- Keep blocked state for micro-movements near the same wall tile. + -- Only clear when monster meaningfully repositions. + if moved >= 2 or cpos.z ~= entry.lastPos.z then + R.clearBlocked(id) + else + entry.lastPos = {x = cpos.x, y = cpos.y, z = cpos.z} + end + end + -- Always drop cached path/reachability so next check reflects new tile. + R.cache[id] = nil + R.cacheTime[id] = nil + end end end) end diff --git a/targetbot/target.lua b/targetbot/target.lua index f3b63a4..42e8f6b 100644 --- a/targetbot/target.lua +++ b/targetbot/target.lua @@ -1359,7 +1359,19 @@ TargetBot.hasTargetableMonstersOnScreen = function() -- Check if this creature has a matching TargetBot config (it's targetable) local cfgs = TargetBot.Creature.getConfigs and TargetBot.Creature.getConfigs(creature) if cfgs and cfgs[1] then - monsterCount = monsterCount + 1 + -- Skip confirmed wall-blocked creatures so they don't stall CaveBot + -- indefinitely. isBlocked() checks the escalating-TTL blacklist only + -- (cheap: no pathfinding). Threshold ≥ 2 attempts means one transient + -- fail is tolerated; repeated fails = genuinely unreachable. + local okId, cid = pcall(function() return creature:getId() end) + local blocked = false + if okId and cid and MonsterAI and MonsterAI.Reachability then + local isBlocked, reason = MonsterAI.Reachability.isBlocked(cid) + blocked = isBlocked and (reason == "no_path" or reason == "blocked_tile" or reason == "not_possible") + end + if not blocked then + monsterCount = monsterCount + 1 + end end end end @@ -1888,6 +1900,19 @@ local function processCandidate(creature, pos, isCurrentTarget, batchPaths) -- Get creature ID for path caching local okId, creatureId = pcall(function() return creature:getId() end) creatureId = okId and creatureId or nil + + -- Respect AttackStateMachine skip-list globally. + if creatureId and AttackStateMachine and AttackStateMachine.isSkipped and AttackStateMachine.isSkipped(creatureId) then + return nil, nil + end + + -- Respect confirmed blocked states before doing any extra path work. + if creatureId and MonsterAI and MonsterAI.Reachability and MonsterAI.Reachability.isBlocked then + local isBlocked, reason = MonsterAI.Reachability.isBlocked(creatureId) + if isBlocked and (reason == "no_path" or reason == "blocked_tile" or reason == "not_possible") then + return nil, nil + end + end -- v2.2: Calculate distance first - adjacent creatures should ALWAYS be targetable local dist = math.max(math.abs(cpos.x - pos.x), math.abs(cpos.y - pos.y)) @@ -1928,13 +1953,13 @@ local function processCandidate(creature, pos, isCurrentTarget, batchPaths) -- If no cached path, calculate new one if not path then - -- Use MonsterAI.Reachability if available (but don't let it block adjacent/current targets) - if MonsterAI and MonsterAI.Reachability and MonsterAI.Reachability.isReachable and not isCurrentTarget then + -- Use strict MonsterAI.Reachability gate for all non-adjacent candidates. + if MonsterAI and MonsterAI.Reachability and MonsterAI.Reachability.isReachable then local reachResult, reason, cachedPath = MonsterAI.Reachability.isReachable(creature) if reachResult and cachedPath then path = cachedPath - elseif not reachResult and dist > 3 then - -- Only skip if truly far and blocked + elseif not reachResult then + -- Unreachable is unreachable — skip candidate immediately. return nil, nil end end @@ -1965,33 +1990,6 @@ local function processCandidate(creature, pos, isCurrentTarget, batchPaths) end end - -- If still no path for nearby creatures, create a simple direction-based path - if (not path or #path == 0) and dist <= 3 then - -- Create a simple path towards the creature - local simplePath = {} - local dx = cpos.x - pos.x - local dy = cpos.y - pos.y - - -- Determine direction - local dir = nil - if dx > 0 and dy < 0 then dir = NorthEast or 4 - elseif dx > 0 and dy > 0 then dir = SouthEast or 5 - elseif dx < 0 and dy > 0 then dir = SouthWest or 6 - elseif dx < 0 and dy < 0 then dir = NorthWest or 7 - elseif dx > 0 then dir = East or 1 - elseif dx < 0 then dir = West or 3 - elseif dy > 0 then dir = South or 2 - elseif dy < 0 then dir = North or 0 - end - - if dir then - for i = 1, dist do - simplePath[i] = dir - end - path = simplePath - end - end - if not path or #path == 0 then -- v2.2: Only mark as blocked if really far if dist > 5 then @@ -2180,7 +2178,7 @@ recalculateBestTarget = function() local okId, id = pcall(function() return creature:getId() end) id = id or i - -- v2.2: Special handling for current target - be more lenient with path validation + -- Current target tracking only affects switch logic, not reachability. local isCurrentTarget = (currentTargetId and id == currentTargetId) -- Calculate path and params (pass isCurrentTarget for enhanced path finding) @@ -2188,22 +2186,6 @@ recalculateBestTarget = function() local dist = math.max(math.abs(cpos.x - pos.x), math.abs(cpos.y - pos.y)) local params, path = processCandidate(creature, pos, isCurrentTarget, batchPaths) - -- For current target, try harder to find a path if initial attempt failed - if isCurrentTarget and (not path or not params or not params.config) then - -- Try with relaxed path params - local relaxedPath = findPath(pos, cpos, 12, { - ignoreLastCreature = true, - ignoreNonPathable = true, - ignoreCost = true, - ignoreCreatures = true, - allowOnlyVisibleTiles = false -- More relaxed - }) - if relaxedPath and #relaxedPath > 0 then - path = relaxedPath - params = TargetBot.Creature.calculateParams(creature, path) - end - end - -- IMPROVED: Track all creatures, not just those with perfect paths -- Creatures with blocked paths can still be targeted if they're close if path and params and params.config then @@ -2232,36 +2214,6 @@ recalculateBestTarget = function() bestPriority = params.priority bestTarget = params end - elseif isCurrentTarget and not creature:isDead() then - -- v2.2: Current target has no path but is not dead - -- Still add it to cache to prevent "losing" the target - local dist = math.max(math.abs(cpos.x - pos.x), math.abs(cpos.y - pos.y)) - if dist <= 2 then - -- Very close - might just be blocked by other creatures, keep targeting - local fallbackParams = TargetBot.Creature.calculateParams(creature, {1}) -- Fake short path - if fallbackParams and fallbackParams.config then - fallbackParams.priority = getAdjustedPriority(creature, fallbackParams, dist) - CreatureCache.monsters[id] = { - creature = creature, - path = nil, - pathTime = now, - lastUpdate = now, - reachable = false, - closeButBlocked = true - } - CreatureCache.monsterCount = CreatureCache.monsterCount + 1 - currentTargetStillValid = true - currentTargetParams = fallbackParams - - -- Give it a slightly reduced priority but don't abandon it - if fallbackParams.priority * 0.8 > bestPriority then - bestPriority = fallbackParams.priority * 0.8 - bestTarget = fallbackParams - end - end - else - unreachableCount = unreachableCount + 1 - end else -- Creature has blocked path - don't add to active targeting unreachableCount = unreachableCount + 1 @@ -2610,6 +2562,18 @@ targetbotMacro = macro(250, function() if okPos and cpos and cpos.z == pos.z then local dist = getDistanceBetween(pos, cpos) if dist and dist <= 3 then + local okAtkId, atkId = pcall(function() return currentAttack:getId() end) + local skipSticky = false + if okAtkId and atkId and AttackStateMachine and AttackStateMachine.isSkipped then + skipSticky = AttackStateMachine.isSkipped(atkId) + end + if okAtkId and atkId and not skipSticky and MonsterAI and MonsterAI.Reachability and MonsterAI.Reachability.isBlocked then + local isBlocked, reason = MonsterAI.Reachability.isBlocked(atkId) + skipSticky = isBlocked and (reason == "no_path" or reason == "blocked_tile" or reason == "not_possible") + end + if skipSticky then + bestTarget = nil + else local cfgs = TargetBot.Creature.getConfigs and TargetBot.Creature.getConfigs(currentAttack) if cfgs and cfgs[1] then bestTarget = { @@ -2621,6 +2585,7 @@ targetbotMacro = macro(250, function() -- prevent walking away while target is still alive cavebotAllowance = now + 600 end + end end end end @@ -2908,6 +2873,79 @@ TargetBot.stopAttack = function(clearWalk) end end +-- ========================================================================== +-- UNREACHABLE FEEDBACK INTERCEPTOR +-- If the client returns "Sorry, not possible." while attacking a monster, +-- treat it as immediate reachability failure and skip that creature. +-- Works on OTCv8 and OpenTibiaBR. +-- ========================================================================== + +local UNREACHABLE_TEXT_PATTERNS = { + "sorry, not possible", + "there is no way", + "creature is not reachable", +} + +local function getCurrentAttackCreature() + local C = getClient() + if C and C.getAttackingCreature then + local ok, c = pcall(C.getAttackingCreature) + if ok and c then return c end + end + if g_game and g_game.getAttackingCreature then + local ok, c = pcall(g_game.getAttackingCreature) + if ok and c then return c end + end + return nil +end + +local function isUnreachableMessage(text) + if type(text) ~= "string" then return false end + local t = text:lower() + for i = 1, #UNREACHABLE_TEXT_PATTERNS do + if t:find(UNREACHABLE_TEXT_PATTERNS[i], 1, true) then + return true + end + end + return false +end + +onTextMessage(function(mode, text) + if not TargetBot or not TargetBot.canAttack or not TargetBot.canAttack() then return end + if not isUnreachableMessage(text) then return end + + local creature = getCurrentAttackCreature() + if not creature then return end + + local okMonster, isMonster = pcall(function() return creature:isMonster() end) + if not okMonster or not isMonster then return end + + local okId, cid = pcall(function() return creature:getId() end) + if not okId or not cid then return end + + -- Reachability layer: mark blocked immediately from live client feedback. + if MonsterAI and MonsterAI.Reachability and MonsterAI.Reachability.markBlocked then + pcall(function() MonsterAI.Reachability.markBlocked(cid, "not_possible", creature) end) + end + + -- Attack layer: skip this creature for a window so ASM won't re-lock it. + if AttackStateMachine and AttackStateMachine.skipCreature then + pcall(function() AttackStateMachine.skipCreature(cid, 15000) end) + end + + -- Stop current attack immediately and force next tick to pick another target. + if AttackStateMachine and AttackStateMachine.stop then + pcall(AttackStateMachine.stop) + else + pcall(function() TargetBot.stopAttack(true) end) + end + + invalidateCache() + if debouncedInvalidateAndRecalc then + debouncedInvalidateAndRecalc() + end +end) + -- Note: Profile restoration is handled early in configs.lua -- before Config.setup() is called, so the dropdown loads correctly