Files
DopingControl/ui/report.lua
T
2026-08-03 09:47:19 +02:00

421 lines
14 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
-- 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"
local expect = (dbt and dbt.expectations) or DC.DEFAULT_EXPECT or {}
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(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