448 lines
16 KiB
Lua
448 lines
16 KiB
Lua
-- DopingControl ui/report.lua
|
|
-- Report preview overlay + raid-chat send queue.
|
|
--
|
|
-- Pure part (offline-testable):
|
|
-- DC_Report.SendDisabled(source) -> bool (true iff source == "sim")
|
|
-- DC_Report.QueueCreate(lines) -> queue (independent copy of lines)
|
|
-- DC_Report.QueueTick(q, now, src) -> line-to-send-now | nil
|
|
-- DC_Report.QueueLabel(q) -> "sending k/n" button label
|
|
-- DC_Report.BuildLines(store, db, tab) -> lines, vm (via DC_Model)
|
|
--
|
|
-- Queue semantics:
|
|
-- * First line goes out immediately on Send; every further line waits
|
|
-- >= SEND_INTERVAL (0.6 s) measured from the PREVIOUS ACTUAL SEND
|
|
-- (the client chat throttle is ~10 msgs/10 s --
|
|
-- 0.6 s spacing stays safely under it even with other addons chatting).
|
|
-- * ABORT: if store.source flips to "sim" mid-drain, the very next tick
|
|
-- aborts the queue without sending (synthetic names must
|
|
-- never reach real chat). An aborted queue never resumes.
|
|
-- * In sim mode the Send button never activates in the first place
|
|
-- (SendDisabled predicate), with tooltip "disabled in test mode".
|
|
--
|
|
-- WoW wiring (behind `if CreateFrame then`): overlay frame
|
|
-- DopingControlReportFrame, DIALOG strata, anchored over the matrix frame,
|
|
-- scrollable preview FontString showing DC_Model.reportLines output EXACTLY
|
|
-- as it will be sent, buttons "Send to raid chat" + "Close".
|
|
-- SendChatMessage(line, "RAID") only ever fires from the queue drainer here
|
|
-- (single-sender rule).
|
|
--
|
|
-- Deliberate resolutions:
|
|
-- * DC_Report.Open() is exposed with OPTIONAL (vm, tab) arguments: the
|
|
-- matrix "Report..." button may pass its already-built vm; with no
|
|
-- arguments the report builds its own vm from DC.store via
|
|
-- DC_Model/DC_Roles and the current tab (DC_Matrix.GetTab()/.tab if
|
|
-- the matrix exposes it, else DC.currentTab, else "CONSUMABLES").
|
|
-- * The matrix frame global name is not fixed; anchoring
|
|
-- tries DC_Matrix.frame, then DopingControlFrame /
|
|
-- DopingControlMatrixFrame, else UIParent.
|
|
-- * Closing the overlay (Close / ESC) while draining cancels the rest of
|
|
-- the queue -- hidden frames get no OnUpdate on 1.12, so a hidden
|
|
-- "paused" queue would silently resume minutes later; cancel is the
|
|
-- predictable behavior.
|
|
|
|
DopingControl = DopingControl or {}
|
|
local DC = DopingControl
|
|
|
|
DC_Report = DC_Report or {}
|
|
local R = DC_Report
|
|
|
|
-- ==================================================================
|
|
-- Pure queue core
|
|
-- ==================================================================
|
|
|
|
R.SEND_INTERVAL = 0.6 -- seconds between two sent lines
|
|
R.CHAT_TYPE = "RAID"
|
|
|
|
-- Send is dead exactly in test mode. nil source (no scan
|
|
-- yet) is NOT sim: sending a "coverage 0/0" report is pointless but safe.
|
|
function R.SendDisabled(source)
|
|
return source == "sim"
|
|
end
|
|
|
|
-- The chat API rejects the WHOLE message with "Invalid escape code in chat
|
|
-- message" when it meets a "|" that does not open a valid escape sequence
|
|
-- (|c |r |H |h |T |t |n ||). Our header line uses " | " as a plain ASCII
|
|
-- separator, so every report was refused. "||" is the escape for a literal
|
|
-- pipe and still renders as a single "|", which is why this runs at the
|
|
-- send point and NOT inside DC_Model.reportLines: the preview shows what
|
|
-- reportLines returns and must keep showing the text as it APPEARS in chat.
|
|
-- Report lines are pure ASCII by design (no item links, no colour codes),
|
|
-- so doubling every pipe cannot damage a real escape sequence here.
|
|
function R.ChatSafe(line)
|
|
if not line then
|
|
return line
|
|
end
|
|
return (string.gsub(line, "|", "||"))
|
|
end
|
|
|
|
-- Create a drain queue from an array of lines. Takes an independent copy
|
|
-- (the preview may be rebuilt while an old queue is still draining).
|
|
function R.QueueCreate(lines)
|
|
local q = {
|
|
lines = {},
|
|
n = 0,
|
|
sent = 0,
|
|
nextAt = 0, -- absolute time the next send is allowed
|
|
done = false,
|
|
aborted = false,
|
|
}
|
|
local n = table.getn(lines)
|
|
for i = 1, n do
|
|
table.insert(q.lines, lines[i])
|
|
end
|
|
q.n = n
|
|
if n == 0 then
|
|
q.done = true
|
|
end
|
|
return q
|
|
end
|
|
|
|
-- One drain step. now = current time (GetTime() in-game), source = current
|
|
-- store source ("scan"/"sim"/nil). Returns the line to send NOW, or nil.
|
|
-- Sets q.done when the last line went out, q.aborted on a sim flip.
|
|
function R.QueueTick(q, now, source)
|
|
if not q or q.done or q.aborted then
|
|
return nil
|
|
end
|
|
if source == "sim" then
|
|
q.aborted = true -- never resumes
|
|
return nil
|
|
end
|
|
if now < q.nextAt then
|
|
return nil
|
|
end
|
|
q.sent = q.sent + 1
|
|
local line = q.lines[q.sent]
|
|
q.nextAt = now + R.SEND_INTERVAL
|
|
if q.sent >= q.n then
|
|
q.done = true
|
|
end
|
|
return line
|
|
end
|
|
|
|
-- Button label while a queue exists.
|
|
function R.QueueLabel(q)
|
|
if not q then
|
|
return nil
|
|
end
|
|
if q.aborted then
|
|
return "aborted"
|
|
end
|
|
return string.format("sending %d/%d", q.sent, q.n)
|
|
end
|
|
|
|
-- ==================================================================
|
|
-- Line building (pure given a store; uses DC_Model + DC_Roles)
|
|
-- ==================================================================
|
|
|
|
-- "14:04"-style timestamp: WoW 1.12 exposes a global date(); plain
|
|
-- Lua 5.0 has os.date.
|
|
function R.TimeStr()
|
|
if type(date) == "function" then
|
|
return date("%H:%M")
|
|
end
|
|
if os and type(os.date) == "function" then
|
|
return os.date("%H:%M")
|
|
end
|
|
return "--:--"
|
|
end
|
|
|
|
-- Build the report lines for a store + db + tab. Returns lines, vm.
|
|
function R.BuildLines(store, dbt, tab)
|
|
store = store or DC.store or { players = {} }
|
|
tab = tab or "CONSUMABLES"
|
|
-- Same expectation resolver the matrix uses, or the report preview
|
|
-- would show different columns and different gap totals than the grid
|
|
-- it was opened from (demo mode). Safe for the SEND path too: the demo
|
|
-- layer only applies over the "sim" store, and R.SendDisabled blocks
|
|
-- sending on exactly that source. Guarded because ui/report.lua is
|
|
-- loadable without ui/matrix.lua (tools/luatests/test_report_queue.lua).
|
|
local expect
|
|
if DC_Matrix and DC_Matrix.ExpectTable then
|
|
expect = DC_Matrix.ExpectTable(dbt)
|
|
else
|
|
expect = (dbt and dbt.expectations) or DC.DEFAULT_EXPECT or {}
|
|
end
|
|
local function roleOf(p)
|
|
return DC_Roles.resolve(dbt, store.source, p)
|
|
end
|
|
local vm = DC_Model.build(store, roleOf, expect, tab)
|
|
local label = (DC.TAB_LABEL and DC.TAB_LABEL[tab]) or tab
|
|
return DC_Model.reportLines(vm, label, R.TimeStr()), vm
|
|
end
|
|
|
|
-- ==================================================================
|
|
-- WoW wiring
|
|
-- ==================================================================
|
|
if CreateFrame then
|
|
|
|
local frame = nil -- DopingControlReportFrame (lazy)
|
|
local previewText = nil -- the preview FontString
|
|
local scrollChild = nil
|
|
local infoText = nil
|
|
local sendBtn = nil
|
|
local sendBlocker = nil -- invisible tooltip overlay while disabled
|
|
local lines = nil -- current preview lines (array)
|
|
local queue = nil -- active drain queue or nil
|
|
|
|
local function db()
|
|
return DopingControlDB
|
|
end
|
|
|
|
local function currentSource()
|
|
return DC.store and DC.store.source
|
|
end
|
|
|
|
local function currentTab()
|
|
if DC_Matrix then
|
|
if DC_Matrix.GetTab then
|
|
return DC_Matrix.GetTab()
|
|
end
|
|
if DC_Matrix.tab then
|
|
return DC_Matrix.tab
|
|
end
|
|
end
|
|
return DC.currentTab or "CONSUMABLES"
|
|
end
|
|
|
|
-- The matrix frame global name is not guaranteed; try the likely
|
|
-- handles, fall back safely.
|
|
local function anchorFrame()
|
|
if DC_Matrix and DC_Matrix.frame then
|
|
return DC_Matrix.frame
|
|
end
|
|
local m = getglobal("DopingControlFrame")
|
|
or getglobal("DopingControlMatrixFrame")
|
|
return m or UIParent
|
|
end
|
|
|
|
local function panelBackdrop()
|
|
if DC_Widgets and DC_Widgets.PANEL_BACKDROP then
|
|
return DC_Widgets.PANEL_BACKDROP
|
|
end
|
|
if DC.PANEL_BACKDROP then
|
|
return DC.PANEL_BACKDROP
|
|
end
|
|
return {
|
|
bgFile = "Interface\\AddOns\\DopingControl\\textures\\panel_bg",
|
|
edgeFile = "Interface\\AddOns\\DopingControl\\textures\\panel_border",
|
|
tile = true, tileSize = 128, edgeSize = 16,
|
|
insets = { left = 5, right = 5, top = 5, bottom = 5 },
|
|
}
|
|
end
|
|
|
|
-- Send button + blocker state machine: draining -> disabled "sending
|
|
-- k/n"; sim -> disabled with tooltip blocker; else enabled.
|
|
local function UpdateSendState()
|
|
if not sendBtn then
|
|
return
|
|
end
|
|
if queue and not queue.done and not queue.aborted then
|
|
sendBtn:Disable()
|
|
sendBtn:SetText(R.QueueLabel(queue))
|
|
sendBlocker:Hide()
|
|
elseif R.SendDisabled(currentSource()) then
|
|
sendBtn:Disable()
|
|
sendBtn:SetText("Send to raid chat")
|
|
sendBlocker:Show()
|
|
else
|
|
sendBtn:Enable()
|
|
sendBtn:SetText("Send to raid chat")
|
|
sendBlocker:Hide()
|
|
end
|
|
end
|
|
|
|
-- OnUpdate drainer (installed on the frame only while a queue exists;
|
|
-- no allocation on the idle path).
|
|
local function OnUpdateDrain()
|
|
if not queue then
|
|
this:SetScript("OnUpdate", nil)
|
|
return
|
|
end
|
|
local line = R.QueueTick(queue, GetTime(), currentSource())
|
|
if line then
|
|
SendChatMessage(R.ChatSafe(line), R.CHAT_TYPE)
|
|
UpdateSendState()
|
|
end
|
|
if queue.done or queue.aborted then
|
|
queue = nil
|
|
this:SetScript("OnUpdate", nil)
|
|
UpdateSendState()
|
|
end
|
|
end
|
|
|
|
local function OnSendClick()
|
|
if queue then
|
|
return
|
|
end
|
|
if not lines or table.getn(lines) == 0 then
|
|
return
|
|
end
|
|
if R.SendDisabled(currentSource()) then
|
|
return
|
|
end
|
|
queue = R.QueueCreate(lines)
|
|
frame:SetScript("OnUpdate", OnUpdateDrain)
|
|
UpdateSendState()
|
|
end
|
|
|
|
local function BuildReportFrame()
|
|
if frame then
|
|
return frame
|
|
end
|
|
local f = CreateFrame("Frame", "DopingControlReportFrame", UIParent)
|
|
f:SetWidth(460)
|
|
f:SetHeight(340)
|
|
f:SetFrameStrata("DIALOG")
|
|
f:SetPoint("CENTER", anchorFrame(), "CENTER", 0, 0)
|
|
f:SetBackdrop(panelBackdrop())
|
|
f:SetBackdropColor(1, 1, 1, 0.97)
|
|
f:SetMovable(true)
|
|
f:EnableMouse(true)
|
|
f:RegisterForDrag("LeftButton")
|
|
f:SetScript("OnDragStart", function() this:StartMoving() end)
|
|
f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
|
|
f:SetClampedToScreen(true)
|
|
|
|
-- title (gold)
|
|
local title = f:CreateFontString("DopingControlReportTitle",
|
|
"ARTWORK", "GameFontNormal")
|
|
title:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -14)
|
|
local g = DC.COLORS and DC.COLORS.goldHi
|
|
if g then
|
|
title:SetTextColor(g.r, g.g, g.b)
|
|
else
|
|
title:SetTextColor(0.85, 0.66, 0.31)
|
|
end
|
|
title:SetText("Report preview")
|
|
|
|
-- info line (line count + pacing note)
|
|
infoText = f:CreateFontString("DopingControlReportInfo",
|
|
"ARTWORK", "GameFontNormalSmall")
|
|
infoText:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -30)
|
|
infoText:SetTextColor(0.6, 0.6, 0.6)
|
|
infoText:SetText("")
|
|
|
|
-- close X
|
|
local closeX = CreateFrame("Button", "DopingControlReportCloseX",
|
|
f, "UIPanelCloseButton")
|
|
closeX:SetPoint("TOPRIGHT", f, "TOPRIGHT", -8, -8)
|
|
|
|
-- scrollable preview: shows reportLines output EXACTLY as sent
|
|
local scroll = CreateFrame("ScrollFrame", "DopingControlReportScroll",
|
|
f, "UIPanelScrollFrameTemplate")
|
|
scroll:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -48)
|
|
scroll:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -36, 44)
|
|
|
|
scrollChild = CreateFrame("Frame", "DopingControlReportScrollChild",
|
|
scroll)
|
|
scrollChild:SetWidth(400)
|
|
scrollChild:SetHeight(1)
|
|
scroll:SetScrollChild(scrollChild)
|
|
|
|
previewText = scrollChild:CreateFontString("DopingControlReportText",
|
|
"ARTWORK", "GameFontHighlightSmall")
|
|
previewText:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 0, 0)
|
|
previewText:SetWidth(390)
|
|
previewText:SetJustifyH("LEFT")
|
|
previewText:SetJustifyV("TOP")
|
|
-- mono-ish: 1.12 ships no true monospace; the condensed number
|
|
-- font keeps the indented name lists readable
|
|
previewText:SetFont("Fonts\\ARIALN.TTF", 12)
|
|
|
|
-- buttons
|
|
sendBtn = CreateFrame("Button", "DopingControlReportSendButton",
|
|
f, "UIPanelButtonTemplate")
|
|
sendBtn:SetWidth(150)
|
|
sendBtn:SetHeight(22)
|
|
sendBtn:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 16, 14)
|
|
sendBtn:SetText("Send to raid chat")
|
|
sendBtn:SetScript("OnClick", OnSendClick)
|
|
|
|
local closeBtn = CreateFrame("Button", "DopingControlReportCloseButton",
|
|
f, "UIPanelButtonTemplate")
|
|
closeBtn:SetWidth(80)
|
|
closeBtn:SetHeight(22)
|
|
closeBtn:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -16, 14)
|
|
closeBtn:SetText("Close")
|
|
closeBtn:SetScript("OnClick", function() f:Hide() end)
|
|
|
|
if pfUI and pfUI.api then
|
|
if pfUI.api.SkinButton then
|
|
pfUI.api.SkinButton(sendBtn)
|
|
pfUI.api.SkinButton(closeBtn)
|
|
end
|
|
if pfUI.api.SkinCloseButton then
|
|
pfUI.api.SkinCloseButton(closeX)
|
|
end
|
|
end
|
|
|
|
-- invisible overlay for the disabled-in-test-mode tooltip
|
|
-- (disabled buttons receive no mouse events on 1.12)
|
|
sendBlocker = CreateFrame("Frame", "DopingControlReportSendBlocker", f)
|
|
sendBlocker:SetAllPoints(sendBtn)
|
|
sendBlocker:SetFrameLevel(sendBtn:GetFrameLevel() + 2)
|
|
sendBlocker:EnableMouse(true)
|
|
sendBlocker:SetScript("OnEnter", function()
|
|
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
|
|
GameTooltip:SetText("disabled in test mode", 1, 1, 1)
|
|
GameTooltip:AddLine("Simulated names never reach real chat.", 0.8, 0.8, 0.8)
|
|
GameTooltip:AddLine("The preview stays fully usable.", 0.8, 0.8, 0.8)
|
|
GameTooltip:Show()
|
|
end)
|
|
sendBlocker:SetScript("OnLeave", function() GameTooltip:Hide() end)
|
|
sendBlocker:Hide()
|
|
|
|
-- closing cancels a running drain (see header)
|
|
f:SetScript("OnHide", function()
|
|
if queue then
|
|
queue = nil
|
|
this:SetScript("OnUpdate", nil)
|
|
end
|
|
end)
|
|
|
|
-- ESC closes
|
|
table.insert(UISpecialFrames, "DopingControlReportFrame")
|
|
|
|
f:Hide()
|
|
frame = f
|
|
return f
|
|
end
|
|
|
|
-- Rebuild the preview content. vm/tab optional (see header).
|
|
function R.Refresh(vm, tab)
|
|
BuildReportFrame()
|
|
tab = tab or currentTab()
|
|
if vm then
|
|
local label = (DC.TAB_LABEL and DC.TAB_LABEL[tab]) or tab
|
|
lines = DC_Model.reportLines(vm, label, R.TimeStr())
|
|
else
|
|
lines = R.BuildLines(DC.store, db(), tab)
|
|
end
|
|
local n = table.getn(lines)
|
|
previewText:SetText(table.concat(lines, "\n"))
|
|
scrollChild:SetHeight(n * 14 + 24)
|
|
infoText:SetText(string.format(
|
|
"%d line%s - sent one per %.1f s", n, (n == 1) and "" or "s",
|
|
R.SEND_INTERVAL))
|
|
UpdateSendState()
|
|
end
|
|
|
|
-- Public entry point (matrix "Report..." button, /dc report).
|
|
function R.Open(vm, tab)
|
|
local f = BuildReportFrame()
|
|
R.Refresh(vm, tab)
|
|
f:Show()
|
|
end
|
|
|
|
function R.Toggle(vm, tab)
|
|
local f = BuildReportFrame()
|
|
if f:IsShown() then
|
|
f:Hide()
|
|
else
|
|
R.Open(vm, tab)
|
|
end
|
|
end
|
|
|
|
end
|