Module:Bracket: Difference between revisions
From eSportsAmaze
More actions
Esportsamaze (talk | contribs) Created page with "local p = {} local html = mw.html function p.main(frame) local args = frame:getParent().args local teamCount = tonumber(args.teams) or 8 -- Default to 8 teams local rounds = math.log(teamCount) / math.log(2) -- Calculate rounds (8 -> 3, 16 -> 4) local container = html.create('div'):addClass('bracket-scroll-wrapper') local wrapper = container:tag('div'):addClass('bracket-wrapper') -- Iterate Rounds (1 to Total Rounds) for r = 1, rounds d..." |
Esportsamaze (talk | contribs) No edit summary |
||
| (4 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
-- ================================================================ | |||
-- Module:Bracket v2.0 | |||
-- Supports: Single Elimination, Double Elimination, GSL, Round Robin, Swiss | |||
-- Data: Cargo-driven (BracketMatch table) + inline template args | |||
-- Match popup: embeds data-* attrs, JS handles modal | |||
-- ================================================================ | |||
function | local p = {} | ||
local | local html = mw.html | ||
local | local cargo = mw.ext.cargo | ||
local rounds = math.log(teamCount) / math.log(2) -- | |||
-- ── Helpers ────────────────────────────────────────────────────── | |||
local | |||
local wrapper = | local function esc(s) | ||
if not s then return "" end | |||
return s:gsub("\\","\\\\"):gsub("'","\\'") | |||
end | |||
local function clean(s) | |||
if not s then return nil end | |||
local c = s:gsub("[\r\n]",""):gsub("^%s*(.-)%s*$","%1") | |||
return c ~= "" and c or nil | |||
end | |||
-- Fetch team logo HTML (light/dark aware, matches your existing pattern) | |||
local teamLogoCache = {} | |||
local function getTeamLogo(teamName, size) | |||
if not teamName or teamName == "" then return nil end | |||
size = size or "20px" | |||
if teamLogoCache[teamName] then | |||
local t = teamLogoCache[teamName] | |||
if not t then return nil end | |||
local light = t.image or "" | |||
local dark = (t.image_dark and t.image_dark ~= "") and t.image_dark or light | |||
if light == "" then return nil end | |||
return "[[File:"..light.."|"..size.."|link=|class=logo-lightmode bk-team-logo]]" | |||
.. "[[File:"..dark .."|"..size.."|link=|class=logo-darkmode bk-team-logo]]" | |||
end | |||
local rows = cargo.query("Teams","image,image_dark", | |||
{where="name='"..esc(teamName).."'", limit=1}) | |||
local t = (rows and #rows > 0) and rows[1] or false | |||
teamLogoCache[teamName] = t | |||
if not t then return nil end | |||
local light = t.image or "" | |||
local dark = (t.image_dark and t.image_dark ~= "") and t.image_dark or light | |||
if light == "" then return nil end | |||
return "[[File:"..light.."|"..size.."|link=|class=logo-lightmode bk-team-logo]]" | |||
.. "[[File:"..dark .."|"..size.."|link=|class=logo-darkmode bk-team-logo]]" | |||
end | |||
-- Fetch match data from Cargo BracketMatch table | |||
-- Returns: { team1, score1, team2, score2, winner, bo, date, casters, vod } | |||
local function fetchMatch(event, matchID) | |||
local rows = cargo.query("BracketMatch", | |||
"team1,score1,team2,score2,winner,bo,match_date,casters,vod,notes,short1,short2", | |||
{ where = "event='"..esc(event).."' AND match_id='"..esc(matchID).."'", | |||
limit = 1 }) | |||
if rows and #rows > 0 then return rows[1] end | |||
return {} | |||
end | |||
-- Merge cargo data with inline template args (inline wins if set) | |||
local function mergeMatchData(cargoData, args, prefix) | |||
local d = {} | |||
d.team1 = clean(args[prefix.."team1"]) or clean(cargoData.team1) or "TBD" | |||
d.team2 = clean(args[prefix.."team2"]) or clean(cargoData.team2) or "TBD" | |||
-- short names: used for display in bracket card; falls back to full team name | |||
d.short1 = clean(args[prefix.."short1"]) or clean(cargoData.short1) or d.team1 | |||
d.short2 = clean(args[prefix.."short2"]) or clean(cargoData.short2) or d.team2 | |||
d.score1 = clean(args[prefix.."score1"]) or clean(cargoData.score1) or "" | |||
d.score2 = clean(args[prefix.."score2"]) or clean(cargoData.score2) or "" | |||
d.winner = clean(args[prefix.."win"]) or clean(cargoData.winner) or "" | |||
d.bo = clean(args[prefix.."bo"]) or clean(cargoData.bo) or "" | |||
d.date = clean(args[prefix.."date"]) or clean(cargoData.match_date) or "" | |||
d.casters = clean(args[prefix.."casters"]) or clean(cargoData.casters) or "" | |||
d.vod = clean(args[prefix.."vod"]) or clean(cargoData.vod) or "" | |||
d.notes = clean(args[prefix.."notes"]) or clean(cargoData.notes) or "" | |||
return d | |||
end | |||
-- ── Match Card Renderer ─────────────────────────────────────────── | |||
-- Used by ALL bracket types. Renders one match box. | |||
-- winner: '1' or '2' (or team name matching team1/team2) | |||
-- matchID: used for popup data attribute | |||
-- label: optional label above card (e.g. "Upper Final") | |||
local function renderMatchCard(container, d, matchID, label, extraClass) | |||
local wrap = container:tag('div'):addClass('bk-match-wrap') | |||
-- Header row: match label (left) + BO badge (right) on same line | |||
local headerRow = wrap:tag('div'):addClass('bk-header-row') | |||
if label and label ~= "" then | |||
headerRow:tag('span'):addClass('bk-match-label-inline'):wikitext(label) | |||
end | |||
if d.bo and d.bo ~= "" then | |||
headerRow:tag('span'):addClass('bk-bo-badge'):wikitext('Bo'..d.bo) | |||
end | |||
local matchBox = wrap:tag('div'):addClass('bk-match') | |||
if extraClass and extraClass ~= "" then matchBox:addClass(extraClass) end | |||
-- Determine winner | |||
local win1 = (d.winner == "1" or d.winner == d.team1) and d.team1 ~= "TBD" | |||
local win2 = (d.winner == "2" or d.winner == d.team2) and d.team2 ~= "TBD" | |||
local isPlayed = (d.score1 ~= "" or d.score2 ~= "") or (d.winner ~= "") | |||
-- Data attrs for popup (JS reads these) | |||
matchBox:attr('data-match-id', matchID) | |||
matchBox:attr('data-team1', d.team1) | |||
matchBox:attr('data-team2', d.team2) | |||
matchBox:attr('data-score1', d.score1) | |||
matchBox:attr('data-score2', d.score2) | |||
matchBox:attr('data-bo', d.bo) | |||
matchBox:attr('data-date', d.date) | |||
matchBox:attr('data-casters', d.casters) | |||
matchBox:attr('data-vod', d.vod) | |||
matchBox:attr('data-notes', d.notes) | |||
if isPlayed then matchBox:addClass('bk-match-played') end | |||
matchBox:addClass('bk-match-clickable') -- JS hook | |||
-- Team 1 | |||
local row1 = matchBox:tag('div'):addClass('bk-team') | |||
if win1 then row1:addClass('bk-win') end | |||
if not win1 and win2 then row1:addClass('bk-lose') end | |||
local logo1 = getTeamLogo(d.team1, "18px") | |||
local nameCell1 = row1:tag('div'):addClass('bk-team-name') | |||
if logo1 then nameCell1:wikitext(logo1) end | |||
if d.team1 ~= "TBD" then | |||
-- link uses full team name, display uses short name | |||
nameCell1:tag('span'):wikitext("[["..d.team1.."|"..d.short1.."]]") | |||
else | |||
nameCell1:tag('span'):addClass('bk-tbd'):wikitext("TBD") | |||
end | |||
if win1 then row1:tag('div'):addClass('bk-win-icon'):wikitext('✓') end | |||
local scoreBox1 = row1:tag('div'):addClass('bk-score') | |||
if d.score1 ~= "" then scoreBox1:wikitext(d.score1) end | |||
-- Divider | |||
matchBox:tag('div'):addClass('bk-divider') | |||
-- Team 2 | |||
local row2 = matchBox:tag('div'):addClass('bk-team') | |||
if win2 then row2:addClass('bk-win') end | |||
if not win2 and win1 then row2:addClass('bk-lose') end | |||
local logo2 = getTeamLogo(d.team2, "18px") | |||
local nameCell2 = row2:tag('div'):addClass('bk-team-name') | |||
if logo2 then nameCell2:wikitext(logo2) end | |||
if d.team2 ~= "TBD" then | |||
nameCell2:tag('span'):wikitext("[["..d.team2.."|"..d.short2.."]]") | |||
else | |||
nameCell2:tag('span'):addClass('bk-tbd'):wikitext("TBD") | |||
end | |||
if win2 then row2:tag('div'):addClass('bk-win-icon'):wikitext('✓') end | |||
local scoreBox2 = row2:tag('div'):addClass('bk-score') | |||
if d.score2 ~= "" then scoreBox2:wikitext(d.score2) end | |||
return matchBox | |||
end | |||
-- ════════════════════════════════════════════════════════════════ | |||
-- FORMAT 1: SINGLE ELIMINATION | |||
-- ════════════════════════════════════════════════════════════════ | |||
local function renderSingleElim(args, event, teamCount) | |||
-- teamCount must be power of 2: 4, 8, 16, 32 | |||
local rounds = math.floor(math.log(teamCount) / math.log(2)) | |||
if 2^rounds ~= teamCount then | |||
return "<div class='bk-error'>Single elimination requires power-of-2 team count (4, 8, 16, 32). Got: "..teamCount.."</div>" | |||
end | |||
local roundNames = { | |||
[1] = args.r1name or "Round 1", | |||
} | |||
-- Auto-name rounds by position from the end | |||
local suffixNames = { "Grand Final", "Semifinal", "Quarterfinal", "Round of 16", "Round of 32" } | |||
for i = rounds, 1, -1 do | |||
local pos = rounds - i + 1 -- 1 = Grand Final | |||
roundNames[i] = args["r"..i.."name"] or suffixNames[pos] or ("Round "..i) | |||
end | |||
local scroll = html.create('div'):addClass('bk-scroll') | |||
local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-single-elim') | |||
for r = 1, rounds do | for r = 1, rounds do | ||
local col = wrapper:tag('div'):addClass(' | local col = wrapper:tag('div'):addClass('bk-col bk-col-'..r) | ||
local | col:tag('div'):addClass('bk-col-header'):wikitext(roundNames[r]) | ||
local matchCount = teamCount / (2^r) | |||
for m = 1, matchCount do | |||
local matchID = "R"..r.."M"..m | |||
local cd = fetchMatch(event, matchID) | |||
local d = mergeMatchData(cd, args, matchID) | |||
local spacer = col:tag('div'):addClass('bk-spacer') | |||
renderMatchCard(spacer, d, matchID, nil, "has-connector") | |||
end | end | ||
end | |||
return tostring(scroll) | |||
end | |||
-- ════════════════════════════════════════════════════════════════ | |||
-- FORMAT 2: DOUBLE ELIMINATION | |||
-- Standard: Upper Bracket + Lower Bracket + Grand Final | |||
-- ════════════════════════════════════════════════════════════════ | |||
local function renderDoubleElim(args, event, teamCount) | |||
-- Supports 4, 8, 16 teams | |||
local ubRounds -- Upper bracket rounds before GF | |||
local lbRounds -- Lower bracket rounds | |||
if teamCount == 4 then | |||
ubRounds = 2; lbRounds = 2 | |||
elseif teamCount == 8 then | |||
ubRounds = 3; lbRounds = 4 | |||
elseif teamCount == 16 then | |||
ubRounds = 4; lbRounds = 6 | |||
else | |||
return "<div class='bk-error'>Double elimination supports 4, 8, or 16 teams. Got: "..teamCount.."</div>" | |||
end | |||
local scroll = html.create('div'):addClass('bk-scroll') | |||
local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-double-elim') | |||
-- ── Upper Bracket ── | |||
local ubSection = wrapper:tag('div'):addClass('bk-section') | |||
ubSection:tag('div'):addClass('bk-section-title'):wikitext('Upper Bracket') | |||
local ubRow = ubSection:tag('div'):addClass('bk-row') | |||
local ubRoundNames = {} | |||
local ubSuffix = {"Upper Final","Upper Semifinal","Upper Quarterfinal","Upper Round 1"} | |||
for r = ubRounds, 1, -1 do | |||
local pos = ubRounds - r + 1 | |||
ubRoundNames[r] = args["ubr"..r.."name"] or ubSuffix[pos] or ("UB Round "..r) | |||
end | |||
-- | for r = 1, ubRounds do | ||
local | local col = ubRow:tag('div'):addClass('bk-col bk-col-'..r) | ||
if | col:tag('div'):addClass('bk-col-header'):wikitext(ubRoundNames[r]) | ||
local matchCount = teamCount / (2^r) | |||
for m = 1, matchCount do | |||
local matchID = "UBR"..r.."M"..m | |||
local cd = fetchMatch(event, matchID) | |||
local d = mergeMatchData(cd, args, matchID) | |||
local sp = col:tag('div'):addClass('bk-spacer') | |||
renderMatchCard(sp, d, matchID, nil, r < ubRounds and "has-connector" or "") | |||
end | |||
end | |||
-- ── Lower Bracket ── | |||
local lbSection = wrapper:tag('div'):addClass('bk-section bk-lower-section') | |||
lbSection:tag('div'):addClass('bk-section-title bk-lower-title'):wikitext('Lower Bracket') | |||
local lbRow = lbSection:tag('div'):addClass('bk-row') | |||
local lbRoundNames = {} | |||
local lbSuffix = {"Lower Final","Lower Semifinal","Lower Round 4","Lower Round 3","Lower Round 2","Lower Round 1"} | |||
for r = lbRounds, 1, -1 do | |||
local pos = lbRounds - r + 1 | |||
lbRoundNames[r] = args["lbr"..r.."name"] or lbSuffix[pos] or ("LB Round "..r) | |||
end | |||
for r = 1, lbRounds do | |||
local col = lbRow:tag('div'):addClass('bk-col bk-col-lb-'..r) | |||
col:tag('div'):addClass('bk-col-header bk-lb-header'):wikitext(lbRoundNames[r]) | |||
-- LB match count: alternates between dropping & non-dropping rounds | |||
local matchCount | |||
if r == 1 then | |||
matchCount = teamCount / 4 | |||
elseif r % 2 == 1 then | |||
matchCount = math.max(1, teamCount / (2^(math.ceil(r/2)+1))) | |||
else | |||
matchCount = math.max(1, teamCount / (2^(math.ceil(r/2)+1))) | |||
end | |||
matchCount = math.max(1, matchCount) | |||
for m = 1, matchCount do | |||
local matchID = "LBR"..r.."M"..m | |||
local cd = fetchMatch(event, matchID) | |||
local d = mergeMatchData(cd, args, matchID) | |||
local sp = col:tag('div'):addClass('bk-spacer') | |||
renderMatchCard(sp, d, matchID, nil, r < lbRounds and "has-connector" or "") | |||
end | |||
end | |||
-- ── Grand Final ── | |||
local gfSection = wrapper:tag('div'):addClass('bk-section bk-gf-section') | |||
gfSection:tag('div'):addClass('bk-section-title bk-gf-title'):wikitext('Grand Final') | |||
local gfRow = gfSection:tag('div'):addClass('bk-row bk-gf-row') | |||
-- Game 1 (UB winner vs LB winner) | |||
local gf1 = fetchMatch(event, "GF1") | |||
local gf1d = mergeMatchData(gf1, args, "GF1") | |||
local gf1col = gfRow:tag('div'):addClass('bk-col bk-col-gf') | |||
gf1col:tag('div'):addClass('bk-col-header'):wikitext(args.gf1name or "Grand Final") | |||
local sp1 = gf1col:tag('div'):addClass('bk-spacer') | |||
renderMatchCard(sp1, gf1d, "GF1", nil, "") | |||
-- Optional bracket reset | |||
local gf2 = fetchMatch(event, "GF2") | |||
local gf2d = mergeMatchData(gf2, args, "GF2") | |||
if gf2d.team1 ~= "TBD" or gf2d.team2 ~= "TBD" or clean(args.GF2team1) or clean(args.GF2team2) then | |||
local gf2col = gfRow:tag('div'):addClass('bk-col bk-col-gf') | |||
gf2col:tag('div'):addClass('bk-col-header'):wikitext(args.gf2name or "Bracket Reset") | |||
local sp2 = gf2col:tag('div'):addClass('bk-spacer') | |||
renderMatchCard(sp2, gf2d, "GF2", nil, "") | |||
end | |||
return tostring(scroll) | |||
end | |||
-- ════════════════════════════════════════════════════════════════ | |||
-- FORMAT 3: GSL / 4-TEAM DOUBLE ELIM | |||
-- Qualifier 1: #1 vs #2 → winner → Final, loser → Qualifier 2 | |||
-- Eliminator: #3 vs #4 → winner → Qualifier 2, loser → OUT | |||
-- Qualifier 2: loser(Q1) vs winner(Elim) → winner → Final, loser → OUT | |||
-- Final: winner(Q1) vs winner(Q2) → WINNER | |||
-- ════════════════════════════════════════════════════════════════ | |||
local function renderGSL(args, event) | |||
local scroll = html.create('div'):addClass('bk-scroll') | |||
local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-gsl') | |||
-- ── Column 1: Qualifier 1 + Eliminator (run simultaneously) ── | |||
local col1 = wrapper:tag('div'):addClass('bk-col bk-gsl-col1') | |||
col1:tag('div'):addClass('bk-col-header'):wikitext(args.q1name or 'Qualifier 1') | |||
local q1 = fetchMatch(event, "Q1") | |||
local q1d = mergeMatchData(q1, args, "Q1") | |||
local q1sp = col1:tag('div'):addClass('bk-spacer bk-gsl-top') | |||
renderMatchCard(q1sp, q1d, "Q1", nil, "has-connector") | |||
-- Gap between Q1 and Elim | |||
col1:tag('div'):addClass('bk-gsl-gap') | |||
col1:tag('div'):addClass('bk-col-header bk-col-header-2'):wikitext(args.elimname or 'Eliminator') | |||
local elim = fetchMatch(event, "ELIM") | |||
local elimd = mergeMatchData(elim, args, "ELIM") | |||
local elimsp = col1:tag('div'):addClass('bk-spacer bk-gsl-bot') | |||
renderMatchCard(elimsp, elimd, "ELIM", nil, "has-connector") | |||
-- ── Column 2: Qualifier 2 ── | |||
local col2 = wrapper:tag('div'):addClass('bk-col bk-gsl-col2') | |||
col2:tag('div'):addClass('bk-col-header'):wikitext(args.q2name or 'Qualifier 2') | |||
local q2 = fetchMatch(event, "Q2") | |||
local q2d = mergeMatchData(q2, args, "Q2") | |||
local q2sp = col2:tag('div'):addClass('bk-spacer bk-gsl-mid') | |||
renderMatchCard(q2sp, q2d, "Q2", nil, "has-connector") | |||
-- ── Column 3: Final ── | |||
local col3 = wrapper:tag('div'):addClass('bk-col bk-gsl-col3') | |||
col3:tag('div'):addClass('bk-col-header bk-gf-title'):wikitext(args.finalname or 'Grand Final') | |||
local final = fetchMatch(event, "FINAL") | |||
local finald = mergeMatchData(final, args, "FINAL") | |||
local finalsp = col3:tag('div'):addClass('bk-spacer bk-gsl-final') | |||
renderMatchCard(finalsp, finald, "FINAL", nil, "bk-final-match") | |||
-- ── Fate Labels (under the bracket) ── | |||
local legend = scroll:tag('div'):addClass('bk-gsl-legend') | |||
legend:tag('span'):addClass('bk-legend-item bk-legend-adv'):wikitext('✓ Advances to next round') | |||
legend:tag('span'):addClass('bk-legend-item bk-legend-elim'):wikitext('✗ Eliminated') | |||
return tostring(scroll) | |||
end | |||
-- ════════════════════════════════════════════════════════════════ | |||
-- FORMAT 4: ROUND ROBIN | |||
-- ════════════════════════════════════════════════════════════════ | |||
local function renderRoundRobin(args, event, teamCount) | |||
-- Build team list | |||
local teams = {} | |||
for i = 1, teamCount do | |||
local t = clean(args["team"..i]) | |||
if t then table.insert(teams, t) end | |||
end | |||
-- If fewer teams passed as args, try to figure out from BracketMatch | |||
if #teams == 0 then | |||
return "<div class='bk-error'>Round Robin: please pass team1=, team2=, ... up to team"..teamCount.."= arguments.</div>" | |||
end | |||
local n = #teams | |||
local scroll = html.create('div'):addClass('bk-scroll') | |||
local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-rr') | |||
-- ── Results Matrix Table ── | |||
local section = wrapper:tag('div'):addClass('bk-rr-section') | |||
section:tag('div'):addClass('bk-section-title'):wikitext('Match Results') | |||
local tbl = section:tag('table'):addClass('bk-rr-table') | |||
-- Header row | |||
local hdr = tbl:tag('tr') | |||
hdr:tag('th'):addClass('bk-rr-corner'):wikitext('') | |||
for _, t in ipairs(teams) do | |||
local logo = getTeamLogo(t, "16px") | |||
local th = hdr:tag('th'):addClass('bk-rr-th') | |||
if logo then th:wikitext(logo) end | |||
th:tag('span'):wikitext(t) | |||
end | |||
-- Data rows | |||
for i, t1 in ipairs(teams) do | |||
local tr = tbl:tag('tr') | |||
-- Row header | |||
local rowHdr = tr:tag('th'):addClass('bk-rr-row-hdr') | |||
local logo1 = getTeamLogo(t1, "16px") | |||
if logo1 then rowHdr:wikitext(logo1) end | |||
rowHdr:tag('span'):wikitext(t1) | |||
for j, t2 in ipairs(teams) do | |||
if i == j then | |||
tr:tag('td'):addClass('bk-rr-self'):wikitext('—') | |||
else | |||
-- Match ID: RR_i_j (always smaller index first for canonical ID) | |||
local matchID | |||
if i < j then | |||
matchID = "RR"..i.."v"..j | |||
else | |||
matchID = "RR"..j.."v"..i | |||
end | |||
local cd = fetchMatch(event, matchID) | |||
local d = mergeMatchData(cd, args, matchID) | |||
local score | |||
if i < j then | |||
score = d.score1 | |||
else | |||
score = d.score2 | |||
end | |||
local isWin = (i < j and d.winner == "1") or (i > j and d.winner == "2") | |||
or (i < j and d.winner == t1) or (i > j and d.winner == t1) | |||
local isLose = (i < j and d.winner == "2") or (i > j and d.winner == "1") | |||
or (i < j and d.winner == t2) or (i > j and d.winner == t2) | |||
local td = tr:tag('td'):addClass('bk-rr-cell bk-match-clickable') | |||
:attr('data-match-id', matchID) | |||
:attr('data-team1', d.team1) | |||
:attr('data-team2', d.team2) | |||
:attr('data-score1', d.score1) | |||
:attr('data-score2', d.score2) | |||
:attr('data-bo', d.bo) | |||
:attr('data-date', d.date) | |||
:attr('data-casters', d.casters) | |||
:attr('data-vod', d.vod) | |||
if isWin then td:addClass('bk-rr-win') | |||
elseif isLose then td:addClass('bk-rr-lose') | |||
end | |||
if score ~= "" then td:wikitext(score) | |||
elseif isWin then td:wikitext('W') | |||
elseif isLose then td:wikitext('L') | |||
else td:addClass('bk-rr-pending'):wikitext('·') | |||
end | |||
end | end | ||
end | end | ||
end | end | ||
return tostring(container) | -- ── Standings Table ── | ||
local standSection = wrapper:tag('div'):addClass('bk-rr-standings') | |||
standSection:tag('div'):addClass('bk-section-title'):wikitext('Standings') | |||
-- Calculate W/L/D from cargo data | |||
local wld = {} | |||
for _, t in ipairs(teams) do | |||
wld[t] = { w = 0, l = 0, d = 0, pts = 0 } | |||
end | |||
for i = 1, n-1 do | |||
for j = i+1, n do | |||
local matchID = "RR"..i.."v"..j | |||
local cd = fetchMatch(event, matchID) | |||
local d = mergeMatchData(cd, args, matchID) | |||
if d.winner ~= "" then | |||
local winner = d.winner | |||
local t1n = teams[i]; local t2n = teams[j] | |||
local actualWinner | |||
if winner == "1" or winner == t1n then actualWinner = t1n | |||
elseif winner == "2" or winner == t2n then actualWinner = t2n | |||
end | |||
if actualWinner then | |||
local loser = actualWinner == t1n and t2n or t1n | |||
if wld[actualWinner] then wld[actualWinner].w = wld[actualWinner].w + 1; wld[actualWinner].pts = wld[actualWinner].pts + 3 end | |||
if wld[loser] then wld[loser].l = wld[loser].l + 1 end | |||
end | |||
end | |||
end | |||
end | |||
-- Sort by points desc | |||
local sorted = {} | |||
for _, t in ipairs(teams) do table.insert(sorted, t) end | |||
table.sort(sorted, function(a,b) | |||
local wa = wld[a] or {pts=0}; local wb = wld[b] or {pts=0} | |||
if wa.pts ~= wb.pts then return wa.pts > wb.pts end | |||
return (wa.w or 0) > (wb.w or 0) | |||
end) | |||
local stbl = standSection:tag('table'):addClass('bk-rr-stand-table') | |||
local shdr = stbl:tag('tr') | |||
shdr:tag('th'):wikitext('#') | |||
shdr:tag('th'):css('text-align','left'):wikitext('Team') | |||
shdr:tag('th'):wikitext('W') | |||
shdr:tag('th'):wikitext('L') | |||
shdr:tag('th'):wikitext('Pts') | |||
for pos, t in ipairs(sorted) do | |||
local wl = wld[t] or {w=0,l=0,pts=0} | |||
local tr = stbl:tag('tr'):addClass('bk-rr-stand-row') | |||
tr:tag('td'):addClass('bk-rr-rank'):wikitext(tostring(pos)) | |||
local nameTd = tr:tag('td'):addClass('bk-rr-stand-name') | |||
local logo = getTeamLogo(t, "18px") | |||
if logo then nameTd:wikitext(logo) end | |||
nameTd:tag('span'):wikitext("[["..t.."]]") | |||
tr:tag('td'):addClass('bk-rr-w'):wikitext(tostring(wl.w)) | |||
tr:tag('td'):addClass('bk-rr-l'):wikitext(tostring(wl.l)) | |||
tr:tag('td'):addClass('bk-rr-pts'):wikitext(tostring(wl.pts)) | |||
end | |||
return tostring(scroll) | |||
end | |||
-- ════════════════════════════════════════════════════════════════ | |||
-- FORMAT 5: SWISS | |||
-- ════════════════════════════════════════════════════════════════ | |||
local function renderSwiss(args, event, teamCount, swissRounds) | |||
swissRounds = swissRounds or tonumber(args.rounds) or 5 | |||
local scroll = html.create('div'):addClass('bk-scroll') | |||
local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-swiss') | |||
for r = 1, swissRounds do | |||
local roundSection = wrapper:tag('div'):addClass('bk-swiss-round') | |||
roundSection:tag('div'):addClass('bk-section-title'):wikitext( | |||
args["r"..r.."name"] or ("Round "..r)) | |||
local matchCount = math.floor(teamCount / 2) | |||
local matchRow = roundSection:tag('div'):addClass('bk-swiss-matches') | |||
for m = 1, matchCount do | |||
local matchID = "SR"..r.."M"..m | |||
local cd = fetchMatch(event, matchID) | |||
local d = mergeMatchData(cd, args, matchID) | |||
renderMatchCard(matchRow, d, matchID, nil, "") | |||
end | |||
end | |||
return tostring(scroll) | |||
end | |||
-- ════════════════════════════════════════════════════════════════ | |||
-- MAIN ENTRY POINT | |||
-- ════════════════════════════════════════════════════════════════ | |||
function p.main(frame) | |||
local args = (frame:getParent() and frame:getParent().args) or frame.args | |||
local format = clean(args.format) or "single" | |||
local event = clean(args.event) or "" | |||
local teamCount = tonumber(args.teams) or 8 | |||
-- Wrap everything in a root container with the modal placeholder | |||
local root = html.create('div'):addClass('bk-root') | |||
root:attr('data-event', event) | |||
-- Format selector tabs (if multiple formats on same page via JS tabs — optional) | |||
local bracketHTML | |||
format = format:lower() | |||
if format == "single" or format == "se" then | |||
bracketHTML = renderSingleElim(args, event, teamCount) | |||
elseif format == "double" or format == "de" then | |||
bracketHTML = renderDoubleElim(args, event, teamCount) | |||
elseif format == "gsl" or format == "4team" then | |||
bracketHTML = renderGSL(args, event) | |||
elseif format == "rr" or format == "roundrobin" then | |||
bracketHTML = renderRoundRobin(args, event, teamCount) | |||
elseif format == "swiss" then | |||
bracketHTML = renderSwiss(args, event, teamCount) | |||
else | |||
bracketHTML = "<div class='bk-error'>Unknown bracket format: <b>"..format.."</b>. " | |||
.."Valid: single, double, gsl, rr, swiss</div>" | |||
end | |||
root:wikitext(bracketHTML) | |||
-- Modal built with mw.html (avoids MediaWiki sanitizer stripping raw HTML strings) | |||
local overlay = root:tag('div') | |||
:addClass('bk-modal-overlay') | |||
:css('display','none') | |||
local modal = overlay:tag('div'):addClass('bk-modal') | |||
-- Use span instead of button (<button> is stripped by MediaWiki sanitizer) | |||
modal:tag('span') | |||
:addClass('bk-modal-close') | |||
:attr('title','Close') | |||
:attr('role','button') | |||
:attr('tabindex','0') | |||
:wikitext('✕') | |||
local mheader = modal:tag('div'):addClass('bk-modal-header') | |||
local mteams = mheader:tag('div'):addClass('bk-modal-teams') | |||
mteams:tag('span'):addClass('bk-modal-t1') | |||
mteams:tag('span'):addClass('bk-modal-vs'):wikitext('vs') | |||
mteams:tag('span'):addClass('bk-modal-t2') | |||
local mscore = mheader:tag('div'):addClass('bk-modal-score') | |||
mscore:tag('span'):addClass('bk-modal-s1') | |||
mscore:tag('span'):addClass('bk-modal-dash'):wikitext('–') | |||
mscore:tag('span'):addClass('bk-modal-s2') | |||
local mmeta = modal:tag('div'):addClass('bk-modal-meta') | |||
mmeta:tag('div'):addClass('bk-modal-row bk-modal-bo') | |||
mmeta:tag('div'):addClass('bk-modal-row bk-modal-date') | |||
mmeta:tag('div'):addClass('bk-modal-row bk-modal-casters') | |||
mmeta:tag('div'):addClass('bk-modal-row bk-modal-notes') | |||
mmeta:tag('div'):addClass('bk-modal-row bk-modal-vod') | |||
return tostring(root) | |||
end | |||
-- ── Convenience wrappers ────────────────────────────────────────── | |||
function p.single(frame) | |||
local args = (frame:getParent() and frame:getParent().args) or frame.args | |||
args.format = "single" | |||
return p.main(frame) | |||
end | |||
function p.double(frame) | |||
local args = (frame:getParent() and frame:getParent().args) or frame.args | |||
args.format = "double" | |||
return p.main(frame) | |||
end | |||
function p.gsl(frame) | |||
local args = (frame:getParent() and frame:getParent().args) or frame.args | |||
args.format = "gsl" | |||
return p.main(frame) | |||
end | |||
function p.rr(frame) | |||
local args = (frame:getParent() and frame:getParent().args) or frame.args | |||
args.format = "rr" | |||
return p.main(frame) | |||
end | |||
function p.swiss(frame) | |||
local args = (frame:getParent() and frame:getParent().args) or frame.args | |||
args.format = "swiss" | |||
return p.main(frame) | |||
end | end | ||
return p | return p | ||
Latest revision as of 01:00, 20 March 2026
Documentation for this module may be created at Module:Bracket/doc
-- ================================================================
-- Module:Bracket v2.0
-- Supports: Single Elimination, Double Elimination, GSL, Round Robin, Swiss
-- Data: Cargo-driven (BracketMatch table) + inline template args
-- Match popup: embeds data-* attrs, JS handles modal
-- ================================================================
local p = {}
local html = mw.html
local cargo = mw.ext.cargo
-- ── Helpers ──────────────────────────────────────────────────────
local function esc(s)
if not s then return "" end
return s:gsub("\\","\\\\"):gsub("'","\\'")
end
local function clean(s)
if not s then return nil end
local c = s:gsub("[\r\n]",""):gsub("^%s*(.-)%s*$","%1")
return c ~= "" and c or nil
end
-- Fetch team logo HTML (light/dark aware, matches your existing pattern)
local teamLogoCache = {}
local function getTeamLogo(teamName, size)
if not teamName or teamName == "" then return nil end
size = size or "20px"
if teamLogoCache[teamName] then
local t = teamLogoCache[teamName]
if not t then return nil end
local light = t.image or ""
local dark = (t.image_dark and t.image_dark ~= "") and t.image_dark or light
if light == "" then return nil end
return "[[File:"..light.."|"..size.."|link=|class=logo-lightmode bk-team-logo]]"
.. "[[File:"..dark .."|"..size.."|link=|class=logo-darkmode bk-team-logo]]"
end
local rows = cargo.query("Teams","image,image_dark",
{where="name='"..esc(teamName).."'", limit=1})
local t = (rows and #rows > 0) and rows[1] or false
teamLogoCache[teamName] = t
if not t then return nil end
local light = t.image or ""
local dark = (t.image_dark and t.image_dark ~= "") and t.image_dark or light
if light == "" then return nil end
return "[[File:"..light.."|"..size.."|link=|class=logo-lightmode bk-team-logo]]"
.. "[[File:"..dark .."|"..size.."|link=|class=logo-darkmode bk-team-logo]]"
end
-- Fetch match data from Cargo BracketMatch table
-- Returns: { team1, score1, team2, score2, winner, bo, date, casters, vod }
local function fetchMatch(event, matchID)
local rows = cargo.query("BracketMatch",
"team1,score1,team2,score2,winner,bo,match_date,casters,vod,notes,short1,short2",
{ where = "event='"..esc(event).."' AND match_id='"..esc(matchID).."'",
limit = 1 })
if rows and #rows > 0 then return rows[1] end
return {}
end
-- Merge cargo data with inline template args (inline wins if set)
local function mergeMatchData(cargoData, args, prefix)
local d = {}
d.team1 = clean(args[prefix.."team1"]) or clean(cargoData.team1) or "TBD"
d.team2 = clean(args[prefix.."team2"]) or clean(cargoData.team2) or "TBD"
-- short names: used for display in bracket card; falls back to full team name
d.short1 = clean(args[prefix.."short1"]) or clean(cargoData.short1) or d.team1
d.short2 = clean(args[prefix.."short2"]) or clean(cargoData.short2) or d.team2
d.score1 = clean(args[prefix.."score1"]) or clean(cargoData.score1) or ""
d.score2 = clean(args[prefix.."score2"]) or clean(cargoData.score2) or ""
d.winner = clean(args[prefix.."win"]) or clean(cargoData.winner) or ""
d.bo = clean(args[prefix.."bo"]) or clean(cargoData.bo) or ""
d.date = clean(args[prefix.."date"]) or clean(cargoData.match_date) or ""
d.casters = clean(args[prefix.."casters"]) or clean(cargoData.casters) or ""
d.vod = clean(args[prefix.."vod"]) or clean(cargoData.vod) or ""
d.notes = clean(args[prefix.."notes"]) or clean(cargoData.notes) or ""
return d
end
-- ── Match Card Renderer ───────────────────────────────────────────
-- Used by ALL bracket types. Renders one match box.
-- winner: '1' or '2' (or team name matching team1/team2)
-- matchID: used for popup data attribute
-- label: optional label above card (e.g. "Upper Final")
local function renderMatchCard(container, d, matchID, label, extraClass)
local wrap = container:tag('div'):addClass('bk-match-wrap')
-- Header row: match label (left) + BO badge (right) on same line
local headerRow = wrap:tag('div'):addClass('bk-header-row')
if label and label ~= "" then
headerRow:tag('span'):addClass('bk-match-label-inline'):wikitext(label)
end
if d.bo and d.bo ~= "" then
headerRow:tag('span'):addClass('bk-bo-badge'):wikitext('Bo'..d.bo)
end
local matchBox = wrap:tag('div'):addClass('bk-match')
if extraClass and extraClass ~= "" then matchBox:addClass(extraClass) end
-- Determine winner
local win1 = (d.winner == "1" or d.winner == d.team1) and d.team1 ~= "TBD"
local win2 = (d.winner == "2" or d.winner == d.team2) and d.team2 ~= "TBD"
local isPlayed = (d.score1 ~= "" or d.score2 ~= "") or (d.winner ~= "")
-- Data attrs for popup (JS reads these)
matchBox:attr('data-match-id', matchID)
matchBox:attr('data-team1', d.team1)
matchBox:attr('data-team2', d.team2)
matchBox:attr('data-score1', d.score1)
matchBox:attr('data-score2', d.score2)
matchBox:attr('data-bo', d.bo)
matchBox:attr('data-date', d.date)
matchBox:attr('data-casters', d.casters)
matchBox:attr('data-vod', d.vod)
matchBox:attr('data-notes', d.notes)
if isPlayed then matchBox:addClass('bk-match-played') end
matchBox:addClass('bk-match-clickable') -- JS hook
-- Team 1
local row1 = matchBox:tag('div'):addClass('bk-team')
if win1 then row1:addClass('bk-win') end
if not win1 and win2 then row1:addClass('bk-lose') end
local logo1 = getTeamLogo(d.team1, "18px")
local nameCell1 = row1:tag('div'):addClass('bk-team-name')
if logo1 then nameCell1:wikitext(logo1) end
if d.team1 ~= "TBD" then
-- link uses full team name, display uses short name
nameCell1:tag('span'):wikitext("[["..d.team1.."|"..d.short1.."]]")
else
nameCell1:tag('span'):addClass('bk-tbd'):wikitext("TBD")
end
if win1 then row1:tag('div'):addClass('bk-win-icon'):wikitext('✓') end
local scoreBox1 = row1:tag('div'):addClass('bk-score')
if d.score1 ~= "" then scoreBox1:wikitext(d.score1) end
-- Divider
matchBox:tag('div'):addClass('bk-divider')
-- Team 2
local row2 = matchBox:tag('div'):addClass('bk-team')
if win2 then row2:addClass('bk-win') end
if not win2 and win1 then row2:addClass('bk-lose') end
local logo2 = getTeamLogo(d.team2, "18px")
local nameCell2 = row2:tag('div'):addClass('bk-team-name')
if logo2 then nameCell2:wikitext(logo2) end
if d.team2 ~= "TBD" then
nameCell2:tag('span'):wikitext("[["..d.team2.."|"..d.short2.."]]")
else
nameCell2:tag('span'):addClass('bk-tbd'):wikitext("TBD")
end
if win2 then row2:tag('div'):addClass('bk-win-icon'):wikitext('✓') end
local scoreBox2 = row2:tag('div'):addClass('bk-score')
if d.score2 ~= "" then scoreBox2:wikitext(d.score2) end
return matchBox
end
-- ════════════════════════════════════════════════════════════════
-- FORMAT 1: SINGLE ELIMINATION
-- ════════════════════════════════════════════════════════════════
local function renderSingleElim(args, event, teamCount)
-- teamCount must be power of 2: 4, 8, 16, 32
local rounds = math.floor(math.log(teamCount) / math.log(2))
if 2^rounds ~= teamCount then
return "<div class='bk-error'>Single elimination requires power-of-2 team count (4, 8, 16, 32). Got: "..teamCount.."</div>"
end
local roundNames = {
[1] = args.r1name or "Round 1",
}
-- Auto-name rounds by position from the end
local suffixNames = { "Grand Final", "Semifinal", "Quarterfinal", "Round of 16", "Round of 32" }
for i = rounds, 1, -1 do
local pos = rounds - i + 1 -- 1 = Grand Final
roundNames[i] = args["r"..i.."name"] or suffixNames[pos] or ("Round "..i)
end
local scroll = html.create('div'):addClass('bk-scroll')
local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-single-elim')
for r = 1, rounds do
local col = wrapper:tag('div'):addClass('bk-col bk-col-'..r)
col:tag('div'):addClass('bk-col-header'):wikitext(roundNames[r])
local matchCount = teamCount / (2^r)
for m = 1, matchCount do
local matchID = "R"..r.."M"..m
local cd = fetchMatch(event, matchID)
local d = mergeMatchData(cd, args, matchID)
local spacer = col:tag('div'):addClass('bk-spacer')
renderMatchCard(spacer, d, matchID, nil, "has-connector")
end
end
return tostring(scroll)
end
-- ════════════════════════════════════════════════════════════════
-- FORMAT 2: DOUBLE ELIMINATION
-- Standard: Upper Bracket + Lower Bracket + Grand Final
-- ════════════════════════════════════════════════════════════════
local function renderDoubleElim(args, event, teamCount)
-- Supports 4, 8, 16 teams
local ubRounds -- Upper bracket rounds before GF
local lbRounds -- Lower bracket rounds
if teamCount == 4 then
ubRounds = 2; lbRounds = 2
elseif teamCount == 8 then
ubRounds = 3; lbRounds = 4
elseif teamCount == 16 then
ubRounds = 4; lbRounds = 6
else
return "<div class='bk-error'>Double elimination supports 4, 8, or 16 teams. Got: "..teamCount.."</div>"
end
local scroll = html.create('div'):addClass('bk-scroll')
local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-double-elim')
-- ── Upper Bracket ──
local ubSection = wrapper:tag('div'):addClass('bk-section')
ubSection:tag('div'):addClass('bk-section-title'):wikitext('Upper Bracket')
local ubRow = ubSection:tag('div'):addClass('bk-row')
local ubRoundNames = {}
local ubSuffix = {"Upper Final","Upper Semifinal","Upper Quarterfinal","Upper Round 1"}
for r = ubRounds, 1, -1 do
local pos = ubRounds - r + 1
ubRoundNames[r] = args["ubr"..r.."name"] or ubSuffix[pos] or ("UB Round "..r)
end
for r = 1, ubRounds do
local col = ubRow:tag('div'):addClass('bk-col bk-col-'..r)
col:tag('div'):addClass('bk-col-header'):wikitext(ubRoundNames[r])
local matchCount = teamCount / (2^r)
for m = 1, matchCount do
local matchID = "UBR"..r.."M"..m
local cd = fetchMatch(event, matchID)
local d = mergeMatchData(cd, args, matchID)
local sp = col:tag('div'):addClass('bk-spacer')
renderMatchCard(sp, d, matchID, nil, r < ubRounds and "has-connector" or "")
end
end
-- ── Lower Bracket ──
local lbSection = wrapper:tag('div'):addClass('bk-section bk-lower-section')
lbSection:tag('div'):addClass('bk-section-title bk-lower-title'):wikitext('Lower Bracket')
local lbRow = lbSection:tag('div'):addClass('bk-row')
local lbRoundNames = {}
local lbSuffix = {"Lower Final","Lower Semifinal","Lower Round 4","Lower Round 3","Lower Round 2","Lower Round 1"}
for r = lbRounds, 1, -1 do
local pos = lbRounds - r + 1
lbRoundNames[r] = args["lbr"..r.."name"] or lbSuffix[pos] or ("LB Round "..r)
end
for r = 1, lbRounds do
local col = lbRow:tag('div'):addClass('bk-col bk-col-lb-'..r)
col:tag('div'):addClass('bk-col-header bk-lb-header'):wikitext(lbRoundNames[r])
-- LB match count: alternates between dropping & non-dropping rounds
local matchCount
if r == 1 then
matchCount = teamCount / 4
elseif r % 2 == 1 then
matchCount = math.max(1, teamCount / (2^(math.ceil(r/2)+1)))
else
matchCount = math.max(1, teamCount / (2^(math.ceil(r/2)+1)))
end
matchCount = math.max(1, matchCount)
for m = 1, matchCount do
local matchID = "LBR"..r.."M"..m
local cd = fetchMatch(event, matchID)
local d = mergeMatchData(cd, args, matchID)
local sp = col:tag('div'):addClass('bk-spacer')
renderMatchCard(sp, d, matchID, nil, r < lbRounds and "has-connector" or "")
end
end
-- ── Grand Final ──
local gfSection = wrapper:tag('div'):addClass('bk-section bk-gf-section')
gfSection:tag('div'):addClass('bk-section-title bk-gf-title'):wikitext('Grand Final')
local gfRow = gfSection:tag('div'):addClass('bk-row bk-gf-row')
-- Game 1 (UB winner vs LB winner)
local gf1 = fetchMatch(event, "GF1")
local gf1d = mergeMatchData(gf1, args, "GF1")
local gf1col = gfRow:tag('div'):addClass('bk-col bk-col-gf')
gf1col:tag('div'):addClass('bk-col-header'):wikitext(args.gf1name or "Grand Final")
local sp1 = gf1col:tag('div'):addClass('bk-spacer')
renderMatchCard(sp1, gf1d, "GF1", nil, "")
-- Optional bracket reset
local gf2 = fetchMatch(event, "GF2")
local gf2d = mergeMatchData(gf2, args, "GF2")
if gf2d.team1 ~= "TBD" or gf2d.team2 ~= "TBD" or clean(args.GF2team1) or clean(args.GF2team2) then
local gf2col = gfRow:tag('div'):addClass('bk-col bk-col-gf')
gf2col:tag('div'):addClass('bk-col-header'):wikitext(args.gf2name or "Bracket Reset")
local sp2 = gf2col:tag('div'):addClass('bk-spacer')
renderMatchCard(sp2, gf2d, "GF2", nil, "")
end
return tostring(scroll)
end
-- ════════════════════════════════════════════════════════════════
-- FORMAT 3: GSL / 4-TEAM DOUBLE ELIM
-- Qualifier 1: #1 vs #2 → winner → Final, loser → Qualifier 2
-- Eliminator: #3 vs #4 → winner → Qualifier 2, loser → OUT
-- Qualifier 2: loser(Q1) vs winner(Elim) → winner → Final, loser → OUT
-- Final: winner(Q1) vs winner(Q2) → WINNER
-- ════════════════════════════════════════════════════════════════
local function renderGSL(args, event)
local scroll = html.create('div'):addClass('bk-scroll')
local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-gsl')
-- ── Column 1: Qualifier 1 + Eliminator (run simultaneously) ──
local col1 = wrapper:tag('div'):addClass('bk-col bk-gsl-col1')
col1:tag('div'):addClass('bk-col-header'):wikitext(args.q1name or 'Qualifier 1')
local q1 = fetchMatch(event, "Q1")
local q1d = mergeMatchData(q1, args, "Q1")
local q1sp = col1:tag('div'):addClass('bk-spacer bk-gsl-top')
renderMatchCard(q1sp, q1d, "Q1", nil, "has-connector")
-- Gap between Q1 and Elim
col1:tag('div'):addClass('bk-gsl-gap')
col1:tag('div'):addClass('bk-col-header bk-col-header-2'):wikitext(args.elimname or 'Eliminator')
local elim = fetchMatch(event, "ELIM")
local elimd = mergeMatchData(elim, args, "ELIM")
local elimsp = col1:tag('div'):addClass('bk-spacer bk-gsl-bot')
renderMatchCard(elimsp, elimd, "ELIM", nil, "has-connector")
-- ── Column 2: Qualifier 2 ──
local col2 = wrapper:tag('div'):addClass('bk-col bk-gsl-col2')
col2:tag('div'):addClass('bk-col-header'):wikitext(args.q2name or 'Qualifier 2')
local q2 = fetchMatch(event, "Q2")
local q2d = mergeMatchData(q2, args, "Q2")
local q2sp = col2:tag('div'):addClass('bk-spacer bk-gsl-mid')
renderMatchCard(q2sp, q2d, "Q2", nil, "has-connector")
-- ── Column 3: Final ──
local col3 = wrapper:tag('div'):addClass('bk-col bk-gsl-col3')
col3:tag('div'):addClass('bk-col-header bk-gf-title'):wikitext(args.finalname or 'Grand Final')
local final = fetchMatch(event, "FINAL")
local finald = mergeMatchData(final, args, "FINAL")
local finalsp = col3:tag('div'):addClass('bk-spacer bk-gsl-final')
renderMatchCard(finalsp, finald, "FINAL", nil, "bk-final-match")
-- ── Fate Labels (under the bracket) ──
local legend = scroll:tag('div'):addClass('bk-gsl-legend')
legend:tag('span'):addClass('bk-legend-item bk-legend-adv'):wikitext('✓ Advances to next round')
legend:tag('span'):addClass('bk-legend-item bk-legend-elim'):wikitext('✗ Eliminated')
return tostring(scroll)
end
-- ════════════════════════════════════════════════════════════════
-- FORMAT 4: ROUND ROBIN
-- ════════════════════════════════════════════════════════════════
local function renderRoundRobin(args, event, teamCount)
-- Build team list
local teams = {}
for i = 1, teamCount do
local t = clean(args["team"..i])
if t then table.insert(teams, t) end
end
-- If fewer teams passed as args, try to figure out from BracketMatch
if #teams == 0 then
return "<div class='bk-error'>Round Robin: please pass team1=, team2=, ... up to team"..teamCount.."= arguments.</div>"
end
local n = #teams
local scroll = html.create('div'):addClass('bk-scroll')
local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-rr')
-- ── Results Matrix Table ──
local section = wrapper:tag('div'):addClass('bk-rr-section')
section:tag('div'):addClass('bk-section-title'):wikitext('Match Results')
local tbl = section:tag('table'):addClass('bk-rr-table')
-- Header row
local hdr = tbl:tag('tr')
hdr:tag('th'):addClass('bk-rr-corner'):wikitext('')
for _, t in ipairs(teams) do
local logo = getTeamLogo(t, "16px")
local th = hdr:tag('th'):addClass('bk-rr-th')
if logo then th:wikitext(logo) end
th:tag('span'):wikitext(t)
end
-- Data rows
for i, t1 in ipairs(teams) do
local tr = tbl:tag('tr')
-- Row header
local rowHdr = tr:tag('th'):addClass('bk-rr-row-hdr')
local logo1 = getTeamLogo(t1, "16px")
if logo1 then rowHdr:wikitext(logo1) end
rowHdr:tag('span'):wikitext(t1)
for j, t2 in ipairs(teams) do
if i == j then
tr:tag('td'):addClass('bk-rr-self'):wikitext('—')
else
-- Match ID: RR_i_j (always smaller index first for canonical ID)
local matchID
if i < j then
matchID = "RR"..i.."v"..j
else
matchID = "RR"..j.."v"..i
end
local cd = fetchMatch(event, matchID)
local d = mergeMatchData(cd, args, matchID)
local score
if i < j then
score = d.score1
else
score = d.score2
end
local isWin = (i < j and d.winner == "1") or (i > j and d.winner == "2")
or (i < j and d.winner == t1) or (i > j and d.winner == t1)
local isLose = (i < j and d.winner == "2") or (i > j and d.winner == "1")
or (i < j and d.winner == t2) or (i > j and d.winner == t2)
local td = tr:tag('td'):addClass('bk-rr-cell bk-match-clickable')
:attr('data-match-id', matchID)
:attr('data-team1', d.team1)
:attr('data-team2', d.team2)
:attr('data-score1', d.score1)
:attr('data-score2', d.score2)
:attr('data-bo', d.bo)
:attr('data-date', d.date)
:attr('data-casters', d.casters)
:attr('data-vod', d.vod)
if isWin then td:addClass('bk-rr-win')
elseif isLose then td:addClass('bk-rr-lose')
end
if score ~= "" then td:wikitext(score)
elseif isWin then td:wikitext('W')
elseif isLose then td:wikitext('L')
else td:addClass('bk-rr-pending'):wikitext('·')
end
end
end
end
-- ── Standings Table ──
local standSection = wrapper:tag('div'):addClass('bk-rr-standings')
standSection:tag('div'):addClass('bk-section-title'):wikitext('Standings')
-- Calculate W/L/D from cargo data
local wld = {}
for _, t in ipairs(teams) do
wld[t] = { w = 0, l = 0, d = 0, pts = 0 }
end
for i = 1, n-1 do
for j = i+1, n do
local matchID = "RR"..i.."v"..j
local cd = fetchMatch(event, matchID)
local d = mergeMatchData(cd, args, matchID)
if d.winner ~= "" then
local winner = d.winner
local t1n = teams[i]; local t2n = teams[j]
local actualWinner
if winner == "1" or winner == t1n then actualWinner = t1n
elseif winner == "2" or winner == t2n then actualWinner = t2n
end
if actualWinner then
local loser = actualWinner == t1n and t2n or t1n
if wld[actualWinner] then wld[actualWinner].w = wld[actualWinner].w + 1; wld[actualWinner].pts = wld[actualWinner].pts + 3 end
if wld[loser] then wld[loser].l = wld[loser].l + 1 end
end
end
end
end
-- Sort by points desc
local sorted = {}
for _, t in ipairs(teams) do table.insert(sorted, t) end
table.sort(sorted, function(a,b)
local wa = wld[a] or {pts=0}; local wb = wld[b] or {pts=0}
if wa.pts ~= wb.pts then return wa.pts > wb.pts end
return (wa.w or 0) > (wb.w or 0)
end)
local stbl = standSection:tag('table'):addClass('bk-rr-stand-table')
local shdr = stbl:tag('tr')
shdr:tag('th'):wikitext('#')
shdr:tag('th'):css('text-align','left'):wikitext('Team')
shdr:tag('th'):wikitext('W')
shdr:tag('th'):wikitext('L')
shdr:tag('th'):wikitext('Pts')
for pos, t in ipairs(sorted) do
local wl = wld[t] or {w=0,l=0,pts=0}
local tr = stbl:tag('tr'):addClass('bk-rr-stand-row')
tr:tag('td'):addClass('bk-rr-rank'):wikitext(tostring(pos))
local nameTd = tr:tag('td'):addClass('bk-rr-stand-name')
local logo = getTeamLogo(t, "18px")
if logo then nameTd:wikitext(logo) end
nameTd:tag('span'):wikitext("[["..t.."]]")
tr:tag('td'):addClass('bk-rr-w'):wikitext(tostring(wl.w))
tr:tag('td'):addClass('bk-rr-l'):wikitext(tostring(wl.l))
tr:tag('td'):addClass('bk-rr-pts'):wikitext(tostring(wl.pts))
end
return tostring(scroll)
end
-- ════════════════════════════════════════════════════════════════
-- FORMAT 5: SWISS
-- ════════════════════════════════════════════════════════════════
local function renderSwiss(args, event, teamCount, swissRounds)
swissRounds = swissRounds or tonumber(args.rounds) or 5
local scroll = html.create('div'):addClass('bk-scroll')
local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-swiss')
for r = 1, swissRounds do
local roundSection = wrapper:tag('div'):addClass('bk-swiss-round')
roundSection:tag('div'):addClass('bk-section-title'):wikitext(
args["r"..r.."name"] or ("Round "..r))
local matchCount = math.floor(teamCount / 2)
local matchRow = roundSection:tag('div'):addClass('bk-swiss-matches')
for m = 1, matchCount do
local matchID = "SR"..r.."M"..m
local cd = fetchMatch(event, matchID)
local d = mergeMatchData(cd, args, matchID)
renderMatchCard(matchRow, d, matchID, nil, "")
end
end
return tostring(scroll)
end
-- ════════════════════════════════════════════════════════════════
-- MAIN ENTRY POINT
-- ════════════════════════════════════════════════════════════════
function p.main(frame)
local args = (frame:getParent() and frame:getParent().args) or frame.args
local format = clean(args.format) or "single"
local event = clean(args.event) or ""
local teamCount = tonumber(args.teams) or 8
-- Wrap everything in a root container with the modal placeholder
local root = html.create('div'):addClass('bk-root')
root:attr('data-event', event)
-- Format selector tabs (if multiple formats on same page via JS tabs — optional)
local bracketHTML
format = format:lower()
if format == "single" or format == "se" then
bracketHTML = renderSingleElim(args, event, teamCount)
elseif format == "double" or format == "de" then
bracketHTML = renderDoubleElim(args, event, teamCount)
elseif format == "gsl" or format == "4team" then
bracketHTML = renderGSL(args, event)
elseif format == "rr" or format == "roundrobin" then
bracketHTML = renderRoundRobin(args, event, teamCount)
elseif format == "swiss" then
bracketHTML = renderSwiss(args, event, teamCount)
else
bracketHTML = "<div class='bk-error'>Unknown bracket format: <b>"..format.."</b>. "
.."Valid: single, double, gsl, rr, swiss</div>"
end
root:wikitext(bracketHTML)
-- Modal built with mw.html (avoids MediaWiki sanitizer stripping raw HTML strings)
local overlay = root:tag('div')
:addClass('bk-modal-overlay')
:css('display','none')
local modal = overlay:tag('div'):addClass('bk-modal')
-- Use span instead of button (<button> is stripped by MediaWiki sanitizer)
modal:tag('span')
:addClass('bk-modal-close')
:attr('title','Close')
:attr('role','button')
:attr('tabindex','0')
:wikitext('✕')
local mheader = modal:tag('div'):addClass('bk-modal-header')
local mteams = mheader:tag('div'):addClass('bk-modal-teams')
mteams:tag('span'):addClass('bk-modal-t1')
mteams:tag('span'):addClass('bk-modal-vs'):wikitext('vs')
mteams:tag('span'):addClass('bk-modal-t2')
local mscore = mheader:tag('div'):addClass('bk-modal-score')
mscore:tag('span'):addClass('bk-modal-s1')
mscore:tag('span'):addClass('bk-modal-dash'):wikitext('–')
mscore:tag('span'):addClass('bk-modal-s2')
local mmeta = modal:tag('div'):addClass('bk-modal-meta')
mmeta:tag('div'):addClass('bk-modal-row bk-modal-bo')
mmeta:tag('div'):addClass('bk-modal-row bk-modal-date')
mmeta:tag('div'):addClass('bk-modal-row bk-modal-casters')
mmeta:tag('div'):addClass('bk-modal-row bk-modal-notes')
mmeta:tag('div'):addClass('bk-modal-row bk-modal-vod')
return tostring(root)
end
-- ── Convenience wrappers ──────────────────────────────────────────
function p.single(frame)
local args = (frame:getParent() and frame:getParent().args) or frame.args
args.format = "single"
return p.main(frame)
end
function p.double(frame)
local args = (frame:getParent() and frame:getParent().args) or frame.args
args.format = "double"
return p.main(frame)
end
function p.gsl(frame)
local args = (frame:getParent() and frame:getParent().args) or frame.args
args.format = "gsl"
return p.main(frame)
end
function p.rr(frame)
local args = (frame:getParent() and frame:getParent().args) or frame.args
args.format = "rr"
return p.main(frame)
end
function p.swiss(frame)
local args = (frame:getParent() and frame:getParent().args) or frame.args
args.format = "swiss"
return p.main(frame)
end
return p