Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Module:Bracket: Difference between revisions

From eSportsAmaze
No edit summary
No edit summary
 
(2 intermediate revisions by the same user not shown)
Line 53: Line 53:
local function fetchMatch(event, matchID)
local function fetchMatch(event, matchID)
     local rows = cargo.query("BracketMatch",
     local rows = cargo.query("BracketMatch",
         "team1,score1,team2,score2,winner,bo,match_date,casters,vod,notes",
         "team1,score1,team2,score2,winner,bo,match_date,casters,vod,notes,short1,short2",
         { where = "event='"..esc(event).."' AND match_id='"..esc(matchID).."'",
         { where = "event='"..esc(event).."' AND match_id='"..esc(matchID).."'",
           limit = 1 })
           limit = 1 })
Line 65: Line 65:
     d.team1  = clean(args[prefix.."team1"])  or clean(cargoData.team1)  or "TBD"
     d.team1  = clean(args[prefix.."team1"])  or clean(cargoData.team1)  or "TBD"
     d.team2  = clean(args[prefix.."team2"])  or clean(cargoData.team2)  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.score1  = clean(args[prefix.."score1"])  or clean(cargoData.score1)  or ""
     d.score2  = clean(args[prefix.."score2"])  or clean(cargoData.score2)  or ""
     d.score2  = clean(args[prefix.."score2"])  or clean(cargoData.score2)  or ""
Line 84: Line 87:
local function renderMatchCard(container, d, matchID, label, extraClass)
local function renderMatchCard(container, d, matchID, label, extraClass)
     local wrap = container:tag('div'):addClass('bk-match-wrap')
     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
     if label and label ~= "" then
         wrap:tag('div'):addClass('bk-match-label'):wikitext(label)
         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
     end


Line 109: Line 118:
     if isPlayed then matchBox:addClass('bk-match-played') end
     if isPlayed then matchBox:addClass('bk-match-played') end
     matchBox:addClass('bk-match-clickable') -- JS hook
     matchBox:addClass('bk-match-clickable') -- JS hook
    -- BO badge
    if d.bo and d.bo ~= "" then
        matchBox:tag('div'):addClass('bk-bo-badge'):wikitext('Bo'..d.bo)
    end


     -- Team 1
     -- Team 1
Line 123: Line 127:
     if logo1 then nameCell1:wikitext(logo1) end
     if logo1 then nameCell1:wikitext(logo1) end
     if d.team1 ~= "TBD" then
     if d.team1 ~= "TBD" then
         nameCell1:tag('span'):wikitext("[["..d.team1.."]]")
        -- link uses full team name, display uses short name
         nameCell1:tag('span'):wikitext("[["..d.team1.."|"..d.short1.."]]")
     else
     else
         nameCell1:tag('span'):addClass('bk-tbd'):wikitext("TBD")
         nameCell1:tag('span'):addClass('bk-tbd'):wikitext("TBD")
Line 142: Line 147:
     if logo2 then nameCell2:wikitext(logo2) end
     if logo2 then nameCell2:wikitext(logo2) end
     if d.team2 ~= "TBD" then
     if d.team2 ~= "TBD" then
         nameCell2:tag('span'):wikitext("[["..d.team2.."]]")
         nameCell2:tag('span'):wikitext("[["..d.team2.."|"..d.short2.."]]")
     else
     else
         nameCell2:tag('span'):addClass('bk-tbd'):wikitext("TBD")
         nameCell2:tag('span'):addClass('bk-tbd'):wikitext("TBD")
Line 583: Line 588:
     root:wikitext(bracketHTML)
     root:wikitext(bracketHTML)


     -- Modal HTML (populated by JS on click)
     -- Modal built with mw.html (avoids MediaWiki sanitizer stripping raw HTML strings)
     root:wikitext([[
     local overlay = root:tag('div')
<div id="bk-modal-overlay" class="bk-modal-overlay" style="display:none">
        :addClass('bk-modal-overlay')
  <div class="bk-modal">
        :css('display','none')
     <button class="bk-modal-close" title="Close">&#10005;</button>
 
     <div class="bk-modal-header">
    local modal = overlay:tag('div'):addClass('bk-modal')
      <div class="bk-modal-teams">
 
        <span class="bk-modal-t1"></span>
     -- Use span instead of button (<button> is stripped by MediaWiki sanitizer)
        <span class="bk-modal-vs">vs</span>
    modal:tag('span')
        <span class="bk-modal-t2"></span>
        :addClass('bk-modal-close')
      </div>
        :attr('title','Close')
      <div class="bk-modal-score">
        :attr('role','button')
        <span class="bk-modal-s1"></span>
        :attr('tabindex','0')
        <span class="bk-modal-dash">–</span>
        :wikitext('&#10005;')
        <span class="bk-modal-s2"></span>
 
      </div>
     local mheader = modal:tag('div'):addClass('bk-modal-header')
     </div>
    local mteams  = mheader:tag('div'):addClass('bk-modal-teams')
    <div class="bk-modal-meta">
    mteams:tag('span'):addClass('bk-modal-t1')
      <div class="bk-modal-row bk-modal-bo"></div>
    mteams:tag('span'):addClass('bk-modal-vs'):wikitext('vs')
      <div class="bk-modal-row bk-modal-date"></div>
    mteams:tag('span'):addClass('bk-modal-t2')
      <div class="bk-modal-row bk-modal-casters"></div>
 
      <div class="bk-modal-row bk-modal-notes"></div>
    local mscore = mheader:tag('div'):addClass('bk-modal-score')
      <div class="bk-modal-row bk-modal-vod"></div>
    mscore:tag('span'):addClass('bk-modal-s1')
    </div>
    mscore:tag('span'):addClass('bk-modal-dash'):wikitext('&#8211;')
  </div>
    mscore:tag('span'):addClass('bk-modal-s2')
</div>
 
]])
     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)
     return tostring(root)

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('&#10005;')

    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('&#8211;')
    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