diff --git a/Conditionals.lua b/Conditionals.lua index 17c6d2b..13d72ad 100644 --- a/Conditionals.lua +++ b/Conditionals.lua @@ -4769,3 +4769,357 @@ CleveRoids.Keywords = { end, conditionals, "noresisted") end } + +-- ============================================================================= +-- MULTISCAN SYSTEM +-- ============================================================================= +-- [multiscan:priority] - Scan nearby enemies and find best target matching priority +-- Requires UnitXP_SP3 for enemy enumeration +-- Uses SuperWoW for soft-casting (CastSpellByName with GUID) + +-- Raid mark indices (WoW API GetRaidTargetIndex values) +CleveRoids.RAID_MARKS = { + star = 1, circle = 2, diamond = 3, triangle = 4, + moon = 5, square = 6, cross = 7, skull = 8, +} + +-- Priority types for multiscan +-- String values = custom handling, number values = raid mark index +CleveRoids.MULTISCAN_PRIORITIES = { + -- Distance-based (use UnitXP direct targeting where possible) + nearest = "nearest", + farthest = "farthest", + -- Health percentage + highesthp = "highesthp", + lowesthp = "lowesthp", + -- Raw health value + highestrawhp = "highestrawhp", + lowestrawhp = "lowestrawhp", + -- Raid mark order (skull → cross → square → moon → triangle → diamond → circle → star) + markorder = "markorder", + -- Individual raid marks (direct unit reference via SuperWoW "mark#" tokens) + skull = 8, cross = 7, square = 6, moon = 5, + triangle = 4, diamond = 3, circle = 2, star = 1, +} + +-- Static conditionals that don't depend on target (checked before scanning) +CleveRoids.STATIC_CONDITIONALS = { + group = true, nogroup = true, + combat = true, nocombat = true, + zone = true, subzone = true, + instance = true, noinstance = true, + stealth = true, nostealth = true, + form = true, noform = true, stance = true, nostance = true, + talent = true, notalent = true, + equipped = true, noequipped = true, + mod = true, nomod = true, + btn = true, + outdoors = true, indoors = true, + swimming = true, noswimming = true, + resting = true, noresting = true, + flying = true, noflying = true, + mounted = true, nomounted = true, + vehicle = true, novehicle = true, + bar = true, +} + +--- Calculate score for a unit based on priority type +--- @param unit string Unit token or GUID +--- @param priority string Priority type (e.g., "nearest", "highesthp") +--- @param currentTargetGuid string|nil GUID of player's current target (exempt from combat check) +--- @param specifiedUnitGuid string|nil GUID of @unit specified in macro (also exempt from combat check) +--- @return number|nil Score (lower = better) or nil if invalid candidate +function CleveRoids.GetMultiscanScore(unit, priority, currentTargetGuid, specifiedUnitGuid) + if not unit or not UnitExists(unit) then return nil end + if UnitIsDeadOrGhost(unit) then return nil end + + -- Must be attackable + if not UnitCanAttack("player", unit) then return nil end + + -- Combat check: must be in combat with player, UNLESS it's current target OR specified @unit + local _, unitGuid = UnitExists(unit) + local isCurrentTarget = currentTargetGuid and unitGuid == currentTargetGuid + local isSpecifiedUnit = specifiedUnitGuid and unitGuid == specifiedUnitGuid + if not isCurrentTarget and not isSpecifiedUnit and not UnitAffectingCombat(unit) then + return nil + end + + -- Calculate score based on priority + if priority == "nearest" then + if CleveRoids.hasUnitXP then + local distance = UnitXP("distanceBetween", "player", unit) + return distance or 9999 + end + return 0 -- No distance info, treat as equal + + elseif priority == "farthest" then + if CleveRoids.hasUnitXP then + local distance = UnitXP("distanceBetween", "player", unit) + return distance and -distance or -9999 + end + return 0 + + elseif priority == "highesthp" then + local maxHp = UnitHealthMax(unit) + if maxHp <= 0 then return nil end + local hpPct = 100 * UnitHealth(unit) / maxHp + return -hpPct -- Negative so highest becomes lowest score + + elseif priority == "lowesthp" then + local maxHp = UnitHealthMax(unit) + if maxHp <= 0 then return nil end + local hpPct = 100 * UnitHealth(unit) / maxHp + return hpPct + + elseif priority == "highestrawhp" then + local rawHp = UnitHealth(unit) + return -rawHp -- Negative so highest becomes lowest score + + elseif priority == "lowestrawhp" then + local rawHp = UnitHealth(unit) + return rawHp + end + + return 0 -- Unknown priority, treat as equal +end + +--- Check if a candidate passes all target-dependent conditionals +--- @param conditionals table The conditionals table +--- @param candidateUnit string Unit token or GUID to test +--- @return boolean True if candidate passes all conditionals +function CleveRoids.ValidateMultiscanCandidate(conditionals, candidateUnit) + local originalTarget = conditionals.target + conditionals.target = candidateUnit + + local passes = true + for k, _ in pairs(conditionals) do + -- Skip static conditionals (already checked) and metadata + if not CleveRoids.ignoreKeywords[k] and not CleveRoids.STATIC_CONDITIONALS[k] then + local fn = CleveRoids.Keywords[k] + if fn and not fn(conditionals) then + passes = false + break + end + end + end + + conditionals.target = originalTarget + return passes +end + +--- Check static conditionals before scanning +--- @param conditionals table The conditionals table +--- @return boolean True if all static conditionals pass +function CleveRoids.CheckStaticConditionals(conditionals) + for k, _ in pairs(conditionals) do + if CleveRoids.STATIC_CONDITIONALS[k] then + local fn = CleveRoids.Keywords[k] + if fn and not fn(conditionals) then + return false + end + end + end + return true +end + +--- Main multiscan resolution function +--- Scans enemies using UnitXP, finds best target matching priority and conditionals +--- @param conditionals table The conditionals table containing multiscan value +--- @param specifiedUnit string|nil The @unit specified in the macro (exempt from combat check) +--- @return string|nil GUID of best target, or nil if none found +function CleveRoids.ResolveMultiscanTarget(conditionals, specifiedUnit) + -- Require UnitXP for enemy scanning + if not CleveRoids.hasUnitXP then + return nil + end + + -- Parse priority from conditionals.multiscan + local priority = nil + if type(conditionals.multiscan) == "table" then + priority = conditionals.multiscan[1] + elseif type(conditionals.multiscan) == "string" then + priority = conditionals.multiscan + end + + if not priority then return nil end + priority = string.lower(CleveRoids.Trim(priority)) + + -- Validate priority type + local priorityType = CleveRoids.MULTISCAN_PRIORITIES[priority] + if not priorityType then + CleveRoids.Print("|cffff0000[multiscan]|r Unknown priority: " .. priority) + return nil + end + + -- Check static conditionals first (group, combat, zone, etc.) + if not CleveRoids.CheckStaticConditionals(conditionals) then + return nil + end + + -- Save current target for restoration and combat-check exemption + local currentTargetGuid = nil + if UnitExists("target") then + local _, guid = UnitExists("target") + currentTargetGuid = guid + end + + -- Resolve specified @unit GUID (also exempt from combat check) + local specifiedUnitGuid = nil + if specifiedUnit and UnitExists(specifiedUnit) then + local _, guid = UnitExists(specifiedUnit) + specifiedUnitGuid = guid + end + + -- Handle raid mark priorities (direct unit reference) + if type(priorityType) == "number" then + local markUnit = "mark" .. priorityType + if UnitExists(markUnit) and UnitCanAttack("player", markUnit) then + -- Validate against target-dependent conditionals + if CleveRoids.ValidateMultiscanCandidate(conditionals, markUnit) then + local _, guid = UnitExists(markUnit) + return guid + end + end + return nil -- Raid mark not found or doesn't pass conditionals + end + + -- Handle markorder priority (skull → cross → square → moon → triangle → diamond → circle → star) + if priorityType == "markorder" then + -- Iterate marks from skull (8) down to star (1) in standard kill order + for markIndex = 8, 1, -1 do + local markUnit = "mark" .. markIndex + if UnitExists(markUnit) and UnitCanAttack("player", markUnit) then + -- Validate against target-dependent conditionals + if CleveRoids.ValidateMultiscanCandidate(conditionals, markUnit) then + local _, guid = UnitExists(markUnit) + return guid + end + end + end + return nil -- No valid marked target found + end + + -- Handle UnitXP direct targeting for simple priorities + if priorityType == "nearest" then + -- If no specified unit, use UnitXP's nearestEnemy directly for efficiency + if not specifiedUnitGuid then + local found = UnitXP("target", "nearestEnemy") + if found and UnitExists("target") then + local _, foundGuid = UnitExists("target") + -- Restore original target + if currentTargetGuid then + TargetUnit(currentTargetGuid) + else + ClearTarget() + end + -- Validate the found target + if CleveRoids.ValidateMultiscanCandidate(conditionals, foundGuid) then + return foundGuid + end + end + -- Restore target if nearestEnemy failed + if currentTargetGuid then + TargetUnit(currentTargetGuid) + else + ClearTarget() + end + return nil + end + -- Fall through to custom scan if there's a specified unit to consider + end + + if priorityType == "highesthp" then + -- If no specified unit, try UnitXP's mostHP directly for efficiency + if not specifiedUnitGuid then + local found = UnitXP("target", "mostHP") + if found and UnitExists("target") then + local _, foundGuid = UnitExists("target") + -- Restore original target + if currentTargetGuid then + TargetUnit(currentTargetGuid) + else + ClearTarget() + end + -- Validate the found target + if CleveRoids.ValidateMultiscanCandidate(conditionals, foundGuid) then + return foundGuid + end + end + -- Restore target if mostHP failed validation + if currentTargetGuid then + TargetUnit(currentTargetGuid) + else + ClearTarget() + end + end + -- Fall through to custom scan if there's a specified unit or built-in didn't pass validation + end + + -- Custom scan for complex priorities (lowesthp, highestrawhp, lowestrawhp, farthest) + -- Also used as fallback if UnitXP direct targeting didn't pass validation + local candidates = {} -- { { guid = X, score = Y }, ... } + local seenGuids = {} + + -- Helper to evaluate a candidate + local function evaluateCandidate(unit) + if not UnitExists(unit) then return end + + local _, guid = UnitExists(unit) + if not guid or seenGuids[guid] then return end + seenGuids[guid] = true + + local score = CleveRoids.GetMultiscanScore(unit, priorityType, currentTargetGuid, specifiedUnitGuid) + if not score then return end + + -- Validate against target-dependent conditionals + if CleveRoids.ValidateMultiscanCandidate(conditionals, guid) then + table.insert(candidates, { guid = guid, score = score }) + end + end + + -- Always consider current target (exempt from combat check via GetMultiscanScore) + if currentTargetGuid then + evaluateCandidate("target") + end + + -- Also consider specified @unit (exempt from combat check via GetMultiscanScore) + if specifiedUnitGuid and specifiedUnitGuid ~= currentTargetGuid then + evaluateCandidate(specifiedUnit) + end + + -- Scan via UnitXP enemy iteration + local firstGuid = nil + local maxIterations = 50 + + for i = 1, maxIterations do + local found = UnitXP("target", "nextEnemyConsideringDistance") + if not found then break end + + if not UnitExists("target") then break end + local _, currentGuid = UnitExists("target") + if not currentGuid then break end + + if firstGuid == nil then + firstGuid = currentGuid + elseif currentGuid == firstGuid then + break -- Completed full cycle + end + + evaluateCandidate("target") + end + + -- Restore original target + if currentTargetGuid then + TargetUnit(currentTargetGuid) + else + ClearTarget() + end + + -- Find best candidate (lowest score wins) + if table.getn(candidates) == 0 then + return nil + end + + table.sort(candidates, function(a, b) return a.score < b.score end) + return candidates[1].guid +end diff --git a/Core.lua b/Core.lua index 022f332..a70b740 100644 --- a/Core.lua +++ b/Core.lua @@ -2131,6 +2131,22 @@ function CleveRoids.DoWithConditionals(msg, hook, fixEmptyTargetFunc, targetBefo end end + -- Handle [multiscan:priority] - scan enemies and find best target + -- Must be processed BEFORE the Keywords loop since it sets conditionals.target + -- Pass origTarget so @unit syntax makes that unit exempt from combat check + if conditionals.multiscan then + local scanResult = CleveRoids.ResolveMultiscanTarget(conditionals, origTarget) + if not scanResult then + -- No valid target found - fail this conditional line (try next) + conditionals.target = origTarget + return false + end + -- Set target to the found GUID for soft-casting via SuperWoW + conditionals.target = scanResult + -- Don't need to retarget since we're using GUID directly + needRetarget = false + end + for k, v in pairs(conditionals) do if not CleveRoids.ignoreKeywords[k] then local result = CleveRoids.Keywords[k] and CleveRoids.Keywords[k](conditionals) diff --git a/Init.lua b/Init.lua index 769c649..1451e56 100644 --- a/Init.lua +++ b/Init.lua @@ -133,6 +133,7 @@ CleveRoids.ignoreKeywords = { cancelaura = true, _operators = true, -- Metadata for AND/OR operator tracking _groups = true, -- Grouped conditional values for AND/OR evaluation + multiscan = true, -- Processed before Keywords loop (target resolution) } -- TODO: Localize? diff --git a/README.md b/README.md index 7de2bf4..8c499a1 100644 --- a/README.md +++ b/README.md @@ -577,6 +577,7 @@ Default `@unitid` is usually `@target` if not specified. | level | [level:>=60] | * | | Unit level | | meleerange | [meleerange] | | * | In melee range | | member | [member] | * | | In party OR raid | +| multiscan | [multiscan:priority] | * | | Scan for best enemy target | | notarget | [notarget] | | | Player has no target | | outrange | [outrange:"Name"] | | | Out of spell range | | party | [party]/[party:unitid] | | * | Unit in party | @@ -682,6 +683,88 @@ Crowd control detection using DBC spell mechanic data (785 spells). `[cc]` or `[nocc]` without a type checks for any **loss-of-control** effect: stun, fear, sleep, charm, polymorph, banish, horror, freeze, disorient, shackle +## Multiscan (Target Scanning) - *Credits to Pepopo and the Cursive addon* + +Scans nearby enemies using UnitXP and finds the best target matching the specified priority. Uses SuperWoW for **soft-casting** (no target change required). + +**Requirements:** +- UnitXP_SP3 for enemy enumeration +- SuperWoW for GUID-based casting + +### Syntax +```lua +[multiscan:priority] -- Find best enemy by priority +[multiscan:raidmark] -- Target specific raid mark +``` + +### Available Priorities + +| Priority | Description | +|----------|-------------| +| `nearest` | Closest enemy (UnitXP nearestEnemy) | +| `farthest` | Farthest enemy | +| `highesthp` | Highest health % (UnitXP mostHP) | +| `lowesthp` | Lowest health % | +| `highestrawhp` | Highest raw HP value | +| `lowestrawhp` | Lowest raw HP value | +| `markorder` | First valid raid mark in kill order (skull→cross→square→moon→triangle→diamond→circle→star) | +| `skull` | Enemy with Skull raid mark | +| `cross` | Enemy with Cross raid mark | +| `square` | Enemy with Square raid mark | +| `moon` | Enemy with Moon raid mark | +| `triangle` | Enemy with Triangle raid mark | +| `diamond` | Enemy with Diamond raid mark | +| `circle` | Enemy with Circle raid mark | +| `star` | Enemy with Star raid mark | + +### Combat Requirement + +Scanned targets must be **in combat with the player** (UnitXP default behavior), with these exceptions: +- Player's current target is always considered +- `@unit` specified in the macro is always considered (e.g., `[@targettarget,multiscan:nearest]`) + +### Soft Targeting + +Uses SuperWoW's `CastSpellByName(spell, guid)` to cast on the scanned target **without changing the player's actual target**. Your target frame stays unchanged while spells are cast on the optimal enemy. + +### Examples + +```lua +-- Solo: scan for nearest enemy without Rake +/cast [nogroup,multiscan:nearest,nodebuff:Rake] Rake + +-- Group: priority on skull-marked target +/cast [group,multiscan:skull,harm] Eviscerate + +-- Highest HP without Rake, check immunity +/cast [multiscan:highesthp,nodebuff:Rake,noimmune] Rake + +-- Use targettarget (exempt from combat check) +/cast [@targettarget,multiscan:nearest] Rake + +-- Multi-DoT with multiscan +/cast [multiscan:nearest,nodebuff:Moonfire] Moonfire +/cast [multiscan:nearest,nodebuff:Insect_Swarm] Insect Swarm +/cast Wrath + +-- Focus fire marked targets in kill order (skull first, then cross, etc.) +/cast [group,multiscan:markorder] Sinister Strike +/cast [nogroup,multiscan:lowesthp] Sinister Strike + +-- Markorder with conditionals - attacks highest priority mark that's low HP +/cast [multiscan:markorder,hp:<30] Execute +``` + +### Group Integration + +Combine with `[group]`/`[nogroup]` to switch behavior based on context: + +```lua +-- Different priorities for solo vs group +/cast [nogroup,multiscan:nearest] Rake -- Solo: nearest target +/cast [group,multiscan:skull] Rake -- Group: skull priority +``` + --- # Supported Addons diff --git a/SuperCleveRoidMacros.toc b/SuperCleveRoidMacros.toc index c3079f5..f756e7c 100644 --- a/SuperCleveRoidMacros.toc +++ b/SuperCleveRoidMacros.toc @@ -1,8 +1,8 @@ ## Interface: 11200 -## Author: DWG, _brain, Torio, yani9o +## Author: DWG, _brain, Torio, yani9o, Pepopo ## Title: [Super]CleveRoidMacros ## Notes: /cleveroid for settings -## Version: 1.6 +## Version: 1.7 ## OptionalDeps: ClassicFocus, FocusFrame, pfUI, SuperMacro, Bongos_ActionBar ## SavedVariables: CleveRoidMacros, CleveRoids_LearnedDurations, CleveRoids_AuraTextures, CleveRoids_ImmunityData, CleveRoids_ComboDurations, CleveRoids_SpellSchools Localization.lua