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 0000000..75e5a60
Binary files /dev/null and b/LockPort.dll differ
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()