Apply Patch
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
<Bindings>
|
||||
<Binding name="LOCKPORT_SUMMON_NEXT" header="LOCKPORT">
|
||||
LockPort:SummonNext()
|
||||
</Binding>
|
||||
<Binding name="LOCKPORT_TOGGLE">
|
||||
LockPort:Toggle()
|
||||
</Binding>
|
||||
</Bindings>
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Binary file not shown.
+1719
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
+865
@@ -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 <zone>\" 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
|
||||
@@ -0,0 +1 @@
|
||||
96bdb518e0b58106d585b7a9c577cd3437b497f97178656b4fc7fe68acd777c8 LockPort.dll
|
||||
@@ -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 <windows.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
Vendored
+1435
File diff suppressed because it is too large
Load Diff
Vendored
+1210
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <assert.h>
|
||||
#include <string.h>
|
||||
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;i<sizeof(regions)/sizeof(regions[0]);i++)
|
||||
assert(VirtualAlloc((void *)regions[i],0x10000,
|
||||
MEM_COMMIT|MEM_RESERVE,PAGE_EXECUTE_READWRITE)==(void *)regions[i]);
|
||||
client=(unsigned char *)0x400000;
|
||||
FILE *f=fopen(clientPath,"rb");assert(f);
|
||||
fseek(f,0,SEEK_END);long size=ftell(f);rewind(f);
|
||||
unsigned char *raw=malloc((size_t)size);assert(raw);
|
||||
assert(fread(raw,1,(size_t)size,f)==(size_t)size);fclose(f);
|
||||
IMAGE_DOS_HEADER *dos=(IMAGE_DOS_HEADER *)raw;
|
||||
IMAGE_NT_HEADERS32 *nt=(IMAGE_NT_HEADERS32 *)(raw+dos->e_lfanew);
|
||||
memcpy(client,raw,nt->OptionalHeader.SizeOfHeaders);
|
||||
IMAGE_SECTION_HEADER *sections=IMAGE_FIRST_SECTION(nt);
|
||||
for(unsigned j=1;j<sizeof(regions)/sizeof(regions[0]);j++){
|
||||
DWORD rva=(DWORD)(regions[j]-0x400000);
|
||||
for(int i=0;i<nt->FileHeader.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;i<sizeof(invalid)/sizeof(invalid[0]);i++)assert(!validUnit(invalid[i]));
|
||||
puts("PASS unit validation excludes non-group tokens and script input");
|
||||
// Exercise the real relocated prologue, replacing only its subsequent work.
|
||||
*(unsigned char *)0x706350=0xC3;
|
||||
*(unsigned char *)0x490256=0x5E; // original loader eventually restores esi
|
||||
redirect(0x490257,(void *)(uintptr_t)&FakeOriginal);
|
||||
stubFunctions();
|
||||
assert(!install());
|
||||
puts("PASS missing Lua-registration compatibility hook rejected");
|
||||
*(unsigned char *)0x42A320=0xC3; // supplied by existing client mods
|
||||
assert(install());
|
||||
((LoadFunctionsFn)0x490250)();
|
||||
assert(originalCalls==1&®istrations==2&&versionAPI&&summonAPI);
|
||||
puts("PASS relocated loader trampoline and API registration");
|
||||
assert(versionAPI((void *)1)==1&&!strcmp(versionValue,"1.2.1"));
|
||||
*(uintptr_t *)0xCEEAC0=0x12345678;
|
||||
*(uint32_t *)0xCEEAC4=3;
|
||||
FakeBookCast(7,0,UINT64_C(0x1122334455667788));
|
||||
assert(executions==0);
|
||||
puts("PASS frame-script context reproduces the old silent cast refusal");
|
||||
inputUnit="raid2";assert(summonAPI((void *)1)==2&&booleanValue==1&&executions==1&&topRestores==0);
|
||||
assert(summonAPI((void *)1)==2&&booleanValue==0&&executions==1);
|
||||
assert(*(uintptr_t *)0xCEEAC0==0x12345678 && *(uint32_t *)0xCEEAC4==3);
|
||||
puts("PASS scoped command context permits cast and restores both values");
|
||||
puts("PASS native summon bridge, explicit spellbook slot and target GUID, and rate limit");
|
||||
hasAttempt=0; inputUnit="raid2x";summonAPI((void *)1);
|
||||
assert(booleanValue==0&&executions==1);
|
||||
inputUnit="raid2";luaThread=GetCurrentThreadId()+1;summonAPI((void *)1);
|
||||
assert(booleanValue==0&&executions==1);
|
||||
puts("PASS bad target and wrong game thread rejected");
|
||||
luaThread=GetCurrentThreadId();hasAttempt=0;inputUnit="party4";summonAPI((void *)1);
|
||||
assert(booleanValue==1&&executions==2);
|
||||
puts("PASS native party summon dispatch");
|
||||
// Exercise coexistence with an earlier mod's loader detour.
|
||||
DWORD old;VirtualProtect((void *)0x490250,6,PAGE_EXECUTE_READWRITE,&old);
|
||||
memcpy((void *)0x490250,signatures[0].bytes,6);
|
||||
redirect(0x490250,(void *)(uintptr_t)&FakeOriginal);
|
||||
assert(install());((LoadFunctionsFn)0x490250)();
|
||||
assert(originalCalls==2&®istrations==4);
|
||||
puts("PASS existing mod loader detour preserved");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
PASS off without native DLL
|
||||
PASS requires group
|
||||
PASS enables open announcements
|
||||
PASS auto starts without input after settling
|
||||
PASS no duplicate or immediate uncertain retry
|
||||
PASS movement pauses auto casting
|
||||
PASS live ClassicAPI casting pauses auto casting
|
||||
PASS works without ClassicAPI
|
||||
PASS not ready pauses auto casting
|
||||
PASS leaving raid disables and restores announcements
|
||||
PASS disabling auto always disables announcements
|
||||
PASS native disappearance disables
|
||||
PASS interruption with arrival
|
||||
PASS interruption with unconfirmed completion
|
||||
PASS automatic native spellbook Summon dispatch
|
||||
PASS manual Summon dispatch
|
||||
PASS syntax LockPort.lua
|
||||
PASS syntax LockPort_UI.lua
|
||||
PASS UI native-only visibility, expanded layout, raid gating, and button toggle
|
||||
PASS party enables automatic casting
|
||||
PASS raid to party conversion stays enabled
|
||||
PASS leaving party disables and restores announcements
|
||||
PASS old raid-only DLL is rejected
|
||||
PASS live idle clears stale busy flag
|
||||
PASS missing stop event releases pending summon conservatively
|
||||
PASS active channel is never cleared by recovery
|
||||
PASS unknown live state preserves tracking
|
||||
PASS brief idle gap during casting is tolerated
|
||||
PASS integrated automatic party2 cast receives start event
|
||||
PASS integrated automatic raid2 cast receives start event
|
||||
PASS off clears announcements even when already off
|
||||
PASS reload clears announcements auto
|
||||
PASS reload clears announcements manual
|
||||
PASS ClassicAPI chat payload survives overwritten globals
|
||||
PASS legacy chat fallback snapshots sender before roster scan
|
||||
PASS empty ClassicAPI chat result uses legacy fallback
|
||||
PASS ClassicAPI chat errors use legacy fallback
|
||||
PASS non-request actual payload does not use corrupted trigger globals
|
||||
PASS nested spell event does not replay outer chat
|
||||
PASS addon sync payload is not replaced by chat reader
|
||||
PASS mute blocks summon say and whisper then resumes
|
||||
PASS mute clears pending status whispers and skips new replies
|
||||
PASS mute blocks forced availability announcements
|
||||
PASS raid group selection defaults to all and supports none
|
||||
PASS group selection ignores party roster
|
||||
PASS group selection releases outstanding reservation
|
||||
PASS real raid roster subgroup parsing and subgroup changes
|
||||
PASS Summon and direct row casts respect raid group selection
|
||||
PASS saved raid group selection survives initialization
|
||||
@@ -0,0 +1,273 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
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")
|
||||
setup=r'''
|
||||
now=100; raidSize=5; partySize=0; moving=0; casting=nil; nativeOK=true
|
||||
function GetTime() return now end
|
||||
function GetRaidRosterInfo() return nil end
|
||||
function GetNumRaidMembers() return raidSize end
|
||||
function GetNumPartyMembers() return partySize end
|
||||
function GetLockPortVersion() return "1.2.1" end
|
||||
function LockPortNativeSummon(unit) nativeCalls=(nativeCalls or 0)+1; lastUnit=unit; return nativeOK end
|
||||
function GetUnitSpeed() return moving end
|
||||
function UnitName(u) if u=="player" then return "Tester" end return "Bob" end
|
||||
function UnitClass() return "Warlock","WARLOCK" end
|
||||
function UnitAffectingCombat() return false end
|
||||
function UnitIsConnected() return true end
|
||||
function UnitIsDeadOrGhost() return false end
|
||||
function CreateFrame() return {RegisterEvent=function() end, SetScript=function() end} end
|
||||
function SendAddonMessage() end
|
||||
function SendChatMessage() end
|
||||
function GetZoneText() return "Test" end
|
||||
function GetSubZoneText() return "" end
|
||||
function TargetUnit() end
|
||||
function CastSpellByName() manualCalls=(manualCalls or 0)+1 end
|
||||
C_Spell={UnitCastingInfo=function() return casting end,UnitChannelInfo=function() return nil end}
|
||||
SlashCmdList={}
|
||||
LockPortOptions={announceOpen=false,askHelpers=false,autoRemove=true}
|
||||
'''
|
||||
def env():
|
||||
L=LuaRuntime()
|
||||
L.execute(setup); L.execute(core)
|
||||
L.execute(r'''
|
||||
LP=LockPort; LP.mods={}
|
||||
LP.Print=function(self,text) lastPrint=text end
|
||||
LP.RefreshOptions=function() end; LP.UpdateUI=function() end
|
||||
LP.OpenAnnounce=function() openCalls=(openCalls or 0)+1 end
|
||||
LP.ReadyState=function() return true end
|
||||
LP.NextCandidate=function() if LP.autoRetryAfter and LP.autoRetryAfter.Bob and now<LP.autoRetryAfter.Bob then return nil end;return {name="Bob"} end
|
||||
LP.Summon=function(self,name,automatic) casts=(casts or 0)+1; wasAutomatic=automatic end
|
||||
''')
|
||||
return L
|
||||
def check(name,script):
|
||||
L=env(); L.execute(script); print("PASS",name)
|
||||
check("off without native DLL",r'''GetLockPortVersion=nil; LP:SetRaidAutoSummon(true); assert(not LP.raidAutoSummon)''')
|
||||
check("requires group",r'''raidSize=0; LP:SetRaidAutoSummon(true); assert(not LP.raidAutoSummon)''')
|
||||
check("enables open announcements",r'''LP:SetRaidAutoSummon(true); assert(LP.raidAutoSummon and LockPortOptions.announceOpen and openCalls==1)''')
|
||||
check("auto starts without input after settling",r'''LP:SetRaidAutoSummon(true); LP:CheckRaidAutoSummon(100); LP:CheckRaidAutoSummon(101); assert(not casts); LP:CheckRaidAutoSummon(102); assert(casts==1 and wasAutomatic)''')
|
||||
check("no duplicate or immediate uncertain retry",r'''LP:SetRaidAutoSummon(true); LP:CheckRaidAutoSummon(100); now=102;LP:CheckRaidAutoSummon(now); now=103;LP:CheckRaidAutoSummon(now);now=106;LP:CheckRaidAutoSummon(now); assert(casts==1)''')
|
||||
check("movement pauses auto casting",r'''LP:SetRaidAutoSummon(true);moving=7; LP:CheckRaidAutoSummon(100);LP:CheckRaidAutoSummon(110);assert(not casts)''')
|
||||
check("live ClassicAPI casting pauses auto casting",r'''LP:SetRaidAutoSummon(true);casting="Shadow Bolt"; LP:CheckRaidAutoSummon(100);LP:CheckRaidAutoSummon(110);assert(not casts)''')
|
||||
check("works without ClassicAPI",r'''GetUnitSpeed=nil;C_Spell=nil;LP:SetRaidAutoSummon(true); LP:CheckRaidAutoSummon(100); LP:CheckRaidAutoSummon(102);assert(casts==1)''')
|
||||
check("not ready pauses auto casting",r'''LP.ReadyState=function() return false end;LP:SetRaidAutoSummon(true);LP:CheckRaidAutoSummon(100);LP:CheckRaidAutoSummon(102);assert(not casts)''')
|
||||
check("leaving raid disables and restores announcements",r'''LP:SetRaidAutoSummon(true);raidSize=0;LP.CheckRaidAutoSummon(LP,101);assert(not LP.raidAutoSummon and not LockPortOptions.announceOpen)''')
|
||||
check("disabling auto always disables announcements",r'''LockPortOptions.announceOpen=true;LP:SetRaidAutoSummon(true);LP:SetRaidAutoSummon(false);assert(not LockPortOptions.announceOpen)''')
|
||||
check("native disappearance disables",r'''LP:SetRaidAutoSummon(true);LockPortNativeSummon=nil;LP:CheckRaidAutoSummon(101);assert(not LP.raidAutoSummon)''')
|
||||
# Test actual FinishPending and arrival watcher, with mocked game state.
|
||||
for arrived in (True,False):
|
||||
L=env()
|
||||
L.execute(r'''
|
||||
LP.pending={name="Bob",unit="raid2",started=true,interrupted=true}
|
||||
LP.ScanRoster=function() return {Bob={unit="raid3"}} end
|
||||
LP.IsNearby=function() return '''+("true" if arrived else "false")+r''' end
|
||||
LP.RemoveRequest=function() removed=true end
|
||||
LP.AddRequest=function() requeued=true end
|
||||
LP.Fail=function() failureAnnouncements=(failureAnnouncements or 0)+1 end
|
||||
LP:FinishPending()
|
||||
assert(not failureAnnouncements)
|
||||
assert(not LP.failCount.Bob)
|
||||
''')
|
||||
L.execute('assert(removed)' if arrived else 'assert(requeued and LP.watch.unconfirmed)')
|
||||
print("PASS interruption with "+("arrival" if arrived else "unconfirmed completion"))
|
||||
# Real Summon path: native attempt, manual fallback, and native rejection.
|
||||
for native in (True,False):
|
||||
L=env();L.execute(core)
|
||||
L.execute(r'''
|
||||
LP=LockPort;LP.Print=function() end;LP.UpdateUI=function() end
|
||||
LP.IsWarlock=function() return true end;LP.KnowsRitual=function() return true end
|
||||
LP.ScanRoster=function() return {Bob={unit="raid2"}} end
|
||||
LP.TargetProblem=function() return nil end;LP.CountShards=function() return 5 end
|
||||
LP.CountHelpers=function() return 2 end;LP.SendAddon=function() end
|
||||
LP:Summon("Bob",'''+("true" if native else "false")+r''')
|
||||
assert(LP.pending.name=="Bob")
|
||||
''')
|
||||
L.execute('assert(nativeCalls==1 and not manualCalls)' if native else 'assert(manualCalls==1 and not nativeCalls)')
|
||||
print("PASS "+("automatic native spellbook" if native else "manual")+" Summon dispatch")
|
||||
# All delivered Lua files parse with a Lua 5.1 runtime; source uses Lua 5.0 syntax.
|
||||
L=LuaRuntime()
|
||||
for p in PACKAGE.glob("*.lua"):
|
||||
L.execute('assert(loadstring(...))',p.read_text(encoding="utf-8"))
|
||||
print("PASS syntax",p.name)
|
||||
|
||||
# Build UI with lightweight frame mocks to check control placement and gating.
|
||||
L=env()
|
||||
L.execute(r'''
|
||||
StaticPopupDialogs={};UISpecialFrames={};RAID_CLASS_COLORS={}
|
||||
frames={}
|
||||
local methods={}
|
||||
function methods:GetName() return self.name end
|
||||
function methods:SetText(v) self.text=v end
|
||||
function methods:SetWidth(v) self.width=v end
|
||||
function methods:SetHeight(v) self.height=v end
|
||||
function methods:SetPoint(...) self.point={...} end
|
||||
function methods:ClearAllPoints() self.point=nil end
|
||||
function methods:SetScript(name,fn) self.scripts[name]=fn end
|
||||
function methods:Hide() self.visible=false end
|
||||
function methods:Show() self.visible=true end
|
||||
function methods:IsVisible() return self.visible end
|
||||
function methods:Enable() self.enabled=true end
|
||||
function methods:Disable() self.enabled=false end
|
||||
function methods:SetChecked(v) self.checked=v end
|
||||
function methods:GetChecked() return self.checked end
|
||||
local function frame(name)
|
||||
local f={name=name,scripts={},visible=true,enabled=true}
|
||||
setmetatable(f,{__index=function(t,k) return methods[k] or function() end end})
|
||||
if name then _G[name]=f;frames[name]=f end
|
||||
return f
|
||||
end
|
||||
function methods:CreateTexture(name) return frame(name) end
|
||||
function methods:CreateFontString(name) return frame(name) end
|
||||
function CreateFrame(kind,name) return frame(name) end
|
||||
function getglobal(name) return _G[name] end
|
||||
function GetBindingKey() return nil end
|
||||
function PlaySound() end
|
||||
LP.RefreshOptions=function() end
|
||||
LP.ScanRoster=function() return {} end
|
||||
LP.SortedRequests=function() return {} end
|
||||
LP.CountShards=function() return 5 end
|
||||
LP.InGroup=function() return raidSize>0 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])''')
|
||||
@@ -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
|
||||
@@ -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<end-0.001:
|
||||
self.now=round(self.now+0.1,4)
|
||||
for L in self.clients.values():L.globals().now=self.now
|
||||
while self.events and self.events[0][0]<=self.now+0.00001:
|
||||
_,_,fn=heapq.heappop(self.events);fn()
|
||||
for name in order or self.clients:
|
||||
if self.online.get(name):self.clients[name].execute('LP:OnUpdate(0.1)')
|
||||
|
||||
def unique(self,expected=None):
|
||||
targets=[c[2] for c in self.casts]
|
||||
assert len(targets)==len(set(targets)),self.casts
|
||||
if expected is not None:assert len(targets)==expected,self.casts
|
||||
|
||||
|
||||
def suite():
|
||||
# Demonstrate the actual old bug before applying the new protocol.
|
||||
w=World(names=('Alpha','Bravo'),targets=1,delay=0.3)
|
||||
for n in w.names:w.client(n,legacy=True)
|
||||
for n in w.names:w.enable(n)
|
||||
w.run(6)
|
||||
assert len(w.casts)==2 and w.casts[0][2]==w.casts[1][2],w.casts
|
||||
print('PASS reproduced duplicate cast with two uncoordinated 3.7.8 clients')
|
||||
|
||||
for count in (2,3):
|
||||
for party in (False,True):
|
||||
targets=min(count,5-count) if party else count
|
||||
w=World(names=('Alpha','Bravo','Charlie')[:count],targets=targets,party=party)
|
||||
for n in w.names:w.client(n)
|
||||
for n in w.names:w.enable(n)
|
||||
w.run(16);w.unique(targets)
|
||||
assert len(set(c[1] for c in w.casts))==min(count,targets),w.casts
|
||||
print('PASS',count,'warlocks split requests in', 'party' if party else 'raid',w.casts)
|
||||
|
||||
for seed in range(40):
|
||||
w=World(targets=5,delay=lambda rng:rng.uniform(0.05,1.4),seed=seed)
|
||||
for n in w.names:w.client(n)
|
||||
for n in reversed(w.names):w.enable(n)
|
||||
w.run(35,order=list(reversed(w.names)));w.unique(5)
|
||||
print('PASS 40 randomized delayed/reordered three-client runs, five requests each')
|
||||
|
||||
w=World(targets=1)
|
||||
for n in w.names:w.client(n)
|
||||
for n in w.names:w.enable(n)
|
||||
w.extra_delay=lambda s,r,p,m:2.6 if p=='LPCoord' and ':A:' in m else 0
|
||||
w.run(14);w.unique(1)
|
||||
assert w.casts[0][0]>=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()
|
||||
Reference in New Issue
Block a user