From 9cb517a2ab60b546a70da895b06e4fe365ae9a53 Mon Sep 17 00:00:00 2001 From: OEVG <950+oevg@noreply.octowow.st> Date: Fri, 30 Oct 2026 19:40:16 +0000 Subject: [PATCH] Apply Patch --- Bindings.xml | 8 + CHANGELOG.md | 63 + CLASSICAPI-REVIEW.md | 64 + LockPort.dll | Bin 0 -> 39936 bytes LockPort.lua | 1719 ++++++++++++++++++++++++++ LockPort.toc | 8 + LockPort_UI.lua | 865 +++++++++++++ SHA256SUMS.txt | 1 + native/LockPort.c | 214 ++++ native/README.md | 64 + native/build.ps1 | 7 + tests/COORDINATION-REPORT.md | 106 ++ tests/fixtures/LockPort-3.7.8.lua | 1435 +++++++++++++++++++++ tests/fixtures/LockPort-original.lua | 1210 ++++++++++++++++++ tests/native_tests.c | 146 +++ tests/test_addon-results.txt | 49 + tests/test_addon.py | 273 ++++ tests/test_coordination-results.txt | 35 + tests/test_coordination.py | 390 ++++++ 19 files changed, 6657 insertions(+) create mode 100644 Bindings.xml create mode 100644 CHANGELOG.md create mode 100644 CLASSICAPI-REVIEW.md create mode 100644 LockPort.dll create mode 100644 LockPort.lua create mode 100644 LockPort.toc create mode 100644 LockPort_UI.lua create mode 100644 SHA256SUMS.txt create mode 100644 native/LockPort.c create mode 100644 native/README.md create mode 100644 native/build.ps1 create mode 100644 tests/COORDINATION-REPORT.md create mode 100644 tests/fixtures/LockPort-3.7.8.lua create mode 100644 tests/fixtures/LockPort-original.lua create mode 100644 tests/native_tests.c create mode 100644 tests/test_addon-results.txt create mode 100644 tests/test_addon.py create mode 100644 tests/test_coordination-results.txt create mode 100644 tests/test_coordination.py diff --git a/Bindings.xml b/Bindings.xml new file mode 100644 index 0000000..bd862fe --- /dev/null +++ b/Bindings.xml @@ -0,0 +1,8 @@ + + + LockPort:SummonNext() + + + LockPort:Toggle() + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1b0de2f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog + +## 3.9.3 + +Fixed invalid chat escape errors from coordination messages when joining a group. + +## 3.9.2 + +Keep Groups visible and greyed out until the player joins a raid. + +## 3.9.1 + +Fixed the repository layout so fresh addon-manager installations appear as **LockPort**. + +## 3.9.0 + +- Added the raid-group picker beside the renamed **Auto** button. +- Queue selected groups without requiring chat requests. +- Saved per-character group assignments filter manual and automatic summons. + +## 3.8.0 + +- Added target reservations and acknowledgements for multiple automatic casters. +- Added independent-client simulations and older-version compatibility checks. +- Documented mixed-version and communication-loss limits. + +## 3.7.8 + +Shortened the main action to **Summon** and restored equal button widths after label updates. + +## 3.7.7 + +Matched the Chat and Summon button widths to Options and Add Target. + +## 3.7.6 + +Added the persistent **Chat: On/Off** toggle to mute outgoing announcements and whispers. + +## 3.7.5 + +Added optional ClassicAPI `GetCurrentChatEvent()` support with a legacy fallback. + +## 3.7.4 + +Availability announcements now reset alongside Auto Summon after login/reload; +turning Auto off always clears the announcement option. + +## 3.7.3 + +Added consistent **[LockPort]** prefixes to all outgoing player-facing chat. + +## 3.7.2 + +Updated DLL 1.2.1 to preserve and restore the client command context around native casts. +Automatic summoning was confirmed working in-game with this DLL. + +## Earlier updates in this fork + +- Added optional Auto Summon with party and raid support. +- Avoided false failure announcements caused solely by an interrupted channel. +- Expanded Options and added ClassicAPI/LockPort detection. + +DLL **1.2.1** remains compatible with addon versions 3.7.2 through 3.9.3. diff --git a/CLASSICAPI-REVIEW.md b/CLASSICAPI-REVIEW.md new file mode 100644 index 0000000..e6a2610 --- /dev/null +++ b/CLASSICAPI-REVIEW.md @@ -0,0 +1,64 @@ +# ClassicAPI 1.17.6 follow-up + +Compared tagged v1.17.1 with v1.17.6. LockPort 3.7.5 optionally uses the new +GetCurrentChatEvent() for summon-request message and sender values. The reader +is feature-detected, protected with pcall, and falls back to snapshotted legacy +arguments if unavailable, empty, or failing. It is used only in the supported +request-chat event branch; spell and addon-sync events keep their original paths. +Roster membership remains mandatory. The DLL stays at 1.2.1. + +Regression coverage includes overwritten globals, legacy fallback, reader errors, +non-trigger payloads, nested spell events, and addon synchronization. + +The new profiling APIs are useful for developer diagnostics, but are not enabled +or queried automatically. The modifier-key fix benefits Lua bindings upstream. +Player-cache improvements are unnecessary for the current group roster logic. + +Sources: +- https://github.com/brues-code/ClassicAPI/releases/tag/v1.17.6 +- https://github.com/brues-code/ClassicAPI/compare/v1.17.1...v1.17.6 +- https://github.com/brues-code/ClassicAPI/blob/v1.17.6/docs/API.md#getcurrentchatevent + +--- + +# ClassicAPI 1.17.1 review + +Reviewed the tagged 1.17.1 API documentation, release, and source comparison +against 1.16.0, rather than assuming the current default branch matches the release. + +## What changed + +The 1.17.1 patch itself replaces the Cooldown frame's Model-based indicator with +native cooldown drawing (sweep, flash, reverse, and edge behavior). LockPort has +no cooldown-sweep UI, so this patch offers no direct summon fix. + +The broader 1.16.0 -> 1.17.1 comparison includes corrected spell-failure events: +UNIT_SPELLCAST_INTERRUPTED versus UNIT_SPELLCAST_FAILED follows the engine's +failure reason, with improved cast identity and stop handling. +These are useful for a future event-tracking revision, but a successful ritual +spell event still needs interpretation: starting/completing the portal and the +target actually arriving are different stages. This build keeps the conservative +arrival/uncertain-completion fix rather than mixing another event stream into it. + +## Useful APIs used in this build + +Verified present in 1.17.1: +- GetUnitSpeed("player"): pause automatic summons while moving. +- C_Spell.UnitCastingInfo("player"): pause while another regular cast is active. +- C_Spell.UnitChannelInfo("player"): pause while another channel is active. + +These are optional, feature-detected improvements. Their presence in 1.17.1 does +not mean they were newly introduced in 1.17.1. LockPort continues to work without +ClassicAPI, using its existing cast-event tracking. +UnitXP already supplies accurate distance information in your setup, so another +distance provider would add little value. + +## Primary sources + +- Release: https://github.com/brues-code/ClassicAPI/releases/tag/v1.17.1 +- Exact patch: https://github.com/brues-code/ClassicAPI/compare/v1.17.0...v1.17.1 +- Since 1.16.0: https://github.com/brues-code/ClassicAPI/compare/v1.16.0...v1.17.1 +- Tagged API reference: https://github.com/brues-code/ClassicAPI/blob/v1.17.1/docs/API.md +- Spell-event correction: https://github.com/brues-code/ClassicAPI/commit/ff57450b7c7fc6b4ae9d3d8a58d51f654ccd1bd4 +- Native function ABI/offset reference: https://github.com/brues-code/nampower/blob/main/nampower/main.hpp +- Native offset reference: https://github.com/brues-code/nampower/blob/main/nampower/offsets.hpp diff --git a/LockPort.dll b/LockPort.dll new file mode 100644 index 0000000000000000000000000000000000000000..75e5a60c79d94007fd396fa192cce14007cc7df4 GIT binary patch literal 39936 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

Bj=Tn&7-FF&nUPGmw*_ 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 QjM6-s$^7g3zfS}IAKGWwrT_o{ literal 0 HcmV?d00001 diff --git a/LockPort.lua b/LockPort.lua new file mode 100644 index 0000000..b14fdc9 --- /dev/null +++ b/LockPort.lua @@ -0,0 +1,1719 @@ +--[[ + LockPort - easy warlock summoning for World of Warcraft 1.12 / Turtle WoW 1.18 + Originally written by Gurky, maintained by Phaestas. + v3: classic UI, failure announcements, optional client-mod support + (SuperWoW, Nampower, UnitXP SP3 are all optional). + + Written for the Lua 5.0 runtime of the 1.12 client: + no '#', no '%' operator, no select(), no string.match. +]] + +LockPort = {} +local LP = LockPort + +LP.SPELL = "Ritual of Summoning" +LP.SHARD_ID = 6265 -- Soul Shard +LP.EVILTWIN_ID = 23445 -- Evil Twin (Dimensional Ripper / Ultrasafe Transporter) +LP.EVILTWIN_TEX = "spell_shadow_charm" +LP.NEARBY_YARDS = 30 + +-- Addon message prefixes. RSAdd / RSRemove / lp are kept so v3 still syncs +-- with older LockPort versions in the same raid. +local PREFIX_ADD = "RSAdd" +local PREFIX_REMOVE = "RSRemove" +local PREFIX_VERSION = "lp" +local PREFIX_WHISPER = "LPWhisper" -- ":" claim so only one warlock replies + +local CHAT_TAG = "|cff9482c9LockPort|r: " +local ANNOUNCE_TAG = "[LockPort] " + +-- Keep all player-facing chat consistent, including custom summon messages. +local function SendTaggedChatMessage(message, channel, language, target) + if LockPortOptions and LockPortOptions.chatMuted then return end + if string.lower(string.sub(message, 1, string.len(ANNOUNCE_TAG))) == string.lower(ANNOUNCE_TAG) then + message = string.sub(message, string.len(ANNOUNCE_TAG) + 1) + end + SendChatMessage(ANNOUNCE_TAG .. message, channel, language, target) +end +local REPO_URL = "https://github.com/Phaestas323/LockPort" + +LP.requests = {} -- { {name=, t=}, ... } in request order +LP.roster = {} +LP.mods = {} +LP.users = {} -- names seen sending LockPort addon messages +LP.claims = {} -- ":" -> time a status whisper went out +LP.whisperQueue = {} + +local DEFAULTS = { + chatMuted = false, -- mute all outgoing player-facing chat while travelling + whisper = true, -- whisper the target "Summoning you..." + zone = true, -- include zone in the /say message + shards = true, -- include shard count in the /say message + sound = true, -- sound on a new request + message = "", -- custom /say message + customMessage = false, + announceFail = true, -- tell the party/raid why a summon failed + whisperFail = true, -- whisper the target why their summon failed + whisperStatus = true, -- reply to "123" when already here / already on the list + announceOpen = false, -- post "summons are available - type 123" every few minutes + announceOpenEvery = "3", -- minutes between those posts + readySound = true, -- chime when a summon becomes possible + askHelpers = true, -- ask the raid for portal clickers when fewer than 2 are near + flash = true, -- flash the taskbar on a new request (UnitXP SP3) + autoRemove = true, -- drop people from the list once they're standing next to you + autoShow = true, -- pop the window open on a new request + locked = false, -- lock window position + theme = "classic", -- window style: "classic" or "pfui" + triggers = "123", -- comma separated request words +} + +-- Failure reasons: what the group sees, and what the summonee is whispered. +local REASONS = { + offline = { group = "%s is offline" }, + dead = { group = "%s is dead", whisper = "you're dead. Release or get a res and type 123 again" }, + combat = { group = "%s is in combat", whisper = "you're in combat. Drop combat and I'll try again" }, + eviltwin = { group = "%s has Evil Twin (must die or walk)", whisper = "you have the Evil Twin debuff. You'll need to die or walk" }, + mecombat = { group = "I'm in combat", whisper = "I'm in combat. You're still on the list" }, + noshards = { group = "I'm out of Soul Shards", whisper = "I'm out of Soul Shards" }, + instance = { group = "%s is inside %s", whisper = "you're inside %s - zone out first. You're still on the list" }, + interrupt = { group = "the ritual was interrupted", whisper = "the ritual was interrupted. You're still on the list" }, + nostart = { group = "the ritual didn't start", whisper = "the ritual didn't start. You're still on the list" }, +} + +--------------------------------------------------------------------------- +-- Helpers +--------------------------------------------------------------------------- +function LP:SetChatMuted(muted) + LockPortOptions.chatMuted = muted and true or false + -- Never send status replies queued before or during travel after unmuting. + self.whisperQueue = {} + self:UpdateUI() +end + +function LP:Print(msg) + DEFAULT_CHAT_FRAME:AddMessage(CHAT_TAG .. msg) +end + +local function OnOff(v) + if v then return "|cff40ff40on|r" end + return "|cffff4040off|r" +end +LP.OnOff = OnOff + +local function Trim(s) + s = string.gsub(s or "", "^%s+", "") + s = string.gsub(s, "%s+$", "") + return s +end + +local function EscapeRepl(s) + return (string.gsub(s or "", "%%", "%%%%")) +end + +function LP:IsWarlock() + local _, class = UnitClass("player") + return class == "WARLOCK" +end + +function LP:InGroup() + return GetNumRaidMembers() > 0 or GetNumPartyMembers() > 0 +end + +function LP:GroupChannel() + if GetNumRaidMembers() > 0 then return "RAID" end + if GetNumPartyMembers() > 0 then return "PARTY" end + return nil +end + +function LP:SendAddon(prefix, msg) + local chan = self:GroupChannel() + if chan then SendAddonMessage(prefix, msg, chan) end +end + +--------------------------------------------------------------------------- +-- Optional client mods +--------------------------------------------------------------------------- +function LP:DetectMods() + local m = self.mods + m.classicapi = CLASSIC_API_VERSION ~= nil + m.lockport = self:HasNativeMod() + -- SuperWoW: GUIDs from UnitExists, UNIT_CASTEVENT, SpellInfo, UnitPosition + m.superwow = (SUPERWOW_VERSION ~= nil) or (SpellInfo ~= nil and UnitPosition ~= nil) + -- UnitXP SP3: true yard distances + taskbar flash + m.unitxp = false + if UnitXP then + local ok, d = pcall(UnitXP, "distanceBetween", "player", "player") + m.unitxp = ok and (d == nil or type(d) == "number") + end + -- Nampower: SPELL_FAILED_SELF / SPELL_CAST_EVENT + m.nampower = (GetNampowerVersion ~= nil) or (QueueSpellByName ~= nil) or (GetSpellIdForName ~= nil) +end + +function LP:ModStatusText() + local function s(label, on) return label .. ": " .. (on and "|cff40ff40yes|r" or "|cff808080no|r") end + local m = self.mods + return s("SuperWoW", m.superwow) .. " " .. s("Nampower", m.nampower) .. " " .. + s("UnitXP SP3", m.unitxp) .. "\n" .. + s("ClassicAPI", CLASSIC_API_VERSION ~= nil) .. " " .. s("LockPort", self:HasNativeMod()) +end + +function LP:PrintMods() + local m = self.mods + self:Print("client mods detected:") + DEFAULT_CHAT_FRAME:AddMessage(" ClassicAPI: " .. OnOff(CLASSIC_API_VERSION ~= nil) .. " |cffaaaaaa- movement and active-cast checks|r") + DEFAULT_CHAT_FRAME:AddMessage(" LockPort: " .. OnOff(self:HasNativeMod()) .. " |cffaaaaaa- Auto Summon|r") + DEFAULT_CHAT_FRAME:AddMessage(" SuperWoW: " .. OnOff(m.superwow) .. " |cffaaaaaa- exact cast start/fail tracking for the ritual, Evil Twin by spell id, yard distances|r") + DEFAULT_CHAT_FRAME:AddMessage(" Nampower: " .. OnOff(m.nampower) .. " |cffaaaaaa- server-confirmed spell failure events|r") + DEFAULT_CHAT_FRAME:AddMessage(" UnitXP SP3: " .. OnOff(m.unitxp) .. " |cffaaaaaa- precise distance (\"already here\" check), taskbar flash on new requests|r") +end + +function LP:MyGUID() + if not self.mods.superwow then return nil end + local _, guid = UnitExists("player") + return guid +end + +-- Is this spell id our ritual? (needs SuperWoW's SpellInfo; otherwise assume yes) +function LP:IsRitualId(id) + if not id then return true end + if SpellInfo then + local name = SpellInfo(id) + if name then return name == self.SPELL end + end + return true +end + +--------------------------------------------------------------------------- +-- Distance +--------------------------------------------------------------------------- +function LP:Distance(unit) + if self.mods.unitxp then + local ok, d = pcall(UnitXP, "distanceBetween", "player", unit) + if ok and type(d) == "number" then return d end + end + if self.mods.superwow and UnitPosition then + local x1, y1, z1 = UnitPosition("player") + local x2, y2, z2 = UnitPosition(unit) + if x1 and x2 then + local dx, dy, dz = x1 - x2, y1 - y2, (z1 or 0) - (z2 or 0) + return math.sqrt(dx * dx + dy * dy + dz * dz) + end + end + return nil +end + +function LP:IsNearby(unit) + if not UnitIsVisible(unit) then return false end + local d = self:Distance(unit) + if d then return d <= self.NEARBY_YARDS end + return CheckInteractDistance(unit, 4) and true or false -- ~28 yards +end + +-- Group members (other than me and the summonee) close enough to click the portal. +function LP:CountHelpers(exclude) + local n = 0 + local raid = GetNumRaidMembers() + if raid > 0 then + for i = 1, raid do + local u = "raid" .. i + local name = UnitName(u) + if name and name ~= UnitName("player") and name ~= exclude + and UnitIsConnected(u) and not UnitIsDeadOrGhost(u) and self:IsNearby(u) then + n = n + 1 + end + end + else + for i = 1, GetNumPartyMembers() do + local u = "party" .. i + local name = UnitName(u) + if name and name ~= exclude and UnitIsConnected(u) and not UnitIsDeadOrGhost(u) and self:IsNearby(u) then + n = n + 1 + end + end + end + return n +end + +--------------------------------------------------------------------------- +-- Roster / spellbook / bags +--------------------------------------------------------------------------- +function LP:ScanRoster() + local roster = {} + local raid = GetNumRaidMembers() + if raid > 0 then + for i = 1, raid do + local name, _, subgroup, _, classLocal, class, zone, online, dead = GetRaidRosterInfo(i) + if name then + roster[name] = { unit = "raid" .. i, class = class, classLocal = classLocal, + zone = zone, online = online, dead = dead, subgroup = subgroup } + end + end + else + for i = 1, GetNumPartyMembers() do + local u = "party" .. i + local name = UnitName(u) + if name then + local classLocal, class = UnitClass(u) + roster[name] = { unit = u, class = class, classLocal = classLocal, + online = UnitIsConnected(u), dead = UnitIsDeadOrGhost(u) } + end + end + end + self.roster = roster + return roster +end + +-- nil selection means all raid groups; an empty table means none. +-- Party summoning ignores this raid-only assignment. +function LP:IsSelectedRaidGroup(info) + if GetNumRaidMembers() == 0 then return true end + local groups = LockPortOptions and LockPortOptions.summonGroups + if type(groups) ~= "table" then return true end + return info and groups[info.subgroup] == true or false +end + +function LP:SummonGroupSummary() + local groups = LockPortOptions and LockPortOptions.summonGroups + if type(groups) ~= "table" then return "All", 8 end + local selected = {} + for i = 1, 8 do if groups[i] then table.insert(selected, tostring(i)) end end + if not selected[1] then return "None", 0 end + return table.concat(selected, ", "), table.getn(selected) +end + +function LP:SetSummonGroups(groups) + if type(groups) == "table" then + local copy = {} + for i = 1, 8 do if groups[i] then copy[i] = true end end + LockPortOptions.summonGroups = copy + else + LockPortOptions.summonGroups = nil + end + self:CoordRelease() + self.autoReadyName, self.autoReadySince = nil, nil + -- Replies queued for the previous assignment should not claim the new one. + self.whisperQueue = {} + self:UpdateUI() + if self.RefreshGroupsUI then self:RefreshGroupsUI() end +end + +function LP:ToggleSummonGroup(group) + local groups = {} + local current = LockPortOptions.summonGroups + for i = 1, 8 do groups[i] = type(current) ~= "table" or current[i] == true end + groups[group] = not groups[group] + self:SetSummonGroups(groups) +end + +function LP:QueueSelectedGroups() + if not self:IsWarlock() or GetNumRaidMembers() == 0 then return self:Print("Group summoning needs a warlock in a raid.") end + local names = {} + for name, info in pairs(self:ScanRoster()) do + if name ~= UnitName("player") and self:IsSelectedRaidGroup(info) and info.online + and not UnitIsDeadOrGhost(info.unit) and not self:IsNearby(info.unit) then table.insert(names, name) end + end + table.sort(names) + local added = 0 + for _, name in ipairs(names) do if self:AddRequest(name, true) then added = added + 1 end end + self:Print("queued " .. added .. " player(s) from raid groups " .. self:SummonGroupSummary() .. ".") + return added +end + +function LP:KnowsRitual() + if self.knowsRitual ~= nil then return self.knowsRitual end + local i = 1 + while true do + local name = GetSpellName(i, BOOKTYPE_SPELL) + if not name then break end + if name == self.SPELL then + self.knowsRitual = true + return true + end + i = i + 1 + end + self.knowsRitual = false + return false +end + +function LP:CountShards() + local total = 0 + local pattern = "item:" .. self.SHARD_ID .. ":" + for bag = 0, 4 do + local slots = GetContainerNumSlots(bag) or 0 + for slot = 1, slots do + local link = GetContainerItemLink(bag, slot) + if link and string.find(link, pattern, 1, true) then + local _, count = GetContainerItemInfo(bag, slot) + total = total + math.abs(count or 1) + end + end + end + return total +end + +function LP:HasEvilTwin(unit) + for i = 1, 16 do + local tex, _, _, id = UnitDebuff(unit, i) + if not tex then break end + if id == self.EVILTWIN_ID then return true end + if string.find(string.lower(tex), self.EVILTWIN_TEX, 1, true) then return true end + end + return false +end + +--------------------------------------------------------------------------- +-- Instances: a player inside a dungeon/raid/battleground you're not in can't +-- be summoned out. Zone names come from the raid roster (raids only; 1.12 +-- doesn't share zones for 5-man parties). English zone names. +--------------------------------------------------------------------------- +LP.INSTANCES = {} +for _, z in ipairs({ + -- dungeons + "Ragefire Chasm", "Wailing Caverns", "The Deadmines", "Shadowfang Keep", "Blackfathom Deeps", + "The Stockade", "Gnomeregan", "Razorfen Kraul", "Scarlet Monastery", "Razorfen Downs", "Uldaman", + "Zul'Farrak", "Maraudon", "The Temple of Atal'Hakkar", "Sunken Temple", "Blackrock Depths", + "Blackrock Spire", "Dire Maul", "Stratholme", "Scholomance", + -- raids + "Molten Core", "Onyxia's Lair", "Blackwing Lair", "Zul'Gurub", "Ruins of Ahn'Qiraj", + "Ahn'Qiraj", "Temple of Ahn'Qiraj", "Naxxramas", + -- battlegrounds + "Warsong Gulch", "Arathi Basin", "Alterac Valley", + -- Turtle WoW + "Crescent Grove", "Karazhan Crypt", "Stormwind Vault", "Gilneas City", "Hateforge Quarry", + "The Black Morass", "Black Morass", "Dragonmaw Retreat", "Stormwrought Ruins", + "Emerald Sanctum", "Lower Karazhan Halls", "Upper Karazhan Halls", "Tower of Karazhan", +}) do LP.INSTANCES[z] = true end + +-- Returns the instance name if the player is inside an instance I'm not in. +function LP:InstanceBlock(info) + local z = info and info.zone + if not z or z == "" or z == GetRealZoneText() then return nil end + if self.INSTANCES[z] then return z end + return nil +end + +--------------------------------------------------------------------------- +-- Request list +--------------------------------------------------------------------------- +function LP:FindRequest(name) + for i, r in ipairs(self.requests) do + if r.name == name then return i end + end + return nil +end + +-- Returns true if the name was newly added. +function LP:AddRequest(name, broadcast) + if not name or name == "" or name == UnitName("player") then return false end + if broadcast then self:SendAddon(PREFIX_ADD, name) end + if not self:IsWarlock() then return false end + if self:FindRequest(name) then return false end + if not self:ScanRoster()[name] then return false end + table.insert(self.requests, { name = name, t = GetTime() }) + self:OnNewRequest(name) + self:UpdateUI() + return true +end + +function LP:RemoveRequest(name, broadcast) + if broadcast then self:SendAddon(PREFIX_REMOVE, name) end + local i = self:FindRequest(name) + if i then + table.remove(self.requests, i) + self:UpdateUI() + end +end + +function LP:ClearRequests() + for i = table.getn(self.requests), 1, -1 do + self:RemoveRequest(self.requests[i].name, true) + end +end + +function LP:OnNewRequest(name) + local O = LockPortOptions + if O.sound then + PlaySoundFile("Sound\\Creature\\Necromancer\\NecromancerReady1.wav") + end + if O.flash and self.mods.unitxp then + pcall(UnitXP, "notify", "taskbarIcon") + end + if O.autoShow and LockPortFrame and not LockPortFrame:IsVisible() then + LockPortFrame:Show() + end +end + +-- Drop names that left the group; auto-remove people who already arrived. +function LP:PruneRequests() + local roster = self:ScanRoster() + local O = LockPortOptions + for i = table.getn(self.requests), 1, -1 do + local r = self.requests[i] + local info = roster[r.name] + if not info then + table.remove(self.requests, i) + elseif O.autoRemove and not (self.pending and self.pending.name == r.name) + and GetTime() - r.t > 5 and info.online and self:IsNearby(info.unit) then + self:Print(r.name .. " is already here - removed from the list.") + self:RemoveRequest(r.name, true) + end + end +end + +-- Sorted view for the UI: warlocks first (they help click), then oldest request first. +function LP:SortedRequests() + local roster = self.roster + local list = {} + for i, r in ipairs(self.requests) do + local info = roster[r.name] + if info then + table.insert(list, { name = r.name, t = r.t, info = info, lock = (info.class == "WARLOCK") }) + end + end + table.sort(list, function(a, b) + if a.lock ~= b.lock then return a.lock end + if a.t ~= b.t then return a.t < b.t end + return a.name < b.name + end) + return list +end + +-- Status for one row: text, r, g, b +function LP:RequestStatus(entry) + local info, u = entry.info, entry.info.unit + if not self:IsSelectedRaidGroup(info) then return "Other group", 0.5, 0.5, 0.5 end + if self.pending and self.pending.name == entry.name and not self.pending.done then + return "Summoning...", 0.58, 0.51, 0.79 + end + if not UnitIsConnected(u) then return "Offline", 0.5, 0.5, 0.5 end + if UnitIsDeadOrGhost(u) then return "Dead", 1, 0.25, 0.25 end + if UnitAffectingCombat(u) then return "In combat", 1, 0.5, 0.1 end + if UnitIsVisible(u) then + local d = self:Distance(u) + if d then + if d <= self.NEARBY_YARDS then return "Here", 0.25, 1, 0.25 end + return string.format("%d yd", d), 0.85, 0.85, 0.85 + end + if CheckInteractDistance(u, 4) then return "Here", 0.25, 1, 0.25 end + end + if info.zone and info.zone ~= "" then + if info.zone == GetRealZoneText() then return "Same zone", 0.85, 0.85, 0.85 end + if self:InstanceBlock(info) then return info.zone, 1, 0.5, 0.1 end + return info.zone, 0.85, 0.85, 0.85 + end + return "", 1, 1, 1 +end + +--------------------------------------------------------------------------- +-- Failure reporting +--------------------------------------------------------------------------- +function LP:Fail(name, key, rawText, extra, quiet) + local O = LockPortOptions + local reason = REASONS[key] + local groupText, whisperText + if reason then + groupText = string.format(reason.group, name, extra or "") + whisperText = reason.whisper and string.format(reason.whisper, extra or "") + else + -- server / client error text, e.g. "Target is in combat", "You must be standing to do that" + groupText = rawText or "unknown error" + whisperText = rawText + end + + self:Print("|cffff4040can't summon " .. name .. ":|r " .. groupText) + if quiet then return end + + -- don't spam the same failure for the same person + local now = GetTime() + local k = name .. "\001" .. groupText + if self.lastFailKey == k and now - (self.lastFailTime or 0) < 10 then return end + self.lastFailKey, self.lastFailTime = k, now + + local chan = self:GroupChannel() + if O.announceFail and chan then + SendTaggedChatMessage(ANNOUNCE_TAG .. "Can't summon " .. name .. ": " .. groupText .. ".", chan) + end + if O.whisperFail and whisperText and key ~= "offline" and name ~= UnitName("player") then + SendTaggedChatMessage(ANNOUNCE_TAG .. "Couldn't summon you: " .. whisperText .. ".", "WHISPER", nil, name) + end +end + +--------------------------------------------------------------------------- +-- Summoning +--------------------------------------------------------------------------- +function LP:SummonNext() + local e = self:NextCandidate() + if e then return self:Summon(e.name) end + if self.requests[1] then + self:Print("nobody on the list can be summoned right now (offline, dead, in combat, in an instance, or being summoned by another warlock).") + else + self:Print("nobody is waiting for a summon.") + end +end + + +--------------------------------------------------------------------------- +-- Auto Summon: session-only opt-in, available only with LockPort.dll. +-- The DLL returns true for an attempted cast, never as proof of arrival. +--------------------------------------------------------------------------- +-- Protocol 1: one reservation per warlock, deterministic conflicts, explicit ACKs. +-- TTLs use the receiver's clock; no cross-client clock comparison is needed. +local COORD_PREFIX = "LPCoord" +local COORD_PEER_TTL = 8 +local COORD_RESERVE_TTL = 6 + +function LP:CoordInit() + if self.coordPeers then return end + self.coordPeers = {} + self.coordHolds = {} + self.coordSession = math.floor((type(time) == "function" and time() or GetTime()) * 1000) + + math.floor(math.mod(GetTime(), 1) * 1000) + self.coordSerial, self.coordHeartbeat = 0, 0 +end + +function LP:CoordSend(kind, id, target, recipient) + self:CoordInit() + self:SendAddon(COORD_PREFIX, "1:" .. kind .. ":" .. self.coordSession .. ":" .. id .. ":" .. (target or "") .. ":" .. (recipient or "")) +end + +function LP:CoordRelease() + local r = self.coordReservation + if not r or r.phase == "started" then return end + self:CoordSend("X", r.id, r.target) + self.coordReservation = nil +end + +function LP:CoordBlocked(target, now) + self:CoordInit() + local me = UnitName("player") + for name, hold in pairs(self.coordHolds) do + if now >= hold.expires then self.coordHolds[name] = nil + elseif name == target and hold.owner ~= me then return true end + end + local own = self.coordReservation + for name, peer in pairs(self.coordPeers) do + local r = peer.reservation + if r and r.target == target and now < r.expires then + if r.phase ~= "reserve" or not own or own.target ~= target or name < me then return true end + end + end + return false +end + +function LP:CoordUpdate(now, force) + self:CoordInit() + -- Drop peers that left, without depending on stable raid/party indices. + local roster = self:ScanRoster() + if not self:InGroup() then self.coordPeers, self.coordHolds = {}, {} end + for name, peer in pairs(self.coordPeers) do + local info = roster[name] + if not info or not info.online then + self.coordPeers[name] = nil + elseif peer.reservation and now >= peer.reservation.expires then + peer.reservation = nil + end + end + if self:InGroup() and (force or now >= (self.coordNextHeartbeat or 0)) then + self.coordHeartbeat = self.coordHeartbeat + 1 + self:CoordSend("H", self.coordHeartbeat, self.raidAutoSummon and "1" or "0") + self.coordNextHeartbeat = now + 2 + end + local r = self.coordReservation + if r and r.phase == "reserve" and now - r.at >= COORD_RESERVE_TTL then + self:TraceAuto("reservation timed out: " .. r.target) + self:CoordRelease() + self.autoReadyName, self.autoReadySince = nil, nil + end +end + +function LP:CoordReserve(target, now) + self:CoordRelease() + self:CoordInit() + self.coordSerial = self.coordSerial + 1 + local r = { id = self.coordSerial, target = target, at = now, phase = "reserve", acks = {}, required = {} } + self.coordReservation = r + for name, peer in pairs(self.coordPeers) do + if peer.enabled and now - (peer.seen or -100) < COORD_PEER_TTL then r.required[name] = true end + end + self:CoordSend("R", r.id, target) + self:TraceAuto("reserved: " .. target .. "; waiting for peers") +end + +function LP:CoordCanCast(target, now) + local r = self.coordReservation + if not r or r.phase ~= "reserve" or r.target ~= target or now - r.at < 2 then return false end + if self:CoordBlocked(target, now) then return false end + for name in pairs(r.required) do + local peer = self.coordPeers[name] + if peer and peer.enabled and now - (peer.seen or -100) < COORD_PEER_TTL and not r.acks[name] then + self:TraceAuto("waiting for reservation ACK: " .. name) + return false + end + end + return true +end + +function LP:CoordCasting(target, started) + self:CoordInit() + local r = self.coordReservation + if not r or r.target ~= target or r.phase == "started" then + self:CoordRelease() + self.coordSerial = self.coordSerial + 1 + r = {id = self.coordSerial, target = target, at = GetTime()} + self.coordReservation = r + end + r.phase = started and "started" or "casting" + self:CoordSend(started and "S" or "C", r.id, target) +end + +function LP:OnCoordMessage(message, sender, channel) + if channel ~= self:GroupChannel() or sender == UnitName("player") then return end + local info = self:ScanRoster()[sender] + if not info or not info.online or info.class ~= "WARLOCK" then return end + -- Bare pipes are WoW escape characters and some clients reject them even + -- in addon messages. Send colon-delimited packets; accept old packets too. + local _, _, kind, sessionText, idText, target, recipient = string.find(message or "", "^1:([HRACXS]):(%d+):(%d+):([^:]*):([^:]*)$") + if not kind then + _, _, kind, sessionText, idText, target, recipient = string.find(message or "", "^1|([HRACXS])|(%d+)|(%d+)|([^|]*)|([^|]*)$") + end + if not kind then return end + local session, id, now = tonumber(sessionText), tonumber(idText), GetTime() + if not session or not id or id < 0 then return end + self:CoordInit() + local own = self.coordReservation + if kind == "A" then + if own and own.phase == "reserve" and recipient == UnitName("player") and session == self.coordSession + and own.id == id and own.target == target then own.acks[sender] = true end + return + end + local peer = self.coordPeers[sender] + if peer and session < peer.session and now - (peer.lastSeen or 0) < COORD_PEER_TTL then return end + if not peer or peer.session ~= session then + peer = {session = session, lastId = -1, heartbeat = -1} + self.coordPeers[sender] = peer + -- A reloaded peer must acknowledge again, even if its name is unchanged. + if own and own.acks then own.acks[sender] = nil end + end + peer.lastSeen = now + if kind == "H" then + if id <= peer.heartbeat then return end + peer.heartbeat, peer.seen, peer.enabled = id, now, target == "1" + if not peer.enabled and peer.reservation and peer.reservation.phase == "reserve" then peer.reservation = nil end + if own and own.phase == "reserve" and peer.enabled then + own.required[sender] = true + self:CoordSend("R", own.id, own.target) + end + return + end + -- Each new reservation supersedes only that sender's previous one. Remember + -- released IDs so a delayed reserve cannot resurrect an expired transaction. + if id < peer.lastId then return end + if kind == "X" then + peer.lastId = id + if peer.reservation and peer.reservation.id <= id then peer.reservation = nil end + peer.closedId = id + if self.coordLegacyOwners and self.coordLegacyOwners[target] == sender then self.claims["summon:" .. target] = nil end + return + end + if id == peer.closedId then return end + if target == "" or not self.roster[target] then return end + local old = peer.reservation + if old and old.id == id then + if old.phase == "started" and kind ~= "S" then return end + if old.phase == "casting" and kind == "R" then return end + end + peer.lastId = id + peer.reservation = {id = id, target = target, phase = kind == "R" and "reserve" or (kind == "C" and "casting" or "started"), + expires = now + (kind == "R" and COORD_RESERVE_TTL or (kind == "C" and 20 or 130))} + if kind == "S" then self.coordHolds[target] = {owner = sender, expires = now + 130} end + if kind == "R" then + local p = self.pending + if p and not p.done and p.name == target then return end + if own and own.target == target then + if own.phase ~= "reserve" or UnitName("player") < sender then return end + self:TraceAuto("reservation yielded to " .. sender .. ": " .. target) + self:CoordRelease() + self.autoReadyName, self.autoReadySince = nil, nil + end + -- ACK binds the owner's session and transaction, not the sender's clock. + self:SendAddon(COORD_PREFIX, "1:A:" .. sessionText .. ":" .. idText .. ":" .. target .. ":" .. sender) + else + if own and own.target == target and own.phase == "reserve" then + self:CoordRelease() + self.autoReadyName, self.autoReadySince = nil, nil + end + end +end + +function LP:HasNativeMod() + if type(GetLockPortVersion) ~= "function" or type(LockPortNativeSummon) ~= "function" then return false end + local ok, version = pcall(GetLockPortVersion) + return ok and version == "1.2.1" +end + +function LP:SetRaidAutoSummon(enabled) + if enabled and (not self:HasNativeMod() or not self:InGroup() or not self:IsWarlock()) then + self:Print("Auto Summon needs LockPort.dll, a warlock, and a party or raid.") + return + end + if enabled and self.raidAutoSummon then return end + self:CoordRelease() + self.raidAutoSummon = enabled and true or false + self:CoordUpdate(GetTime(), true) + self:TraceAuto("Auto Summon " .. (enabled and "enabled" or "disabled")) + self.autoReadyName, self.autoReadySince = nil, nil + if enabled then + LockPortOptions.announceOpen = true + self:OpenAnnounce(GetTime(), true) + else + LockPortOptions.announceOpen = false + end + self:Print("Auto Summon: " .. OnOff(self.raidAutoSummon)) + self:RefreshOptions() + self:UpdateUI() +end + +-- nil means no authoritative live cast-state provider is available. +function LP:LiveCastBusy() + if not C_Spell or type(C_Spell.UnitCastingInfo) ~= "function" + or type(C_Spell.UnitChannelInfo) ~= "function" then return nil end + local castOK, cast = pcall(C_Spell.UnitCastingInfo, "player") + local channelOK, channel = pcall(C_Spell.UnitChannelInfo, "player") + if not castOK or not channelOK then return nil end + return (cast ~= nil and cast ~= false) or (channel ~= nil and channel ~= false) +end + +function LP:RecoverCastTracking(now) + if now < (self.nextCastPoll or 0) then return end + self.nextCastPoll = now + 0.25 + local live = self:LiveCastBusy() + if live == nil then return end + -- Live state supersedes a cast-start flag whose stop event was missed. + self.castBusy = live and true or nil + local p = self.pending + if not p or p.done or not p.started then return end + if live or now - p.t < 2 then p.inactiveSince = nil; return end + p.inactiveSince = p.inactiveSince or now + if now - p.inactiveSince < 1.5 then return end + -- No completion event: resolve conservatively rather than claiming failure. + p.interrupted, p.intT = true, now + self:Print("recovered stale summon tracking for " .. p.name .. ".") + self:FinishPending() +end + +function LP:TraceAuto(message) + self.autoTrace = self.autoTrace or {} + if self.lastAutoTrace == message then return end + self.lastAutoTrace = message + table.insert(self.autoTrace, {t = GetTime(), text = message}) + if table.getn(self.autoTrace) > 12 then table.remove(self.autoTrace, 1) end +end + +function LP:DebugSummon() + for _, entry in ipairs(self.autoTrace or {}) do + self:Print("trace " .. math.floor(GetTime() - entry.t) .. "s ago: " .. entry.text) + end + local ready, status = self:ReadyState() + self:Print("auto=" .. tostring(self.raidAutoSummon) .. " ready=" .. tostring(ready) + .. " eventBusy=" .. tostring(self.castBusy) .. " liveBusy=" .. tostring(self:LiveCastBusy())) + self:Print("status=" .. tostring(status)) + local r = self.coordReservation + if r then self:Print("reservation=" .. r.target .. " phase=" .. r.phase .. " id=" .. r.id) end + local peers = 0 + for _, peer in pairs(self.coordPeers or {}) do + if peer.enabled and GetTime() - (peer.seen or -100) < COORD_PEER_TTL then peers = peers + 1 end + end + self:Print("coordination peers=" .. peers) + local p = self.pending + if p then + self:Print("pending=" .. tostring(p.name) .. " started=" .. tostring(p.started) + .. " done=" .. tostring(p.done) .. " age=" .. math.floor(GetTime() - p.t) + .. " stopped=" .. tostring(p.stopT) .. " interrupted=" .. tostring(p.interrupted)) + end + if type(GetUnitSpeed) == "function" then + local ok, speed = pcall(GetUnitSpeed, "player") + self:Print("movement=" .. (ok and tostring(speed) or "unavailable")) + end + if type(GetLockPortVersion) == "function" then + local ok, version = pcall(GetLockPortVersion) + self:Print("native=" .. (ok and tostring(version) or "unavailable")) + end +end + +function LP:CheckRaidAutoSummon(now) + self:CoordUpdate(now) + if not self.raidAutoSummon then return end + if not self:HasNativeMod() or not self:InGroup() then + self:SetRaidAutoSummon(false) + return + end + -- ClassicAPI is optional. Use its live movement and cast state when present. + local live = self:LiveCastBusy() + local busy = self.castBusy + if live ~= nil then busy = live end + if type(GetUnitSpeed) == "function" then + local ok, speed = pcall(GetUnitSpeed, "player") + if ok and type(speed) == "number" and speed > 0 then busy = true end + end + local ready, reason = self:ReadyState() + if not ready or busy then + self:CoordRelease() + self:TraceAuto(busy and "waiting: moving or casting" or ("waiting: " .. (reason or "request list empty"))) + self.autoReadyName, self.autoReadySince = nil, nil + return + end + local e = self:NextCandidate() + if not e then self:CoordRelease(); return end + if self.autoReadyName ~= e.name or not self.coordReservation then + self:TraceAuto("ready: " .. e.name .. "; waiting 2 seconds") + self.autoReadyName, self.autoReadySince = e.name, now + self:CoordReserve(e.name, now) + return + end + -- Give other warlocks' claims time to arrive before casting. + if now - self.autoReadySince < 2 or not self:CoordCanCast(e.name, now) then return end + self.autoReadyName, self.autoReadySince = nil, nil + self.autoRetryAfter = self.autoRetryAfter or {} + -- An interrupted ritual may still have an acceptance prompt outstanding. + -- Wait before an automatic retry; manual retries remain available. + self.autoRetryAfter[e.name] = now + 130 + self:Summon(e.name, true) +end + +function LP:Summon(name, automatic) + if self:CoordBlocked(name, GetTime()) then return self:Print(name .. " is reserved by another warlock.") end + if not self:IsWarlock() then return self:Print("only warlocks can summon.") end + if self.pending and not self.pending.done then + return self:Print("already summoning " .. self.pending.name .. ".") + end + if not self:KnowsRitual() then return self:Print("you don't know " .. self.SPELL .. " yet.") end + + local info = self:ScanRoster()[name] + if not info then + self:Print(name .. " is no longer in your group - removed.") + return self:RemoveRequest(name, true) + end + local unit = info.unit + if not self:IsSelectedRaidGroup(info) then return self:Print(name .. " is outside your selected raid groups.") end + + -- target-side checks + local problem, extra = self:TargetProblem(info) + if problem == "eviltwin" then + self:Fail(name, "eviltwin") + return self:RemoveRequest(name, true) + elseif problem == "here" then + self:Print(name .. " is already here - removed from the list.") + return self:RemoveRequest(name, true) + elseif problem then + return self:Fail(name, problem, nil, extra) + end + -- my side + if UnitAffectingCombat("player") then return self:Fail(name, "mecombat") end + if self:CountShards() < 1 then return self:Fail(name, "noshards") end + + local helpers = self:CountHelpers(name) + if helpers < 2 then + self:Print("|cffffd200heads up:|r only " .. helpers .. " group member(s) nearby to click the portal (2 needed).") + end + + -- The ritual summons the warlock's target, so keep it targeted until the portal completes. + self:CoordCasting(name, false) + self.pending = { name = name, unit = unit, t = GetTime(), automatic = automatic } + self:TraceAuto((automatic and "automatic" or "manual") .. " cast requested: " .. name .. " (" .. unit .. ")") + self:SendAddon(PREFIX_WHISPER, "summon:" .. name) -- other warlocks' Summon Next skips this player for a bit + TargetUnit(unit) + if automatic then + local ok, attempted, reason = pcall(LockPortNativeSummon, unit) + self:TraceAuto("native result: " .. tostring(attempted) .. "; " .. tostring(reason)) + if not ok or not attempted then + self.pending = nil + self:CoordRelease() + self.autoRetryAfter = self.autoRetryAfter or {} + self.autoRetryAfter[name] = GetTime() + 10 + self:Print("native summon not sent: " .. tostring(reason or attempted)) + end + else + CastSpellByName(self.SPELL) + end + self:UpdateUI() +end + +-- Why this player can't be summoned right now: nil, or a REASONS key (+ extra). +-- "here" means they're already next to me. +function LP:TargetProblem(info) + local u = info.unit + if not UnitIsConnected(u) then return "offline" end + if UnitIsDeadOrGhost(u) then return "dead" end + if UnitAffectingCombat(u) then return "combat" end + local inst = self:InstanceBlock(info) + if inst then return "instance", inst end + if UnitIsVisible(u) and self:HasEvilTwin(u) then return "eviltwin" end + if self:IsNearby(u) then return "here" end + return nil +end + +--------------------------------------------------------------------------- +-- Picking who's next. Summon Next skips anyone who can't be summoned right +-- now, anyone another warlock just started summoning ("summon:" claim), +-- and anyone who failed 3 times in a row (for a minute). +--------------------------------------------------------------------------- +LP.failCount = {} +LP.skipUntil = {} + +function LP:NoteResult(name, ok) + if ok then + self.failCount[name] = nil + self.skipUntil[name] = nil + return + end + self.failCount[name] = (self.failCount[name] or 0) + 1 + if self.failCount[name] >= 3 then + self.failCount[name] = nil + self.skipUntil[name] = GetTime() + 60 + self:Print(name .. " failed 3 times in a row - Summon Next skips them for a minute.") + end +end + +-- First player who can be summoned right now (or nil). +function LP:NextCandidate() + local now = GetTime() + self:ScanRoster() + for _, e in ipairs(self:SortedRequests()) do + local claimT = self.claims["summon:" .. e.name] + local claimed = (claimT and now - claimT < 20) or self:CoordBlocked(e.name, now) + local skipped = self.skipUntil[e.name] and now < self.skipUntil[e.name] + local autoWaiting = self.raidAutoSummon and self.autoRetryAfter and self.autoRetryAfter[e.name] and now < self.autoRetryAfter[e.name] + if self:IsSelectedRaidGroup(e.info) and not claimed and not skipped and not autoWaiting and not self:TargetProblem(e.info) then + return e + end + end + return nil +end + +-- What the Summon button would do right now: ready (bool), text for the window. +function LP:ReadyState() + local p = self.pending + if p and not p.done then return false, "Summoning " .. p.name .. "..." end + if not self.requests[1] then return false, nil end + if UnitIsDeadOrGhost("player") then return false, "You're dead" end + if UnitAffectingCombat("player") then return false, "You're in combat" end + if self:CountShards() < 1 then return false, "No Soul Shards" end + local e = self:NextCandidate() + if not e then + if GetNumRaidMembers() > 0 and type(LockPortOptions.summonGroups) == "table" then return false, "No selected-group member is ready" end + return false, "Nobody can be summoned yet" + end + local h = self:CountHelpers(e.name) + if h < 2 then return false, "Waiting for helpers (" .. h .. "/2)" end + return true, "Ready: " .. e.name +end + +-- Chime (and flash the taskbar) when a summon becomes possible. +function LP:CheckReady() + local ready, text = self:ReadyState() + self.readyText = text + if ready and not self.wasReady and self:IsWarlock() then + local O = LockPortOptions + if O.readySound then PlaySound("ReadyCheck") end + if O.flash and self.mods.unitxp then pcall(UnitXP, "notify", "taskbarIcon") end + end + self.wasReady = ready + self.isReady = ready +end + +--------------------------------------------------------------------------- +-- Not enough helpers: ask the group to come click the portal. +-- Fires when someone is waiting (or a ritual is up) and fewer than 2 group +-- members are near you for ~8 s. At most every 2 min, never in combat, and +-- only one LockPort warlock posts (a "help" claim resets the others' timers). +--------------------------------------------------------------------------- +function LP:WhereText() + local where = GetZoneText() or "" + local sub = GetSubZoneText() + if sub and sub ~= "" and sub ~= where then where = where .. " - " .. sub end + return where +end + +function LP:HelperCall(now) + local O = LockPortOptions + if O.chatMuted or not O.askHelpers or not self:IsWarlock() then self.helpShortSince = nil; return end + local chan = self:GroupChannel() + if not chan then self.helpShortSince = nil; return end + + local p = self.pending + local channelling = p and not p.done and p.started + local forName + if channelling then + forName = p.name + else + if UnitIsDeadOrGhost("player") or UnitAffectingCombat("player") or self:CountShards() < 1 then + self.helpShortSince = nil + return + end + local e = self:NextCandidate() + if not e then self.helpShortSince = nil; return end + forName = e.name + end + + local h = self:CountHelpers(forName) + if h >= 2 then self.helpShortSince = nil; return end + self.helpShortSince = self.helpShortSince or now + if now - self.helpShortSince < 8 then return end + if UnitAffectingCombat("player") then return end + if now < (self.nextHelpAt or 0) then return end + local claimT = self.claims["help"] + if claimT and now - claimT < 115 then + self.nextHelpAt = claimT + 120 + return + end + + local need = 2 - h + local who = (need == 1) and "1 more person" or (need .. " more people") + local msg + if channelling then + msg = ANNOUNCE_TAG .. "Need " .. who .. " at " .. self:WhereText() .. " to click the summoning portal for " .. forName .. "!" + else + local waiting = table.getn(self.requests) + msg = ANNOUNCE_TAG .. "Need " .. who .. " at " .. self:WhereText() .. " to help summon - " .. + waiting .. (waiting == 1 and " player" or " players") .. " waiting." + end + SendTaggedChatMessage(msg, chan) + self:SendAddon(PREFIX_WHISPER, "help") + self.nextHelpAt = now + 120 +end + +--------------------------------------------------------------------------- +-- "Summons are available - type 123" to the party/raid every N minutes (option). +-- Skipped in combat; with several LockPort warlocks only one posts per round. +--------------------------------------------------------------------------- +function LP:TriggerWord() + local _, _, w = string.find(LockPortOptions.triggers or "123", "^%s*([^,]+)") + w = Trim(w or "") + if w == "" then w = "123" end + return w +end + +function LP:OpenAnnounce(now, force) + if LockPortOptions.chatMuted then return end + local O = LockPortOptions + if not O.announceOpen and not force then return end + if not self:IsWarlock() then return end + local chan = self:GroupChannel() + if not chan then return end + local every = tonumber(O.announceOpenEvery) or 3 + if every < 1 then every = 1 end + local period = every * 60 + if not force and now < (self.nextAnnounceAt or 0) then return end + if UnitAffectingCombat("player") then return end + local claimT = self.claims["announce"] + if not force and claimT and now - claimT < period - 5 then + self.nextAnnounceAt = claimT + period + return + end + local n = table.getn(self.requests) + local msg = ANNOUNCE_TAG .. "Summons are available - type " .. self:TriggerWord() .. " to get summoned." + if n > 0 then msg = msg .. " (" .. n .. " in line)" end + SendTaggedChatMessage(msg, chan) + self:SendAddon(PREFIX_WHISPER, "announce") + self.nextAnnounceAt = now + period +end + +-- The ritual really started: announce and take the name off everyone's list. +function LP:OnRitualStarted() + local p = self.pending + if not p or p.done or p.started then return end + p.started = true + self:CoordCasting(p.name, true) + self:TraceAuto("cast started: " .. p.name) + p.failed = nil + local O = LockPortOptions + local name = p.name + local shards = self:CountShards() - 1 + if shards < 0 then shards = 0 end + + local where = self:WhereText() + + local msg + if O.customMessage and O.message and O.message ~= "" then + msg = O.message + msg = string.gsub(msg, "{name}", EscapeRepl(name)) + msg = string.gsub(msg, "{zone}", EscapeRepl(where)) + msg = string.gsub(msg, "{shards}", shards) + else + msg = "Summoning " .. name + if O.zone then msg = msg .. " to " .. where end + if O.shards then msg = msg .. " [" .. shards .. " shards left]" end + msg = msg .. ", please click the portal!" + end + SendTaggedChatMessage(msg, "SAY") + + if O.whisper then + local w = "Summoning you" + if O.zone then w = w .. " to " .. where end + SendTaggedChatMessage(w .. ". Accept when the prompt shows up!", "WHISPER", nil, name) + end + + self:RemoveRequest(name, true) + self:UpdateUI() +end + +function LP:FinishPending() + local p = self.pending + if not p or p.done then return end + p.done = true + if p.started and not p.interrupted then + self.watch = { name = p.name, unit = p.unit, t = GetTime() } + self:Print("ritual for " .. p.name .. " complete - waiting for them to accept.") + elseif p.started and p.interrupted then + -- Ending the summoning channel is ambiguous on 1.12 clients: it can + -- happen when helpers complete the ritual as well as when cancelled. + -- Never announce failure or penalize the player from this alone. + -- Resolve the current roster unit; party/raid indices may have changed. + local info = self:ScanRoster()[p.name] + if info and UnitName(info.unit) == p.name and self:IsNearby(info.unit) then + self:RemoveRequest(p.name, true) + self:NoteResult(p.name, true) + self:Print("|cff40ff40" .. p.name .. " has arrived.|r") + else + self:AddRequest(p.name, true) + self.watch = { name = p.name, unit = info and info.unit or p.unit, + t = GetTime(), unconfirmed = true } + self:Print("ritual for " .. p.name .. " ended - completion unconfirmed; kept on the list for retry.") + end + else + self:CoordRelease() + self:TraceAuto("cast did not start: " .. p.name .. "; " .. tostring(p.err)) + if p.automatic then + self.autoRetryAfter = self.autoRetryAfter or {} + self.autoRetryAfter[p.name] = GetTime() + 10 + end + if p.err then self:Fail(p.name, nil, p.err) else self:Fail(p.name, "nostart") end + end + if not (p.started and p.interrupted) then + self:NoteResult(p.name, p.started and not p.interrupted) + end + self:UpdateUI() +end + +-- Called every frame from the event frame. +function LP:OnUpdate(elapsed) + local now = GetTime() + self:RecoverCastTracking(now) + if self.whisperQueue[1] then self:ProcessWhisperQueue(now) end + local p = self.pending + if p and not p.done then + if not p.started then + if p.failed and now - p.failT >= (p.hardFail and 0.25 or 0.5) then + self:FinishPending() + elseif now - p.t > 4 then + self:FinishPending() + end + else + if p.stopT and now - p.stopT >= 0.4 then + self:FinishPending() + elseif p.interrupted and now - p.intT >= 0.6 then + self:FinishPending() + end + end + end + + self.tick = (self.tick or 0) + elapsed + if self.tick < 1 then return end + self.tick = 0 + + -- did the last summoned player arrive? + local w = self.watch + if w then + if now - w.t > 180 then + self.watch = nil + else + local info = self:ScanRoster()[w.name] + if info and UnitName(info.unit) == w.name and self:IsNearby(info.unit) then + if w.unconfirmed then + self:RemoveRequest(w.name, true) + self:NoteResult(w.name, true) + end + self:Print("|cff40ff40" .. w.name .. " has arrived.|r") + self.watch = nil + end + end + end + if table.getn(self.requests) > 0 then + self:PruneRequests() + end + self:OpenAnnounce(now) + self:CheckReady() + self:CheckRaidAutoSummon(now) + self:HelperCall(now) + self:UpdateUI() +end + +--------------------------------------------------------------------------- +-- Status whispers ("you're already here", "you're #3 in line...") +-- +-- Every LockPort warlock in the group sees the same "123", so replies are +-- coordinated: the warlock summoning that player answers at once, the others +-- wait a staggered delay (alphabetical among known LockPort warlocks) and +-- drop their reply if someone else's LPWhisper claim arrives first. +--------------------------------------------------------------------------- +function LP:OnTrigger(name, info, event) + if name == UnitName("player") then return end + if not self:IsWarlock() then + -- non-warlocks with LockPort just relay the request + self:SendAddon(PREFIX_ADD, name) + return + end + + if not self:IsSelectedRaidGroup(info) then self:AddRequest(name, true); return end + + -- already standing next to me + if info.online and self:IsNearby(info.unit) then + if self:FindRequest(name) then self:RemoveRequest(name, true) end + self:QueueWhisper(name, "here", self:HereText(info.unit)) + return + end + + local p = self.pending + if self:FindRequest(name) or (p and not p.done and p.name == name) then + -- repeat request: tell them why they're still waiting + self:SendAddon(PREFIX_ADD, name) + self:QueueWhisper(name, "status", self:StatusText(name, info)) + return + end + + local w = self.watch + local recent = w and w.name == name and GetTime() - w.t < 120 + if self:AddRequest(name, true) then + if recent then + self:QueueWhisper(name, "status", "I cast your summon " .. math.floor(GetTime() - w.t) .. + "s ago - accept the prompt if you still see it. I put you back on the list in case it expired.") + self.watch = nil + elseif event == "CHAT_MSG_WHISPER" then + local pos = self:QueuePosition(name) + SendTaggedChatMessage(ANNOUNCE_TAG .. "You're on the summon list" .. (pos and (" (#" .. pos .. ")") or "") .. ".", "WHISPER", nil, name) + end + end +end + +function LP:QueuePosition(name) + self:ScanRoster() + for i, e in ipairs(self:SortedRequests()) do + if e.name == name then return i end + end + return nil +end + +function LP:HereText(unit) + local d = self:Distance(unit) + if d then + return "You're already here (" .. math.floor(d) .. " yd from me) - no summon needed." + end + return "You're already standing next to me - no summon needed." +end + +function LP:StatusText(name, info) + local u = info.unit + local p = self.pending + if p and not p.done and p.name == name then + if not p.started then return "Casting your summon right now - accept the prompt when it shows up." end + local h = self:CountHelpers(name) + if h >= 2 then + return "Summoning you now - the portal is up, waiting for 2 people to click it. Accept the prompt when it shows up." + end + return "Summoning you now - waiting for helpers to click the portal (" .. h .. "/2 near me). Accept the prompt when it shows up." + end + + local pos = self:QueuePosition(name) + local line = pos and ("You're #" .. pos .. " in line") or "You're on the list" + if UnitIsDeadOrGhost(u) then return line .. ", but you're dead - release or get a res first." end + if UnitAffectingCombat(u) then return line .. ", but you're in combat - drop combat first." end + local inst = self:InstanceBlock(info) + if inst then return line .. ", but you're inside " .. inst .. " - zone out first." end + if p and not p.done then return line .. " - I'm summoning " .. p.name .. " right now." end + if UnitAffectingCombat("player") then return line .. " - I'm in combat, I'll summon you right after." end + if self:CountShards() < 1 then return line .. " - I'm out of Soul Shards." end + local h = self:CountHelpers(name) + if h < 2 then + return line .. " - waiting on helpers: I need 2 people near me to click the portal (" .. h .. " here)." + end + if pos and pos > 1 then + return line .. " - " .. (pos - 1) .. " ahead of you." + end + return line .. " - you're next!" +end + +function LP:ResponderDelay(name) + local p = self.pending + if p and not p.done and p.name == name then return 0 end + local me = UnitName("player") + local locks, seenMe = {}, false + for n, info in pairs(self.roster) do + if info.class == "WARLOCK" and info.online and (n == me or self.users[n]) then + table.insert(locks, n) + if n == me then seenMe = true end + end + end + if not seenMe then table.insert(locks, me) end + table.sort(locks) + for i, n in ipairs(locks) do + if n == me then return 0.4 + (i - 1) * 1.0 end + end + return 0.4 +end + +function LP:QueueWhisper(name, kind, text) + if LockPortOptions.chatMuted then return end + if not LockPortOptions.whisperStatus then return end + local key = kind .. ":" .. name + local now = GetTime() + if self.claims[key] and now - self.claims[key] < 12 then return end + for _, q in ipairs(self.whisperQueue) do + if q.key == key then return end + end + table.insert(self.whisperQueue, { key = key, name = name, text = text, at = now + self:ResponderDelay(name) }) +end + +function LP:ProcessWhisperQueue(now) + if LockPortOptions.chatMuted then self.whisperQueue = {}; return end + local q = self.whisperQueue + for i = table.getn(q), 1, -1 do + local w = q[i] + if self.claims[w.key] and now - self.claims[w.key] < 12 then + table.remove(q, i) + elseif now >= w.at then + table.remove(q, i) + self.claims[w.key] = now + self:SendAddon(PREFIX_WHISPER, w.key) + SendTaggedChatMessage(ANNOUNCE_TAG .. w.text, "WHISPER", nil, w.name) + end + end +end + +--------------------------------------------------------------------------- +-- Events +--------------------------------------------------------------------------- +function LP:MatchesTrigger(msg) + msg = string.lower(Trim(msg)) + local triggers = LockPortOptions and LockPortOptions.triggers or DEFAULTS.triggers + for word in string.gfind(triggers, "[^,]+") do + word = string.lower(Trim(word)) + if word ~= "" then + local len = string.len(word) + if string.sub(msg, 1, len) == word then + local nextc = string.sub(msg, len + 1, len + 1) + if nextc == "" or not string.find(nextc, "%w") then return true end + end + end + end + return false +end + +local CHAT_EVENTS = { + CHAT_MSG_SAY = true, CHAT_MSG_YELL = true, CHAT_MSG_WHISPER = true, + CHAT_MSG_PARTY = true, CHAT_MSG_RAID = true, CHAT_MSG_RAID_LEADER = true, +} + +function LP:OnEvent(event) + -- Track all casts so automatic summons do not interrupt another spell. + if event == "SPELLCAST_START" or event == "SPELLCAST_CHANNEL_START" then + self.castBusy = true + elseif event == "SPELLCAST_STOP" or event == "SPELLCAST_CHANNEL_STOP" + or event == "SPELLCAST_FAILED" or event == "SPELLCAST_INTERRUPTED" then + self.castBusy = nil + end + if event == "PLAYER_ENTERING_WORLD" then + self.castBusy = nil + end + if (event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED") + and self.raidAutoSummon and not self:InGroup() then + self:SetRaidAutoSummon(false) + end + if event == "VARIABLES_LOADED" then return self:Initialize() end + if not LockPortOptions then return end + if CHAT_EVENTS[event] then + -- Snapshot chat arguments before calling other addon/game code. Newer + -- ClassicAPI reads the actual event payload even if a hook changed globals. + local message, sender, chatEvent = arg1, arg2, event + if type(GetCurrentChatEvent) == "function" then + local ok, actualEvent, actualMessage, actualSender = pcall(GetCurrentChatEvent) + if ok and CHAT_EVENTS[actualEvent] then + chatEvent, message, sender = actualEvent, actualMessage, actualSender + end + end + if type(message) == "string" and type(sender) == "string" and self:MatchesTrigger(message) then + local info = self:ScanRoster()[sender] + if info then self:OnTrigger(sender, info, chatEvent) end + end + + elseif event == "CHAT_MSG_ADDON" then + if arg1 == COORD_PREFIX then return self:OnCoordMessage(arg2, arg4, arg3) end + if arg1 == PREFIX_ADD or arg1 == PREFIX_REMOVE or arg1 == PREFIX_VERSION or arg1 == PREFIX_WHISPER then + if arg4 then self.users[arg4] = true end + end + if arg1 == PREFIX_ADD then + -- another warlock added someone who is standing next to me: they don't need a summon + local info = self:ScanRoster()[arg2] + if self:IsWarlock() and LockPortOptions.autoRemove and info and info.online and self:IsNearby(info.unit) then + self:SendAddon(PREFIX_REMOVE, arg2) + else + self:AddRequest(arg2, false) + end + elseif arg1 == PREFIX_REMOVE then + self:RemoveRequest(arg2, false) + elseif arg1 == PREFIX_VERSION then + self:OnVersionMessage(arg2) + elseif arg1 == PREFIX_WHISPER then + -- another LockPort warlock already answered / is summoning this player + if arg4 ~= UnitName("player") then + self.claims[arg2] = GetTime() + local _, _, target = string.find(arg2 or "", "^summon:(.+)$") + if target then + self.coordLegacyOwners = self.coordLegacyOwners or {} + self.coordLegacyOwners[target] = arg4 + local r = self.coordReservation + if r and r.phase == "reserve" and r.target == target then + self:CoordRelease() + self.autoReadyName, self.autoReadySince = nil, nil + end + end + end + end + + -- cast tracking (works on a plain client) -------------------------------- + elseif event == "UI_ERROR_MESSAGE" then + local p = self.pending + if p and not p.done and not p.started and GetTime() - p.t < 4 then + p.err = arg1 + if not p.failed then p.failed, p.failT = true, GetTime() end + end + elseif event == "SPELLCAST_START" then + if arg1 == self.SPELL then self:OnRitualStarted() end + elseif event == "SPELLCAST_CHANNEL_START" then + local p = self.pending + if p and not p.done and not p.started and GetTime() - p.t < 4 then self:OnRitualStarted() end + elseif event == "SPELLCAST_FAILED" or event == "SPELLCAST_INTERRUPTED" then + local p = self.pending + if p and not p.done then + if p.started then + p.interrupted, p.intT = true, GetTime() + else + p.failed, p.failT, p.hardFail = true, GetTime(), true + end + end + elseif event == "SPELLCAST_CHANNEL_STOP" then + local p = self.pending + if p and not p.done and p.started then p.stopT = GetTime() end + + -- SuperWoW: exact, GUID-matched cast events --------------------------------- + elseif event == "UNIT_CASTEVENT" then + local p = self.pending + if p and not p.done and arg1 == self:MyGUID() and self:IsRitualId(arg4) then + if arg3 == "START" or arg3 == "CHANNEL" then + self:OnRitualStarted() + elseif arg3 == "FAIL" then + if p.started then p.interrupted, p.intT = true, GetTime() + else p.failed, p.failT, p.hardFail = true, GetTime(), true end + end + end + + -- Nampower: server-confirmed failures ------------------------------------ + elseif event == "SPELL_FAILED_SELF" then + local p = self.pending + if p and not p.done and self:IsRitualId(arg1) then + if p.started then p.interrupted, p.intT = true, GetTime() + else p.failed, p.failT, p.hardFail = true, GetTime(), true end + end + + elseif event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED" then + self:PruneRequests() + self:UpdateUI() + self:OnGroupChanged() + elseif event == "SPELLS_CHANGED" or event == "LEARNED_SPELL_IN_TAB" then + self.knowsRitual = nil + elseif event == "BAG_UPDATE" then + if LockPortFrame and LockPortFrame:IsVisible() then self:UpdateUI() end + elseif event == "PLAYER_ENTERING_WORLD" then + self:SendVersion(true) + end +end + +function LP:Initialize() + if type(LockPortOptions) ~= "table" then LockPortOptions = {} end + local O = LockPortOptions + -- v2 saved a message without the toggle + if O.customMessage == nil and O.message and O.message ~= "" then O.customMessage = true end + for k, v in pairs(DEFAULTS) do + if O[k] == nil then O[k] = v end + end + -- Availability announcements, like Auto Summon, require a fresh opt-in each session. + self.raidAutoSummon = false + O.announceOpen = false + self.nextAnnounceAt = nil + self:DetectMods() + local ev = self.eventFrame + if self.mods.superwow then ev:RegisterEvent("UNIT_CASTEVENT") end + if self.mods.nampower then ev:RegisterEvent("SPELL_FAILED_SELF") end + if self.CreateUI then self:CreateUI() end + self:Print("v" .. tostring(GetAddOnMetadata("LockPort", "Version")) .. " loaded. Type |cffffd200/lockport|r to open, |cffffd200/lockport help|r for commands.") +end + +--------------------------------------------------------------------------- +-- Version check (compatible with v2's "lp" VERSION: messages) +--------------------------------------------------------------------------- +local function VersionNumber(v) + local _, _, a, b, c = string.find(tostring(v or ""), "(%d+)%.(%d+)%.?(%d*)") + return (tonumber(a) or 0) * 10000 + (tonumber(b) or 0) * 100 + (tonumber(c) or 0) +end + +function LP:SendVersion(login) + local v = "VERSION:" .. VersionNumber(GetAddOnMetadata("LockPort", "Version")) + self:SendAddon(PREFIX_VERSION, v) + if login and IsInGuild() then SendAddonMessage(PREFIX_VERSION, v, "GUILD") end +end + +function LP:OnGroupChanged() + self:CoordUpdate(GetTime(), true) + local size = GetNumRaidMembers() + if size == 0 then size = GetNumPartyMembers() end + if size > (self.groupSize or 0) then self:SendVersion(false) end + self.groupSize = size +end + +function LP:OnVersionMessage(msg) + local _, _, kind, num = string.find(msg or "", "^(%a+):(%d+)") + if kind ~= "VERSION" then return end + local remote = tonumber(num) or 0 + if remote > VersionNumber(GetAddOnMetadata("LockPort", "Version")) and not self.updateShown then + self.updateShown = true + self:Print("a newer version is available: " .. REPO_URL) + end +end + +--------------------------------------------------------------------------- +-- Slash commands +--------------------------------------------------------------------------- +local TOGGLES = { + zone = "zone info in /say", whisper = "whisper the target", shards = "shard count in /say", + sound = "sound on new request", announce = "announce failures to group", + flash = "taskbar flash", autoremove = "auto-remove arrived players", lock = "window lock", + status = "status whispers (already here / place in line)", + helpers = "ask the raid for portal helpers", +} +local TOGGLE_KEYS = { announce = "announceFail", autoremove = "autoRemove", lock = "locked", status = "whisperStatus", helpers = "askHelpers" } + +function LP:Slash(msg) + msg = Trim(msg) + local _, _, cmd, rest = string.find(msg, "^(%S*)%s*(.-)$") + cmd = string.lower(cmd or "") + local O = LockPortOptions + + if cmd == "" or cmd == "toggle" then + self:Toggle() + elseif cmd == "debug" then + self:DebugSummon() + elseif cmd == "help" then + self:Print("commands:") + local lines = { + "|cffffd200/lockport|r - show / hide the window", + "|cffffd200/lockport next|r - summon the next person in line", + "|cffffd200/lockport available|r - toggle the \"summons are available\" raid posts", + "|cffffd200/lockport add [name]|r - add a name (or your target)", + "|cffffd200/lockport remove |r, |cffffd200clear|r - edit the list", + "|cffffd200/lockport show|r - print the list", + "|cffffd200/lockport options|r - open the options window", + "|cffffd200/lockport message |r or |cffffd200message clear|r - custom /say ({name} {zone} {shards})", + "|cffffd200/lockport zone, whisper, shards, sound, announce, status, helpers, flash, autoremove, lock|r - toggles", + "|cffffd200/lockport mods|r - which client mods LockPort is using", + "|cffffd200/lockport style|r - switch window style (Classic / pfUI)", + "|cffffd200/lockport reset|r - reset the window position", + } + for _, l in ipairs(lines) do DEFAULT_CHAT_FRAME:AddMessage(" " .. l) end + elseif cmd == "next" or cmd == "summon" then + if rest ~= "" then self:Summon(rest) else self:SummonNext() end + elseif cmd == "add" then + local name = rest + if name == "" then name = UnitName("target") end + if not name then return self:Print("target someone or use /lockport add .") end + if not self:ScanRoster()[name] then return self:Print(name .. " isn't in your group.") end + if not self:AddRequest(name, true) then self:Print(name .. " is already on the list.") end + elseif cmd == "remove" then + self:RemoveRequest(rest, true) + elseif cmd == "clear" then + self:ClearRequests() + self:Print("summon list cleared.") + elseif cmd == "show" then + if not self.requests[1] then return self:Print("the list is empty.") end + for i, r in ipairs(self.requests) do DEFAULT_CHAT_FRAME:AddMessage(" " .. i .. ". " .. r.name) end + elseif cmd == "options" or cmd == "settings" or cmd == "config" then + self:ToggleOptions() + elseif cmd == "message" then + if string.lower(rest) == "clear" then rest = "" end + O.message = rest + O.customMessage = rest ~= "" + self:Print("custom /say message " .. (rest ~= "" and ("set: " .. rest) or "cleared.")) + self:RefreshOptions() + elseif cmd == "style" or cmd == "theme" then + self:ToggleTheme() + elseif cmd == "available" or cmd == "open" then + if self.raidAutoSummon then return self:Print("turn off Auto Summon before disabling availability announcements.") end + O.announceOpen = not O.announceOpen + self:Print("\"summons are available\" announcements: " .. OnOff(O.announceOpen)) + if O.announceOpen then self:OpenAnnounce(GetTime(), true) end + self:RefreshOptions() + elseif cmd == "mods" then + self:PrintMods() + elseif cmd == "reset" then + O.pos = nil + if LockPortFrame then + LockPortFrame:ClearAllPoints() + LockPortFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + end + elseif TOGGLES[cmd] then + local key = TOGGLE_KEYS[cmd] or cmd + O[key] = not O[key] + self:Print(TOGGLES[cmd] .. ": " .. OnOff(O[key])) + self:RefreshOptions() + else + self:Print("unknown command. Type /lockport help.") + end +end + +--------------------------------------------------------------------------- +-- UI hooks (implemented in LockPort_UI.lua) +--------------------------------------------------------------------------- +function LP:UpdateUI() if self.RefreshList then self:RefreshList() end end +function LP:RefreshOptions() if self.RefreshOptionsUI then self:RefreshOptionsUI() end end +function LP:Toggle() + if not LockPortFrame then return end + if LockPortFrame:IsVisible() then LockPortFrame:Hide() else LockPortFrame:Show() end +end +function LP:ToggleOptions() + if not LockPortOptionsFrame then return end + if LockPortOptionsFrame:IsVisible() then LockPortOptionsFrame:Hide() else LockPortOptionsFrame:Show() end +end + +-- Key bindings (Bindings.xml) +BINDING_HEADER_LOCKPORT = "LockPort" +BINDING_NAME_LOCKPORT_SUMMON_NEXT = "Summon next in line" +BINDING_NAME_LOCKPORT_TOGGLE = "Show / hide window" + +--------------------------------------------------------------------------- +-- Boot +--------------------------------------------------------------------------- +local ev = CreateFrame("Frame", "LockPortEventFrame") -- not parented to UIParent so Alt+Z doesn't stop it +LP.eventFrame = ev +local EVENTS = { + "VARIABLES_LOADED", "PLAYER_ENTERING_WORLD", "CHAT_MSG_ADDON", + "CHAT_MSG_SAY", "CHAT_MSG_YELL", "CHAT_MSG_WHISPER", + "CHAT_MSG_PARTY", "CHAT_MSG_RAID", "CHAT_MSG_RAID_LEADER", + "UI_ERROR_MESSAGE", "SPELLCAST_START", "SPELLCAST_STOP", "SPELLCAST_CHANNEL_START", "SPELLCAST_CHANNEL_STOP", + "SPELLCAST_FAILED", "SPELLCAST_INTERRUPTED", + "RAID_ROSTER_UPDATE", "PARTY_MEMBERS_CHANGED", "SPELLS_CHANGED", "LEARNED_SPELL_IN_TAB", "BAG_UPDATE", +} +for _, e in ipairs(EVENTS) do ev:RegisterEvent(e) end +ev:SetScript("OnEvent", function() LP:OnEvent(event) end) +ev:SetScript("OnUpdate", function() LP:OnUpdate(arg1 or 0) end) + +SLASH_LOCKPORT1 = "/lockport" +SLASH_LOCKPORT2 = "/gurky" +SlashCmdList["LOCKPORT"] = function(msg) LP:Slash(msg) end diff --git a/LockPort.toc b/LockPort.toc new file mode 100644 index 0000000..b5b5847 --- /dev/null +++ b/LockPort.toc @@ -0,0 +1,8 @@ +## Interface: 11200 +## Title: |cff9482c9LockPort|r +## Notes: Warlock summon assistant - 123 in chat to get on the list, click to summon, failures are explained to the group. +## Version: 3.9.3 +## Author: Phaestas (originally Gurky) +## SavedVariablesPerCharacter: LockPortOptions +LockPort.lua +LockPort_UI.lua diff --git a/LockPort_UI.lua b/LockPort_UI.lua new file mode 100644 index 0000000..e7408cc --- /dev/null +++ b/LockPort_UI.lua @@ -0,0 +1,865 @@ +--[[ + LockPort UI - built from the stock 1.12 Blizzard art so it looks like part of the game: + dialog border + header plate, gold GameFont text, class icons from the character + creation sheet, UIPanelButtons, UICheckButtons and an InputBox. +]] + +local LP = LockPort + +local MAX_ROWS = 10 +local ROW_HEIGHT = 18 +local FRAME_W = 230 +local INNER_W = FRAME_W - 36 + +local CLASS_ICONS = "Interface\\Glues\\CharacterCreate\\UI-CharacterCreate-Classes" +local CLASS_COORDS = { + WARRIOR = { 0, 0.25, 0, 0.25 }, MAGE = { 0.25, 0.49609375, 0, 0.25 }, + ROGUE = { 0.49609375, 0.7421875, 0, 0.25 }, DRUID = { 0.7421875, 0.98828125, 0, 0.25 }, + HUNTER = { 0, 0.25, 0.25, 0.5 }, SHAMAN = { 0.25, 0.49609375, 0.25, 0.5 }, + PRIEST = { 0.49609375, 0.7421875, 0.25, 0.5 }, WARLOCK = { 0.7421875, 0.98828125, 0.25, 0.5 }, + PALADIN = { 0, 0.25, 0.5, 0.75 }, +} + +local DIALOG_BACKDROP = { + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, tileSize = 32, edgeSize = 32, + insets = { left = 11, right = 12, top = 12, bottom = 11 }, +} + +--------------------------------------------------------------------------- +-- Window styles: "classic" (Blizzard dialog art) or "pfui" (flat, dark, like +-- the original LockPort; uses pfUI's own skinning + font when pfUI is loaded). +-- Picked once when the UI is built, so switching needs a /reload. +--------------------------------------------------------------------------- +local FLAT = false +local PF = nil +local FONT = "Fonts\\ARIALN.TTF" +local WHITE = "Interface\\Buttons\\WHITE8X8" +local OLD_TITLE = "|cffb700b7L|cffff00ffo|cffff50ffc|cffff99ffk|cffffc4ffP|cffffffffort|r" +local FLAT_BACKDROP = { + bgFile = WHITE, edgeFile = WHITE, tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 }, +} + +local function SetupTheme() + FLAT = (LockPortOptions.theme == "pfui") + PF = pfUI and pfUI.api + if FLAT and pfUI and type(pfUI.font_default) == "string" then FONT = pfUI.font_default end +end + +local function Font(fs, size) + if FLAT and fs then fs:SetFont(FONT, size) end +end + +local function FlatBackdrop(f, alpha) + f:SetBackdrop(FLAT_BACKDROP) + f:SetBackdropColor(0, 0, 0, alpha or 0.75) + f:SetBackdropBorderColor(0.2, 0.2, 0.2, 1) +end + +-- divider / accent colour per style +local function AccentLine(tex) + if FLAT then tex:SetTexture(1, 1, 1, 0.12) else tex:SetTexture(1, 0.82, 0, 0.25) end +end + +local function SkinButton(b) + if not FLAT then return end + if PF and PF.SkinButton and pcall(PF.SkinButton, b) then return end + b:SetNormalTexture("") + b:SetPushedTexture("") + b:SetHighlightTexture("") + b:SetDisabledTexture("") + FlatBackdrop(b, 0.6) + local hl = b:CreateTexture(nil, "HIGHLIGHT") + hl:SetTexture(1, 1, 1, 0.12) + hl:SetPoint("TOPLEFT", b, "TOPLEFT", 1, -1) + hl:SetPoint("BOTTOMRIGHT", b, "BOTTOMRIGHT", -1, 1) +end + +local function SkinCheck(cb) + if not FLAT then return end + if PF and PF.SkinCheckbox then pcall(PF.SkinCheckbox, cb) end +end + +local function SkinInput(eb) + if not FLAT then return end + local n = eb:GetName() + for _, part in ipairs({ "Left", "Middle", "Right" }) do + local t = getglobal(n .. part) + if t then t:Hide() end + end + FlatBackdrop(eb, 0.5) + if eb.SetTextInsets then eb:SetTextInsets(4, 4, 0, 0) end + Font(eb, 11) +end + +StaticPopupDialogs["LOCKPORT_RELOAD"] = { + text = "LockPort: reload the UI now to apply the new window style?", + button1 = "Reload", + button2 = "Later", + OnAccept = function() ReloadUI() end, + timeout = 0, whileDead = 1, hideOnEscape = 1, +} + +function LP:ToggleTheme() + LockPortOptions.theme = (LockPortOptions.theme == "pfui") and "classic" or "pfui" + self:Print("window style: " .. (LockPortOptions.theme == "pfui" and "pfUI" or "Classic") .. " - /reload to apply.") + if self.RefreshOptionsUI then self:RefreshOptionsUI() end + StaticPopup_Show("LOCKPORT_RELOAD") +end + +local function ClassColor(class) + local c = class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[class] + if c then return c.r, c.g, c.b end + return 1, 0.82, 0 +end + +local function MakeDialog(name, width, height, title) + local f = CreateFrame("Frame", name, UIParent) + f:SetWidth(width) + f:SetHeight(height) + f:SetFrameStrata("MEDIUM") + if f.SetToplevel then f:SetToplevel(true) end + if FLAT then FlatBackdrop(f, 0.8) else f:SetBackdrop(DIALOG_BACKDROP) end + f:SetMovable(true) + f:EnableMouse(true) + if f.SetClampedToScreen then f:SetClampedToScreen(true) end + f:RegisterForDrag("LeftButton") + f:Hide() + + if FLAT then + -- old-LockPort title bar: dark strip, gradient name + local bar = f:CreateTexture(name .. "TitleBar", "BACKGROUND") + bar:SetTexture(1, 1, 1, 0.06) + bar:SetPoint("TOPLEFT", f, "TOPLEFT", 1, -1) + bar:SetPoint("TOPRIGHT", f, "TOPRIGHT", -1, -1) + bar:SetHeight(20) + local text = f:CreateFontString(name .. "Title", "OVERLAY", "GameFontNormal") + text:SetPoint("TOP", f, "TOP", 0, -5) + Font(text, 14) + local _, _, rest = string.find(title, "^LockPort%s*(.*)$") + text:SetText(OLD_TITLE .. ((rest and rest ~= "") and (" |cffffffff" .. rest .. "|r") or "")) + f.title = text + + local close = CreateFrame("Button", name .. "CloseButton", f, "UIPanelCloseButton") + if not (PF and PF.SkinCloseButton and pcall(PF.SkinCloseButton, close, f, -3, -3)) then + close:SetWidth(24) + close:SetHeight(24) + close:SetPoint("TOPRIGHT", f, "TOPRIGHT", 2, 2) + end + return f + end + + local header = f:CreateTexture(name .. "Header", "ARTWORK") + header:SetTexture("Interface\\DialogFrame\\UI-DialogBox-Header") + header:SetWidth(width > 280 and 300 or 230) + header:SetHeight(64) + header:SetPoint("TOP", f, "TOP", 0, 12) + + local text = f:CreateFontString(name .. "Title", "OVERLAY", "GameFontNormal") + text:SetPoint("TOP", header, "TOP", 0, -14) + text:SetText(title) + f.title = text + + local close = CreateFrame("Button", name .. "CloseButton", f, "UIPanelCloseButton") + close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -5, -5) + return f +end + +--------------------------------------------------------------------------- +-- Request window +--------------------------------------------------------------------------- +local function Row_OnEnter() + local e = this.entry + if not e then return end + local info = e.info + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:AddLine(e.name, ClassColor(info.class)) + local line = info.classLocal or "" + if info.zone and info.zone ~= "" then line = line .. " - " .. info.zone end + if line ~= "" then GameTooltip:AddLine(line, 1, 1, 1) end + local status, r, g, b = LP:RequestStatus(e) + if status ~= "" then GameTooltip:AddLine(status, r, g, b) end + local waited = math.floor((GetTime() - e.t) / 60) + GameTooltip:AddLine("Waiting " .. (waited < 1 and "under a minute" or (waited .. " min")), 0.6, 0.6, 0.6) + GameTooltip:AddLine(" ") + GameTooltip:AddLine("Left-click: summon", 0.2, 1, 0.2) + GameTooltip:AddLine("Ctrl+click: target", 0.8, 0.8, 0.8) + GameTooltip:AddLine("Right-click: remove", 1, 0.3, 0.3) + GameTooltip:Show() +end + +local function Row_OnClick() + local e = this.entry + if not e then return end + if arg1 == "RightButton" then + LP:RemoveRequest(e.name, true) + elseif IsControlKeyDown() then + TargetUnit(e.info.unit) + else + LP:Summon(e.name) + end + GameTooltip:Hide() +end + +local function CreateRow(parent, i) + local row = CreateFrame("Button", "LockPortRow" .. i, parent) + row:SetWidth(INNER_W) + row:SetHeight(ROW_HEIGHT) + row:SetPoint("TOPLEFT", parent, "TOPLEFT", 18, -46 - (i - 1) * ROW_HEIGHT) + row:RegisterForClicks("LeftButtonUp", "RightButtonUp") + + if math.mod(i, 2) == 0 then + local stripe = row:CreateTexture(nil, "BACKGROUND") + stripe:SetAllPoints(row) + stripe:SetTexture(1, 1, 1, 0.05) + end + + local hl = row:CreateTexture(nil, "HIGHLIGHT") + if FLAT then + hl:SetTexture(1, 1, 1, 0.1) + else + hl:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") + hl:SetBlendMode("ADD") + end + hl:SetAllPoints(row) + + local icon = row:CreateTexture(nil, "ARTWORK") + icon:SetTexture(CLASS_ICONS) + icon:SetWidth(14) + icon:SetHeight(14) + icon:SetPoint("LEFT", row, "LEFT", 3, 0) + row.icon = icon + + local name = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight") + name:SetPoint("LEFT", icon, "RIGHT", 5, 0) + name:SetWidth(92) + name:SetHeight(ROW_HEIGHT) + name:SetJustifyH("LEFT") + Font(name, 12) + row.name = name + + local status = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + status:SetPoint("RIGHT", row, "RIGHT", -3, 0) + status:SetWidth(INNER_W - 118) + status:SetHeight(ROW_HEIGHT) + status:SetJustifyH("RIGHT") + Font(status, 10) + row.status = status + + row:SetScript("OnClick", Row_OnClick) + row:SetScript("OnEnter", Row_OnEnter) + row:SetScript("OnLeave", function() GameTooltip:Hide() end) + row:Hide() + return row +end + +local function SavePosition(f) + local point, _, relPoint, x, y = f:GetPoint() + LockPortOptions.pos = { point = point, relPoint = relPoint, x = x, y = y } +end + +local function RestorePosition(f) + f:ClearAllPoints() + local p = LockPortOptions.pos + if p and p.point then + f:SetPoint(p.point, UIParent, p.relPoint or p.point, p.x or 0, p.y or 0) + else + f:SetPoint("CENTER", UIParent, "CENTER", 0, 120) + end +end + +local function CreateGroupsFrame() + local f = MakeDialog("LockPortGroupsFrame", 230, 336, "Raid Groups") + f:SetParent(LockPortFrame) + f:SetPoint("TOPLEFT", LockPortFrame, "TOPRIGHT", 6, 0) + local tip = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + tip:SetPoint("TOPLEFT", f, "TOPLEFT", 18, -30) + tip:SetWidth(194); tip:SetHeight(24) + tip:SetText("Select your raid groups, then queue their players for summoning.") + f.groups = {} + for i = 1, 8 do + local group = i + local cb = CreateFrame("CheckButton", "LockPortGroupCheck" .. i, f, "UICheckButtonTemplate") + cb:SetWidth(24); cb:SetHeight(24) + cb:SetPoint("TOPLEFT", f, "TOPLEFT", 18, -56 - (i - 1) * 22) + local label = cb:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + label:SetPoint("LEFT", cb, "RIGHT", 2, 0) + cb.label = label + cb:SetScript("OnClick", function() LP:ToggleSummonGroup(group) end) + SkinCheck(cb) + f.groups[i] = cb + end + local all = CreateFrame("Button", "LockPortGroupsAll", f, "UIPanelButtonTemplate") + all:SetWidth(95); all:SetHeight(22) + all:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 16, 46) + all:SetText("All groups") + all:SetScript("OnClick", function() LP:SetSummonGroups(nil) end) + SkinButton(all) + local none = CreateFrame("Button", "LockPortGroupsNone", f, "UIPanelButtonTemplate") + none:SetWidth(95); none:SetHeight(22) + none:SetPoint("LEFT", all, "RIGHT", 2, 0) + none:SetText("None") + none:SetScript("OnClick", function() LP:SetSummonGroups({}) end) + SkinButton(none) + local queue = CreateFrame("Button", "LockPortQueueGroups", f, "UIPanelButtonTemplate") + queue:SetWidth(192); queue:SetHeight(22) + queue:SetPoint("TOPLEFT", all, "BOTTOMLEFT", 0, -2) + queue:SetText("Queue selected groups") + queue:SetScript("OnClick", function() LP:QueueSelectedGroups() end) + SkinButton(queue) + f:SetScript("OnShow", function() LP:RefreshGroupsUI() end) + return f +end + +function LP:RefreshGroupsUI() + local f = LockPortGroupsFrame + if not f then return end + if GetNumRaidMembers() == 0 then f:Hide(); return end + local counts = {} + for _, info in pairs(self:ScanRoster()) do + if info.subgroup then counts[info.subgroup] = (counts[info.subgroup] or 0) + 1 end + end + local groups = LockPortOptions.summonGroups + for i = 1, 8 do + f.groups[i]:SetChecked((type(groups) ~= "table" or groups[i]) and 1 or nil) + f.groups[i].label:SetText("Group " .. i .. " (" .. (counts[i] or 0) .. " players)") + end +end + +function LP:ToggleGroups() + if GetNumRaidMembers() == 0 then return self:Print("Group summoning is available in raids.") end + local f = LockPortGroupsFrame + if not f then return end + if f:IsVisible() then f:Hide() else f:Show(); self:RefreshGroupsUI() end +end + +local function CreateMainFrame() + -- fixed height for 10 rows, like the original LockPort + local f = MakeDialog("LockPortFrame", FRAME_W, 46 + MAX_ROWS * ROW_HEIGHT + 104, "LockPort") + f:SetScript("OnDragStart", function() if not LockPortOptions.locked then this:StartMoving() end end) + f:SetScript("OnDragStop", function() this:StopMovingOrSizing(); SavePosition(this) end) + f:SetScript("OnShow", function() PlaySound("igMainMenuOpen"); LP:RefreshList() end) + f:SetScript("OnHide", function() PlaySound("igMainMenuClose") end) + f:SetScript("OnUpdate", function() + this.elapsed = (this.elapsed or 0) + arg1 + if this.elapsed > 0.5 then this.elapsed = 0; LP:RefreshList() end + end) + + local sub = f:CreateFontString("LockPortFrameSubtitle", "OVERLAY", "GameFontNormalSmall") + sub:SetPoint("TOPLEFT", f, "TOPLEFT", 20, -28) + sub:SetText("Summon requests") + sub:SetWidth(INNER_W - 4) + sub:SetHeight(12) + sub:SetJustifyH("LEFT") + Font(sub, 11) + f.subtitle = sub + + local count = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + count:SetPoint("TOPRIGHT", f, "TOPRIGHT", -20, -28) + f.count = count + + local div = f:CreateTexture(nil, "ARTWORK") + AccentLine(div) + div:SetHeight(1) + div:SetPoint("TOPLEFT", f, "TOPLEFT", 18, -42) + div:SetPoint("TOPRIGHT", f, "TOPRIGHT", -18, -42) + + f.rows = {} + for i = 1, MAX_ROWS do f.rows[i] = CreateRow(f, i) end + + local empty = f:CreateFontString(nil, "OVERLAY", "GameFontDisable") + empty:SetPoint("TOP", f, "TOP", 0, -52) + empty:SetText("No one needs a summon.") + Font(empty, 12) + f.empty = empty + + local more = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + more:SetPoint("TOPRIGHT", f.rows[MAX_ROWS], "BOTTOMRIGHT", -3, -1) + f.more = more + + -- footer: shards + helpers + local footer = CreateFrame("Frame", nil, f) + footer:SetWidth(INNER_W) + footer:SetHeight(20) + footer:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 18, 66) + f.footer = footer + + local fdiv = footer:CreateTexture(nil, "ARTWORK") + AccentLine(fdiv) + fdiv:SetHeight(1) + fdiv:SetPoint("TOPLEFT", footer, "TOPLEFT", 0, 2) + fdiv:SetPoint("TOPRIGHT", footer, "TOPRIGHT", 0, 2) + + local shardIcon = footer:CreateTexture(nil, "ARTWORK") + shardIcon:SetTexture("Interface\\Icons\\INV_Misc_Gem_Amethyst_02") + shardIcon:SetWidth(16) + shardIcon:SetHeight(16) + shardIcon:SetPoint("LEFT", footer, "LEFT", 2, -3) + + local shards = footer:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + shards:SetPoint("LEFT", shardIcon, "RIGHT", 4, 0) + Font(shards, 10) + f.shards = shards + + local helpers = footer:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + helpers:SetPoint("RIGHT", footer, "RIGHT", -2, -3) + Font(helpers, 10) + f.helpers = helpers + + -- buttons + local summon = CreateFrame("Button", "LockPortSummonButton", f, "UIPanelButtonTemplate") + summon:SetWidth(95) + summon:SetHeight(22) + summon:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 16, 40) + summon:SetText("Summon") + summon:SetScript("OnClick", function() LP:SummonNext() end) + summon:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_TOP") + GameTooltip:AddLine("Summon next in line") + if LP.readyText then GameTooltip:AddLine(LP.readyText, 1, 0.82, 0) end + GameTooltip:AddLine("Skips anyone offline, dead, in combat, inside an instance, being summoned by another warlock, or who failed 3 times in a row.", 1, 1, 1, 1) + local key = GetBindingKey("LOCKPORT_SUMMON_NEXT") + if key then GameTooltip:AddLine("Hotkey: " .. key, 0.6, 0.6, 0.6) end + GameTooltip:Show() + end) + summon:SetScript("OnLeave", function() GameTooltip:Hide() end) + f.summon = summon + + local chat = CreateFrame("Button", "LockPortChatToggle", f, "UIPanelButtonTemplate") + chat:SetWidth(95) + chat:SetHeight(22) + chat:SetPoint("LEFT", summon, "RIGHT", 2, 0) + chat:SetText("Chat: On") + chat:SetScript("OnClick", function() LP:SetChatMuted(not LockPortOptions.chatMuted) end) + chat:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_TOP") + GameTooltip:AddLine("Outgoing chat") + GameTooltip:AddLine("Turn off while travelling to mute all LockPort announcements and whispers. Requests and summoning still work. This setting is saved across reloads.", 1, 1, 1, 1) + GameTooltip:Show() + end) + chat:SetScript("OnLeave", function() GameTooltip:Hide() end) + SkinButton(chat) + f.chatToggle = chat + + local add = CreateFrame("Button", "LockPortAddButton", f, "UIPanelButtonTemplate") + add:SetWidth(95) + add:SetHeight(22) + add:SetPoint("TOPLEFT", summon, "BOTTOMLEFT", 0, -2) + add:SetText("Add Target") + add:SetScript("OnClick", function() LP:Slash("add") end) + + local opts = CreateFrame("Button", "LockPortOptionsButton", f, "UIPanelButtonTemplate") + opts:SetWidth(95) + opts:SetHeight(22) + opts:SetPoint("LEFT", add, "RIGHT", 2, 0) + opts:SetText("Options") + opts:SetScript("OnClick", function() LP:ToggleOptions() end) + + -- Hidden entirely when the companion client DLL is absent. + local auto = CreateFrame("Button", "LockPortRaidAutoSummon", f, "UIPanelButtonTemplate") + auto:SetWidth(95) + auto:SetHeight(22) + auto:SetPoint("TOPLEFT", add, "BOTTOMLEFT", 0, -2) + auto:SetText("Auto: Off") + auto:SetScript("OnClick", function() + LP:SetRaidAutoSummon(not LP.raidAutoSummon) + LP:RefreshList() + end) + auto:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_TOP") + GameTooltip:AddLine("Auto Summon") + GameTooltip:AddLine("Automatically summons queued party or raid members when two helpers are nearby. Enables summons-available announcements. Turns off when you leave the group or reload.", 1, 1, 1, 1) + GameTooltip:Show() + end) + auto:SetScript("OnLeave", function() GameTooltip:Hide() end) + auto:Hide() + SkinButton(auto) + f.raidAuto = auto + + local groups = CreateFrame("Button", "LockPortGroupsButton", f, "UIPanelButtonTemplate") + groups:SetWidth(95); groups:SetHeight(22) + groups:SetPoint("LEFT", auto, "RIGHT", 2, 0) + groups:SetText("Groups: All") + groups:SetScript("OnClick", function() LP:ToggleGroups() end) + groups:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_TOP") + GameTooltip:AddLine("Group summoning") + if GetNumRaidMembers() == 0 then GameTooltip:AddLine("Join a raid to select groups.", 1, 0.5, 0.1) end + GameTooltip:AddLine("Selected raid groups: " .. LP:SummonGroupSummary(), 1, 0.82, 0) + GameTooltip:AddLine("Select your groups and queue their players without a 123 request. Summon and Auto use your selection. Other warlocks should select their own groups.", 1, 1, 1, 1) + GameTooltip:Show() + end) + groups:SetScript("OnLeave", function() GameTooltip:Hide() end) + SkinButton(groups) + groups:Hide() + f.groupSummon = groups + + SkinButton(summon); SkinButton(add); SkinButton(opts) + RestorePosition(f) + return f +end + +function LP:RefreshList() + local f = LockPortFrame + if not f or not LockPortOptions then return end + if GetNumRaidMembers() == 0 and LockPortGroupsFrame then LockPortGroupsFrame:Hide() end + if not self:IsWarlock() then f:Hide(); return end + if not f:IsVisible() then return end + + f.chatToggle:SetText(LockPortOptions.chatMuted and "|cffff4040Chat: Off|r" or "Chat: On") + local native = self:HasNativeMod() + local raid = GetNumRaidMembers() > 0 + local extra = 26 -- Keep the Groups control visible even outside raids. + f:SetHeight(46 + MAX_ROWS * ROW_HEIGHT + 104 + extra) + f.summon:ClearAllPoints() + f.summon:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 16, 40 + extra) + f.footer:ClearAllPoints() + f.footer:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 18, 66 + extra) + if native then + f.raidAuto:Show() + f.raidAuto:SetText(self.raidAutoSummon and "|cff40ff40Auto: On|r" or "Auto: Off") + if self:InGroup() then f.raidAuto:Enable() else f.raidAuto:Disable() end + else + f.raidAuto:Hide() + end + f.groupSummon:Show() + if raid then + local summary, count = self:SummonGroupSummary() + f.groupSummon:SetText(count == 0 and "|cffff4040Groups: 0|r" or ("Groups: " .. (count == 8 and "All" or count))) + f.groupSummon:Enable() + self:RefreshGroupsUI() + else + f.groupSummon:SetText("Groups") + f.groupSummon:Disable() + if LockPortGroupsFrame then LockPortGroupsFrame:Hide() end + end + self:ScanRoster() + local list = self:SortedRequests() + local n = table.getn(list) + local shown = n + if shown > MAX_ROWS then shown = MAX_ROWS end + + for i = 1, MAX_ROWS do + local row, e = f.rows[i], list[i] + if e then + row.entry = e + local c = CLASS_COORDS[e.info.class or ""] + if c then + row.icon:SetTexCoord(c[1], c[2], c[3], c[4]) + row.icon:Show() + else + row.icon:Hide() + end + row.name:SetText(e.name) + row.name:SetTextColor(ClassColor(e.info.class)) + local text, r, g, b = self:RequestStatus(e) + row.status:SetText(text) + row.status:SetTextColor(r, g, b) + row:Show() + else + row.entry = nil + row:Hide() + end + end + + if n == 0 then f.empty:Show() else f.empty:Hide() end + if n > MAX_ROWS then f.more:SetText("+" .. (n - MAX_ROWS) .. " more") else f.more:SetText("") end + f.count:SetText("") + + local shardCount = self:CountShards() + f.shards:SetText(shardCount .. (shardCount == 1 and " Soul Shard" or " Soul Shards")) + if shardCount == 0 then f.shards:SetTextColor(1, 0.25, 0.25) else f.shards:SetTextColor(1, 1, 1) end + + local helpers = self:InGroup() and self:CountHelpers(nil) or 0 + f.helpers:SetText("Helpers: " .. helpers .. "/2") + if helpers >= 2 then f.helpers:SetTextColor(0.25, 1, 0.25) else f.helpers:SetTextColor(1, 0.5, 0.1) end + + if self.pending and not self.pending.done then f.summon:Disable() else f.summon:Enable() end + + -- status line: ready / why not + local text = self.readyText + if n > 0 and text then + if self.isReady then + f.subtitle:SetTextColor(0.25, 1, 0.25) + elseif string.find(text, "^Summoning") then + f.subtitle:SetTextColor(0.58, 0.51, 0.79) + else + f.subtitle:SetTextColor(1, 0.5, 0.1) + end + f.subtitle:SetText(text .. " (" .. n .. ")") + else + if FLAT then f.subtitle:SetTextColor(0.85, 0.85, 0.85) else f.subtitle:SetTextColor(1, 0.82, 0) end + f.subtitle:SetText("Summon requests (" .. n .. ")") + end + f.summon:SetText(self.isReady and "|cff40ff40Summon|r" or "Summon") + -- UIPanelButton text/skin hooks can resize buttons; restore both columns + -- after refreshing labels so each top button matches the one beneath it. + f.summon:SetWidth(95) + f.chatToggle:SetWidth(95) + f.raidAuto:SetWidth(95) + f.groupSummon:SetWidth(95) + +end + +--------------------------------------------------------------------------- +-- Options window +--------------------------------------------------------------------------- +local checks = {} + +local function MakeCheck(parent, key, label, tip, x, y) + local name = "LockPortCheck_" .. key + local cb = CreateFrame("CheckButton", name, parent, "UICheckButtonTemplate") + cb:SetWidth(24) + cb:SetHeight(24) + cb:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) + local text = getglobal(name .. "Text") + if not text then + text = cb:CreateFontString(name .. "Text", "OVERLAY") + text:SetPoint("LEFT", cb, "RIGHT", 0, 1) + end + text:SetFontObject(GameFontHighlightSmall) + Font(text, 11) + text:SetText(label) + cb.label = text + cb.key = key + cb.tip = tip + cb:SetScript("OnClick", function() + if this.key == "announceOpen" and LP.raidAutoSummon then this:SetChecked(1); return end + LockPortOptions[this.key] = this:GetChecked() and true or false + PlaySound(this:GetChecked() and "igMainMenuOptionCheckBoxOn" or "igMainMenuOptionCheckBoxOff") + LP:RefreshList() + end) + cb:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:AddLine(this.label:GetText()) + GameTooltip:AddLine(this.tip, 1, 1, 1, 1) + GameTooltip:Show() + end) + cb:SetScript("OnLeave", function() GameTooltip:Hide() end) + SkinCheck(cb) + checks[key] = cb + return cb +end + +local function MakeSection(parent, text, y) + local fs = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal") + fs:SetPoint("TOPLEFT", parent, "TOPLEFT", 20, y) + fs:SetText(text) + if FLAT then Font(fs, 12); fs:SetTextColor(0.58, 0.51, 0.79) end + return fs +end + +local function MakeInput(parent, name, key, width, x, y) + local eb = CreateFrame("EditBox", name, parent, "InputBoxTemplate") + eb:SetWidth(width) + eb:SetHeight(20) + eb:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) + eb:SetAutoFocus(false) + eb:SetMaxLetters(255) + eb.key = key + eb:SetScript("OnEditFocusGained", function() this.focused = true end) + eb:SetScript("OnEditFocusLost", function() this.focused = nil end) + eb:SetScript("OnEnterPressed", function() this:ClearFocus() end) + eb:SetScript("OnEscapePressed", function() this:ClearFocus() end) + eb:SetScript("OnTextChanged", function() + if LockPortOptions then LockPortOptions[this.key] = this:GetText() end + end) + SkinInput(eb) + return eb +end + +local function CreateOptionsFrame() + local f = MakeDialog("LockPortOptionsFrame", 400, 612, "LockPort Options") + f:SetFrameStrata("DIALOG") + f:SetPoint("CENTER", UIParent, "CENTER", 0, 40) + f:SetScript("OnDragStart", function() this:StartMoving() end) + f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + f:SetScript("OnShow", function() PlaySound("igCharacterInfoOpen"); LP:RefreshOptionsUI() end) + f:SetScript("OnHide", function() PlaySound("igCharacterInfoClose") end) + table.insert(UISpecialFrames, "LockPortOptionsFrame") -- Esc closes it + + local y = -32 + MakeSection(f, "When summoning", y) + MakeCheck(f, "whisper", "Whisper the player you're summoning", "Sends \"Summoning you to \" to the target when the ritual starts.", 20, y - 16) + MakeCheck(f, "zone", "Include zone in the /say message", "Adds the zone and subzone to the summon announcement.", 20, y - 38) + MakeCheck(f, "shards", "Include Soul Shard count", "Adds \"[N shards left]\" to the summon announcement.", 20, y - 60) + MakeCheck(f, "customMessage", "Use a custom /say message:", "Replaces the generated message. {name}, {zone} and {shards} are filled in for you.", 20, y - 82) + local msg = MakeInput(f, "LockPortMessageBox", "message", 340, 30, y - 106) + local hint = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + hint:SetPoint("TOPLEFT", msg, "BOTTOMLEFT", -4, -2) + hint:SetText("Tokens: {name} {zone} {shards}") + + y = -170 + MakeSection(f, "Whispers & announcements", y) + MakeCheck(f, "announceFail", "Tell the party/raid why a summon failed", "e.g. \"[LockPort] Can't summon Bob: Bob is in combat.\"", 20, y - 16) + MakeCheck(f, "whisperFail", "Whisper the player why it failed", "Lets them know they're still on the list and what to fix.", 20, y - 38) + MakeCheck(f, "whisperStatus", "Answer repeat 123s with a status whisper", "If someone types 123 while already here, or again while waiting, they're told why: already here, place in line, waiting on helpers, summoning someone else, etc. Only one LockPort warlock replies.", 20, y - 60) + MakeCheck(f, "announceOpen", "Post \"summons are available\" every", "Posts \"[LockPort] Summons are available - type 123 to get summoned. (N in line)\" on a timer while you're in a group. Skipped while you're in combat; with several LockPort warlocks only one posts each round.", 20, y - 82) + local every = MakeInput(f, "LockPortAnnounceEveryBox", "announceOpenEvery", 26, 262, y - 84) + every:ClearAllPoints() + every:SetPoint("LEFT", checks.announceOpen.label, "RIGHT", 10, 0) + every:SetMaxLetters(2) + local everyHint = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + everyHint:SetPoint("LEFT", every, "RIGHT", 6, 0) + everyHint:SetText("min") + Font(everyHint, 11) + MakeCheck(f, "askHelpers", "Ask the raid for helpers when fewer than 2 are near", "Posts e.g. \"[LockPort] Need 1 more person at Blackrock Mountain to help summon - 3 players waiting.\" when someone is waiting (or your portal is up) and fewer than 2 group members are near you. At most every 2 minutes, not in combat, and only one LockPort warlock posts.", 20, y - 104) + + y = -306 + MakeSection(f, "Requests", y) + MakeCheck(f, "sound", "Play a sound on new requests", "Plays a short sound when someone is added to the list.", 20, y - 16) + MakeCheck(f, "readySound", "Chime when a summon is ready", "Plays the ready-check sound when the next player can be summoned: you're out of combat with shards and 2 helpers are near you.", 20, y - 38) + local flash = MakeCheck(f, "flash", "Flash the taskbar when alt-tabbed", "Flashes on new requests and when a summon is ready. Needs the UnitXP SP3 client mod.", 20, y - 60) + MakeCheck(f, "autoShow", "Open this window on new requests", "Pops the LockPort window up when someone asks for a summon.", 20, y - 82) + MakeCheck(f, "autoRemove", "Remove players once they arrive", "Takes people off everyone's list when they're already standing near you.", 20, y - 104) + MakeCheck(f, "locked", "Lock window position", "Stops the summon window from being dragged.", 20, y - 126) + + local trigLabel = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + trigLabel:SetPoint("TOPLEFT", f, "TOPLEFT", 24, y - 158) + trigLabel:SetText("Request words:") + Font(trigLabel, 11) + local trig = MakeInput(f, "LockPortTriggerBox", "triggers", 170, 124, y - 153) + trig:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:AddLine("Request words") + GameTooltip:AddLine("Comma separated. A group member starting a message with one of these is added to the list. Default: 123", 1, 1, 1, 1) + GameTooltip:Show() + end) + trig:SetScript("OnLeave", function() GameTooltip:Hide() end) + + -- Summon Next hotkey: click, then press a key (right-click clears) + local keyLabel = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + keyLabel:SetPoint("TOPLEFT", f, "TOPLEFT", 24, y - 186) + keyLabel:SetText("Summon Next key:") + Font(keyLabel, 11) + local bind = CreateFrame("Button", "LockPortBindButton", f, "UIPanelButtonTemplate") + bind:SetWidth(120) + bind:SetHeight(20) + bind:SetPoint("LEFT", keyLabel, "RIGHT", 10, 0) + bind:RegisterForClicks("LeftButtonUp", "RightButtonUp") + bind:SetScript("OnClick", function() + if arg1 == "RightButton" then + LP:ClearSummonKey() + else + this.waiting = true + this:EnableKeyboard(true) + this:SetText("Press a key...") + end + end) + bind:SetScript("OnKeyDown", function() LP:CaptureSummonKey(this, arg1) end) + bind:SetScript("OnHide", function() LP:StopKeyCapture(this) end) + bind:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:AddLine("Summon Next hotkey") + GameTooltip:AddLine("Click, then press the key (Shift/Ctrl/Alt combos work). Esc cancels, right-click clears it. Same as Key Bindings > LockPort.", 1, 1, 1, 1) + GameTooltip:Show() + end) + bind:SetScript("OnLeave", function() GameTooltip:Hide() end) + SkinButton(bind) + f.bind = bind + + local styleLabel = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + styleLabel:SetPoint("TOPLEFT", f, "TOPLEFT", 24, y - 212) + styleLabel:SetText("Window style:") + Font(styleLabel, 11) + local style = CreateFrame("Button", "LockPortStyleButton", f, "UIPanelButtonTemplate") + style:SetWidth(90) + style:SetHeight(20) + style:SetPoint("LEFT", styleLabel, "RIGHT", 10, 0) + style:SetScript("OnClick", function() LP:ToggleTheme() end) + style:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:AddLine("Window style") + GameTooltip:AddLine("Classic: Blizzard dialog look.\npfUI: flat dark look like the original LockPort (uses pfUI's skin when pfUI is installed).\nNeeds a /reload.", 1, 1, 1, 1) + GameTooltip:Show() + end) + style:SetScript("OnLeave", function() GameTooltip:Hide() end) + SkinButton(style) + f.style = style + + local modsHeader = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + modsHeader:SetPoint("BOTTOM", f, "BOTTOM", 0, 52) + modsHeader:SetText("Client mods") + local mods = f:CreateFontString("LockPortModsText", "OVERLAY", "GameFontHighlightSmall") + mods:SetPoint("TOP", modsHeader, "BOTTOM", 0, -4) + mods:SetWidth(360) + mods:SetHeight(32) + mods:SetJustifyH("CENTER") + f.mods = mods + + f.flash = flash + return f +end + +function LP:RefreshOptionsUI() + local O = LockPortOptions + local f = LockPortOptionsFrame + if not O or not f then return end + for key, cb in pairs(checks) do cb:SetChecked(O[key] and 1 or nil) end + if not LockPortMessageBox.focused then LockPortMessageBox:SetText(O.message or "") end + if not LockPortAnnounceEveryBox.focused then LockPortAnnounceEveryBox:SetText(O.announceOpenEvery or "3") end + if not f.bind.waiting then f.bind:SetText(GetBindingKey("LOCKPORT_SUMMON_NEXT") or "Not bound") end + if not LockPortTriggerBox.focused then LockPortTriggerBox:SetText(O.triggers or "123") end + if self.mods.unitxp then + f.flash:Enable() + f.flash.label:SetTextColor(1, 1, 1) + else + f.flash:Disable() + f.flash.label:SetTextColor(0.5, 0.5, 0.5) + end + f.mods:SetText(self:ModStatusText()) + f.style:SetText(O.theme == "pfui" and "pfUI" or "Classic") +end + +--------------------------------------------------------------------------- +-- Summon Next hotkey (stored as a normal WoW key binding) +--------------------------------------------------------------------------- +local MODIFIER_KEYS = { LSHIFT = 1, RSHIFT = 1, LCTRL = 1, RCTRL = 1, LALT = 1, RALT = 1, UNKNOWN = 1 } + +local function SaveKeyBindings() + if GetCurrentBindingSet then SaveBindings(GetCurrentBindingSet()) else SaveBindings(1) end +end + +function LP:StopKeyCapture(b) + b.waiting = false + b:EnableKeyboard(false) + self:RefreshOptionsUI() +end + +function LP:ClearSummonKey() + local k1, k2 = GetBindingKey("LOCKPORT_SUMMON_NEXT") + if k1 then SetBinding(k1) end + if k2 then SetBinding(k2) end + SaveKeyBindings() + self:Print("Summon Next key cleared.") + self:RefreshOptionsUI() +end + +function LP:CaptureSummonKey(b, key) + if not b.waiting or not key or MODIFIER_KEYS[key] then return end + if key == "ESCAPE" then return self:StopKeyCapture(b) end + local combo = (IsAltKeyDown() and "ALT-" or "") .. (IsControlKeyDown() and "CTRL-" or "") .. + (IsShiftKeyDown() and "SHIFT-" or "") .. key + local old = GetBindingAction(combo) + local k1, k2 = GetBindingKey("LOCKPORT_SUMMON_NEXT") + if k1 then SetBinding(k1) end + if k2 then SetBinding(k2) end + SetBinding(combo, "LOCKPORT_SUMMON_NEXT") + SaveKeyBindings() + if old and old ~= "" and old ~= "LOCKPORT_SUMMON_NEXT" then + self:Print(combo .. " was bound to " .. old .. " - it now summons the next player.") + else + self:Print("Summon Next is now on " .. combo .. ".") + end + self:StopKeyCapture(b) +end + +function LP:CreateUI() + if LockPortFrame then return end + SetupTheme() + CreateMainFrame() + CreateGroupsFrame() + CreateOptionsFrame() +end diff --git a/SHA256SUMS.txt b/SHA256SUMS.txt new file mode 100644 index 0000000..af264b4 --- /dev/null +++ b/SHA256SUMS.txt @@ -0,0 +1 @@ +96bdb518e0b58106d585b7a9c577cd3437b497f97178656b4fc7fe68acd777c8 LockPort.dll diff --git a/native/LockPort.c b/native/LockPort.c new file mode 100644 index 0000000..9420876 --- /dev/null +++ b/native/LockPort.c @@ -0,0 +1,214 @@ +// LockPort companion client mod, API 1.2.1. +// Build: zig cc -target x86-windows-gnu -shared -O2 LockPort.c -o LockPort.dll +// Designed for the inspected OctoWoW 1.18.1 x86 executable layout. +// No background game thread: Lua APIs run only on the game's Lua thread. +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include + +typedef void (__stdcall *LoadFunctionsFn)(void); +typedef int (__fastcall *LuaFunction)(void *); +typedef void (__fastcall *RegisterFn)(const char *, LuaFunction); +typedef const char *(__fastcall *ToStringFn)(void *, int); +typedef void (__fastcall *PushStringFn)(void *, const char *); +typedef void (__fastcall *PushBooleanFn)(void *, int); +typedef int (__fastcall *GetTopFn)(void *); +typedef void (__fastcall *SetTopFn)(void *, int); +typedef int32_t (__fastcall *SpellSlotFn)(const char *, uint32_t *); +typedef uint64_t (__fastcall *UnitGuidFn)(const char *); +typedef void (__fastcall *BookCastFn)(uint32_t, uint32_t, uint64_t); + +static LoadFunctionsFn originalLoad; +static void *trampoline; +static DWORD luaThread; +static DWORD lastAttempt; +static int hasAttempt; +static unsigned char saved[6]; +static SIZE_T patchLength; + +static int __fastcall Version(void *state) { + ((PushStringFn)0x006F3890)(state, "1.2.1"); + return 1; +} +static int validUnit(const char *s) { + if (!s) return 0; + int limit; + if (strncmp(s, "raid", 4) == 0) { s += 4; limit = 40; } + else if (strncmp(s, "party", 5) == 0) { s += 5; limit = 4; } + else return 0; + if (*s < '1' || *s > '9') return 0; + int number = *s++ - '0'; + if (*s >= '0' && *s <= '9') number = number * 10 + *s++ - '0'; + return *s == 0 && number >= 1 && number <= limit; +} +static int result(void *state, int attempted, const char *reason) { + ((PushBooleanFn)0x006F39F0)(state, attempted); + ((PushStringFn)0x006F3890)(state, reason); + return 2; +} +static int __fastcall Summon(void *state) { + DWORD now = GetTickCount(); + const char *unit = ((ToStringFn)0x006F3690)(state, 1); + if (GetCurrentThreadId() != luaThread) return result(state, 0, "wrong thread"); + if (!validUnit(unit)) return result(state, 0, "invalid group unit"); + if (hasAttempt && (DWORD)(now - lastAttempt) < 2000) + return result(state, 0, "native rate limit"); + // Resolve an explicit target and the actual spellbook entry. No Lua + // string execution, hardware-input simulation, or target-dependent cast. + uint64_t guid = ((UnitGuidFn)0x00515970)(unit); + if (!guid) return result(state, 0, "unit unavailable"); + uint32_t book = 0; + int32_t slot = ((SpellSlotFn)0x004B3950)("Ritual of Summoning", &book); + if (slot < 0 || slot >= 1024 || book != 0) + return result(state, 0, "ritual not in spellbook"); + lastAttempt = now; + hasAttempt = 1; + // The action gate (0x494A50, kind 2) refuses casts from restricted + // frame scripts such as OnUpdate, including native calls made by Lua. + // Mirror command-dispatch context scoping: save, clear for this one + // engine call, and restore immediately. No persistent gate patch. + volatile uintptr_t *context = (volatile uintptr_t *)0x00CEEAC0; + volatile uint32_t *depth = (volatile uint32_t *)0x00CEEAC4; + uintptr_t savedContext = *context; + uint32_t savedDepth = *depth; + *context = 0; + *depth = 0; + ((BookCastFn)0x004B3300)((uint32_t)slot, book, guid); + *context = savedContext; + *depth = savedDepth; + return result(state, 1, "spellbook request sent"); +} +static void __stdcall LoadHook(void) { + originalLoad(); + luaThread = GetCurrentThreadId(); + // Pin the module before giving Lua pointers into its code. + HMODULE pinned = NULL; + GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_PIN, (LPCSTR)(uintptr_t)&Version, &pinned); + ((RegisterFn)0x00704120)("GetLockPortVersion", Version); + ((RegisterFn)0x00704120)("LockPortNativeSummon", Summon); +} +typedef struct { DWORD rva; unsigned char bytes[16]; } Signature; +static const Signature signatures[] = { + {0x90250,{0x56,0xe8,0xfa,0x60,0x27,0x00,0xe8,0x45,0x66,0x0a,0x00,0xe8,0x90,0xf0,0x05,0x00}}, + {0xb3950,{0x55,0x8b,0xec,0x81,0xec,0x04,0x06,0x00,0x00,0x56,0x57,0x68,0x00,0x04,0x00,0x00}}, + {0xb3300,{0x55,0x8b,0xec,0x83,0xec,0x20,0x53,0x56,0x8b,0xf2,0x57,0x89,0x75,0xf8,0x8b,0xd9}}, + {0x115970,{0x55,0x8b,0xec,0x51,0x56,0x68,0x90,0x00,0x00,0x00,0x8b,0xf1,0xe8,0xcf,0x2b,0xf5}}, + {0x304120,{0x56,0x57,0x8b,0xf9,0xe8,0xa7,0xff,0xff,0xff,0x8b,0xf0,0x6a,0x00,0x8b,0xce,0xe8}}, + {0x304cd0,{0x56,0x6a,0x00,0x8b,0xf1,0x52,0x56,0xe8,0x14,0x5a,0xf4,0xff,0x8b,0xd0,0x8b,0xce}}, + {0x2f3690,{0x56,0x57,0x8b,0xf9,0xe8,0x77,0xfd,0xff,0xff,0x8b,0xf0,0x85,0xf6,0x75,0x03,0x5f}}, + {0x2f3890,{0x85,0xd2,0x56,0x8b,0xf1,0x75,0x06,0x5e,0xe9,0x53,0xff,0xff,0xff,0x57,0x83,0xc9}}, + {0x2f39f0,{0x8b,0x41,0x08,0xc7,0x00,0x01,0x00,0x00,0x00,0x56,0x8b,0xf2,0x8b,0x15,0xc0,0xea}}, + {0x2f3070,{0x8b,0x41,0x08,0x2b,0x41,0x0c,0xc1,0xf8,0x04,0xc3,0x90,0x90,0x90,0x90,0x90,0x90}}, + {0x2f3080,{0x85,0xd2,0x8b,0x41,0x08,0x7c,0x38,0x56,0x8b,0x71,0x0c,0xc1,0xe2,0x04,0x03,0xf2}} +}; +static int fileOffset(const unsigned char *image, DWORD rva, DWORD *offset) { + const IMAGE_DOS_HEADER *dos = (const IMAGE_DOS_HEADER *)image; + const IMAGE_NT_HEADERS32 *nt = (const IMAGE_NT_HEADERS32 *)(image + dos->e_lfanew); + const IMAGE_SECTION_HEADER *sections = IMAGE_FIRST_SECTION(nt); + for (unsigned int i = 0; i < nt->FileHeader.NumberOfSections; ++i) { + const IMAGE_SECTION_HEADER *s = sections + i; + if (rva >= s->VirtualAddress && rva + 16 <= s->VirtualAddress + s->SizeOfRawData) { + *offset = s->PointerToRawData + rva - s->VirtualAddress; + return 1; + } + } + return 0; +} +static int matchesClient(void) { + unsigned char *base = (unsigned char *)GetModuleHandleA(NULL); + if ((uintptr_t)base != 0x00400000) return 0; + IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)base; + if (dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew <= 0 || dos->e_lfanew > 4096) return 0; + IMAGE_NT_HEADERS32 *nt = (IMAGE_NT_HEADERS32 *)(base + dos->e_lfanew); + if (nt->Signature != IMAGE_NT_SIGNATURE || nt->FileHeader.Machine != IMAGE_FILE_MACHINE_I386 || + nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR32_MAGIC || + nt->OptionalHeader.SizeOfImage < 0x00900000) return 0; + char filename[MAX_PATH]; + if (!GetModuleFileNameA(NULL, filename, MAX_PATH) || strlen(filename) >= MAX_PATH - 1) return 0; + HANDLE file = CreateFileA(filename, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | + FILE_SHARE_DELETE, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (file == INVALID_HANDLE_VALUE) return 0; + int ok = 1; + for (SIZE_T i = 0; i < sizeof(signatures) / sizeof(signatures[0]); ++i) { + DWORD offset = 0, count = 0; + unsigned char bytes[16]; + if (!fileOffset(base, signatures[i].rva, &offset) || + SetFilePointer(file, offset, NULL, FILE_BEGIN) == INVALID_SET_FILE_POINTER || + !ReadFile(file, bytes, sizeof(bytes), &count, NULL) || + count != sizeof(bytes) || memcmp(bytes, signatures[i].bytes, sizeof(bytes))) { + ok = 0; + break; + } + } + CloseHandle(file); + return ok; +} +static void relativeJump(unsigned char *at, const void *destination) { + at[0] = 0xE9; + int32_t displacement = (int32_t)((uintptr_t)destination - (uintptr_t)at - 5); + memcpy(at + 1, &displacement, 4); +} +static int install(void) { + if (!matchesClient()) return 0; + // These clients require a registration compatibility hook for Lua functions + // living in a DLL. Nampower, SuperWoW, and ClassicAPI provide it. + // Do not alter this shared engine check or interfere with their hooks. + unsigned char registrationSupport = *(unsigned char *)0x0042A320; + if (registrationSupport != 0xE9 && registrationSupport != 0xC3) return 0; + unsigned char *target = (unsigned char *)0x00490250; + // Preserve an existing loader detour (Nampower/SuperWoW load order). + if (target[0] == 0xE9) { + int32_t displacement; + memcpy(&displacement, target + 1, 4); + unsigned char *previous = target + 5 + displacement; + MEMORY_BASIC_INFORMATION info; + if (!VirtualQuery(previous, &info, sizeof(info)) || info.State != MEM_COMMIT || + !(info.Protect & (PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY))) return 0; + originalLoad = (LoadFunctionsFn)previous; + patchLength = 5; + } else { + if (memcmp(target, signatures[0].bytes, 6)) return 0; + // Two whole instructions: push esi; call rel32. Relocate that call. + trampoline = VirtualAlloc(NULL, 16, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if (!trampoline) return 0; + unsigned char *t = (unsigned char *)trampoline; + memcpy(t, target, 6); + int32_t displacement; + memcpy(&displacement, target + 2, 4); + uintptr_t callTarget = (uintptr_t)target + 6 + displacement; + displacement = (int32_t)(callTarget - (uintptr_t)t - 6); + memcpy(t + 2, &displacement, 4); + relativeJump(t + 6, target + 6); + DWORD old; + if (!VirtualProtect(t, 16, PAGE_EXECUTE_READ, &old)) { + VirtualFree(t, 0, MEM_RELEASE); trampoline = NULL; return 0; + } + FlushInstructionCache(GetCurrentProcess(), t, 16); + originalLoad = (LoadFunctionsFn)t; + patchLength = 6; + } + DWORD old; + if (!VirtualProtect(target, patchLength, PAGE_EXECUTE_READWRITE, &old)) return 0; + memcpy(saved, target, patchLength); + relativeJump(target, (void *)(uintptr_t)&LoadHook); + if (patchLength == 6) target[5] = 0x90; + DWORD ignored; + VirtualProtect(target, patchLength, old, &ignored); + FlushInstructionCache(GetCurrentProcess(), target, patchLength); + return 1; +} +BOOL WINAPI DllMain(HINSTANCE module, DWORD reason, LPVOID reserved) { + (void)reserved; + if (reason == DLL_PROCESS_ATTACH) { + DisableThreadLibraryCalls(module); + if (!install()) { + OutputDebugStringA("LockPort.dll: unsupported client or loader hook; no changes made.\n"); + return FALSE; + } + } + // This plugin has process lifetime. Do not hot-unload/reload it. + return TRUE; +} diff --git a/native/README.md b/native/README.md new file mode 100644 index 0000000..24ac1fa --- /dev/null +++ b/native/README.md @@ -0,0 +1,64 @@ +# LockPort client mod 1.2.1 + +Optional companion for LockPort 3.9.3. The addon works without it for manual summons. + +## Install with VanillaFixes + +1. Close WoW. +2. Back up the existing addon folder and dlls.txt. +3. Install this repository directly as Interface/AddOns/LockPort, with LockPort.toc at that folder's top level. +4. Copy LockPort.dll from the repository root beside VanillaFixes.exe. +5. Add one line containing LockPort.dll at the end of dlls.txt, after other client mods. +6. Launch through VanillaFixes. Join a party or raid on a warlock and enable Auto Summon. + +Updating from addon 3.7.2, 3.7.3, or 3.7.4 needs only the addon folder and /reload. +Earlier DLL versions must be replaced, followed by a full client restart. +/lockport debug should report native=1.2.1. + +## Compatibility and behavior + +Built for the inspected 32-bit OctoWoW 1.18.1 WoW.exe and WoW_Modernized.exe +function signatures. This is not a universal build for all client versions. +It refuses unsupported executables and requires Lua-registration compatibility +provided by Nampower, SuperWoW, or ClassicAPI. Load LockPort after those mods. + +The DLL resolves a canonical raid1..raid40 or party1..party4 unit to its GUID, +finds Ritual of Summoning in the spellbook, and invokes the client spellbook +function. It temporarily saves and clears the frame-script execution context +and depth around that call, then restores both. This follows the command context +pattern documented in [ClassicAPI 1.17.1](https://github.com/brues-code/ClassicAPI/blob/v1.17.1/src/Offsets.h). +It limits calls to the Lua thread and rate-limits requests. A successful return +means the call was submitted, not that a server summon or arrival succeeded. + +The addon still requires helpers and target eligibility. Helpers click the +portal and the summoned player accepts. Auto Summon and availability +announcements reset on login/reload; leaving the group disables Auto Summon. + +The user confirmed successful automatic summoning with 1.2.1 in-game. +Regression tests use mocked game APIs and do not cover every client/mod setup. + +To uninstall, close WoW and remove LockPort.dll from dlls.txt. Do not hot-unload. + +## Build and tests + +Source: LockPort.c. Build from the repository root with an LLVM-MinGW i686 compiler: + + powershell -File native/build.ps1 -Compiler i686-w64-mingw32-clang.exe + +Python addon tests require lupa with its lua51 module: + + python -m pip install lupa + python tests/test_addon.py + +Build the native harness as an x86 executable at image base 0x10000000 with ASLR disabled: + + i686-w64-mingw32-clang -O2 -Wall -Wextra -Werror -static-libgcc -Wl,--image-base,0x10000000 -Wl,--disable-dynamicbase tests/native_tests.c -o native_tests.exe + native_tests.exe "path/to/WoW.exe" LockPort.dll + +The first argument defaults to WoW.exe; the second defaults to LockPort.dll. +Run from the repository root. Tests check unsupported-host rejection, client +signatures, hook relocation/chaining, thread/unit/rate checks, GUID and spellbook +forwarding, and scoped context restoration. No server connection is made. + +The binary uses Windows system libraries (UCRT and Kernel32). +SHA256SUMS.txt records its checksum. See CLASSICAPI-REVIEW.md for the API review. diff --git a/native/build.ps1 b/native/build.ps1 new file mode 100644 index 0000000..0dad6e9 --- /dev/null +++ b/native/build.ps1 @@ -0,0 +1,7 @@ +param([string]$Compiler = "i686-w64-mingw32-clang.exe") +$ErrorActionPreference = "Stop" +$source = Join-Path $PSScriptRoot "LockPort.c" +$output = Join-Path (Split-Path $PSScriptRoot -Parent) "LockPort.dll" +& $Compiler -shared -O2 -Wall -Wextra -Werror -static-libgcc $source -o $output +if ($LASTEXITCODE -ne 0) { throw "LockPort.dll build failed ($LASTEXITCODE)" } +Write-Host "Built $output" diff --git a/tests/COORDINATION-REPORT.md b/tests/COORDINATION-REPORT.md new file mode 100644 index 0000000..090780f --- /dev/null +++ b/tests/COORDINATION-REPORT.md @@ -0,0 +1,106 @@ +# Raid group workload tests — LockPort 3.9.0 + +The full 3.8.0 suite below still passes with the group filter enabled by default +for all raid groups. Additional tests cover: + +- Three warlocks assigned groups 1–2 / 3–4 / 5–6 queue six targets without 123 + requests, and summon each once through the real scheduler and message bus. +- Overlapping selections retain duplicate prevention. +- Requests in unassigned groups stay queued without being summoned. +- Changing assignment during reservation releases and reassigns targets. +- Bulk queueing skips offline players, tolerates repeated clicks without + duplicate entries, and queues nobody with no groups selected. +- Raid subgroup moves are read from the current roster before casting. +- Unit checks cover default All / None, parties ignoring the raid filter, + reservation cancellation, actual GetRaidRosterInfo subgroup parsing, direct + and next-in-line manual casts, and group-picker UI controls. + +These are simulated tests. Live UI and multi-client checks are still pending. +The older-version and communication-loss limits below remain applicable. + +--- + +# Coordination test report — LockPort 3.8.0 + +Tested on 2026-10-27 using independent Lua 5.1 runtimes. Production addon +source retains Lua 5.0-compatible syntax. The game APIs, channel start/stop, +and network transport are simulated; no live WoW clients or server were used. + +## Results + +The frozen 3.7.8 fixture reproduces the original race: two warlocks both cast +at the same target at 103.3 seconds with 300 ms message latency. + +With 3.8.0 on every automatic caster: + +- Two and three warlocks divide queued targets in raids and valid five-player + parties. Party unit indices exclude self and stay within party1..party4. +- 100 randomized three-client runs process 560 requests without a duplicate. + Forty runs use 50–1400 ms per-message delay and five requests each; sixty + use 0–1800 ms delay and six requests each. Delays independently reorder messages. +- Delayed ACKs defer casting. Dropped ACKs block casts while peer heartbeats + continue; delivering messages again restores progress. +- Movement, missing helpers, and missing shards exclude an unready caster. +- Disabling Auto Summon, disconnecting, moving, native rejection, and no-start + timeout release a reservation for another caster. +- A stale ACK cannot authorize a newer reservation. A release delivered before + its reserve prevents the old reserve from returning. +- Reloading during reservation performs a fresh handshake. +- A confirmed start holds the target for 130 seconds even when the owner starts + another target. The hold expires rather than blocking forever. +- An updated manual click respects a received remote reservation. +- A request initially received by one caster is shared and summoned once. +- Outsiders, non-warlocks, malformed/unknown protocol messages, and messages + from the wrong chat channel do not create reservations. +- Existing chat-mute, cast tracking, UI, request handling, and ClassicAPI + regression checks continue to pass. + +## Older versions + +Real frozen addon fixtures, not a rewritten legacy event handler, are used: + +- LockPort 3.7.8 from the delivered package. +- Original repository addon from commit c7fe979ddb90f005f727e9bc561d6dd456847f80: + https://github.com/OEVG2/LockPort/blob/c7fe979ddb90f005f727e9bc561d6dd456847f80/LockPort/LockPort.lua + +Both fixtures share add/remove messages in both directions. A received older +manual summon claim prevents the updated automatic client from duplicating it. +This verifies those specific versions and message orders, not every old fork. + +## Explicit limits reproduced by the tests + +A 3.7.8 automatic caster cannot participate in the new reservation/ACK protocol. +When it and 3.8.0 start together, duplicate casts remain possible. Upgrade all +automatic casters to 3.8.0 for the new coordination. + +Total message loss before discovering another automatic caster can also produce +duplicates. Heartbeat expiry permits recovery but cannot prove that an +unreachable client stopped casting. No finite timeout guarantees exclusivity +under arbitrary network partitions. A manual cast made before receiving a +reservation or claim can likewise race with an automatic cast. + +These are executable known-limit demonstrations, not claimed passing guarantees. + +## Protocol + +LPCoord protocol 1 is hidden addon traffic, separate from public chat. Heartbeats +advertise enabled/disabled Auto Summon every 2 seconds. A caster reserves one +target, waits at least 2 seconds, and requires ACKs from discovered enabled peers. +Competing reservations yield by character-name order; losers pick another target. +Reservation leases last 6 seconds and peer heartbeats expire after 8 seconds. +Cast attempts and confirmed starts are separate states. Existing RSAdd, RSRemove, +lp VERSION and LPWhisper messages remain compatible. Chat Off mutes player-facing +messages while retaining synchronization and reservations. + +Run from the package/repository root: + + python -m pip install lupa + python tests/test_addon.py + python tests/test_coordination.py + +## Live check still required + +Update two warlocks to 3.8.0, restart/reload the addon, enable Auto Summon on both, +and have two distant group members request summons together. Confirm each is +summoned once, then repeat with one caster moving or turning Auto Summon off. +DLL 1.2.1 is unchanged. This build still needs live multi-warlock validation. diff --git a/tests/fixtures/LockPort-3.7.8.lua b/tests/fixtures/LockPort-3.7.8.lua new file mode 100644 index 0000000..3c2bb85 --- /dev/null +++ b/tests/fixtures/LockPort-3.7.8.lua @@ -0,0 +1,1435 @@ +--[[ + LockPort - easy warlock summoning for World of Warcraft 1.12 / Turtle WoW 1.18 + Originally written by Gurky, maintained by Phaestas. + v3: classic UI, failure announcements, optional client-mod support + (SuperWoW, Nampower, UnitXP SP3 are all optional). + + Written for the Lua 5.0 runtime of the 1.12 client: + no '#', no '%' operator, no select(), no string.match. +]] + +LockPort = {} +local LP = LockPort + +LP.SPELL = "Ritual of Summoning" +LP.SHARD_ID = 6265 -- Soul Shard +LP.EVILTWIN_ID = 23445 -- Evil Twin (Dimensional Ripper / Ultrasafe Transporter) +LP.EVILTWIN_TEX = "spell_shadow_charm" +LP.NEARBY_YARDS = 30 + +-- Addon message prefixes. RSAdd / RSRemove / lp are kept so v3 still syncs +-- with older LockPort versions in the same raid. +local PREFIX_ADD = "RSAdd" +local PREFIX_REMOVE = "RSRemove" +local PREFIX_VERSION = "lp" +local PREFIX_WHISPER = "LPWhisper" -- ":" claim so only one warlock replies + +local CHAT_TAG = "|cff9482c9LockPort|r: " +local ANNOUNCE_TAG = "[LockPort] " + +-- Keep all player-facing chat consistent, including custom summon messages. +local function SendTaggedChatMessage(message, channel, language, target) + if LockPortOptions and LockPortOptions.chatMuted then return end + if string.lower(string.sub(message, 1, string.len(ANNOUNCE_TAG))) == string.lower(ANNOUNCE_TAG) then + message = string.sub(message, string.len(ANNOUNCE_TAG) + 1) + end + SendChatMessage(ANNOUNCE_TAG .. message, channel, language, target) +end +local REPO_URL = "https://github.com/Phaestas323/LockPort" + +LP.requests = {} -- { {name=, t=}, ... } in request order +LP.roster = {} +LP.mods = {} +LP.users = {} -- names seen sending LockPort addon messages +LP.claims = {} -- ":" -> time a status whisper went out +LP.whisperQueue = {} + +local DEFAULTS = { + chatMuted = false, -- mute all outgoing player-facing chat while travelling + whisper = true, -- whisper the target "Summoning you..." + zone = true, -- include zone in the /say message + shards = true, -- include shard count in the /say message + sound = true, -- sound on a new request + message = "", -- custom /say message + customMessage = false, + announceFail = true, -- tell the party/raid why a summon failed + whisperFail = true, -- whisper the target why their summon failed + whisperStatus = true, -- reply to "123" when already here / already on the list + announceOpen = false, -- post "summons are available - type 123" every few minutes + announceOpenEvery = "3", -- minutes between those posts + readySound = true, -- chime when a summon becomes possible + askHelpers = true, -- ask the raid for portal clickers when fewer than 2 are near + flash = true, -- flash the taskbar on a new request (UnitXP SP3) + autoRemove = true, -- drop people from the list once they're standing next to you + autoShow = true, -- pop the window open on a new request + locked = false, -- lock window position + theme = "classic", -- window style: "classic" or "pfui" + triggers = "123", -- comma separated request words +} + +-- Failure reasons: what the group sees, and what the summonee is whispered. +local REASONS = { + offline = { group = "%s is offline" }, + dead = { group = "%s is dead", whisper = "you're dead. Release or get a res and type 123 again" }, + combat = { group = "%s is in combat", whisper = "you're in combat. Drop combat and I'll try again" }, + eviltwin = { group = "%s has Evil Twin (must die or walk)", whisper = "you have the Evil Twin debuff. You'll need to die or walk" }, + mecombat = { group = "I'm in combat", whisper = "I'm in combat. You're still on the list" }, + noshards = { group = "I'm out of Soul Shards", whisper = "I'm out of Soul Shards" }, + instance = { group = "%s is inside %s", whisper = "you're inside %s - zone out first. You're still on the list" }, + interrupt = { group = "the ritual was interrupted", whisper = "the ritual was interrupted. You're still on the list" }, + nostart = { group = "the ritual didn't start", whisper = "the ritual didn't start. You're still on the list" }, +} + +--------------------------------------------------------------------------- +-- Helpers +--------------------------------------------------------------------------- +function LP:SetChatMuted(muted) + LockPortOptions.chatMuted = muted and true or false + -- Never send status replies queued before or during travel after unmuting. + self.whisperQueue = {} + self:UpdateUI() +end + +function LP:Print(msg) + DEFAULT_CHAT_FRAME:AddMessage(CHAT_TAG .. msg) +end + +local function OnOff(v) + if v then return "|cff40ff40on|r" end + return "|cffff4040off|r" +end +LP.OnOff = OnOff + +local function Trim(s) + s = string.gsub(s or "", "^%s+", "") + s = string.gsub(s, "%s+$", "") + return s +end + +local function EscapeRepl(s) + return (string.gsub(s or "", "%%", "%%%%")) +end + +function LP:IsWarlock() + local _, class = UnitClass("player") + return class == "WARLOCK" +end + +function LP:InGroup() + return GetNumRaidMembers() > 0 or GetNumPartyMembers() > 0 +end + +function LP:GroupChannel() + if GetNumRaidMembers() > 0 then return "RAID" end + if GetNumPartyMembers() > 0 then return "PARTY" end + return nil +end + +function LP:SendAddon(prefix, msg) + local chan = self:GroupChannel() + if chan then SendAddonMessage(prefix, msg, chan) end +end + +--------------------------------------------------------------------------- +-- Optional client mods +--------------------------------------------------------------------------- +function LP:DetectMods() + local m = self.mods + m.classicapi = CLASSIC_API_VERSION ~= nil + m.lockport = self:HasNativeMod() + -- SuperWoW: GUIDs from UnitExists, UNIT_CASTEVENT, SpellInfo, UnitPosition + m.superwow = (SUPERWOW_VERSION ~= nil) or (SpellInfo ~= nil and UnitPosition ~= nil) + -- UnitXP SP3: true yard distances + taskbar flash + m.unitxp = false + if UnitXP then + local ok, d = pcall(UnitXP, "distanceBetween", "player", "player") + m.unitxp = ok and (d == nil or type(d) == "number") + end + -- Nampower: SPELL_FAILED_SELF / SPELL_CAST_EVENT + m.nampower = (GetNampowerVersion ~= nil) or (QueueSpellByName ~= nil) or (GetSpellIdForName ~= nil) +end + +function LP:ModStatusText() + local function s(label, on) return label .. ": " .. (on and "|cff40ff40yes|r" or "|cff808080no|r") end + local m = self.mods + return s("SuperWoW", m.superwow) .. " " .. s("Nampower", m.nampower) .. " " .. + s("UnitXP SP3", m.unitxp) .. "\n" .. + s("ClassicAPI", CLASSIC_API_VERSION ~= nil) .. " " .. s("LockPort", self:HasNativeMod()) +end + +function LP:PrintMods() + local m = self.mods + self:Print("client mods detected:") + DEFAULT_CHAT_FRAME:AddMessage(" ClassicAPI: " .. OnOff(CLASSIC_API_VERSION ~= nil) .. " |cffaaaaaa- movement and active-cast checks|r") + DEFAULT_CHAT_FRAME:AddMessage(" LockPort: " .. OnOff(self:HasNativeMod()) .. " |cffaaaaaa- Auto Summon|r") + DEFAULT_CHAT_FRAME:AddMessage(" SuperWoW: " .. OnOff(m.superwow) .. " |cffaaaaaa- exact cast start/fail tracking for the ritual, Evil Twin by spell id, yard distances|r") + DEFAULT_CHAT_FRAME:AddMessage(" Nampower: " .. OnOff(m.nampower) .. " |cffaaaaaa- server-confirmed spell failure events|r") + DEFAULT_CHAT_FRAME:AddMessage(" UnitXP SP3: " .. OnOff(m.unitxp) .. " |cffaaaaaa- precise distance (\"already here\" check), taskbar flash on new requests|r") +end + +function LP:MyGUID() + if not self.mods.superwow then return nil end + local _, guid = UnitExists("player") + return guid +end + +-- Is this spell id our ritual? (needs SuperWoW's SpellInfo; otherwise assume yes) +function LP:IsRitualId(id) + if not id then return true end + if SpellInfo then + local name = SpellInfo(id) + if name then return name == self.SPELL end + end + return true +end + +--------------------------------------------------------------------------- +-- Distance +--------------------------------------------------------------------------- +function LP:Distance(unit) + if self.mods.unitxp then + local ok, d = pcall(UnitXP, "distanceBetween", "player", unit) + if ok and type(d) == "number" then return d end + end + if self.mods.superwow and UnitPosition then + local x1, y1, z1 = UnitPosition("player") + local x2, y2, z2 = UnitPosition(unit) + if x1 and x2 then + local dx, dy, dz = x1 - x2, y1 - y2, (z1 or 0) - (z2 or 0) + return math.sqrt(dx * dx + dy * dy + dz * dz) + end + end + return nil +end + +function LP:IsNearby(unit) + if not UnitIsVisible(unit) then return false end + local d = self:Distance(unit) + if d then return d <= self.NEARBY_YARDS end + return CheckInteractDistance(unit, 4) and true or false -- ~28 yards +end + +-- Group members (other than me and the summonee) close enough to click the portal. +function LP:CountHelpers(exclude) + local n = 0 + local raid = GetNumRaidMembers() + if raid > 0 then + for i = 1, raid do + local u = "raid" .. i + local name = UnitName(u) + if name and name ~= UnitName("player") and name ~= exclude + and UnitIsConnected(u) and not UnitIsDeadOrGhost(u) and self:IsNearby(u) then + n = n + 1 + end + end + else + for i = 1, GetNumPartyMembers() do + local u = "party" .. i + local name = UnitName(u) + if name and name ~= exclude and UnitIsConnected(u) and not UnitIsDeadOrGhost(u) and self:IsNearby(u) then + n = n + 1 + end + end + end + return n +end + +--------------------------------------------------------------------------- +-- Roster / spellbook / bags +--------------------------------------------------------------------------- +function LP:ScanRoster() + local roster = {} + local raid = GetNumRaidMembers() + if raid > 0 then + for i = 1, raid do + local name, _, _, _, classLocal, class, zone, online, dead = GetRaidRosterInfo(i) + if name then + roster[name] = { unit = "raid" .. i, class = class, classLocal = classLocal, + zone = zone, online = online, dead = dead } + end + end + else + for i = 1, GetNumPartyMembers() do + local u = "party" .. i + local name = UnitName(u) + if name then + local classLocal, class = UnitClass(u) + roster[name] = { unit = u, class = class, classLocal = classLocal, + online = UnitIsConnected(u), dead = UnitIsDeadOrGhost(u) } + end + end + end + self.roster = roster + return roster +end + +function LP:KnowsRitual() + if self.knowsRitual ~= nil then return self.knowsRitual end + local i = 1 + while true do + local name = GetSpellName(i, BOOKTYPE_SPELL) + if not name then break end + if name == self.SPELL then + self.knowsRitual = true + return true + end + i = i + 1 + end + self.knowsRitual = false + return false +end + +function LP:CountShards() + local total = 0 + local pattern = "item:" .. self.SHARD_ID .. ":" + for bag = 0, 4 do + local slots = GetContainerNumSlots(bag) or 0 + for slot = 1, slots do + local link = GetContainerItemLink(bag, slot) + if link and string.find(link, pattern, 1, true) then + local _, count = GetContainerItemInfo(bag, slot) + total = total + math.abs(count or 1) + end + end + end + return total +end + +function LP:HasEvilTwin(unit) + for i = 1, 16 do + local tex, _, _, id = UnitDebuff(unit, i) + if not tex then break end + if id == self.EVILTWIN_ID then return true end + if string.find(string.lower(tex), self.EVILTWIN_TEX, 1, true) then return true end + end + return false +end + +--------------------------------------------------------------------------- +-- Instances: a player inside a dungeon/raid/battleground you're not in can't +-- be summoned out. Zone names come from the raid roster (raids only; 1.12 +-- doesn't share zones for 5-man parties). English zone names. +--------------------------------------------------------------------------- +LP.INSTANCES = {} +for _, z in ipairs({ + -- dungeons + "Ragefire Chasm", "Wailing Caverns", "The Deadmines", "Shadowfang Keep", "Blackfathom Deeps", + "The Stockade", "Gnomeregan", "Razorfen Kraul", "Scarlet Monastery", "Razorfen Downs", "Uldaman", + "Zul'Farrak", "Maraudon", "The Temple of Atal'Hakkar", "Sunken Temple", "Blackrock Depths", + "Blackrock Spire", "Dire Maul", "Stratholme", "Scholomance", + -- raids + "Molten Core", "Onyxia's Lair", "Blackwing Lair", "Zul'Gurub", "Ruins of Ahn'Qiraj", + "Ahn'Qiraj", "Temple of Ahn'Qiraj", "Naxxramas", + -- battlegrounds + "Warsong Gulch", "Arathi Basin", "Alterac Valley", + -- Turtle WoW + "Crescent Grove", "Karazhan Crypt", "Stormwind Vault", "Gilneas City", "Hateforge Quarry", + "The Black Morass", "Black Morass", "Dragonmaw Retreat", "Stormwrought Ruins", + "Emerald Sanctum", "Lower Karazhan Halls", "Upper Karazhan Halls", "Tower of Karazhan", +}) do LP.INSTANCES[z] = true end + +-- Returns the instance name if the player is inside an instance I'm not in. +function LP:InstanceBlock(info) + local z = info and info.zone + if not z or z == "" or z == GetRealZoneText() then return nil end + if self.INSTANCES[z] then return z end + return nil +end + +--------------------------------------------------------------------------- +-- Request list +--------------------------------------------------------------------------- +function LP:FindRequest(name) + for i, r in ipairs(self.requests) do + if r.name == name then return i end + end + return nil +end + +-- Returns true if the name was newly added. +function LP:AddRequest(name, broadcast) + if not name or name == "" or name == UnitName("player") then return false end + if broadcast then self:SendAddon(PREFIX_ADD, name) end + if not self:IsWarlock() then return false end + if self:FindRequest(name) then return false end + if not self:ScanRoster()[name] then return false end + table.insert(self.requests, { name = name, t = GetTime() }) + self:OnNewRequest(name) + self:UpdateUI() + return true +end + +function LP:RemoveRequest(name, broadcast) + if broadcast then self:SendAddon(PREFIX_REMOVE, name) end + local i = self:FindRequest(name) + if i then + table.remove(self.requests, i) + self:UpdateUI() + end +end + +function LP:ClearRequests() + for i = table.getn(self.requests), 1, -1 do + self:RemoveRequest(self.requests[i].name, true) + end +end + +function LP:OnNewRequest(name) + local O = LockPortOptions + if O.sound then + PlaySoundFile("Sound\\Creature\\Necromancer\\NecromancerReady1.wav") + end + if O.flash and self.mods.unitxp then + pcall(UnitXP, "notify", "taskbarIcon") + end + if O.autoShow and LockPortFrame and not LockPortFrame:IsVisible() then + LockPortFrame:Show() + end +end + +-- Drop names that left the group; auto-remove people who already arrived. +function LP:PruneRequests() + local roster = self:ScanRoster() + local O = LockPortOptions + for i = table.getn(self.requests), 1, -1 do + local r = self.requests[i] + local info = roster[r.name] + if not info then + table.remove(self.requests, i) + elseif O.autoRemove and not (self.pending and self.pending.name == r.name) + and GetTime() - r.t > 5 and info.online and self:IsNearby(info.unit) then + self:Print(r.name .. " is already here - removed from the list.") + self:RemoveRequest(r.name, true) + end + end +end + +-- Sorted view for the UI: warlocks first (they help click), then oldest request first. +function LP:SortedRequests() + local roster = self.roster + local list = {} + for i, r in ipairs(self.requests) do + local info = roster[r.name] + if info then + table.insert(list, { name = r.name, t = r.t, info = info, lock = (info.class == "WARLOCK") }) + end + end + table.sort(list, function(a, b) + if a.lock ~= b.lock then return a.lock end + return a.t < b.t + end) + return list +end + +-- Status for one row: text, r, g, b +function LP:RequestStatus(entry) + local info, u = entry.info, entry.info.unit + if self.pending and self.pending.name == entry.name and not self.pending.done then + return "Summoning...", 0.58, 0.51, 0.79 + end + if not UnitIsConnected(u) then return "Offline", 0.5, 0.5, 0.5 end + if UnitIsDeadOrGhost(u) then return "Dead", 1, 0.25, 0.25 end + if UnitAffectingCombat(u) then return "In combat", 1, 0.5, 0.1 end + if UnitIsVisible(u) then + local d = self:Distance(u) + if d then + if d <= self.NEARBY_YARDS then return "Here", 0.25, 1, 0.25 end + return string.format("%d yd", d), 0.85, 0.85, 0.85 + end + if CheckInteractDistance(u, 4) then return "Here", 0.25, 1, 0.25 end + end + if info.zone and info.zone ~= "" then + if info.zone == GetRealZoneText() then return "Same zone", 0.85, 0.85, 0.85 end + if self:InstanceBlock(info) then return info.zone, 1, 0.5, 0.1 end + return info.zone, 0.85, 0.85, 0.85 + end + return "", 1, 1, 1 +end + +--------------------------------------------------------------------------- +-- Failure reporting +--------------------------------------------------------------------------- +function LP:Fail(name, key, rawText, extra, quiet) + local O = LockPortOptions + local reason = REASONS[key] + local groupText, whisperText + if reason then + groupText = string.format(reason.group, name, extra or "") + whisperText = reason.whisper and string.format(reason.whisper, extra or "") + else + -- server / client error text, e.g. "Target is in combat", "You must be standing to do that" + groupText = rawText or "unknown error" + whisperText = rawText + end + + self:Print("|cffff4040can't summon " .. name .. ":|r " .. groupText) + if quiet then return end + + -- don't spam the same failure for the same person + local now = GetTime() + local k = name .. "\001" .. groupText + if self.lastFailKey == k and now - (self.lastFailTime or 0) < 10 then return end + self.lastFailKey, self.lastFailTime = k, now + + local chan = self:GroupChannel() + if O.announceFail and chan then + SendTaggedChatMessage(ANNOUNCE_TAG .. "Can't summon " .. name .. ": " .. groupText .. ".", chan) + end + if O.whisperFail and whisperText and key ~= "offline" and name ~= UnitName("player") then + SendTaggedChatMessage(ANNOUNCE_TAG .. "Couldn't summon you: " .. whisperText .. ".", "WHISPER", nil, name) + end +end + +--------------------------------------------------------------------------- +-- Summoning +--------------------------------------------------------------------------- +function LP:SummonNext() + local e = self:NextCandidate() + if e then return self:Summon(e.name) end + if self.requests[1] then + self:Print("nobody on the list can be summoned right now (offline, dead, in combat, in an instance, or being summoned by another warlock).") + else + self:Print("nobody is waiting for a summon.") + end +end + + +--------------------------------------------------------------------------- +-- Auto Summon: session-only opt-in, available only with LockPort.dll. +-- The DLL returns true for an attempted cast, never as proof of arrival. +--------------------------------------------------------------------------- +function LP:HasNativeMod() + if type(GetLockPortVersion) ~= "function" or type(LockPortNativeSummon) ~= "function" then return false end + local ok, version = pcall(GetLockPortVersion) + return ok and version == "1.2.1" +end + +function LP:SetRaidAutoSummon(enabled) + if enabled and (not self:HasNativeMod() or not self:InGroup() or not self:IsWarlock()) then + self:Print("Auto Summon needs LockPort.dll, a warlock, and a party or raid.") + return + end + if enabled and self.raidAutoSummon then return end + self.raidAutoSummon = enabled and true or false + self:TraceAuto("Auto Summon " .. (enabled and "enabled" or "disabled")) + self.autoReadyName, self.autoReadySince = nil, nil + if enabled then + LockPortOptions.announceOpen = true + self:OpenAnnounce(GetTime(), true) + else + LockPortOptions.announceOpen = false + end + self:Print("Auto Summon: " .. OnOff(self.raidAutoSummon)) + self:RefreshOptions() + self:UpdateUI() +end + +-- nil means no authoritative live cast-state provider is available. +function LP:LiveCastBusy() + if not C_Spell or type(C_Spell.UnitCastingInfo) ~= "function" + or type(C_Spell.UnitChannelInfo) ~= "function" then return nil end + local castOK, cast = pcall(C_Spell.UnitCastingInfo, "player") + local channelOK, channel = pcall(C_Spell.UnitChannelInfo, "player") + if not castOK or not channelOK then return nil end + return (cast ~= nil and cast ~= false) or (channel ~= nil and channel ~= false) +end + +function LP:RecoverCastTracking(now) + if now < (self.nextCastPoll or 0) then return end + self.nextCastPoll = now + 0.25 + local live = self:LiveCastBusy() + if live == nil then return end + -- Live state supersedes a cast-start flag whose stop event was missed. + self.castBusy = live and true or nil + local p = self.pending + if not p or p.done or not p.started then return end + if live or now - p.t < 2 then p.inactiveSince = nil; return end + p.inactiveSince = p.inactiveSince or now + if now - p.inactiveSince < 1.5 then return end + -- No completion event: resolve conservatively rather than claiming failure. + p.interrupted, p.intT = true, now + self:Print("recovered stale summon tracking for " .. p.name .. ".") + self:FinishPending() +end + +function LP:TraceAuto(message) + self.autoTrace = self.autoTrace or {} + if self.lastAutoTrace == message then return end + self.lastAutoTrace = message + table.insert(self.autoTrace, {t = GetTime(), text = message}) + if table.getn(self.autoTrace) > 12 then table.remove(self.autoTrace, 1) end +end + +function LP:DebugSummon() + for _, entry in ipairs(self.autoTrace or {}) do + self:Print("trace " .. math.floor(GetTime() - entry.t) .. "s ago: " .. entry.text) + end + local ready, status = self:ReadyState() + self:Print("auto=" .. tostring(self.raidAutoSummon) .. " ready=" .. tostring(ready) + .. " eventBusy=" .. tostring(self.castBusy) .. " liveBusy=" .. tostring(self:LiveCastBusy())) + self:Print("status=" .. tostring(status)) + local p = self.pending + if p then + self:Print("pending=" .. tostring(p.name) .. " started=" .. tostring(p.started) + .. " done=" .. tostring(p.done) .. " age=" .. math.floor(GetTime() - p.t) + .. " stopped=" .. tostring(p.stopT) .. " interrupted=" .. tostring(p.interrupted)) + end + if type(GetUnitSpeed) == "function" then + local ok, speed = pcall(GetUnitSpeed, "player") + self:Print("movement=" .. (ok and tostring(speed) or "unavailable")) + end + if type(GetLockPortVersion) == "function" then + local ok, version = pcall(GetLockPortVersion) + self:Print("native=" .. (ok and tostring(version) or "unavailable")) + end +end + +function LP:CheckRaidAutoSummon(now) + if not self.raidAutoSummon then return end + if not self:HasNativeMod() or not self:InGroup() then + self:SetRaidAutoSummon(false) + return + end + -- ClassicAPI is optional. Use its live movement and cast state when present. + local live = self:LiveCastBusy() + local busy = self.castBusy + if live ~= nil then busy = live end + if type(GetUnitSpeed) == "function" then + local ok, speed = pcall(GetUnitSpeed, "player") + if ok and type(speed) == "number" and speed > 0 then busy = true end + end + local ready, reason = self:ReadyState() + if not ready or busy then + self:TraceAuto(busy and "waiting: moving or casting" or ("waiting: " .. (reason or "request list empty"))) + self.autoReadyName, self.autoReadySince = nil, nil + return + end + local e = self:NextCandidate() + if not e then return end + if self.autoReadyName ~= e.name then + self:TraceAuto("ready: " .. e.name .. "; waiting 2 seconds") + self.autoReadyName, self.autoReadySince = e.name, now + return + end + -- Give other warlocks' claims time to arrive before casting. + if now - self.autoReadySince < 2 then return end + self.autoReadyName, self.autoReadySince = nil, nil + self.autoRetryAfter = self.autoRetryAfter or {} + -- An interrupted ritual may still have an acceptance prompt outstanding. + -- Wait before an automatic retry; manual retries remain available. + self.autoRetryAfter[e.name] = now + 130 + self:Summon(e.name, true) +end + +function LP:Summon(name, automatic) + if not self:IsWarlock() then return self:Print("only warlocks can summon.") end + if self.pending and not self.pending.done then + return self:Print("already summoning " .. self.pending.name .. ".") + end + if not self:KnowsRitual() then return self:Print("you don't know " .. self.SPELL .. " yet.") end + + local info = self:ScanRoster()[name] + if not info then + self:Print(name .. " is no longer in your group - removed.") + return self:RemoveRequest(name, true) + end + local unit = info.unit + + -- target-side checks + local problem, extra = self:TargetProblem(info) + if problem == "eviltwin" then + self:Fail(name, "eviltwin") + return self:RemoveRequest(name, true) + elseif problem == "here" then + self:Print(name .. " is already here - removed from the list.") + return self:RemoveRequest(name, true) + elseif problem then + return self:Fail(name, problem, nil, extra) + end + -- my side + if UnitAffectingCombat("player") then return self:Fail(name, "mecombat") end + if self:CountShards() < 1 then return self:Fail(name, "noshards") end + + local helpers = self:CountHelpers(name) + if helpers < 2 then + self:Print("|cffffd200heads up:|r only " .. helpers .. " group member(s) nearby to click the portal (2 needed).") + end + + -- The ritual summons the warlock's target, so keep it targeted until the portal completes. + self.pending = { name = name, unit = unit, t = GetTime(), automatic = automatic } + self:TraceAuto((automatic and "automatic" or "manual") .. " cast requested: " .. name .. " (" .. unit .. ")") + self:SendAddon(PREFIX_WHISPER, "summon:" .. name) -- other warlocks' Summon Next skips this player for a bit + TargetUnit(unit) + if automatic then + local ok, attempted, reason = pcall(LockPortNativeSummon, unit) + self:TraceAuto("native result: " .. tostring(attempted) .. "; " .. tostring(reason)) + if not ok or not attempted then + self.pending = nil + self.autoRetryAfter = self.autoRetryAfter or {} + self.autoRetryAfter[name] = GetTime() + 10 + self:Print("native summon not sent: " .. tostring(reason or attempted)) + end + else + CastSpellByName(self.SPELL) + end + self:UpdateUI() +end + +-- Why this player can't be summoned right now: nil, or a REASONS key (+ extra). +-- "here" means they're already next to me. +function LP:TargetProblem(info) + local u = info.unit + if not UnitIsConnected(u) then return "offline" end + if UnitIsDeadOrGhost(u) then return "dead" end + if UnitAffectingCombat(u) then return "combat" end + local inst = self:InstanceBlock(info) + if inst then return "instance", inst end + if UnitIsVisible(u) and self:HasEvilTwin(u) then return "eviltwin" end + if self:IsNearby(u) then return "here" end + return nil +end + +--------------------------------------------------------------------------- +-- Picking who's next. Summon Next skips anyone who can't be summoned right +-- now, anyone another warlock just started summoning ("summon:" claim), +-- and anyone who failed 3 times in a row (for a minute). +--------------------------------------------------------------------------- +LP.failCount = {} +LP.skipUntil = {} + +function LP:NoteResult(name, ok) + if ok then + self.failCount[name] = nil + self.skipUntil[name] = nil + return + end + self.failCount[name] = (self.failCount[name] or 0) + 1 + if self.failCount[name] >= 3 then + self.failCount[name] = nil + self.skipUntil[name] = GetTime() + 60 + self:Print(name .. " failed 3 times in a row - Summon Next skips them for a minute.") + end +end + +-- First player who can be summoned right now (or nil). +function LP:NextCandidate() + local now = GetTime() + self:ScanRoster() + for _, e in ipairs(self:SortedRequests()) do + local claimT = self.claims["summon:" .. e.name] + local claimed = claimT and now - claimT < 20 + local skipped = self.skipUntil[e.name] and now < self.skipUntil[e.name] + local autoWaiting = self.raidAutoSummon and self.autoRetryAfter and self.autoRetryAfter[e.name] and now < self.autoRetryAfter[e.name] + if not claimed and not skipped and not autoWaiting and not self:TargetProblem(e.info) then + return e + end + end + return nil +end + +-- What the Summon button would do right now: ready (bool), text for the window. +function LP:ReadyState() + local p = self.pending + if p and not p.done then return false, "Summoning " .. p.name .. "..." end + if not self.requests[1] then return false, nil end + if UnitIsDeadOrGhost("player") then return false, "You're dead" end + if UnitAffectingCombat("player") then return false, "You're in combat" end + if self:CountShards() < 1 then return false, "No Soul Shards" end + local e = self:NextCandidate() + if not e then return false, "Nobody can be summoned yet" end + local h = self:CountHelpers(e.name) + if h < 2 then return false, "Waiting for helpers (" .. h .. "/2)" end + return true, "Ready: " .. e.name +end + +-- Chime (and flash the taskbar) when a summon becomes possible. +function LP:CheckReady() + local ready, text = self:ReadyState() + self.readyText = text + if ready and not self.wasReady and self:IsWarlock() then + local O = LockPortOptions + if O.readySound then PlaySound("ReadyCheck") end + if O.flash and self.mods.unitxp then pcall(UnitXP, "notify", "taskbarIcon") end + end + self.wasReady = ready + self.isReady = ready +end + +--------------------------------------------------------------------------- +-- Not enough helpers: ask the group to come click the portal. +-- Fires when someone is waiting (or a ritual is up) and fewer than 2 group +-- members are near you for ~8 s. At most every 2 min, never in combat, and +-- only one LockPort warlock posts (a "help" claim resets the others' timers). +--------------------------------------------------------------------------- +function LP:WhereText() + local where = GetZoneText() or "" + local sub = GetSubZoneText() + if sub and sub ~= "" and sub ~= where then where = where .. " - " .. sub end + return where +end + +function LP:HelperCall(now) + local O = LockPortOptions + if O.chatMuted or not O.askHelpers or not self:IsWarlock() then self.helpShortSince = nil; return end + local chan = self:GroupChannel() + if not chan then self.helpShortSince = nil; return end + + local p = self.pending + local channelling = p and not p.done and p.started + local forName + if channelling then + forName = p.name + else + if UnitIsDeadOrGhost("player") or UnitAffectingCombat("player") or self:CountShards() < 1 then + self.helpShortSince = nil + return + end + local e = self:NextCandidate() + if not e then self.helpShortSince = nil; return end + forName = e.name + end + + local h = self:CountHelpers(forName) + if h >= 2 then self.helpShortSince = nil; return end + self.helpShortSince = self.helpShortSince or now + if now - self.helpShortSince < 8 then return end + if UnitAffectingCombat("player") then return end + if now < (self.nextHelpAt or 0) then return end + local claimT = self.claims["help"] + if claimT and now - claimT < 115 then + self.nextHelpAt = claimT + 120 + return + end + + local need = 2 - h + local who = (need == 1) and "1 more person" or (need .. " more people") + local msg + if channelling then + msg = ANNOUNCE_TAG .. "Need " .. who .. " at " .. self:WhereText() .. " to click the summoning portal for " .. forName .. "!" + else + local waiting = table.getn(self.requests) + msg = ANNOUNCE_TAG .. "Need " .. who .. " at " .. self:WhereText() .. " to help summon - " .. + waiting .. (waiting == 1 and " player" or " players") .. " waiting." + end + SendTaggedChatMessage(msg, chan) + self:SendAddon(PREFIX_WHISPER, "help") + self.nextHelpAt = now + 120 +end + +--------------------------------------------------------------------------- +-- "Summons are available - type 123" to the party/raid every N minutes (option). +-- Skipped in combat; with several LockPort warlocks only one posts per round. +--------------------------------------------------------------------------- +function LP:TriggerWord() + local _, _, w = string.find(LockPortOptions.triggers or "123", "^%s*([^,]+)") + w = Trim(w or "") + if w == "" then w = "123" end + return w +end + +function LP:OpenAnnounce(now, force) + if LockPortOptions.chatMuted then return end + local O = LockPortOptions + if not O.announceOpen and not force then return end + if not self:IsWarlock() then return end + local chan = self:GroupChannel() + if not chan then return end + local every = tonumber(O.announceOpenEvery) or 3 + if every < 1 then every = 1 end + local period = every * 60 + if not force and now < (self.nextAnnounceAt or 0) then return end + if UnitAffectingCombat("player") then return end + local claimT = self.claims["announce"] + if not force and claimT and now - claimT < period - 5 then + self.nextAnnounceAt = claimT + period + return + end + local n = table.getn(self.requests) + local msg = ANNOUNCE_TAG .. "Summons are available - type " .. self:TriggerWord() .. " to get summoned." + if n > 0 then msg = msg .. " (" .. n .. " in line)" end + SendTaggedChatMessage(msg, chan) + self:SendAddon(PREFIX_WHISPER, "announce") + self.nextAnnounceAt = now + period +end + +-- The ritual really started: announce and take the name off everyone's list. +function LP:OnRitualStarted() + local p = self.pending + if not p or p.done or p.started then return end + p.started = true + self:TraceAuto("cast started: " .. p.name) + p.failed = nil + local O = LockPortOptions + local name = p.name + local shards = self:CountShards() - 1 + if shards < 0 then shards = 0 end + + local where = self:WhereText() + + local msg + if O.customMessage and O.message and O.message ~= "" then + msg = O.message + msg = string.gsub(msg, "{name}", EscapeRepl(name)) + msg = string.gsub(msg, "{zone}", EscapeRepl(where)) + msg = string.gsub(msg, "{shards}", shards) + else + msg = "Summoning " .. name + if O.zone then msg = msg .. " to " .. where end + if O.shards then msg = msg .. " [" .. shards .. " shards left]" end + msg = msg .. ", please click the portal!" + end + SendTaggedChatMessage(msg, "SAY") + + if O.whisper then + local w = "Summoning you" + if O.zone then w = w .. " to " .. where end + SendTaggedChatMessage(w .. ". Accept when the prompt shows up!", "WHISPER", nil, name) + end + + self:RemoveRequest(name, true) + self:UpdateUI() +end + +function LP:FinishPending() + local p = self.pending + if not p or p.done then return end + p.done = true + if p.started and not p.interrupted then + self.watch = { name = p.name, unit = p.unit, t = GetTime() } + self:Print("ritual for " .. p.name .. " complete - waiting for them to accept.") + elseif p.started and p.interrupted then + -- Ending the summoning channel is ambiguous on 1.12 clients: it can + -- happen when helpers complete the ritual as well as when cancelled. + -- Never announce failure or penalize the player from this alone. + -- Resolve the current roster unit; party/raid indices may have changed. + local info = self:ScanRoster()[p.name] + if info and UnitName(info.unit) == p.name and self:IsNearby(info.unit) then + self:RemoveRequest(p.name, true) + self:NoteResult(p.name, true) + self:Print("|cff40ff40" .. p.name .. " has arrived.|r") + else + self:AddRequest(p.name, true) + self.watch = { name = p.name, unit = info and info.unit or p.unit, + t = GetTime(), unconfirmed = true } + self:Print("ritual for " .. p.name .. " ended - completion unconfirmed; kept on the list for retry.") + end + else + self:TraceAuto("cast did not start: " .. p.name .. "; " .. tostring(p.err)) + if p.automatic then + self.autoRetryAfter = self.autoRetryAfter or {} + self.autoRetryAfter[p.name] = GetTime() + 10 + end + if p.err then self:Fail(p.name, nil, p.err) else self:Fail(p.name, "nostart") end + end + if not (p.started and p.interrupted) then + self:NoteResult(p.name, p.started and not p.interrupted) + end + self:UpdateUI() +end + +-- Called every frame from the event frame. +function LP:OnUpdate(elapsed) + local now = GetTime() + self:RecoverCastTracking(now) + if self.whisperQueue[1] then self:ProcessWhisperQueue(now) end + local p = self.pending + if p and not p.done then + if not p.started then + if p.failed and now - p.failT >= (p.hardFail and 0.25 or 0.5) then + self:FinishPending() + elseif now - p.t > 4 then + self:FinishPending() + end + else + if p.stopT and now - p.stopT >= 0.4 then + self:FinishPending() + elseif p.interrupted and now - p.intT >= 0.6 then + self:FinishPending() + end + end + end + + self.tick = (self.tick or 0) + elapsed + if self.tick < 1 then return end + self.tick = 0 + + -- did the last summoned player arrive? + local w = self.watch + if w then + if now - w.t > 180 then + self.watch = nil + else + local info = self:ScanRoster()[w.name] + if info and UnitName(info.unit) == w.name and self:IsNearby(info.unit) then + if w.unconfirmed then + self:RemoveRequest(w.name, true) + self:NoteResult(w.name, true) + end + self:Print("|cff40ff40" .. w.name .. " has arrived.|r") + self.watch = nil + end + end + end + if table.getn(self.requests) > 0 then + self:PruneRequests() + end + self:OpenAnnounce(now) + self:CheckReady() + self:CheckRaidAutoSummon(now) + self:HelperCall(now) + self:UpdateUI() +end + +--------------------------------------------------------------------------- +-- Status whispers ("you're already here", "you're #3 in line...") +-- +-- Every LockPort warlock in the group sees the same "123", so replies are +-- coordinated: the warlock summoning that player answers at once, the others +-- wait a staggered delay (alphabetical among known LockPort warlocks) and +-- drop their reply if someone else's LPWhisper claim arrives first. +--------------------------------------------------------------------------- +function LP:OnTrigger(name, info, event) + if name == UnitName("player") then return end + if not self:IsWarlock() then + -- non-warlocks with LockPort just relay the request + self:SendAddon(PREFIX_ADD, name) + return + end + + -- already standing next to me + if info.online and self:IsNearby(info.unit) then + if self:FindRequest(name) then self:RemoveRequest(name, true) end + self:QueueWhisper(name, "here", self:HereText(info.unit)) + return + end + + local p = self.pending + if self:FindRequest(name) or (p and not p.done and p.name == name) then + -- repeat request: tell them why they're still waiting + self:SendAddon(PREFIX_ADD, name) + self:QueueWhisper(name, "status", self:StatusText(name, info)) + return + end + + local w = self.watch + local recent = w and w.name == name and GetTime() - w.t < 120 + if self:AddRequest(name, true) then + if recent then + self:QueueWhisper(name, "status", "I cast your summon " .. math.floor(GetTime() - w.t) .. + "s ago - accept the prompt if you still see it. I put you back on the list in case it expired.") + self.watch = nil + elseif event == "CHAT_MSG_WHISPER" then + local pos = self:QueuePosition(name) + SendTaggedChatMessage(ANNOUNCE_TAG .. "You're on the summon list" .. (pos and (" (#" .. pos .. ")") or "") .. ".", "WHISPER", nil, name) + end + end +end + +function LP:QueuePosition(name) + self:ScanRoster() + for i, e in ipairs(self:SortedRequests()) do + if e.name == name then return i end + end + return nil +end + +function LP:HereText(unit) + local d = self:Distance(unit) + if d then + return "You're already here (" .. math.floor(d) .. " yd from me) - no summon needed." + end + return "You're already standing next to me - no summon needed." +end + +function LP:StatusText(name, info) + local u = info.unit + local p = self.pending + if p and not p.done and p.name == name then + if not p.started then return "Casting your summon right now - accept the prompt when it shows up." end + local h = self:CountHelpers(name) + if h >= 2 then + return "Summoning you now - the portal is up, waiting for 2 people to click it. Accept the prompt when it shows up." + end + return "Summoning you now - waiting for helpers to click the portal (" .. h .. "/2 near me). Accept the prompt when it shows up." + end + + local pos = self:QueuePosition(name) + local line = pos and ("You're #" .. pos .. " in line") or "You're on the list" + if UnitIsDeadOrGhost(u) then return line .. ", but you're dead - release or get a res first." end + if UnitAffectingCombat(u) then return line .. ", but you're in combat - drop combat first." end + local inst = self:InstanceBlock(info) + if inst then return line .. ", but you're inside " .. inst .. " - zone out first." end + if p and not p.done then return line .. " - I'm summoning " .. p.name .. " right now." end + if UnitAffectingCombat("player") then return line .. " - I'm in combat, I'll summon you right after." end + if self:CountShards() < 1 then return line .. " - I'm out of Soul Shards." end + local h = self:CountHelpers(name) + if h < 2 then + return line .. " - waiting on helpers: I need 2 people near me to click the portal (" .. h .. " here)." + end + if pos and pos > 1 then + return line .. " - " .. (pos - 1) .. " ahead of you." + end + return line .. " - you're next!" +end + +function LP:ResponderDelay(name) + local p = self.pending + if p and not p.done and p.name == name then return 0 end + local me = UnitName("player") + local locks, seenMe = {}, false + for n, info in pairs(self.roster) do + if info.class == "WARLOCK" and info.online and (n == me or self.users[n]) then + table.insert(locks, n) + if n == me then seenMe = true end + end + end + if not seenMe then table.insert(locks, me) end + table.sort(locks) + for i, n in ipairs(locks) do + if n == me then return 0.4 + (i - 1) * 1.0 end + end + return 0.4 +end + +function LP:QueueWhisper(name, kind, text) + if LockPortOptions.chatMuted then return end + if not LockPortOptions.whisperStatus then return end + local key = kind .. ":" .. name + local now = GetTime() + if self.claims[key] and now - self.claims[key] < 12 then return end + for _, q in ipairs(self.whisperQueue) do + if q.key == key then return end + end + table.insert(self.whisperQueue, { key = key, name = name, text = text, at = now + self:ResponderDelay(name) }) +end + +function LP:ProcessWhisperQueue(now) + if LockPortOptions.chatMuted then self.whisperQueue = {}; return end + local q = self.whisperQueue + for i = table.getn(q), 1, -1 do + local w = q[i] + if self.claims[w.key] and now - self.claims[w.key] < 12 then + table.remove(q, i) + elseif now >= w.at then + table.remove(q, i) + self.claims[w.key] = now + self:SendAddon(PREFIX_WHISPER, w.key) + SendTaggedChatMessage(ANNOUNCE_TAG .. w.text, "WHISPER", nil, w.name) + end + end +end + +--------------------------------------------------------------------------- +-- Events +--------------------------------------------------------------------------- +function LP:MatchesTrigger(msg) + msg = string.lower(Trim(msg)) + local triggers = LockPortOptions and LockPortOptions.triggers or DEFAULTS.triggers + for word in string.gfind(triggers, "[^,]+") do + word = string.lower(Trim(word)) + if word ~= "" then + local len = string.len(word) + if string.sub(msg, 1, len) == word then + local nextc = string.sub(msg, len + 1, len + 1) + if nextc == "" or not string.find(nextc, "%w") then return true end + end + end + end + return false +end + +local CHAT_EVENTS = { + CHAT_MSG_SAY = true, CHAT_MSG_YELL = true, CHAT_MSG_WHISPER = true, + CHAT_MSG_PARTY = true, CHAT_MSG_RAID = true, CHAT_MSG_RAID_LEADER = true, +} + +function LP:OnEvent(event) + -- Track all casts so automatic summons do not interrupt another spell. + if event == "SPELLCAST_START" or event == "SPELLCAST_CHANNEL_START" then + self.castBusy = true + elseif event == "SPELLCAST_STOP" or event == "SPELLCAST_CHANNEL_STOP" + or event == "SPELLCAST_FAILED" or event == "SPELLCAST_INTERRUPTED" then + self.castBusy = nil + end + if event == "PLAYER_ENTERING_WORLD" then + self.castBusy = nil + end + if (event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED") + and self.raidAutoSummon and not self:InGroup() then + self:SetRaidAutoSummon(false) + end + if event == "VARIABLES_LOADED" then return self:Initialize() end + if not LockPortOptions then return end + if CHAT_EVENTS[event] then + -- Snapshot chat arguments before calling other addon/game code. Newer + -- ClassicAPI reads the actual event payload even if a hook changed globals. + local message, sender, chatEvent = arg1, arg2, event + if type(GetCurrentChatEvent) == "function" then + local ok, actualEvent, actualMessage, actualSender = pcall(GetCurrentChatEvent) + if ok and CHAT_EVENTS[actualEvent] then + chatEvent, message, sender = actualEvent, actualMessage, actualSender + end + end + if type(message) == "string" and type(sender) == "string" and self:MatchesTrigger(message) then + local info = self:ScanRoster()[sender] + if info then self:OnTrigger(sender, info, chatEvent) end + end + + elseif event == "CHAT_MSG_ADDON" then + if arg1 == PREFIX_ADD or arg1 == PREFIX_REMOVE or arg1 == PREFIX_VERSION or arg1 == PREFIX_WHISPER then + if arg4 then self.users[arg4] = true end + end + if arg1 == PREFIX_ADD then + -- another warlock added someone who is standing next to me: they don't need a summon + local info = self:ScanRoster()[arg2] + if self:IsWarlock() and LockPortOptions.autoRemove and info and info.online and self:IsNearby(info.unit) then + self:SendAddon(PREFIX_REMOVE, arg2) + else + self:AddRequest(arg2, false) + end + elseif arg1 == PREFIX_REMOVE then + self:RemoveRequest(arg2, false) + elseif arg1 == PREFIX_VERSION then + self:OnVersionMessage(arg2) + elseif arg1 == PREFIX_WHISPER then + -- another LockPort warlock already answered / is summoning this player + if arg4 ~= UnitName("player") then self.claims[arg2] = GetTime() end + end + + -- cast tracking (works on a plain client) -------------------------------- + elseif event == "UI_ERROR_MESSAGE" then + local p = self.pending + if p and not p.done and not p.started and GetTime() - p.t < 4 then + p.err = arg1 + if not p.failed then p.failed, p.failT = true, GetTime() end + end + elseif event == "SPELLCAST_START" then + if arg1 == self.SPELL then self:OnRitualStarted() end + elseif event == "SPELLCAST_CHANNEL_START" then + local p = self.pending + if p and not p.done and not p.started and GetTime() - p.t < 4 then self:OnRitualStarted() end + elseif event == "SPELLCAST_FAILED" or event == "SPELLCAST_INTERRUPTED" then + local p = self.pending + if p and not p.done then + if p.started then + p.interrupted, p.intT = true, GetTime() + else + p.failed, p.failT, p.hardFail = true, GetTime(), true + end + end + elseif event == "SPELLCAST_CHANNEL_STOP" then + local p = self.pending + if p and not p.done and p.started then p.stopT = GetTime() end + + -- SuperWoW: exact, GUID-matched cast events --------------------------------- + elseif event == "UNIT_CASTEVENT" then + local p = self.pending + if p and not p.done and arg1 == self:MyGUID() and self:IsRitualId(arg4) then + if arg3 == "START" or arg3 == "CHANNEL" then + self:OnRitualStarted() + elseif arg3 == "FAIL" then + if p.started then p.interrupted, p.intT = true, GetTime() + else p.failed, p.failT, p.hardFail = true, GetTime(), true end + end + end + + -- Nampower: server-confirmed failures ------------------------------------ + elseif event == "SPELL_FAILED_SELF" then + local p = self.pending + if p and not p.done and self:IsRitualId(arg1) then + if p.started then p.interrupted, p.intT = true, GetTime() + else p.failed, p.failT, p.hardFail = true, GetTime(), true end + end + + elseif event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED" then + self:PruneRequests() + self:UpdateUI() + self:OnGroupChanged() + elseif event == "SPELLS_CHANGED" or event == "LEARNED_SPELL_IN_TAB" then + self.knowsRitual = nil + elseif event == "BAG_UPDATE" then + if LockPortFrame and LockPortFrame:IsVisible() then self:UpdateUI() end + elseif event == "PLAYER_ENTERING_WORLD" then + self:SendVersion(true) + end +end + +function LP:Initialize() + if type(LockPortOptions) ~= "table" then LockPortOptions = {} end + local O = LockPortOptions + -- v2 saved a message without the toggle + if O.customMessage == nil and O.message and O.message ~= "" then O.customMessage = true end + for k, v in pairs(DEFAULTS) do + if O[k] == nil then O[k] = v end + end + -- Availability announcements, like Auto Summon, require a fresh opt-in each session. + self.raidAutoSummon = false + O.announceOpen = false + self.nextAnnounceAt = nil + self:DetectMods() + local ev = self.eventFrame + if self.mods.superwow then ev:RegisterEvent("UNIT_CASTEVENT") end + if self.mods.nampower then ev:RegisterEvent("SPELL_FAILED_SELF") end + if self.CreateUI then self:CreateUI() end + self:Print("v" .. tostring(GetAddOnMetadata("LockPort", "Version")) .. " loaded. Type |cffffd200/lockport|r to open, |cffffd200/lockport help|r for commands.") +end + +--------------------------------------------------------------------------- +-- Version check (compatible with v2's "lp" VERSION: messages) +--------------------------------------------------------------------------- +local function VersionNumber(v) + local _, _, a, b, c = string.find(tostring(v or ""), "(%d+)%.(%d+)%.?(%d*)") + return (tonumber(a) or 0) * 10000 + (tonumber(b) or 0) * 100 + (tonumber(c) or 0) +end + +function LP:SendVersion(login) + local v = "VERSION:" .. VersionNumber(GetAddOnMetadata("LockPort", "Version")) + self:SendAddon(PREFIX_VERSION, v) + if login and IsInGuild() then SendAddonMessage(PREFIX_VERSION, v, "GUILD") end +end + +function LP:OnGroupChanged() + local size = GetNumRaidMembers() + if size == 0 then size = GetNumPartyMembers() end + if size > (self.groupSize or 0) then self:SendVersion(false) end + self.groupSize = size +end + +function LP:OnVersionMessage(msg) + local _, _, kind, num = string.find(msg or "", "^(%a+):(%d+)") + if kind ~= "VERSION" then return end + local remote = tonumber(num) or 0 + if remote > VersionNumber(GetAddOnMetadata("LockPort", "Version")) and not self.updateShown then + self.updateShown = true + self:Print("a newer version is available: " .. REPO_URL) + end +end + +--------------------------------------------------------------------------- +-- Slash commands +--------------------------------------------------------------------------- +local TOGGLES = { + zone = "zone info in /say", whisper = "whisper the target", shards = "shard count in /say", + sound = "sound on new request", announce = "announce failures to group", + flash = "taskbar flash", autoremove = "auto-remove arrived players", lock = "window lock", + status = "status whispers (already here / place in line)", + helpers = "ask the raid for portal helpers", +} +local TOGGLE_KEYS = { announce = "announceFail", autoremove = "autoRemove", lock = "locked", status = "whisperStatus", helpers = "askHelpers" } + +function LP:Slash(msg) + msg = Trim(msg) + local _, _, cmd, rest = string.find(msg, "^(%S*)%s*(.-)$") + cmd = string.lower(cmd or "") + local O = LockPortOptions + + if cmd == "" or cmd == "toggle" then + self:Toggle() + elseif cmd == "debug" then + self:DebugSummon() + elseif cmd == "help" then + self:Print("commands:") + local lines = { + "|cffffd200/lockport|r - show / hide the window", + "|cffffd200/lockport next|r - summon the next person in line", + "|cffffd200/lockport available|r - toggle the \"summons are available\" raid posts", + "|cffffd200/lockport add [name]|r - add a name (or your target)", + "|cffffd200/lockport remove |r, |cffffd200clear|r - edit the list", + "|cffffd200/lockport show|r - print the list", + "|cffffd200/lockport options|r - open the options window", + "|cffffd200/lockport message |r or |cffffd200message clear|r - custom /say ({name} {zone} {shards})", + "|cffffd200/lockport zone, whisper, shards, sound, announce, status, helpers, flash, autoremove, lock|r - toggles", + "|cffffd200/lockport mods|r - which client mods LockPort is using", + "|cffffd200/lockport style|r - switch window style (Classic / pfUI)", + "|cffffd200/lockport reset|r - reset the window position", + } + for _, l in ipairs(lines) do DEFAULT_CHAT_FRAME:AddMessage(" " .. l) end + elseif cmd == "next" or cmd == "summon" then + if rest ~= "" then self:Summon(rest) else self:SummonNext() end + elseif cmd == "add" then + local name = rest + if name == "" then name = UnitName("target") end + if not name then return self:Print("target someone or use /lockport add .") end + if not self:ScanRoster()[name] then return self:Print(name .. " isn't in your group.") end + if not self:AddRequest(name, true) then self:Print(name .. " is already on the list.") end + elseif cmd == "remove" then + self:RemoveRequest(rest, true) + elseif cmd == "clear" then + self:ClearRequests() + self:Print("summon list cleared.") + elseif cmd == "show" then + if not self.requests[1] then return self:Print("the list is empty.") end + for i, r in ipairs(self.requests) do DEFAULT_CHAT_FRAME:AddMessage(" " .. i .. ". " .. r.name) end + elseif cmd == "options" or cmd == "settings" or cmd == "config" then + self:ToggleOptions() + elseif cmd == "message" then + if string.lower(rest) == "clear" then rest = "" end + O.message = rest + O.customMessage = rest ~= "" + self:Print("custom /say message " .. (rest ~= "" and ("set: " .. rest) or "cleared.")) + self:RefreshOptions() + elseif cmd == "style" or cmd == "theme" then + self:ToggleTheme() + elseif cmd == "available" or cmd == "open" then + if self.raidAutoSummon then return self:Print("turn off Auto Summon before disabling availability announcements.") end + O.announceOpen = not O.announceOpen + self:Print("\"summons are available\" announcements: " .. OnOff(O.announceOpen)) + if O.announceOpen then self:OpenAnnounce(GetTime(), true) end + self:RefreshOptions() + elseif cmd == "mods" then + self:PrintMods() + elseif cmd == "reset" then + O.pos = nil + if LockPortFrame then + LockPortFrame:ClearAllPoints() + LockPortFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + end + elseif TOGGLES[cmd] then + local key = TOGGLE_KEYS[cmd] or cmd + O[key] = not O[key] + self:Print(TOGGLES[cmd] .. ": " .. OnOff(O[key])) + self:RefreshOptions() + else + self:Print("unknown command. Type /lockport help.") + end +end + +--------------------------------------------------------------------------- +-- UI hooks (implemented in LockPort_UI.lua) +--------------------------------------------------------------------------- +function LP:UpdateUI() if self.RefreshList then self:RefreshList() end end +function LP:RefreshOptions() if self.RefreshOptionsUI then self:RefreshOptionsUI() end end +function LP:Toggle() + if not LockPortFrame then return end + if LockPortFrame:IsVisible() then LockPortFrame:Hide() else LockPortFrame:Show() end +end +function LP:ToggleOptions() + if not LockPortOptionsFrame then return end + if LockPortOptionsFrame:IsVisible() then LockPortOptionsFrame:Hide() else LockPortOptionsFrame:Show() end +end + +-- Key bindings (Bindings.xml) +BINDING_HEADER_LOCKPORT = "LockPort" +BINDING_NAME_LOCKPORT_SUMMON_NEXT = "Summon next in line" +BINDING_NAME_LOCKPORT_TOGGLE = "Show / hide window" + +--------------------------------------------------------------------------- +-- Boot +--------------------------------------------------------------------------- +local ev = CreateFrame("Frame", "LockPortEventFrame") -- not parented to UIParent so Alt+Z doesn't stop it +LP.eventFrame = ev +local EVENTS = { + "VARIABLES_LOADED", "PLAYER_ENTERING_WORLD", "CHAT_MSG_ADDON", + "CHAT_MSG_SAY", "CHAT_MSG_YELL", "CHAT_MSG_WHISPER", + "CHAT_MSG_PARTY", "CHAT_MSG_RAID", "CHAT_MSG_RAID_LEADER", + "UI_ERROR_MESSAGE", "SPELLCAST_START", "SPELLCAST_STOP", "SPELLCAST_CHANNEL_START", "SPELLCAST_CHANNEL_STOP", + "SPELLCAST_FAILED", "SPELLCAST_INTERRUPTED", + "RAID_ROSTER_UPDATE", "PARTY_MEMBERS_CHANGED", "SPELLS_CHANGED", "LEARNED_SPELL_IN_TAB", "BAG_UPDATE", +} +for _, e in ipairs(EVENTS) do ev:RegisterEvent(e) end +ev:SetScript("OnEvent", function() LP:OnEvent(event) end) +ev:SetScript("OnUpdate", function() LP:OnUpdate(arg1 or 0) end) + +SLASH_LOCKPORT1 = "/lockport" +SLASH_LOCKPORT2 = "/gurky" +SlashCmdList["LOCKPORT"] = function(msg) LP:Slash(msg) end diff --git a/tests/fixtures/LockPort-original.lua b/tests/fixtures/LockPort-original.lua new file mode 100644 index 0000000..2ccf11e --- /dev/null +++ b/tests/fixtures/LockPort-original.lua @@ -0,0 +1,1210 @@ +--[[ + LockPort - easy warlock summoning for World of Warcraft 1.12 / Turtle WoW 1.18 + Originally written by Gurky, maintained by Phaestas. + v3: classic UI, failure announcements, optional client-mod support + (SuperWoW, Nampower, UnitXP SP3 are all optional). + + Written for the Lua 5.0 runtime of the 1.12 client: + no '#', no '%' operator, no select(), no string.match. +]] + +LockPort = {} +local LP = LockPort + +LP.SPELL = "Ritual of Summoning" +LP.SHARD_ID = 6265 -- Soul Shard +LP.EVILTWIN_ID = 23445 -- Evil Twin (Dimensional Ripper / Ultrasafe Transporter) +LP.EVILTWIN_TEX = "spell_shadow_charm" +LP.NEARBY_YARDS = 30 + +-- Addon message prefixes. RSAdd / RSRemove / lp are kept so v3 still syncs +-- with older LockPort versions in the same raid. +local PREFIX_ADD = "RSAdd" +local PREFIX_REMOVE = "RSRemove" +local PREFIX_VERSION = "lp" +local PREFIX_WHISPER = "LPWhisper" -- ":" claim so only one warlock replies + +local CHAT_TAG = "|cff9482c9LockPort|r: " +local ANNOUNCE_TAG = "[LockPort] " +local REPO_URL = "https://github.com/Phaestas323/LockPort" + +LP.requests = {} -- { {name=, t=}, ... } in request order +LP.roster = {} +LP.mods = {} +LP.users = {} -- names seen sending LockPort addon messages +LP.claims = {} -- ":" -> time a status whisper went out +LP.whisperQueue = {} + +local DEFAULTS = { + whisper = true, -- whisper the target "Summoning you..." + zone = true, -- include zone in the /say message + shards = true, -- include shard count in the /say message + sound = true, -- sound on a new request + message = "", -- custom /say message + customMessage = false, + announceFail = true, -- tell the party/raid why a summon failed + whisperFail = true, -- whisper the target why their summon failed + whisperStatus = true, -- reply to "123" when already here / already on the list + announceOpen = false, -- post "summons are open - type 123" every few minutes + announceOpenEvery = "3", -- minutes between those posts + readySound = true, -- chime when a summon becomes possible + askHelpers = true, -- ask the raid for portal clickers when fewer than 2 are near + flash = true, -- flash the taskbar on a new request (UnitXP SP3) + autoRemove = true, -- drop people from the list once they're standing next to you + autoShow = true, -- pop the window open on a new request + locked = false, -- lock window position + theme = "classic", -- window style: "classic" or "pfui" + triggers = "123", -- comma separated request words +} + +-- Failure reasons: what the group sees, and what the summonee is whispered. +local REASONS = { + offline = { group = "%s is offline" }, + dead = { group = "%s is dead", whisper = "you're dead. Release or get a res and type 123 again" }, + combat = { group = "%s is in combat", whisper = "you're in combat. Drop combat and I'll try again" }, + eviltwin = { group = "%s has Evil Twin (must die or walk)", whisper = "you have the Evil Twin debuff. You'll need to die or walk" }, + mecombat = { group = "I'm in combat", whisper = "I'm in combat. You're still on the list" }, + noshards = { group = "I'm out of Soul Shards", whisper = "I'm out of Soul Shards" }, + instance = { group = "%s is inside %s", whisper = "you're inside %s - zone out first. You're still on the list" }, + interrupt = { group = "the ritual was interrupted", whisper = "the ritual was interrupted. You're still on the list" }, + nostart = { group = "the ritual didn't start", whisper = "the ritual didn't start. You're still on the list" }, +} + +--------------------------------------------------------------------------- +-- Helpers +--------------------------------------------------------------------------- +function LP:Print(msg) + DEFAULT_CHAT_FRAME:AddMessage(CHAT_TAG .. msg) +end + +local function OnOff(v) + if v then return "|cff40ff40on|r" end + return "|cffff4040off|r" +end +LP.OnOff = OnOff + +local function Trim(s) + s = string.gsub(s or "", "^%s+", "") + s = string.gsub(s, "%s+$", "") + return s +end + +local function EscapeRepl(s) + return (string.gsub(s or "", "%%", "%%%%")) +end + +function LP:IsWarlock() + local _, class = UnitClass("player") + return class == "WARLOCK" +end + +function LP:InGroup() + return GetNumRaidMembers() > 0 or GetNumPartyMembers() > 0 +end + +function LP:GroupChannel() + if GetNumRaidMembers() > 0 then return "RAID" end + if GetNumPartyMembers() > 0 then return "PARTY" end + return nil +end + +function LP:SendAddon(prefix, msg) + local chan = self:GroupChannel() + if chan then SendAddonMessage(prefix, msg, chan) end +end + +--------------------------------------------------------------------------- +-- Optional client mods +--------------------------------------------------------------------------- +function LP:DetectMods() + local m = self.mods + -- SuperWoW: GUIDs from UnitExists, UNIT_CASTEVENT, SpellInfo, UnitPosition + m.superwow = (SUPERWOW_VERSION ~= nil) or (SpellInfo ~= nil and UnitPosition ~= nil) + -- UnitXP SP3: true yard distances + taskbar flash + m.unitxp = false + if UnitXP then + local ok, d = pcall(UnitXP, "distanceBetween", "player", "player") + m.unitxp = ok and (d == nil or type(d) == "number") + end + -- Nampower: SPELL_FAILED_SELF / SPELL_CAST_EVENT + m.nampower = (GetNampowerVersion ~= nil) or (QueueSpellByName ~= nil) or (GetSpellIdForName ~= nil) +end + +function LP:ModStatusText() + local function s(label, on) return label .. ": " .. (on and "|cff40ff40yes|r" or "|cff808080no|r") end + local m = self.mods + return s("SuperWoW", m.superwow) .. " " .. s("Nampower", m.nampower) .. " " .. + s("UnitXP SP3", m.unitxp) +end + +function LP:PrintMods() + local m = self.mods + self:Print("client mods detected:") + DEFAULT_CHAT_FRAME:AddMessage(" SuperWoW: " .. OnOff(m.superwow) .. " |cffaaaaaa- exact cast start/fail tracking for the ritual, Evil Twin by spell id, yard distances|r") + DEFAULT_CHAT_FRAME:AddMessage(" Nampower: " .. OnOff(m.nampower) .. " |cffaaaaaa- server-confirmed spell failure events|r") + DEFAULT_CHAT_FRAME:AddMessage(" UnitXP SP3: " .. OnOff(m.unitxp) .. " |cffaaaaaa- precise distance (\"already here\" check), taskbar flash on new requests|r") +end + +function LP:MyGUID() + if not self.mods.superwow then return nil end + local _, guid = UnitExists("player") + return guid +end + +-- Is this spell id our ritual? (needs SuperWoW's SpellInfo; otherwise assume yes) +function LP:IsRitualId(id) + if not id then return true end + if SpellInfo then + local name = SpellInfo(id) + if name then return name == self.SPELL end + end + return true +end + +--------------------------------------------------------------------------- +-- Distance +--------------------------------------------------------------------------- +function LP:Distance(unit) + if self.mods.unitxp then + local ok, d = pcall(UnitXP, "distanceBetween", "player", unit) + if ok and type(d) == "number" then return d end + end + if self.mods.superwow and UnitPosition then + local x1, y1, z1 = UnitPosition("player") + local x2, y2, z2 = UnitPosition(unit) + if x1 and x2 then + local dx, dy, dz = x1 - x2, y1 - y2, (z1 or 0) - (z2 or 0) + return math.sqrt(dx * dx + dy * dy + dz * dz) + end + end + return nil +end + +function LP:IsNearby(unit) + if not UnitIsVisible(unit) then return false end + local d = self:Distance(unit) + if d then return d <= self.NEARBY_YARDS end + return CheckInteractDistance(unit, 4) and true or false -- ~28 yards +end + +-- Group members (other than me and the summonee) close enough to click the portal. +function LP:CountHelpers(exclude) + local n = 0 + local raid = GetNumRaidMembers() + if raid > 0 then + for i = 1, raid do + local u = "raid" .. i + local name = UnitName(u) + if name and name ~= UnitName("player") and name ~= exclude + and UnitIsConnected(u) and not UnitIsDeadOrGhost(u) and self:IsNearby(u) then + n = n + 1 + end + end + else + for i = 1, GetNumPartyMembers() do + local u = "party" .. i + local name = UnitName(u) + if name and name ~= exclude and UnitIsConnected(u) and not UnitIsDeadOrGhost(u) and self:IsNearby(u) then + n = n + 1 + end + end + end + return n +end + +--------------------------------------------------------------------------- +-- Roster / spellbook / bags +--------------------------------------------------------------------------- +function LP:ScanRoster() + local roster = {} + local raid = GetNumRaidMembers() + if raid > 0 then + for i = 1, raid do + local name, _, _, _, classLocal, class, zone, online, dead = GetRaidRosterInfo(i) + if name then + roster[name] = { unit = "raid" .. i, class = class, classLocal = classLocal, + zone = zone, online = online, dead = dead } + end + end + else + for i = 1, GetNumPartyMembers() do + local u = "party" .. i + local name = UnitName(u) + if name then + local classLocal, class = UnitClass(u) + roster[name] = { unit = u, class = class, classLocal = classLocal, + online = UnitIsConnected(u), dead = UnitIsDeadOrGhost(u) } + end + end + end + self.roster = roster + return roster +end + +function LP:KnowsRitual() + if self.knowsRitual ~= nil then return self.knowsRitual end + local i = 1 + while true do + local name = GetSpellName(i, BOOKTYPE_SPELL) + if not name then break end + if name == self.SPELL then + self.knowsRitual = true + return true + end + i = i + 1 + end + self.knowsRitual = false + return false +end + +function LP:CountShards() + local total = 0 + local pattern = "item:" .. self.SHARD_ID .. ":" + for bag = 0, 4 do + local slots = GetContainerNumSlots(bag) or 0 + for slot = 1, slots do + local link = GetContainerItemLink(bag, slot) + if link and string.find(link, pattern, 1, true) then + local _, count = GetContainerItemInfo(bag, slot) + total = total + math.abs(count or 1) + end + end + end + return total +end + +function LP:HasEvilTwin(unit) + for i = 1, 16 do + local tex, _, _, id = UnitDebuff(unit, i) + if not tex then break end + if id == self.EVILTWIN_ID then return true end + if string.find(string.lower(tex), self.EVILTWIN_TEX, 1, true) then return true end + end + return false +end + +--------------------------------------------------------------------------- +-- Instances: a player inside a dungeon/raid/battleground you're not in can't +-- be summoned out. Zone names come from the raid roster (raids only; 1.12 +-- doesn't share zones for 5-man parties). English zone names. +--------------------------------------------------------------------------- +LP.INSTANCES = {} +for _, z in ipairs({ + -- dungeons + "Ragefire Chasm", "Wailing Caverns", "The Deadmines", "Shadowfang Keep", "Blackfathom Deeps", + "The Stockade", "Gnomeregan", "Razorfen Kraul", "Scarlet Monastery", "Razorfen Downs", "Uldaman", + "Zul'Farrak", "Maraudon", "The Temple of Atal'Hakkar", "Sunken Temple", "Blackrock Depths", + "Blackrock Spire", "Dire Maul", "Stratholme", "Scholomance", + -- raids + "Molten Core", "Onyxia's Lair", "Blackwing Lair", "Zul'Gurub", "Ruins of Ahn'Qiraj", + "Ahn'Qiraj", "Temple of Ahn'Qiraj", "Naxxramas", + -- battlegrounds + "Warsong Gulch", "Arathi Basin", "Alterac Valley", + -- Turtle WoW + "Crescent Grove", "Karazhan Crypt", "Stormwind Vault", "Gilneas City", "Hateforge Quarry", + "The Black Morass", "Black Morass", "Dragonmaw Retreat", "Stormwrought Ruins", + "Emerald Sanctum", "Lower Karazhan Halls", "Upper Karazhan Halls", "Tower of Karazhan", +}) do LP.INSTANCES[z] = true end + +-- Returns the instance name if the player is inside an instance I'm not in. +function LP:InstanceBlock(info) + local z = info and info.zone + if not z or z == "" or z == GetRealZoneText() then return nil end + if self.INSTANCES[z] then return z end + return nil +end + +--------------------------------------------------------------------------- +-- Request list +--------------------------------------------------------------------------- +function LP:FindRequest(name) + for i, r in ipairs(self.requests) do + if r.name == name then return i end + end + return nil +end + +-- Returns true if the name was newly added. +function LP:AddRequest(name, broadcast) + if not name or name == "" or name == UnitName("player") then return false end + if broadcast then self:SendAddon(PREFIX_ADD, name) end + if not self:IsWarlock() then return false end + if self:FindRequest(name) then return false end + if not self:ScanRoster()[name] then return false end + table.insert(self.requests, { name = name, t = GetTime() }) + self:OnNewRequest(name) + self:UpdateUI() + return true +end + +function LP:RemoveRequest(name, broadcast) + if broadcast then self:SendAddon(PREFIX_REMOVE, name) end + local i = self:FindRequest(name) + if i then + table.remove(self.requests, i) + self:UpdateUI() + end +end + +function LP:ClearRequests() + for i = table.getn(self.requests), 1, -1 do + self:RemoveRequest(self.requests[i].name, true) + end +end + +function LP:OnNewRequest(name) + local O = LockPortOptions + if O.sound then + PlaySoundFile("Sound\\Creature\\Necromancer\\NecromancerReady1.wav") + end + if O.flash and self.mods.unitxp then + pcall(UnitXP, "notify", "taskbarIcon") + end + if O.autoShow and LockPortFrame and not LockPortFrame:IsVisible() then + LockPortFrame:Show() + end +end + +-- Drop names that left the group; auto-remove people who already arrived. +function LP:PruneRequests() + local roster = self:ScanRoster() + local O = LockPortOptions + for i = table.getn(self.requests), 1, -1 do + local r = self.requests[i] + local info = roster[r.name] + if not info then + table.remove(self.requests, i) + elseif O.autoRemove and not (self.pending and self.pending.name == r.name) + and GetTime() - r.t > 5 and info.online and self:IsNearby(info.unit) then + self:Print(r.name .. " is already here - removed from the list.") + self:RemoveRequest(r.name, true) + end + end +end + +-- Sorted view for the UI: warlocks first (they help click), then oldest request first. +function LP:SortedRequests() + local roster = self.roster + local list = {} + for i, r in ipairs(self.requests) do + local info = roster[r.name] + if info then + table.insert(list, { name = r.name, t = r.t, info = info, lock = (info.class == "WARLOCK") }) + end + end + table.sort(list, function(a, b) + if a.lock ~= b.lock then return a.lock end + return a.t < b.t + end) + return list +end + +-- Status for one row: text, r, g, b +function LP:RequestStatus(entry) + local info, u = entry.info, entry.info.unit + if self.pending and self.pending.name == entry.name and not self.pending.done then + return "Summoning...", 0.58, 0.51, 0.79 + end + if not UnitIsConnected(u) then return "Offline", 0.5, 0.5, 0.5 end + if UnitIsDeadOrGhost(u) then return "Dead", 1, 0.25, 0.25 end + if UnitAffectingCombat(u) then return "In combat", 1, 0.5, 0.1 end + if UnitIsVisible(u) then + local d = self:Distance(u) + if d then + if d <= self.NEARBY_YARDS then return "Here", 0.25, 1, 0.25 end + return string.format("%d yd", d), 0.85, 0.85, 0.85 + end + if CheckInteractDistance(u, 4) then return "Here", 0.25, 1, 0.25 end + end + if info.zone and info.zone ~= "" then + if info.zone == GetRealZoneText() then return "Same zone", 0.85, 0.85, 0.85 end + if self:InstanceBlock(info) then return info.zone, 1, 0.5, 0.1 end + return info.zone, 0.85, 0.85, 0.85 + end + return "", 1, 1, 1 +end + +--------------------------------------------------------------------------- +-- Failure reporting +--------------------------------------------------------------------------- +function LP:Fail(name, key, rawText, extra, quiet) + local O = LockPortOptions + local reason = REASONS[key] + local groupText, whisperText + if reason then + groupText = string.format(reason.group, name, extra or "") + whisperText = reason.whisper and string.format(reason.whisper, extra or "") + else + -- server / client error text, e.g. "Target is in combat", "You must be standing to do that" + groupText = rawText or "unknown error" + whisperText = rawText + end + + self:Print("|cffff4040can't summon " .. name .. ":|r " .. groupText) + if quiet then return end + + -- don't spam the same failure for the same person + local now = GetTime() + local k = name .. "\001" .. groupText + if self.lastFailKey == k and now - (self.lastFailTime or 0) < 10 then return end + self.lastFailKey, self.lastFailTime = k, now + + local chan = self:GroupChannel() + if O.announceFail and chan then + SendChatMessage(ANNOUNCE_TAG .. "Can't summon " .. name .. ": " .. groupText .. ".", chan) + end + if O.whisperFail and whisperText and key ~= "offline" and name ~= UnitName("player") then + SendChatMessage(ANNOUNCE_TAG .. "Couldn't summon you: " .. whisperText .. ".", "WHISPER", nil, name) + end +end + +--------------------------------------------------------------------------- +-- Summoning +--------------------------------------------------------------------------- +function LP:SummonNext() + local e = self:NextCandidate() + if e then return self:Summon(e.name) end + if self.requests[1] then + self:Print("nobody on the list can be summoned right now (offline, dead, in combat, in an instance, or being summoned by another warlock).") + else + self:Print("nobody is waiting for a summon.") + end +end + +function LP:Summon(name) + if not self:IsWarlock() then return self:Print("only warlocks can summon.") end + if self.pending and not self.pending.done then + return self:Print("already summoning " .. self.pending.name .. ".") + end + if not self:KnowsRitual() then return self:Print("you don't know " .. self.SPELL .. " yet.") end + + local info = self:ScanRoster()[name] + if not info then + self:Print(name .. " is no longer in your group - removed.") + return self:RemoveRequest(name, true) + end + local unit = info.unit + + -- target-side checks + local problem, extra = self:TargetProblem(info) + if problem == "eviltwin" then + self:Fail(name, "eviltwin") + return self:RemoveRequest(name, true) + elseif problem == "here" then + self:Print(name .. " is already here - removed from the list.") + return self:RemoveRequest(name, true) + elseif problem then + return self:Fail(name, problem, nil, extra) + end + -- my side + if UnitAffectingCombat("player") then return self:Fail(name, "mecombat") end + if self:CountShards() < 1 then return self:Fail(name, "noshards") end + + local helpers = self:CountHelpers(name) + if helpers < 2 then + self:Print("|cffffd200heads up:|r only " .. helpers .. " group member(s) nearby to click the portal (2 needed).") + end + + -- The ritual summons the warlock's target, so keep it targeted until the portal completes. + self.pending = { name = name, unit = unit, t = GetTime() } + self:SendAddon(PREFIX_WHISPER, "summon:" .. name) -- other warlocks' Summon Next skips this player for a bit + TargetUnit(unit) + CastSpellByName(self.SPELL) + self:UpdateUI() +end + +-- Why this player can't be summoned right now: nil, or a REASONS key (+ extra). +-- "here" means they're already next to me. +function LP:TargetProblem(info) + local u = info.unit + if not UnitIsConnected(u) then return "offline" end + if UnitIsDeadOrGhost(u) then return "dead" end + if UnitAffectingCombat(u) then return "combat" end + local inst = self:InstanceBlock(info) + if inst then return "instance", inst end + if UnitIsVisible(u) and self:HasEvilTwin(u) then return "eviltwin" end + if self:IsNearby(u) then return "here" end + return nil +end + +--------------------------------------------------------------------------- +-- Picking who's next. Summon Next skips anyone who can't be summoned right +-- now, anyone another warlock just started summoning ("summon:" claim), +-- and anyone who failed 3 times in a row (for a minute). +--------------------------------------------------------------------------- +LP.failCount = {} +LP.skipUntil = {} + +function LP:NoteResult(name, ok) + if ok then + self.failCount[name] = nil + self.skipUntil[name] = nil + return + end + self.failCount[name] = (self.failCount[name] or 0) + 1 + if self.failCount[name] >= 3 then + self.failCount[name] = nil + self.skipUntil[name] = GetTime() + 60 + self:Print(name .. " failed 3 times in a row - Summon Next skips them for a minute.") + end +end + +-- First player who can be summoned right now (or nil). +function LP:NextCandidate() + local now = GetTime() + self:ScanRoster() + for _, e in ipairs(self:SortedRequests()) do + local claimT = self.claims["summon:" .. e.name] + local claimed = claimT and now - claimT < 20 + local skipped = self.skipUntil[e.name] and now < self.skipUntil[e.name] + if not claimed and not skipped and not self:TargetProblem(e.info) then + return e + end + end + return nil +end + +-- What the Summon button would do right now: ready (bool), text for the window. +function LP:ReadyState() + local p = self.pending + if p and not p.done then return false, "Summoning " .. p.name .. "..." end + if not self.requests[1] then return false, nil end + if UnitIsDeadOrGhost("player") then return false, "You're dead" end + if UnitAffectingCombat("player") then return false, "You're in combat" end + if self:CountShards() < 1 then return false, "No Soul Shards" end + local e = self:NextCandidate() + if not e then return false, "Nobody can be summoned yet" end + local h = self:CountHelpers(e.name) + if h < 2 then return false, "Waiting for helpers (" .. h .. "/2)" end + return true, "Ready: " .. e.name +end + +-- Chime (and flash the taskbar) when a summon becomes possible. +function LP:CheckReady() + local ready, text = self:ReadyState() + self.readyText = text + if ready and not self.wasReady and self:IsWarlock() then + local O = LockPortOptions + if O.readySound then PlaySound("ReadyCheck") end + if O.flash and self.mods.unitxp then pcall(UnitXP, "notify", "taskbarIcon") end + end + self.wasReady = ready + self.isReady = ready +end + +--------------------------------------------------------------------------- +-- Not enough helpers: ask the group to come click the portal. +-- Fires when someone is waiting (or a ritual is up) and fewer than 2 group +-- members are near you for ~8 s. At most every 2 min, never in combat, and +-- only one LockPort warlock posts (a "help" claim resets the others' timers). +--------------------------------------------------------------------------- +function LP:WhereText() + local where = GetZoneText() or "" + local sub = GetSubZoneText() + if sub and sub ~= "" and sub ~= where then where = where .. " - " .. sub end + return where +end + +function LP:HelperCall(now) + local O = LockPortOptions + if not O.askHelpers or not self:IsWarlock() then self.helpShortSince = nil; return end + local chan = self:GroupChannel() + if not chan then self.helpShortSince = nil; return end + + local p = self.pending + local channelling = p and not p.done and p.started + local forName + if channelling then + forName = p.name + else + if UnitIsDeadOrGhost("player") or UnitAffectingCombat("player") or self:CountShards() < 1 then + self.helpShortSince = nil + return + end + local e = self:NextCandidate() + if not e then self.helpShortSince = nil; return end + forName = e.name + end + + local h = self:CountHelpers(forName) + if h >= 2 then self.helpShortSince = nil; return end + self.helpShortSince = self.helpShortSince or now + if now - self.helpShortSince < 8 then return end + if UnitAffectingCombat("player") then return end + if now < (self.nextHelpAt or 0) then return end + local claimT = self.claims["help"] + if claimT and now - claimT < 115 then + self.nextHelpAt = claimT + 120 + return + end + + local need = 2 - h + local who = (need == 1) and "1 more person" or (need .. " more people") + local msg + if channelling then + msg = ANNOUNCE_TAG .. "Need " .. who .. " at " .. self:WhereText() .. " to click the summoning portal for " .. forName .. "!" + else + local waiting = table.getn(self.requests) + msg = ANNOUNCE_TAG .. "Need " .. who .. " at " .. self:WhereText() .. " to help summon - " .. + waiting .. (waiting == 1 and " player" or " players") .. " waiting." + end + SendChatMessage(msg, chan) + self:SendAddon(PREFIX_WHISPER, "help") + self.nextHelpAt = now + 120 +end + +--------------------------------------------------------------------------- +-- "Summons are open - type 123" to the party/raid every N minutes (option). +-- Skipped in combat; with several LockPort warlocks only one posts per round. +--------------------------------------------------------------------------- +function LP:TriggerWord() + local _, _, w = string.find(LockPortOptions.triggers or "123", "^%s*([^,]+)") + w = Trim(w or "") + if w == "" then w = "123" end + return w +end + +function LP:OpenAnnounce(now, force) + local O = LockPortOptions + if not O.announceOpen and not force then return end + if not self:IsWarlock() then return end + local chan = self:GroupChannel() + if not chan then return end + local every = tonumber(O.announceOpenEvery) or 3 + if every < 1 then every = 1 end + local period = every * 60 + if not force and now < (self.nextAnnounceAt or 0) then return end + if UnitAffectingCombat("player") then return end + local claimT = self.claims["announce"] + if not force and claimT and now - claimT < period - 5 then + self.nextAnnounceAt = claimT + period + return + end + local n = table.getn(self.requests) + local msg = ANNOUNCE_TAG .. "Summons are open - type " .. self:TriggerWord() .. " to get summoned." + if n > 0 then msg = msg .. " (" .. n .. " in line)" end + SendChatMessage(msg, chan) + self:SendAddon(PREFIX_WHISPER, "announce") + self.nextAnnounceAt = now + period +end + +-- The ritual really started: announce and take the name off everyone's list. +function LP:OnRitualStarted() + local p = self.pending + if not p or p.started then return end + p.started = true + p.failed = nil + local O = LockPortOptions + local name = p.name + local shards = self:CountShards() - 1 + if shards < 0 then shards = 0 end + + local where = self:WhereText() + + local msg + if O.customMessage and O.message and O.message ~= "" then + msg = O.message + msg = string.gsub(msg, "{name}", EscapeRepl(name)) + msg = string.gsub(msg, "{zone}", EscapeRepl(where)) + msg = string.gsub(msg, "{shards}", shards) + else + msg = "Summoning " .. name + if O.zone then msg = msg .. " to " .. where end + if O.shards then msg = msg .. " [" .. shards .. " shards left]" end + msg = msg .. ", please click the portal!" + end + SendChatMessage(msg, "SAY") + + if O.whisper then + local w = "Summoning you" + if O.zone then w = w .. " to " .. where end + SendChatMessage(w .. ". Accept when the prompt shows up!", "WHISPER", nil, name) + end + + self:RemoveRequest(name, true) + self:UpdateUI() +end + +function LP:FinishPending() + local p = self.pending + if not p or p.done then return end + p.done = true + if p.started and not p.interrupted then + self.watch = { name = p.name, unit = p.unit, t = GetTime() } + self:Print("ritual for " .. p.name .. " complete - waiting for them to accept.") + elseif p.started and p.interrupted then + -- put them back on everyone's list + self:AddRequest(p.name, true) + self:Fail(p.name, "interrupt") + else + if p.err then self:Fail(p.name, nil, p.err) else self:Fail(p.name, "nostart") end + end + self:NoteResult(p.name, p.started and not p.interrupted) + self:UpdateUI() +end + +-- Called every frame from the event frame. +function LP:OnUpdate(elapsed) + local now = GetTime() + if self.whisperQueue[1] then self:ProcessWhisperQueue(now) end + local p = self.pending + if p and not p.done then + if not p.started then + if p.failed and now - p.failT >= (p.hardFail and 0.25 or 0.5) then + self:FinishPending() + elseif now - p.t > 4 then + self:FinishPending() + end + else + if p.stopT and now - p.stopT >= 0.4 then + self:FinishPending() + elseif p.interrupted and now - p.intT >= 0.6 then + self:FinishPending() + end + end + end + + self.tick = (self.tick or 0) + elapsed + if self.tick < 1 then return end + self.tick = 0 + + -- did the last summoned player arrive? + local w = self.watch + if w then + if now - w.t > 180 then + self.watch = nil + elseif UnitName(w.unit) == w.name and self:IsNearby(w.unit) then + self:Print("|cff40ff40" .. w.name .. " has arrived.|r") + self.watch = nil + end + end + if table.getn(self.requests) > 0 then + self:PruneRequests() + end + self:OpenAnnounce(now) + self:CheckReady() + self:HelperCall(now) + self:UpdateUI() +end + +--------------------------------------------------------------------------- +-- Status whispers ("you're already here", "you're #3 in line...") +-- +-- Every LockPort warlock in the group sees the same "123", so replies are +-- coordinated: the warlock summoning that player answers at once, the others +-- wait a staggered delay (alphabetical among known LockPort warlocks) and +-- drop their reply if someone else's LPWhisper claim arrives first. +--------------------------------------------------------------------------- +function LP:OnTrigger(name, info, event) + if name == UnitName("player") then return end + if not self:IsWarlock() then + -- non-warlocks with LockPort just relay the request + self:SendAddon(PREFIX_ADD, name) + return + end + + -- already standing next to me + if info.online and self:IsNearby(info.unit) then + if self:FindRequest(name) then self:RemoveRequest(name, true) end + self:QueueWhisper(name, "here", self:HereText(info.unit)) + return + end + + local p = self.pending + if self:FindRequest(name) or (p and not p.done and p.name == name) then + -- repeat request: tell them why they're still waiting + self:SendAddon(PREFIX_ADD, name) + self:QueueWhisper(name, "status", self:StatusText(name, info)) + return + end + + local w = self.watch + local recent = w and w.name == name and GetTime() - w.t < 120 + if self:AddRequest(name, true) then + if recent then + self:QueueWhisper(name, "status", "I cast your summon " .. math.floor(GetTime() - w.t) .. + "s ago - accept the prompt if you still see it. I put you back on the list in case it expired.") + self.watch = nil + elseif event == "CHAT_MSG_WHISPER" then + local pos = self:QueuePosition(name) + SendChatMessage(ANNOUNCE_TAG .. "You're on the summon list" .. (pos and (" (#" .. pos .. ")") or "") .. ".", "WHISPER", nil, name) + end + end +end + +function LP:QueuePosition(name) + self:ScanRoster() + for i, e in ipairs(self:SortedRequests()) do + if e.name == name then return i end + end + return nil +end + +function LP:HereText(unit) + local d = self:Distance(unit) + if d then + return "You're already here (" .. math.floor(d) .. " yd from me) - no summon needed." + end + return "You're already standing next to me - no summon needed." +end + +function LP:StatusText(name, info) + local u = info.unit + local p = self.pending + if p and not p.done and p.name == name then + if not p.started then return "Casting your summon right now - accept the prompt when it shows up." end + local h = self:CountHelpers(name) + if h >= 2 then + return "Summoning you now - the portal is up, waiting for 2 people to click it. Accept the prompt when it shows up." + end + return "Summoning you now - waiting for helpers to click the portal (" .. h .. "/2 near me). Accept the prompt when it shows up." + end + + local pos = self:QueuePosition(name) + local line = pos and ("You're #" .. pos .. " in line") or "You're on the list" + if UnitIsDeadOrGhost(u) then return line .. ", but you're dead - release or get a res first." end + if UnitAffectingCombat(u) then return line .. ", but you're in combat - drop combat first." end + local inst = self:InstanceBlock(info) + if inst then return line .. ", but you're inside " .. inst .. " - zone out first." end + if p and not p.done then return line .. " - I'm summoning " .. p.name .. " right now." end + if UnitAffectingCombat("player") then return line .. " - I'm in combat, I'll summon you right after." end + if self:CountShards() < 1 then return line .. " - I'm out of Soul Shards." end + local h = self:CountHelpers(name) + if h < 2 then + return line .. " - waiting on helpers: I need 2 people near me to click the portal (" .. h .. " here)." + end + if pos and pos > 1 then + return line .. " - " .. (pos - 1) .. " ahead of you." + end + return line .. " - you're next!" +end + +function LP:ResponderDelay(name) + local p = self.pending + if p and not p.done and p.name == name then return 0 end + local me = UnitName("player") + local locks, seenMe = {}, false + for n, info in pairs(self.roster) do + if info.class == "WARLOCK" and info.online and (n == me or self.users[n]) then + table.insert(locks, n) + if n == me then seenMe = true end + end + end + if not seenMe then table.insert(locks, me) end + table.sort(locks) + for i, n in ipairs(locks) do + if n == me then return 0.4 + (i - 1) * 1.0 end + end + return 0.4 +end + +function LP:QueueWhisper(name, kind, text) + if not LockPortOptions.whisperStatus then return end + local key = kind .. ":" .. name + local now = GetTime() + if self.claims[key] and now - self.claims[key] < 12 then return end + for _, q in ipairs(self.whisperQueue) do + if q.key == key then return end + end + table.insert(self.whisperQueue, { key = key, name = name, text = text, at = now + self:ResponderDelay(name) }) +end + +function LP:ProcessWhisperQueue(now) + local q = self.whisperQueue + for i = table.getn(q), 1, -1 do + local w = q[i] + if self.claims[w.key] and now - self.claims[w.key] < 12 then + table.remove(q, i) + elseif now >= w.at then + table.remove(q, i) + self.claims[w.key] = now + self:SendAddon(PREFIX_WHISPER, w.key) + SendChatMessage(ANNOUNCE_TAG .. w.text, "WHISPER", nil, w.name) + end + end +end + +--------------------------------------------------------------------------- +-- Events +--------------------------------------------------------------------------- +function LP:MatchesTrigger(msg) + msg = string.lower(Trim(msg)) + local triggers = LockPortOptions and LockPortOptions.triggers or DEFAULTS.triggers + for word in string.gfind(triggers, "[^,]+") do + word = string.lower(Trim(word)) + if word ~= "" then + local len = string.len(word) + if string.sub(msg, 1, len) == word then + local nextc = string.sub(msg, len + 1, len + 1) + if nextc == "" or not string.find(nextc, "%w") then return true end + end + end + end + return false +end + +local CHAT_EVENTS = { + CHAT_MSG_SAY = true, CHAT_MSG_YELL = true, CHAT_MSG_WHISPER = true, + CHAT_MSG_PARTY = true, CHAT_MSG_RAID = true, CHAT_MSG_RAID_LEADER = true, +} + +function LP:OnEvent(event) + if event == "VARIABLES_LOADED" then return self:Initialize() end + if not LockPortOptions then return end + if CHAT_EVENTS[event] then + if arg1 and arg2 and self:MatchesTrigger(arg1) then + local info = self:ScanRoster()[arg2] + if info then self:OnTrigger(arg2, info, event) end + end + + elseif event == "CHAT_MSG_ADDON" then + if arg1 == PREFIX_ADD or arg1 == PREFIX_REMOVE or arg1 == PREFIX_VERSION or arg1 == PREFIX_WHISPER then + if arg4 then self.users[arg4] = true end + end + if arg1 == PREFIX_ADD then + -- another warlock added someone who is standing next to me: they don't need a summon + local info = self:ScanRoster()[arg2] + if self:IsWarlock() and LockPortOptions.autoRemove and info and info.online and self:IsNearby(info.unit) then + self:SendAddon(PREFIX_REMOVE, arg2) + else + self:AddRequest(arg2, false) + end + elseif arg1 == PREFIX_REMOVE then + self:RemoveRequest(arg2, false) + elseif arg1 == PREFIX_VERSION then + self:OnVersionMessage(arg2) + elseif arg1 == PREFIX_WHISPER then + -- another LockPort warlock already answered / is summoning this player + if arg4 ~= UnitName("player") then self.claims[arg2] = GetTime() end + end + + -- cast tracking (works on a plain client) -------------------------------- + elseif event == "UI_ERROR_MESSAGE" then + local p = self.pending + if p and not p.done and not p.started and GetTime() - p.t < 4 then + p.err = arg1 + if not p.failed then p.failed, p.failT = true, GetTime() end + end + elseif event == "SPELLCAST_START" then + if arg1 == self.SPELL then self:OnRitualStarted() end + elseif event == "SPELLCAST_CHANNEL_START" then + local p = self.pending + if p and not p.done and not p.started and GetTime() - p.t < 4 then self:OnRitualStarted() end + elseif event == "SPELLCAST_FAILED" or event == "SPELLCAST_INTERRUPTED" then + local p = self.pending + if p and not p.done then + if p.started then + p.interrupted, p.intT = true, GetTime() + else + p.failed, p.failT, p.hardFail = true, GetTime(), true + end + end + elseif event == "SPELLCAST_CHANNEL_STOP" then + local p = self.pending + if p and not p.done and p.started then p.stopT = GetTime() end + + -- SuperWoW: exact, GUID-matched cast events --------------------------------- + elseif event == "UNIT_CASTEVENT" then + local p = self.pending + if p and not p.done and arg1 == self:MyGUID() and self:IsRitualId(arg4) then + if arg3 == "START" or arg3 == "CHANNEL" then + self:OnRitualStarted() + elseif arg3 == "FAIL" then + if p.started then p.interrupted, p.intT = true, GetTime() + else p.failed, p.failT, p.hardFail = true, GetTime(), true end + end + end + + -- Nampower: server-confirmed failures ------------------------------------ + elseif event == "SPELL_FAILED_SELF" then + local p = self.pending + if p and not p.done and self:IsRitualId(arg1) then + if p.started then p.interrupted, p.intT = true, GetTime() + else p.failed, p.failT, p.hardFail = true, GetTime(), true end + end + + elseif event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED" then + self:PruneRequests() + self:UpdateUI() + self:OnGroupChanged() + elseif event == "SPELLS_CHANGED" or event == "LEARNED_SPELL_IN_TAB" then + self.knowsRitual = nil + elseif event == "BAG_UPDATE" then + if LockPortFrame and LockPortFrame:IsVisible() then self:UpdateUI() end + elseif event == "PLAYER_ENTERING_WORLD" then + self:SendVersion(true) + end +end + +function LP:Initialize() + if type(LockPortOptions) ~= "table" then LockPortOptions = {} end + local O = LockPortOptions + -- v2 saved a message without the toggle + if O.customMessage == nil and O.message and O.message ~= "" then O.customMessage = true end + for k, v in pairs(DEFAULTS) do + if O[k] == nil then O[k] = v end + end + self:DetectMods() + local ev = self.eventFrame + if self.mods.superwow then ev:RegisterEvent("UNIT_CASTEVENT") end + if self.mods.nampower then ev:RegisterEvent("SPELL_FAILED_SELF") end + if self.CreateUI then self:CreateUI() end + self:Print("v" .. tostring(GetAddOnMetadata("LockPort", "Version")) .. " loaded. Type |cffffd200/lockport|r to open, |cffffd200/lockport help|r for commands.") +end + +--------------------------------------------------------------------------- +-- Version check (compatible with v2's "lp" VERSION: messages) +--------------------------------------------------------------------------- +local function VersionNumber(v) + local _, _, a, b, c = string.find(tostring(v or ""), "(%d+)%.(%d+)%.?(%d*)") + return (tonumber(a) or 0) * 10000 + (tonumber(b) or 0) * 100 + (tonumber(c) or 0) +end + +function LP:SendVersion(login) + local v = "VERSION:" .. VersionNumber(GetAddOnMetadata("LockPort", "Version")) + self:SendAddon(PREFIX_VERSION, v) + if login and IsInGuild() then SendAddonMessage(PREFIX_VERSION, v, "GUILD") end +end + +function LP:OnGroupChanged() + local size = GetNumRaidMembers() + if size == 0 then size = GetNumPartyMembers() end + if size > (self.groupSize or 0) then self:SendVersion(false) end + self.groupSize = size +end + +function LP:OnVersionMessage(msg) + local _, _, kind, num = string.find(msg or "", "^(%a+):(%d+)") + if kind ~= "VERSION" then return end + local remote = tonumber(num) or 0 + if remote > VersionNumber(GetAddOnMetadata("LockPort", "Version")) and not self.updateShown then + self.updateShown = true + self:Print("a newer version is available: " .. REPO_URL) + end +end + +--------------------------------------------------------------------------- +-- Slash commands +--------------------------------------------------------------------------- +local TOGGLES = { + zone = "zone info in /say", whisper = "whisper the target", shards = "shard count in /say", + sound = "sound on new request", announce = "announce failures to group", + flash = "taskbar flash", autoremove = "auto-remove arrived players", lock = "window lock", + status = "status whispers (already here / place in line)", + helpers = "ask the raid for portal helpers", +} +local TOGGLE_KEYS = { announce = "announceFail", autoremove = "autoRemove", lock = "locked", status = "whisperStatus", helpers = "askHelpers" } + +function LP:Slash(msg) + msg = Trim(msg) + local _, _, cmd, rest = string.find(msg, "^(%S*)%s*(.-)$") + cmd = string.lower(cmd or "") + local O = LockPortOptions + + if cmd == "" or cmd == "toggle" then + self:Toggle() + elseif cmd == "help" then + self:Print("commands:") + local lines = { + "|cffffd200/lockport|r - show / hide the window", + "|cffffd200/lockport next|r - summon the next person in line", + "|cffffd200/lockport open|r - toggle the \"summons are open\" raid posts", + "|cffffd200/lockport add [name]|r - add a name (or your target)", + "|cffffd200/lockport remove |r, |cffffd200clear|r - edit the list", + "|cffffd200/lockport show|r - print the list", + "|cffffd200/lockport options|r - open the options window", + "|cffffd200/lockport message |r or |cffffd200message clear|r - custom /say ({name} {zone} {shards})", + "|cffffd200/lockport zone, whisper, shards, sound, announce, status, helpers, flash, autoremove, lock|r - toggles", + "|cffffd200/lockport mods|r - which client mods LockPort is using", + "|cffffd200/lockport style|r - switch window style (Classic / pfUI)", + "|cffffd200/lockport reset|r - reset the window position", + } + for _, l in ipairs(lines) do DEFAULT_CHAT_FRAME:AddMessage(" " .. l) end + elseif cmd == "next" or cmd == "summon" then + if rest ~= "" then self:Summon(rest) else self:SummonNext() end + elseif cmd == "add" then + local name = rest + if name == "" then name = UnitName("target") end + if not name then return self:Print("target someone or use /lockport add .") end + if not self:ScanRoster()[name] then return self:Print(name .. " isn't in your group.") end + if not self:AddRequest(name, true) then self:Print(name .. " is already on the list.") end + elseif cmd == "remove" then + self:RemoveRequest(rest, true) + elseif cmd == "clear" then + self:ClearRequests() + self:Print("summon list cleared.") + elseif cmd == "show" then + if not self.requests[1] then return self:Print("the list is empty.") end + for i, r in ipairs(self.requests) do DEFAULT_CHAT_FRAME:AddMessage(" " .. i .. ". " .. r.name) end + elseif cmd == "options" or cmd == "settings" or cmd == "config" then + self:ToggleOptions() + elseif cmd == "message" then + if string.lower(rest) == "clear" then rest = "" end + O.message = rest + O.customMessage = rest ~= "" + self:Print("custom /say message " .. (rest ~= "" and ("set: " .. rest) or "cleared.")) + self:RefreshOptions() + elseif cmd == "style" or cmd == "theme" then + self:ToggleTheme() + elseif cmd == "open" then + O.announceOpen = not O.announceOpen + self:Print("\"summons are open\" announcements: " .. OnOff(O.announceOpen)) + if O.announceOpen then self:OpenAnnounce(GetTime(), true) end + self:RefreshOptions() + elseif cmd == "mods" then + self:PrintMods() + elseif cmd == "reset" then + O.pos = nil + if LockPortFrame then + LockPortFrame:ClearAllPoints() + LockPortFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + end + elseif TOGGLES[cmd] then + local key = TOGGLE_KEYS[cmd] or cmd + O[key] = not O[key] + self:Print(TOGGLES[cmd] .. ": " .. OnOff(O[key])) + self:RefreshOptions() + else + self:Print("unknown command. Type /lockport help.") + end +end + +--------------------------------------------------------------------------- +-- UI hooks (implemented in LockPort_UI.lua) +--------------------------------------------------------------------------- +function LP:UpdateUI() if self.RefreshList then self:RefreshList() end end +function LP:RefreshOptions() if self.RefreshOptionsUI then self:RefreshOptionsUI() end end +function LP:Toggle() + if not LockPortFrame then return end + if LockPortFrame:IsVisible() then LockPortFrame:Hide() else LockPortFrame:Show() end +end +function LP:ToggleOptions() + if not LockPortOptionsFrame then return end + if LockPortOptionsFrame:IsVisible() then LockPortOptionsFrame:Hide() else LockPortOptionsFrame:Show() end +end + +-- Key bindings (Bindings.xml) +BINDING_HEADER_LOCKPORT = "LockPort" +BINDING_NAME_LOCKPORT_SUMMON_NEXT = "Summon next in line" +BINDING_NAME_LOCKPORT_TOGGLE = "Show / hide window" + +--------------------------------------------------------------------------- +-- Boot +--------------------------------------------------------------------------- +local ev = CreateFrame("Frame", "LockPortEventFrame") -- not parented to UIParent so Alt+Z doesn't stop it +LP.eventFrame = ev +local EVENTS = { + "VARIABLES_LOADED", "PLAYER_ENTERING_WORLD", "CHAT_MSG_ADDON", + "CHAT_MSG_SAY", "CHAT_MSG_YELL", "CHAT_MSG_WHISPER", + "CHAT_MSG_PARTY", "CHAT_MSG_RAID", "CHAT_MSG_RAID_LEADER", + "UI_ERROR_MESSAGE", "SPELLCAST_START", "SPELLCAST_CHANNEL_START", "SPELLCAST_CHANNEL_STOP", + "SPELLCAST_FAILED", "SPELLCAST_INTERRUPTED", + "RAID_ROSTER_UPDATE", "PARTY_MEMBERS_CHANGED", "SPELLS_CHANGED", "LEARNED_SPELL_IN_TAB", "BAG_UPDATE", +} +for _, e in ipairs(EVENTS) do ev:RegisterEvent(e) end +ev:SetScript("OnEvent", function() LP:OnEvent(event) end) +ev:SetScript("OnUpdate", function() LP:OnUpdate(arg1 or 0) end) + +SLASH_LOCKPORT1 = "/lockport" +SLASH_LOCKPORT2 = "/gurky" +SlashCmdList["LOCKPORT"] = function(msg) LP:Slash(msg) end diff --git a/tests/native_tests.c b/tests/native_tests.c new file mode 100644 index 0000000..baac15d --- /dev/null +++ b/tests/native_tests.c @@ -0,0 +1,146 @@ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +static unsigned char *client; +static const char *clientPath="WoW.exe"; +static HMODULE WINAPI TestGetModuleHandleA(LPCSTR name) { + return name ? GetModuleHandleA(name) : (HMODULE)client; +} +static DWORD WINAPI TestGetModuleFileNameA(HMODULE module, LPSTR dest, DWORD length) { + if (module) return GetModuleFileNameA(module,dest,length); + if(strlen(clientPath)+1>length)return 0; + strcpy(dest,clientPath); return (DWORD)strlen(dest); +} +#define GetModuleHandleA TestGetModuleHandleA +#define GetModuleFileNameA TestGetModuleFileNameA +#include "../native/LockPort.c" +#undef GetModuleHandleA +#undef GetModuleFileNameA + +static LuaFunction versionAPI, summonAPI; +static int registrations, originalCalls, executions, booleanValue, topRestores; +static const char *inputUnit; +static char versionValue[32]; +static void __stdcall FakeOriginal(void) { originalCalls++; } +static void __fastcall FakeRegister(const char *name,LuaFunction fn) { + if(!strcmp(name,"GetLockPortVersion"))versionAPI=fn; + else if(!strcmp(name,"LockPortNativeSummon"))summonAPI=fn; + else assert(0); + registrations++; +} +static const char *__fastcall FakeToString(void *s,int index) { (void)s;assert(index==1);return inputUnit; } +static void __fastcall FakePushString(void *s,const char *text) { (void)s;strcpy(versionValue,text); } +static void __fastcall FakePushBoolean(void *s,int b) { (void)s;booleanValue=b; } +static int __fastcall FakeGetTop(void *s) { (void)s;return 1; } +static void __fastcall FakeSetTop(void *s,int top) { (void)s;assert(top==1);topRestores++; } +static uint64_t __fastcall FakeGuid(const char *unit) { + assert(!strcmp(unit,"raid2") || !strcmp(unit,"party4")); + return UINT64_C(0x1122334455667788); +} +static int32_t __fastcall FakeSlot(const char *spell, uint32_t *book) { + assert(!strcmp(spell,"Ritual of Summoning")); + *book=0;return 7; +} +static void __fastcall FakeBookCast(uint32_t slot,uint32_t book,uint64_t guid) { + assert(slot==7&&book==0&&guid==UINT64_C(0x1122334455667788)); + // Model the real action gate: a frame-script context refuses the cast. + if (*(uintptr_t *)0xCEEAC0 || *(uint32_t *)0xCEEAC4) return; + executions++; +} +static void redirect(uintptr_t address, void *fn) { relativeJump((unsigned char *)address,fn); } +static void loadClient(void) { + // Reserve only the handful of code regions used by these tests. + // A full low-address image can overlap the test process's CRT heap. + const uintptr_t regions[]={0x400000,0x420000,0x490000,0x4b0000,0x510000,0x6f0000,0x700000,0xce0000}; + for(unsigned i=0;ie_lfanew); + memcpy(client,raw,nt->OptionalHeader.SizeOfHeaders); + IMAGE_SECTION_HEADER *sections=IMAGE_FIRST_SECTION(nt); + for(unsigned j=1;jFileHeader.NumberOfSections;i++){ + IMAGE_SECTION_HEADER *s=sections+i; + if(rva>=s->VirtualAddress && rva+0x10000<=s->VirtualAddress+s->SizeOfRawData) + memcpy((void *)regions[j],raw+s->PointerToRawData+rva-s->VirtualAddress,0x10000); + } + } + free(raw); +} +static void stubFunctions(void) { + redirect(0x704120,(void *)(uintptr_t)&FakeRegister); + redirect(0x6f3690,(void *)(uintptr_t)&FakeToString); + redirect(0x6f3890,(void *)(uintptr_t)&FakePushString); + redirect(0x6f39f0,(void *)(uintptr_t)&FakePushBoolean); + redirect(0x6f3070,(void *)(uintptr_t)&FakeGetTop); + redirect(0x6f3080,(void *)(uintptr_t)&FakeSetTop); + redirect(0x515970,(void *)(uintptr_t)&FakeGuid); + redirect(0x4b3950,(void *)(uintptr_t)&FakeSlot); + redirect(0x4b3300,(void *)(uintptr_t)&FakeBookCast); +} +int main(int argc, char **argv) { + if (argc > 1) clientPath = argv[1]; + loadClient(); + // The actual built DLL must reject this unrelated host executable. + const char *dllPath = argc > 2 ? argv[2] : "LockPort.dll"; + assert(GetFileAttributesA(dllPath) != INVALID_FILE_ATTRIBUTES); + HMODULE dll=LoadLibraryA(dllPath); + DWORD loadError=GetLastError(); + assert(!dll && loadError==ERROR_DLL_INIT_FAILED); + puts("PASS DLL rejects an unsupported host"); + assert(matchesClient()); + puts("PASS inspected client function signatures"); + assert(validUnit("raid1")&&validUnit("raid40")&&validUnit("party1")&&validUnit("party4")); + const char *invalid[]={"player","party0","party5","party01","party1x","raid0","raid41","raid01","raid2x","raid2\");print(1)--","raid",NULL}; + for(unsigned i=0;i0 or partySize>0 end +LP.CountHelpers=function() return 2 end +LockPortOptions.theme="classic" +''') +L.execute((PACKAGE/"LockPort_UI.lua").read_text(encoding="utf-8")) +L.execute(r''' +LP:CreateUI() +LockPortFrame:Show();LP:RefreshList() +assert(LockPortFrame.raidAuto.visible) +assert(LockPortFrame.raidAuto.width==95 and LockPortFrame.groupSummon.width==95) +assert(LockPortFrame.groupSummon.visible and LockPortFrame.groupSummon.point[2]==LockPortFrame.raidAuto) +LP:ToggleGroups();assert(LockPortGroupsFrame.visible) +this=LockPortGroupsNone;this.scripts.OnClick();assert(LockPortOptions.summonGroups and not next(LockPortOptions.summonGroups)) +this=LockPortGroupsFrame.groups[3];this.scripts.OnClick();assert(LockPortOptions.summonGroups[3]) +this=LockPortGroupsAll;this.scripts.OnClick();assert(not LockPortOptions.summonGroups) +assert(LockPortFrame.summon.width==95 and LockPortFrame.chatToggle.width==95) +this=LockPortFrame.chatToggle;this.scripts.OnClick();assert(LockPortOptions.chatMuted) +LP:RefreshList();assert(string.find(this.text,"Off")) +this.scripts.OnClick();assert(not LockPortOptions.chatMuted) +local nativeHeight=LockPortFrame.height +assert(LockPortFrame.raidAuto.point[1]=="TOPLEFT") +assert(LockPortFrame.raidAuto.point[3]=="BOTTOMLEFT" and LockPortFrame.raidAuto.point[5]==-2) +GetLockPortVersion=nil;LP:RefreshList() +assert(not LockPortFrame.raidAuto.visible and LockPortFrame.groupSummon.visible and LockPortFrame.height==nativeHeight) +GetLockPortVersion=function() return "1.2.1" end;raidSize=0;LP:RefreshList() +assert(LockPortFrame.raidAuto.visible and not LockPortFrame.raidAuto.enabled) +assert(LockPortFrame.groupSummon.visible and not LockPortFrame.groupSummon.enabled and not LockPortGroupsFrame.visible) +partySize=3;LP:RefreshList();assert(LockPortFrame.groupSummon.visible and not LockPortFrame.groupSummon.enabled) +GetLockPortVersion=nil;LP:RefreshList();assert(LockPortFrame.groupSummon.visible and not LockPortFrame.groupSummon.enabled and LockPortFrame.height==nativeHeight) +GetLockPortVersion=function() return "1.2.1" end;partySize=0 +raidSize=5;LP:RefreshList();assert(LockPortFrame.raidAuto.enabled and LockPortFrame.groupSummon.enabled) +this=LockPortFrame.raidAuto;this.checked=true +this.scripts.OnClick() +assert(LP.raidAutoSummon and LockPortOptions.announceOpen) +''') +print("PASS UI native-only visibility, expanded layout, raid gating, and button toggle") + +check("party enables automatic casting",r'''raidSize=0;partySize=3;LP:SetRaidAutoSummon(true);assert(LP.raidAutoSummon and LockPortOptions.announceOpen);LP:CheckRaidAutoSummon(100);LP:CheckRaidAutoSummon(102);assert(casts==1 and wasAutomatic)''') +check("raid to party conversion stays enabled",r'''LP:SetRaidAutoSummon(true);raidSize=0;partySize=3;LP:CheckRaidAutoSummon(100);assert(LP.raidAutoSummon);LP:CheckRaidAutoSummon(102);assert(casts==1)''') +check("leaving party disables and restores announcements",r'''raidSize=0;partySize=3;LP:SetRaidAutoSummon(true);partySize=0;LP:CheckRaidAutoSummon(101);assert(not LP.raidAutoSummon and not LockPortOptions.announceOpen)''') +check("old raid-only DLL is rejected",r'''GetLockPortVersion=function() return "1.0.0" end;assert(not LP:HasNativeMod());LP:SetRaidAutoSummon(true);assert(not LP.raidAutoSummon)''') + +check("live idle clears stale busy flag",r'''LP.castBusy=true;LP:RecoverCastTracking(100);assert(not LP.castBusy)''') +check("missing stop event releases pending summon conservatively",r'''LP.pending={name="Bob",unit="party1",t=90,started=true};LP.FinishPending=function(self) assert(self.pending.interrupted);self.pending.done=true end;LP:RecoverCastTracking(100);assert(not LP.pending.done);LP:RecoverCastTracking(102);assert(LP.pending.done)''') +check("active channel is never cleared by recovery",r'''C_Spell.UnitChannelInfo=function() return "Ritual of Summoning" end;LP.pending={name="Bob",t=90,started=true};LP:RecoverCastTracking(100);LP:RecoverCastTracking(105);assert(not LP.pending.done and LP.castBusy)''') +check("unknown live state preserves tracking",r'''C_Spell=nil;LP.castBusy=true;LP.pending={name="Bob",t=90,started=true};LP:RecoverCastTracking(100);LP:RecoverCastTracking(105);assert(not LP.pending.done and LP.castBusy)''') +check("brief idle gap during casting is tolerated",r'''LP.pending={name="Bob",t=90,started=true};LP:RecoverCastTracking(100);casting="Ritual of Summoning";LP:RecoverCastTracking(101);assert(not LP.pending.inactiveSince and not LP.pending.done)''') + +# Exercise the real scheduler, candidate checks, Summon, and start-event handler together. +for unit in ("party2", "raid2"): + L=env();L.execute(core) + L.execute(r''' +LP=LockPort +raidSize='''+("0" if unit.startswith("party") else "5")+r''';partySize=3 +LP.Print=function() end;LP.UpdateUI=function() end;LP.RefreshOptions=function() end +LP.OpenAnnounce=function() end;LP.SendAddon=function() end +LP.KnowsRitual=function() return true end +LP.CountShards=function() return 5 end;LP.CountHelpers=function() return 2 end +LP.TargetProblem=function() return nil end +LP.roster={Bob={unit="'''+unit+r'''",online=true}} +LP.ScanRoster=function(self) return self.roster end +LP.SortedRequests=function(self) return {{name="Bob",info=self.roster.Bob}} end +LP.requests={{name="Bob",t=90}} +LockPortNativeSummon=function(unit) actualCasts=actualCasts+1;arg1=LP.SPELL;LP:OnEvent("SPELLCAST_START");return true,"spellbook request sent" end +actualCasts=0 +CastSpellByName=function(name) + actualCasts=actualCasts+1;arg1=name;LP:OnEvent("SPELLCAST_START") +end +LP:SetRaidAutoSummon(true) +LP:CheckRaidAutoSummon(100) +now=102;LP:CheckRaidAutoSummon(now) +assert(actualCasts==1 and LP.pending.started and LP.pending.automatic) +assert(not LP.requests[1] and #LP.autoTrace>0) +''') + print("PASS integrated automatic "+unit+" cast receives start event") + +check("off clears announcements even when already off",r'''LP.raidAutoSummon=false;LockPortOptions.announceOpen=true;LP:SetRaidAutoSummon(false);assert(not LP.raidAutoSummon and not LockPortOptions.announceOpen)''') +# Simulate SavedVariables surviving a reload while addon runtime state is recreated. +for enabled_by_auto in (True, False): + before=env() + if enabled_by_auto: + before.execute("LP:SetRaidAutoSummon(true)") + else: + before.execute("LockPortOptions.announceOpen=true") + after=env() + after.globals().LockPortOptions.announceOpen=before.globals().LockPortOptions.announceOpen + after.execute(r''' + LP.DetectMods=function(self) self.mods={} end + LP.CreateUI=function() end + GetAddOnMetadata=function() return "test" end + LP:Initialize() + assert(not LP.raidAutoSummon and not LockPortOptions.announceOpen) + sent=0;SendChatMessage=function() sent=sent+1 end + ''') + # env overrides OpenAnnounce; restore the production method before exercising its timer gate. + after.execute(core) + after.execute("LockPort:OpenAnnounce(1000); assert(sent==0)") + print("PASS reload clears announcements", "auto" if enabled_by_auto else "manual") + +# Chat dispatch uses stable message/sender values and never replays an outer chat +# event while handling a nested spell or addon-sync event. +def chat_check(name, reader, globals_text="123", globals_sender="Bob"): + L=env() + L.globals().arg1=globals_text; L.globals().arg2=globals_sender + L.execute(r''' + LP.MatchesTrigger=function(self,text) return text=="123" end + LP.ScanRoster=function() arg1="clobbered";arg2="Wrong";return {Bob={unit="raid2"}} end + LP.OnTrigger=function(self,name,info,event) triggered=(triggered or 0)+1;assert(name=="Bob" and info.unit=="raid2");triggerEvent=event end + ''') + L.execute(reader) + L.execute('LP:OnEvent("CHAT_MSG_RAID");assert(triggered==1 and triggerEvent=="CHAT_MSG_RAID")') + print("PASS",name) +chat_check("ClassicAPI chat payload survives overwritten globals",'GetCurrentChatEvent=function() return "CHAT_MSG_RAID","123","Bob" end',"wrong","Wrong") +chat_check("legacy chat fallback snapshots sender before roster scan",'GetCurrentChatEvent=nil') +chat_check("empty ClassicAPI chat result uses legacy fallback",'GetCurrentChatEvent=function() end') +chat_check("ClassicAPI chat errors use legacy fallback",'GetCurrentChatEvent=function() error("unavailable") end') +check("non-request actual payload does not use corrupted trigger globals",r'''GetCurrentChatEvent=function() return "CHAT_MSG_RAID","hello","Bob" end;arg1="123";arg2="Bob";LP.OnTrigger=function() error("false request") end;LP:OnEvent("CHAT_MSG_RAID")''') +check("nested spell event does not replay outer chat",r'''GetCurrentChatEvent=function() return "CHAT_MSG_RAID","123","Bob" end;LP.OnTrigger=function() error("duplicate request") end;arg1="Shadow Bolt";LP:OnEvent("SPELLCAST_START");assert(LP.castBusy)''') +check("addon sync payload is not replaced by chat reader",r'''GetCurrentChatEvent=function() error("must not read addon sync") end;LP.AddRequest=function(self,name) assert(name=="Bob");synced=true end;LP.ScanRoster=function() return {} end;arg1="RSAdd";arg2="Bob";arg4="Other";LockPortOptions.autoRemove=false;LP:OnEvent("CHAT_MSG_ADDON");assert(synced)''') + +check("mute blocks summon say and whisper then resumes",r'''LP.CountShards=function() return 5 end;LP.RemoveRequest=function() end;LP.SendAddon=function() end;sent=0;SendChatMessage=function() sent=sent+1 end;LockPortOptions.whisper=true;LP:SetChatMuted(true);LP.pending={name="Bob"};LP:OnRitualStarted();assert(sent==0 and LP.pending.started);LP:SetChatMuted(false);LP.pending={name="Bob"};LP:OnRitualStarted();assert(sent==2)''') +check("mute clears pending status whispers and skips new replies",r'''LP.whisperQueue={{key="status:Bob",name="Bob",text="old",at=0}};LP:SetChatMuted(true);assert(table.getn(LP.whisperQueue)==0);LockPortOptions.whisperStatus=true;LP:QueueWhisper("Bob","status","new");assert(table.getn(LP.whisperQueue)==0);LP:SetChatMuted(false);LP:ProcessWhisperQueue(100);assert(table.getn(LP.whisperQueue)==0)''') +L=env();L.execute(core);L.execute(r'''LP=LockPort;LockPortOptions.chatMuted=true;GetNumRaidMembers=function() error("must stop before sending") end;LP:OpenAnnounce(100,true)''');print("PASS mute blocks forced availability announcements") + +check("raid group selection defaults to all and supports none",r'''assert(LP:IsSelectedRaidGroup({subgroup=8}));LP:SetSummonGroups({});assert(not LP:IsSelectedRaidGroup({subgroup=1}));LP:SetSummonGroups(nil);assert(LP:IsSelectedRaidGroup({subgroup=1}))''') +check("group selection ignores party roster",r'''raidSize=0;partySize=3;LP:SetSummonGroups({[8]=true});assert(LP:IsSelectedRaidGroup({unit="party1"}))''') +check("group selection releases outstanding reservation",r'''LP.coordReservation={id=1,target="Bob",phase="reserve"};LP.autoReadyName="Bob";LP:SetSummonGroups({[2]=true});assert(not LP.coordReservation and not LP.autoReadyName);assert(LP:IsSelectedRaidGroup({subgroup=2}) and not LP:IsSelectedRaidGroup({subgroup=1}))''') +# Parse the real raid roster's third field and immediately follow subgroup moves. +L=env();L.execute(r'''raidSize=1;group=4;function GetRaidRosterInfo() return "Bob",0,group,60,"Warrior","WARRIOR","Test",true,false end;LP:SetSummonGroups({[4]=true});assert(LP:IsSelectedRaidGroup(LP:ScanRoster().Bob));group=5;assert(not LP:IsSelectedRaidGroup(LP:ScanRoster().Bob))''');print("PASS real raid roster subgroup parsing and subgroup changes") +# Exercise real candidate selection and direct casts against an assignment. +L=env();L.execute(core);L.execute(r''' +LP=LockPort;LP.Print=function() end;LP.UpdateUI=function() end;LP.SendAddon=function() end +LP.roster={Alice={unit="raid2",subgroup=1,online=true},Bob={unit="raid3",subgroup=2,online=true}} +LP.ScanRoster=function(self) return self.roster end;LP.TargetProblem=function() return nil end +LP.CountShards=function() return 5 end;LP.CountHelpers=function() return 2 end;LP.KnowsRitual=function() return true end +LP.requests={{name="Alice",t=1},{name="Bob",t=2}} +LP:SetSummonGroups({[2]=true});assert(LP:NextCandidate().name=="Bob") +LP:Summon("Alice");assert(not LP.pending and not manualCalls) +LP:SummonNext();assert(LP.pending.name=="Bob" and manualCalls==1) +LP.pending=nil;LP:SetSummonGroups({});assert(not LP:NextCandidate()) +''');print("PASS Summon and direct row casts respect raid group selection") + +check("saved raid group selection survives initialization",r'''LockPortOptions.summonGroups={[2]=true,[7]=true};LP.DetectMods=function(self) self.mods={} end;LP.CreateUI=function() end;GetAddOnMetadata=function() return "3.9.0" end;LP:Initialize();assert(LockPortOptions.summonGroups[2] and LockPortOptions.summonGroups[7] and not LockPortOptions.summonGroups[1])''') diff --git a/tests/test_coordination-results.txt b/tests/test_coordination-results.txt new file mode 100644 index 0000000..bb8d9f5 --- /dev/null +++ b/tests/test_coordination-results.txt @@ -0,0 +1,35 @@ +PASS reproduced duplicate cast with two uncoordinated 3.7.8 clients +PASS 2 warlocks split requests in raid [(103.3, 'Alpha', 'Target1'), (104.4, 'Bravo', 'Target2')] +PASS 2 warlocks split requests in party [(103.3, 'Alpha', 'Target1'), (104.4, 'Bravo', 'Target2')] +PASS 3 warlocks split requests in raid [(103.3, 'Alpha', 'Target1'), (104.4, 'Bravo', 'Target2'), (105.5, 'Charlie', 'Target3')] +PASS 3 warlocks split requests in party [(103.3, 'Alpha', 'Target1'), (104.4, 'Bravo', 'Target2')] +PASS 40 randomized delayed/reordered three-client runs, five requests each +PASS delayed acknowledgements prevent early cast +PASS lost acknowledgements hold casting; recovery after delivery resumes +PASS unready warlock excluded: moving +PASS unready warlock excluded: noHelpers +PASS unready warlock excluded: noShards +PASS reservation handoff after off +PASS reservation handoff after disconnect +PASS reservation handoff after movement +PASS handoff after native reject +PASS handoff after native no-start +PASS old manual summon claim prevents new automatic duplicate +PASS bidirectional list synchronization with 3.7.8 +KNOWN LIMIT mixed new/3.7.8 simultaneous auto can still duplicate: [(103.3, 'Alpha', 'Target1'), (103.3, 'Bravo', 'Target1')] +KNOWN LIMIT total message loss before peer discovery can duplicate +PASS delayed ACK cannot authorize a newer reservation +PASS out-of-order release prevents a stale reservation from returning +PASS acceptance hold survives owner starting another target and later expires +PASS reload during reservation re-handshakes without duplicate cast +PASS updated manual click respects another updated warlock reservation +PASS malformed, non-warlock, outsider and wrong-channel messages ignored +PASS request initially visible to one warlock is shared and cast once +PASS 60 additional randomized three-client runs, six requests each +PASS original repository addon: list sync both directions and manual cast claim +PASS three warlocks queue assigned raid groups without requests and split six targets +PASS overlapping group assignments still coordinate without duplicate casts +PASS unassigned-group requests stay queued and are not summoned +PASS assignment change during reservation releases and reassigns targets +PASS group queue skips offline players, is idempotent, and handles no selection +PASS current raid subgroup moves are honored before casting diff --git a/tests/test_coordination.py b/tests/test_coordination.py new file mode 100644 index 0000000..cfed491 --- /dev/null +++ b/tests/test_coordination.py @@ -0,0 +1,390 @@ +"""Independent Lua clients; simulated WoW APIs and delayed addon-message bus. + +Runs the real scheduler, reservation, candidate, cast-start and queue code. +No WoW process, network server, or native DLL is executed. +""" +import heapq +import random +import sys +from pathlib import Path +from zipfile import ZipFile +sys.path.insert(0, str(Path('work/python-deps').resolve())) +from lupa.lua51 import LuaRuntime +PACKAGE = Path(__file__).resolve().parent.parent +CORE = (PACKAGE/'LockPort.lua').read_text(encoding='utf-8') +# Frozen pre-reservation code, used as a real mixed-version client. +LEGACY = (PACKAGE/'tests/fixtures/LockPort-3.7.8.lua').read_text(encoding='utf-8') + +class World: + def __init__(self, names=('Alpha', 'Bravo', 'Charlie'), targets=3, delay=0.2, seed=0, party=False): + self.now=100.0; self.rng=random.Random(seed); self.delay=delay + self.names=list(names); self.targets=['Target'+str(i+1) for i in range(targets)] + self.members=self.names+['HelperOne','HelperTwo']+self.targets + if party:self.members=self.names+self.targets+['HelperOne','HelperTwo'][:max(0,5-len(self.names)-len(self.targets))] + self.subgroups={n:8 for n in self.members}; self.subgroups.update({n:i+1 for i,n in enumerate(self.targets)}) + self.online={n:True for n in self.members}; self.clients={};self.events=[];self.serial=0 + self.casts=[];self.messages=[];self.drop=lambda *args:False;self.party=party + self.reject=set();self.no_start=set();self.extra_delay=lambda *args:0 + + def later(self, delay, fn): + self.serial+=1;heapq.heappush(self.events,(self.now+delay,self.serial,fn)) + + def send(self,sender,prefix,message,channel): + assert "|" not in message, "Invalid escape code in addon message: " + message + self.messages.append((self.now,sender,prefix,message)) + for receiver in self.clients: + if receiver==sender or not self.online.get(receiver):continue + if self.drop(sender,receiver,prefix,message):continue + delay=self.delay(self.rng) if callable(self.delay) else self.delay + delay+=self.extra_delay(sender,receiver,prefix,message) + self.later(delay,lambda r=receiver:self.deliver(r,prefix,message,channel,sender)) + + def deliver(self,receiver,prefix,message,channel,sender): + if not self.online.get(receiver):return + c=self.clients[receiver];g=c.globals() + g.arg1,g.arg2,g.arg3,g.arg4=prefix,message,channel,sender + c.execute('LockPort:OnEvent("CHAT_MSG_ADDON")') + + def roster(self,L,me): + return L.table_from({n:L.table_from(dict(unit=('party' if self.party else 'raid')+str(i+1), + online=self.online[n], subgroup=self.subgroups[n], **{'class':'WARLOCK' if n in self.names else 'WARRIOR'})) + for i,n in enumerate([n for n in self.members if not self.party or n!=me]) if self.online[n]}) + + def client(self,name,legacy=False,source=None): + L=LuaRuntime();g=L.globals();g.me=name;g.now=self.now + unit_names=[n for n in self.members if not self.party or n!=name] + g.pyUnitName=lambda u: name if u=='player' else unit_names[int(u[5:] if u.startswith('party') else u[4:])-1] + g.pyRoster=lambda:self.roster(L,name) + g.pySend=lambda prefix,msg,chan:self.send(name,prefix,msg,chan) + g.pyConnected=lambda unit:self.online.get(g.pyUnitName(unit),False) + g.pyNative=lambda unit:self.cast(name,unit) + L.execute(r''' + function GetTime() return now end + function UnitName(u) return pyUnitName(u) end + function UnitClass(u) return "Warlock","WARLOCK" end + function UnitIsConnected(u) return pyConnected(u) end + function UnitIsDeadOrGhost() return false end + function UnitAffectingCombat() return false end + function UnitIsVisible() return false end + function GetLockPortVersion() return "1.2.1" end + function LockPortNativeSummon(u) return pyNative(u) end + function CastSpellByName() return pyNative(LockPort.pending.unit) end + function TargetUnit() end + function GetUnitSpeed() return moving and 1 or 0 end + C_Spell={UnitCastingInfo=function() return nil end,UnitChannelInfo=function() return channel end} + function CreateFrame() return {RegisterEvent=function() end,SetScript=function() end} end + function SendAddonMessage(p,m,c) pySend(p,m,c) end + function SendChatMessage() chatCount=(chatCount or 0)+1 end + function GetZoneText() return "Test Zone" end + function GetSubZoneText() return "" end + function PlaySound() end + function GetAddOnMetadata() return "3.8.0" end + function GetNumRaidMembers() return inParty and 0 or memberCount end + function GetNumPartyMembers() return inParty and (memberCount-1) or 0 end + function IsInGuild() return false end + SlashCmdList={} + LockPortOptions={announceOpen=false,askHelpers=false,autoRemove=false,sound=false, + readySound=false,flash=false,whisperStatus=false,chatMuted=true,triggers="123"} + ''') + g.inParty=self.party;g.memberCount=len(self.members) + L.execute(source if source is not None else (LEGACY if legacy else CORE)) + L.execute(r''' + LP=LockPort;LP.mods={} + LP.Print=function(self,msg) lastPrint=msg end + LP.UpdateUI=function() end;LP.RefreshOptions=function() end + LP.ScanRoster=function(self) self.roster=pyRoster();return self.roster end + LP.CountShards=function() return noShards and 0 or 5 end + LP.CountHelpers=function() return noHelpers and 0 or 2 end + LP.IsNearby=function() return false end + LP.KnowsRitual=function() return true end + LP.InstanceBlock=function() return nil end + LP:ScanRoster() + ''') + self.clients[name]=L + for target in self.targets:L.globals().LP.AddRequest(L.globals().LP,target,False) + return L + + def enable(self,name): + self.clients[name].execute('LP:SetRaidAutoSummon(true)') + + def cast(self,name,unit): + L=self.clients[name];target=L.globals().LP.pending.name + if name in self.reject:return False + self.casts.append((round(self.now,2),name,target)) + if name not in self.no_start: + L.globals().channel='Ritual of Summoning';L.globals().arg1='Ritual of Summoning' + L.execute('LP:OnEvent("SPELLCAST_START")') + self.later(3,lambda:self.stop(name)) + return True + + def stop(self,name): + if not self.online.get(name):return + L=self.clients[name];L.globals().channel=None + L.execute('LP:OnEvent("SPELLCAST_CHANNEL_STOP")') + + def run(self,seconds,order=None): + end=self.now+seconds + while self.now=104.0,w.casts + print('PASS delayed acknowledgements prevent early cast') + + w=World(targets=1) + for n in w.names:w.client(n) + for n in w.names:w.enable(n) + w.drop=lambda s,r,p,m:p=='LPCoord' and ':A:' in m + w.run(15);assert not w.casts,w.casts + w.drop=lambda *a:False;w.run(15);w.unique(1) + print('PASS lost acknowledgements hold casting; recovery after delivery resumes') + + for reason in ('moving','noHelpers','noShards'): + w=World(targets=2) + for n in w.names:w.client(n) + w.clients['Alpha'].globals()[reason]=True + for n in w.names:w.enable(n) + w.run(18);w.unique(2);assert all(c[1]!='Alpha' for c in w.casts) + print('PASS unready warlock excluded:',reason) + + for action in ('off','disconnect','movement'): + w=World(names=('Alpha','Bravo'),targets=1) + for n in w.names:w.client(n) + for n in w.names:w.enable(n) + w.run(1.2) + if action=='off':w.clients['Alpha'].execute('LP:SetRaidAutoSummon(false)') + elif action=='disconnect':w.online['Alpha']=False + else:w.clients['Alpha'].globals().moving=True + w.run(14);w.unique(1);assert w.casts[0][1]=='Bravo',w.casts + print('PASS reservation handoff after',action) + + for kind in ('reject','no-start'): + w=World(names=('Alpha','Bravo'),targets=1) + for n in w.names:w.client(n) + for n in w.names:w.enable(n) + if kind=='reject':w.reject.add('Alpha') + else:w.no_start.add('Alpha') + w.run(15) + if kind=='reject':w.unique(1);assert w.casts[0][1]=='Bravo' + else: + assert len(w.casts)==2 and w.casts[0][1]=='Alpha' and w.casts[1][1]=='Bravo',w.casts + assert w.casts[1][0]-w.casts[0][0]>4,w.casts + print('PASS handoff after native',kind) + + w=World(names=('Alpha','Bravo'),targets=1) + w.client('Alpha');w.client('Bravo',legacy=True) + w.enable('Alpha');w.clients['Bravo'].execute('LP:Summon("Target1")') + w.run(10);w.unique(1);assert w.casts[0][1]=='Bravo' + print('PASS old manual summon claim prevents new automatic duplicate') + + # Verify real legacy list add/remove without substituting legacy OnEvent. + w=World(names=('Alpha','Bravo'),targets=1) + for n in w.names:w.client(n,legacy=n=='Bravo');w.clients[n].execute('LP.requests={}') + w.clients['Alpha'].execute('LP:AddRequest("Target1",true)');w.run(1) + assert w.clients['Bravo'].eval('table.getn(LP.requests)')==1 + w.clients['Bravo'].execute('LP:RemoveRequest("Target1",true)');w.run(1) + assert w.clients['Alpha'].eval('table.getn(LP.requests)')==0 + print('PASS bidirectional list synchronization with 3.7.8') + + # Old auto peers cannot ACK: explicitly demonstrate that this is NOT guaranteed. + w=World(names=('Alpha','Bravo'),targets=1,delay=0.3) + w.client('Alpha');w.client('Bravo',legacy=True) + for n in w.names:w.enable(n) + w.run(7) + assert len(w.casts)==2,w.casts + print('KNOWN LIMIT mixed new/3.7.8 simultaneous auto can still duplicate:',w.casts) + + # A partition during initial discovery is unknowable; do not claim consensus. + w=World(names=('Alpha','Bravo'),targets=1) + for n in w.names:w.client(n) + w.drop=lambda *a:True + for n in w.names:w.enable(n) + w.run(7);assert len(w.casts)==2,w.casts + print('KNOWN LIMIT total message loss before peer discovery can duplicate') + +if __name__=='__main__':suite() + + +def extended_suite(): + # ACKs from a previous transaction may never authorize a replacement. + w=World(names=('Alpha','Bravo'),targets=1) + for n in w.names:w.client(n) + for n in w.names:w.enable(n) + w.run(1.2) + L=w.clients['Alpha'];old=L.globals().LP.coordReservation.id + L.execute('LP:CoordRelease();LP:CoordReserve("Target1",now)') + fresh=L.globals().LP.coordReservation.id + assert fresh>old + session=L.globals().LP.coordSession + w.deliver('Alpha','LPCoord',f'1:A:{session}:{old}:Target1:Alpha','RAID','Bravo') + assert not L.eval('LP.coordReservation.acks.Bravo') + print('PASS delayed ACK cannot authorize a newer reservation') + + # A release arriving before its reserve leaves a tombstone. + w=World(names=('Alpha','Bravo'),targets=1) + for n in w.names:w.client(n) + w.deliver('Bravo','LPCoord','1:X:100000:5:Target1:','RAID','Alpha') + w.deliver('Bravo','LPCoord','1:R:100000:5:Target1:','RAID','Alpha') + assert not w.clients['Bravo'].eval('LP.coordPeers.Alpha.reservation') + print('PASS out-of-order release prevents a stale reservation from returning') + + w=World(names=('Alpha','Bravo'),targets=2) + for n in w.names:w.client(n) + w.deliver('Bravo','LPCoord','1:S:100000:1:Target1:','RAID','Alpha') + w.deliver('Bravo','LPCoord','1:C:100000:2:Target2:','RAID','Alpha') + L=w.clients['Bravo'];assert L.eval('LP:CoordBlocked("Target1",now)') + w.now+=131;L.globals().now=w.now + assert not L.eval('LP:CoordBlocked("Target1",now)') + print('PASS acceptance hold survives owner starting another target and later expires') + + w=World(names=('Alpha','Bravo'),targets=1) + for n in w.names:w.client(n) + for n in w.names:w.enable(n) + w.run(1.2);w.client('Alpha');w.enable('Alpha');w.run(14);w.unique(1) + print('PASS reload during reservation re-handshakes without duplicate cast') + + w=World(names=('Alpha','Bravo'),targets=1) + for n in w.names:w.client(n) + for n in w.names:w.enable(n) + w.run(1.6) + w.clients['Bravo'].execute('LP:Summon("Target1")') + assert not w.casts + w.run(10);w.unique(1) + print('PASS updated manual click respects another updated warlock reservation') + + w=World(names=('Alpha','Bravo'),targets=1) + for n in w.names:w.client(n) + for sender,chan,msg in [('Outsider','RAID','1:R:100000:1:Target1:'), + ('HelperOne','RAID','1:R:100000:1:Target1:'), + ('Alpha','WHISPER','1:R:100000:1:Target1:'), + ('Alpha','RAID','2:R:100000:1:Target1:'), + ('Alpha','RAID','broken')]: + w.deliver('Bravo','LPCoord',msg,chan,sender) + assert not w.clients['Bravo'].eval('LP.coordPeers and next(LP.coordPeers)') + print('PASS malformed, non-warlock, outsider and wrong-channel messages ignored') + + # A private request initially exists on just one client; real RSAdd shares it. + w=World(targets=1) + for n in w.names:w.client(n);w.clients[n].execute('LP.requests={}') + for n in w.names:w.enable(n) + w.clients['Charlie'].execute('LP:AddRequest("Target1",true)') + w.run(14);w.unique(1) + print('PASS request initially visible to one warlock is shared and cast once') + + for seed in range(60): + w=World(targets=6,delay=lambda rng:rng.uniform(0,1.8),seed=seed+100) + for n in w.names:w.client(n) + for n in w.names:w.enable(n) + order=w.names[:];w.rng.shuffle(order) + w.run(55,order=order);w.unique(6) + print('PASS 60 additional randomized three-client runs, six requests each') + +if __name__=='__main__':extended_suite() + + +def original_compatibility(): + source=(PACKAGE/'tests/fixtures/LockPort-original.lua').read_text(encoding='utf-8') + w=World(names=('Alpha','Bravo'),targets=1) + w.client('Alpha');w.client('Bravo',source=source) + for L in w.clients.values():L.execute('LP.requests={}') + w.clients['Alpha'].execute('LP:AddRequest("Target1",true)');w.run(1) + assert w.clients['Bravo'].eval('table.getn(LP.requests)')==1 + w.clients['Bravo'].execute('LP:RemoveRequest("Target1",true)');w.run(1) + assert w.clients['Alpha'].eval('table.getn(LP.requests)')==0 + for L in w.clients.values():L.execute('LP:AddRequest("Target1",false)') + w.enable('Alpha');w.clients['Bravo'].execute('LP:Summon("Target1")') + w.run(10);w.unique(1);assert w.casts[0][1]=='Bravo' + print('PASS original repository addon: list sync both directions and manual cast claim') + +if __name__=='__main__':original_compatibility() + +def raid_group_suite(): + # Different assignments share one synchronized queue but each caster only + # reserves its own raid subgroups. No 123 requests are needed. + w=World(targets=6) + for n in w.names:w.client(n);w.clients[n].execute('LP.requests={}') + assignments={'Alpha':(1,2),'Bravo':(3,4),'Charlie':(5,6)} + for n,groups in assignments.items(): + w.clients[n].execute('LP:SetSummonGroups({'+','.join('['+str(g)+']=true' for g in groups)+'})') + w.enable(n) + assert w.clients[n].eval('LP:QueueSelectedGroups()')==2 + w.run(30);w.unique(6) + for _,caster,target in w.casts:assert w.subgroups[target] in assignments[caster],w.casts + print('PASS three warlocks queue assigned raid groups without requests and split six targets') + + w=World(names=('Alpha','Bravo'),targets=2) + for n in w.names:w.client(n);w.clients[n].execute('LP:SetSummonGroups({[1]=true,[2]=true})');w.enable(n) + w.run(14);w.unique(2) + print('PASS overlapping group assignments still coordinate without duplicate casts') + + w=World(names=('Alpha','Bravo'),targets=2) + for n in w.names:w.client(n);w.clients[n].execute('LP:SetSummonGroups({[1]=true})');w.enable(n) + w.run(12);w.unique(1);assert w.casts[0][2]=='Target1' + assert all(w.clients[n].eval('LP:FindRequest("Target2")') for n in w.names) + print('PASS unassigned-group requests stay queued and are not summoned') + + w=World(names=('Alpha','Bravo'),targets=2) + for n in w.names:w.client(n);w.enable(n) + w.run(1.6);w.clients['Alpha'].execute('LP:SetSummonGroups({[2]=true})');w.clients['Bravo'].execute('LP:SetSummonGroups({[1]=true})') + w.run(18);w.unique(2) + assert {caster:target for _,caster,target in w.casts}=={'Alpha':'Target2','Bravo':'Target1'},w.casts + print('PASS assignment change during reservation releases and reassigns targets') + + w=World(names=('Alpha',),targets=2) + L=w.client('Alpha');L.execute('LP.requests={};LP:SetSummonGroups({[1]=true,[2]=true})') + w.online['Target2']=False + assert L.eval('LP:QueueSelectedGroups()')==1 + assert L.eval('LP:QueueSelectedGroups()')==0 + w.online['Target2']=True + assert L.eval('LP:QueueSelectedGroups()')==1 + L.execute('LP:SetSummonGroups({})');assert L.eval('LP:QueueSelectedGroups()')==0 + print('PASS group queue skips offline players, is idempotent, and handles no selection') + + w=World(names=('Alpha',),targets=2) + L=w.client('Alpha');L.execute('LP:SetSummonGroups({[2]=true})') + w.subgroups['Target1']=2;w.subgroups['Target2']=1 + w.enable('Alpha');w.run(10);w.unique(1);assert w.casts[0][2]=='Target1' + print('PASS current raid subgroup moves are honored before casting') + +if __name__=='__main__':raid_group_suite()