Module:PillSeasonSchedule: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
NewZimiaGov (talk | contribs)
No edit summary
Line 3: Line 3:
local date = require('Module:BassaridianCalendar')
local date = require('Module:BassaridianCalendar')


-- 1) TEAM DEFINITIONS (20px thumbnails)
------------------------------------------------------------------------
-- 0) CONFIG / CALENDAR
------------------------------------------------------------------------
local START, END = 62, 183    -- season window within the PSSC year
local DAYS_IN_YEAR = 183
 
local function getMonthDay(d)
    if d <= 61 then
        return "Atosiel", d
    elseif d <= 122 then
        return "Thalassiel", d - 61
    else
        return "Opsitheiel", d - 122
    end
end
 
local function md2day(m,d) return (m-1)*61 + d end    -- 61-day “months”
local function absDay(y,d) return y*DAYS_IN_YEAR + d end
 
------------------------------------------------------------------------
-- 1) TEAMS (keep your logos/titles/championships exactly)
------------------------------------------------------------------------
local teams = {
local teams = {
     { code="Allegro Symphonara",      fullName="Allegro Symphonara",      logo="[[File:AllegrosymphonaraB.png|20px]]", championships=4 },
     { code="Allegro Symphonara",      fullName="Allegro Symphonara",      logo="[[File:AllegrosymphonaraB.png|20px]]", championships=4 },
Line 36: Line 57:
     { code="Pillarion Club Caspazani",fullName="Pillarion Club Caspazani",logo="[[File:PCCaspazani.png|20px]]", championships=0 },
     { code="Pillarion Club Caspazani",fullName="Pillarion Club Caspazani",logo="[[File:PCCaspazani.png|20px]]", championships=0 },
     { code="Amberwatch Slevik",      fullName="Amberwatch Slevik",      logo="[[File:AmberwatchSlevik.png|20px]]", championships=0 },
     { code="Amberwatch Slevik",      fullName="Amberwatch Slevik",      logo="[[File:AmberwatchSlevik.png|20px]]", championships=0 },
     { code="Fanghorn Rein",          fullName="Fanghorn Rein",           logo="[[File:FanghornRein.png|20px]]", championships=0 },
     { code="Fanghorn Rein",          fullName="Fanghorn Rein",           logo="[[File:FanghornRein.png|20px]]", championships=0 },
}
local TEAM_BY_CODE = {}
for _,t in ipairs(teams) do TEAM_BY_CODE[t.code] = t end
 
------------------------------------------------------------------------
-- 2) DIVISIONS (your final four quads of eight)
------------------------------------------------------------------------
local DIVISIONS = {
  ["Morovian Division"] = {
    "Emberstone Vaeringheim","Vaeringheim Pillar","Imperial Delphica","Delphica Windborne",
    "Allegro Symphonara","Steppe Luminaria","Jezeraah City","Saluria Skylight"
  },
  ["Southern Strait Division"] = {
    "Sufriya Stormwake","Vine Fleet Mylecia","Port of Blore Heath","Jogi Regiment",
    "Free State Abeis","Sandveil Somniumpolis","Brightpath Aureum","Flameborne Erythros"
  },
  ["Western Highlands Division"] = {
    "Ashguard Pyralis","Aurelia Auric","Celestial Sancta Lunaris","Forgeward Nexa",
    "Hearthkeeper Koinon","Ironbough Sylvapolis","Riftwarden Acheron","Serena Tidesong"
  },
  ["Normarkian Division"] = {
    "Redcliff Citadel","Strider Myrene","Ascendant Aetherium","Pillarion Club Suncliff",
    "Ourid Pegasi","Pillarion Club Caspazani","Amberwatch Slevik","Fanghorn Rein"
  }
}
}
local DIV_OF = {}
for div, list in pairs(DIVISIONS) do
  for _, code in ipairs(list) do DIV_OF[code] = div end
end
------------------------------------------------------------------------
-- 3) DIVISION-WEIGHTED SCHEDULER
--    • In-division: each of 7 rivals twice (H/A)  -> 14 matches
--    • Cross-division: the other 24 once          -> 24 matches
--    = 38 total per club
------------------------------------------------------------------------
local function homeFirst(codeA, codeB)
  -- deterministic “hash” to pick a home side for cross-division fixtures
  local sA, sB = 0, 0
  for i=1,#codeA do sA = (sA + string.byte(codeA,i)) % 9973 end
  for i=1,#codeB do sB = (sB + string.byte(codeB,i)) % 9973 end
  return sA > sB
end
local function buildPairings()
  local intra, inter = {}, {}
  -- in-division double round robin (H/A)
  for _, list in pairs(DIVISIONS) do
    for i=1,#list-1 do
      for j=i+1,#list do
        local A, B = TEAM_BY_CODE[list[i]], TEAM_BY_CODE[list[j]]
        table.insert(intra, {home=A, away=B})
        table.insert(intra, {home=B, away=A})
      end
    end
  end
  -- cross-division single round robin
  local allCodes = {}
  for _,t in ipairs(teams) do table.insert(allCodes, t.code) end
  table.sort(allCodes) -- stable order


-- 2) DOUBLE ROUND‑ROBIN GENERATOR
  for ai=1,#allCodes-1 do
local function generateSchedule()
     for bi=ai+1,#allCodes do
    local n = #teams
      local ca, cb = allCodes[ai], allCodes[bi]
    assert(n % 2 == 0)
      if DIV_OF[ca] ~= DIV_OF[cb] then
    local pool = {}
         local a, b = TEAM_BY_CODE[ca], TEAM_BY_CODE[cb]
    for i = 1, n do pool[i] = teams[i] end
        local hFirst = homeFirst(ca, cb)
    local fixed = table.remove(pool, 1)
        table.insert(inter, {home = hFirst and a or b, away = hFirst and b or a})
    local rounds = {}
      end
     for r = 1, n - 1 do
        local pairings = {}
        if r % 2 == 1 then
            table.insert(pairings, { fixed, pool[1] })
        else
            table.insert(pairings, { pool[1], fixed })
         end
        for i = 2, n / 2 do
            local t1, t2 = pool[i], pool[#pool - (i - 2)]
            if i % 2 == 0 then
                table.insert(pairings, { t1, t2 })
            else
                table.insert(pairings, { t2, t1 })
            end
        end
        rounds[r] = pairings
        table.insert(pool, 1, table.remove(pool))
     end
     end
    for r = 1, n - 1 do
  end
        local rev = {}
 
        for _, m in ipairs(rounds[r]) do
  -- sort (deterministic grouping for nicer pacing)
            table.insert(rev, { m[2], m[1] })
  table.sort(intra, function(a,b)
        end
    if DIV_OF[a.home.code] ~= DIV_OF[b.home.code] then
        rounds[n - 1 + r] = rev
      return DIV_OF[a.home.code] < DIV_OF[b.home.code]
     end
     end
     return rounds
     return a.home.code .. a.away.code < b.home.code .. b.away.code
  end)
  table.sort(inter, function(a,b)
    local ka = DIV_OF[a.home.code] .. "|" .. DIV_OF[a.away.code]
    local kb = DIV_OF[b.home.code] .. "|" .. DIV_OF[b.away.code]
    if ka ~= kb then return ka < kb end
    return a.home.code .. a.away.code < b.home.code .. b.away.code
  end)
 
  return intra, inter
end
end


-- 3) MAP ROUNDS TO PSSC DAYS
local function packRounds(matches)
local START, END = 62, 183
  -- greedy packer so no club appears twice in a round
local function mapDays()
  local rounds, used = {}, {}
     local total  = END - START + 1
  local function canPlace(r, m)
    local rounds = (#teams - 1) * 2
     return not (used[r] and (used[r][m.home.code] or used[r][m.away.code]))
     local iv     = math.floor(total / rounds)
  end
    local ex    = total % rounds
  for _, m in ipairs(matches) do
    local m, d  = {}, START
     local placed = false
    for i = 1, rounds do
     for r=1,#rounds do
         m[d] = i
      if canPlace(r, m) then
         d    = d + iv + (ex > 0 and 1 or 0)
        rounds[r][#rounds[r]+1] = { m.home, m.away }
         if ex > 0 then ex = ex - 1 end
        used[r] = used[r] or {}
        used[r][m.home.code] = true
         used[r][m.away.code] = true
         placed = true
         break
      end
    end
    if not placed then
      rounds[#rounds+1] = { { m.home, m.away } }
      used[#rounds] = {}
      used[#rounds][m.home.code] = true
      used[#rounds][m.away.code] = true
     end
     end
    return m
  end
  return rounds
end
end


-- 4) SIMULATE RESULTS FOR A DAY
-- Build the master schedule once (deterministic)
local function simulateDayResults(day)
local SCHED = (function()
    local sched = generateSchedule()
  local intra, inter = buildPairings()
    local dm    = mapDays()
 
    local idx   = dm[day]
   -- simple interleave: one intra block, one inter block, repeat
    if not idx then return {} end
  local function chunk(arr, size)
    math.randomseed(day)
     local out = {}
     local out = {}
     for _, m in ipairs(sched[idx]) do
     for i=1,#arr,size do
        local h, a = m[1], m[2]
      local b = {}
        local p    = math.random()
      for j=i, math.min(i+size-1, #arr) do b[#b+1] = arr[j] end
        local hg, ag
      out[#out+1] = b
        if p < 0.1 then
            hg = math.random(0, 12); ag = hg
        else
            local wh, wa = h.championships + 1, a.championships + 1
            local rem    = (p - 0.1) / 0.9
            local homeWin= rem < (wh / (wh + wa))
            local winG  = math.random(1, 12)
            local loseG  = math.random(0, math.min(winG - 1, 12))
            if homeWin then hg, ag = winG, loseG else hg, ag = loseG, winG end
        end
        table.insert(out, { home = h, away = a, hg = hg, ag = ag })
     end
     end
     return out
     return out
  end
  -- block sizes chosen to avoid very long runs of a single type
  local intraBlocks = chunk(intra, 64)
  local interBlocks = chunk(inter, 128)
  local merged = {}
  local ia, ib = 1, 1
  while ia <= #intraBlocks or ib <= #interBlocks do
    if ia <= #intraBlocks then
      for _,m in ipairs(intraBlocks[ia]) do merged[#merged+1] = m end
      ia = ia + 1
    end
    if ib <= #interBlocks then
      for _,m in ipairs(interBlocks[ib]) do merged[#merged+1] = m end
      ib = ib + 1
    end
  end
  return packRounds(merged)
end)()
local function generateSchedule() return SCHED end
-- Map rounds to PSSC dates across the season window
local function mapDays()
  local totalDays  = END - START + 1
  local totalRnds  = #SCHED
  local iv        = math.floor(totalDays / totalRnds)
  local ex        = totalDays % totalRnds
  local m, d      = {}, START
  for i=1,totalRnds do
    m[d] = i
    d = d + iv + (ex > 0 and 1 or 0)
    if ex > 0 then ex = ex - 1 end
  end
  return m
end
end


-- helper: convert PSSC day → month/day
------------------------------------------------------------------------
local function getMonthDay(d)
-- 4) MATCH SIMULATION (deterministic by day & pairing)
    if d <= 61 then
------------------------------------------------------------------------
        return "Atosiel", d
local function simScoreFor(h, a, seed)
     elseif d <= 122 then
  math.randomseed(seed)
        return "Thalassiel", d - 61
  local p = math.random()
     else
  local hg, ag
        return "Opsitheiel", d - 122
  if p < 0.1 then
     end
    hg = math.random(0, 12); ag = hg
  else
    local wh, wa = h.championships + 1, a.championships + 1
     local rem    = (p - 0.1) / 0.9
    local homeWin= rem < (wh / (wh + wa))
    local winG  = math.random(1, 12)
    local loseG  = math.random(0, math.min(winG - 1, 12))
     if homeWin then hg, ag = winG, loseG else hg, ag = loseG, winG end
  end
  return hg, ag
end
 
local function simulateDayResults(day)
  local dm = mapDays()
  local idx = dm[day]
  if not idx then return {} end
  local out = {}
  for k, m in ipairs(SCHED[idx]) do
    local h, a = m[1], m[2]
    local salt = day*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
    local hg, ag = simScoreFor(h, a, salt)
     out[#out+1] = { home=h, away=a, hg=hg, ag=ag }
  end
  return out
end
end


-- 5) RENDER TODAY or NEXT MATCHDAY
------------------------------------------------------------------------
-- 5) TODAY / NEXT MATCHDAY RENDER
------------------------------------------------------------------------
local function renderRound(curDay, year)
local function renderRound(curDay, year)
     local dm      = mapDays()
     local dm      = mapDays()
Line 156: Line 292:
             local hs = string.format("<span style='color:%s;'>'''%d'''</span>", hc, hg)
             local hs = string.format("<span style='color:%s;'>'''%d'''</span>", hc, hg)
             local as = string.format("<span style='color:%s;'>'''%d'''</span>", ac, ag)
             local as = string.format("<span style='color:%s;'>'''%d'''</span>", ac, ag)
             table.insert(rows,
             rows[#rows+1] =
                 string.format("%s %s %s vs %s %s %s",
                 string.format("%s %s %s vs %s %s %s",
                     h.logo, hs, h.fullName,
                     h.logo, hs, h.fullName,
                     a.logo, as, a.fullName
                     a.logo, as, a.fullName
                 )
                 )
            )
         end
         end
     else
     else
        local sched  = generateSchedule()
         local idx = dm[target]
         local idx   = dm[target]
         for _, m in ipairs(SCHED[idx]) do
         for _, m in ipairs(sched[idx]) do
             local h, a = m[1], m[2]
             local h, a = m[1], m[2]
             table.insert(rows,
             rows[#rows+1] =
                 string.format("%s %s vs %s %s",
                 string.format("%s %s vs %s %s",
                     h.logo, h.fullName,
                     h.logo, h.fullName,
                     a.logo, a.fullName
                     a.logo, a.fullName
                 )
                 )
            )
         end
         end
     end
     end
Line 183: Line 316:
end
end


-- 6) Invoke: schedule
function p.renderSchedule(frame)
function p.renderSchedule(frame)
     local curr = date.getCurrentDate()
     local curr = date.getCurrentDate()
Line 196: Line 328:
end
end


-- 7) CURRENT STANDINGS (with logos and conditional row coloring)
------------------------------------------------------------------------
-- 6) DIVISION STANDINGS
------------------------------------------------------------------------
function p.renderStandings(frame)
function p.renderStandings(frame)
     local curr  = date.getCurrentDate()
     local curr  = date.getCurrentDate()
     local today  = tonumber(curr:match("^(%d+),")) or 0
     local today  = tonumber(curr:match("^(%d+),")) or 0
     local last  = math.min(today, END)
     local last  = math.min(today, END)
    -- init records
     local recs  = {}
     local recs  = {}
    -- initialize records
     for _, t in ipairs(teams) do
     for _, t in ipairs(teams) do
         recs[t.code] = { team = t, W = 0, D = 0, L = 0, Pts = 0, PF = 0, PA = 0 }
         recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0 }
     end
     end


     -- accumulate W/D/L and points
     -- accumulate results up to 'last'
    local dm = mapDays()
     for d = START, last do
     for d = START, last do
         for _, r in ipairs(simulateDayResults(d)) do
         local idx = dm[d]
            local hR, aR = recs[r.home.code], recs[r.away.code]
        if idx then
            hR.PF, hR.PA = hR.PF + r.hg, hR.PA + r.ag
            for k, m in ipairs(SCHED[idx]) do
            aR.PF, aR.PA = aR.PF + r.ag, aR.PA + r.hg
                local h, a = m[1], m[2]
            if r.hg > r.ag then
                local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
                hR.W, hR.Pts = hR.W + 1, hR.Pts + 3
                local hg, ag = simScoreFor(h, a, salt)
                aR.L         = aR.L + 1
 
            elseif r.hg < r.ag then
                local H, A = recs[h.code], recs[a.code]
                aR.W, aR.Pts = aR.W + 1, aR.Pts + 3
                H.PF, H.PA = H.PF + hg, H.PA + ag
                hR.L         = hR.L + 1
                A.PF, A.PA = A.PF + ag, A.PA + hg
            else
 
                hR.D, hR.Pts = hR.D + 1, hR.Pts + 1
                if hg > ag then
                aR.D, aR.Pts = aR.D + 1, aR.Pts + 1
                    H.W, H.Pts = H.W + 1, H.Pts + 3
                    A.L = A.L + 1
                elseif hg < ag then
                    A.W, A.Pts = A.W + 1, A.Pts + 3
                    H.L = H.L + 1
                else
                    H.D, H.Pts = H.D + 1, H.Pts + 1
                    A.D, A.Pts = A.D + 1, A.Pts + 1
                end
             end
             end
         end
         end
     end
     end


     -- prepare sorted list
     -- bucket by division
     local list = {}
     local buckets = {}
     for _, rec in pairs(recs) do
     for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
         rec.PD = rec.PF - rec.PA
    for code, r in pairs(recs) do
         table.insert(list, rec)
         r.PD = r.PF - r.PA
        local div = DIV_OF[code] or "—"
        buckets[div] = buckets[div] or {}
         table.insert(buckets[div], r)
     end
     end
     table.sort(list, function(a, b)
 
     local function cmp(a,b)
         if a.Pts ~= b.Pts then return a.Pts > b.Pts end
         if a.Pts ~= b.Pts then return a.Pts > b.Pts end
         if a.PD  ~= b.PD  then return a.PD  > b.PD  end
         if a.PD  ~= b.PD  then return a.PD  > b.PD  end
         if a.W  ~= b.W  then return a.W  > b.W  end
         if a.W  ~= b.W  then return a.W  > b.W  end
         return a.team.fullName < b.team.fullName
         return a.team.fullName < b.team.fullName
     end)
     end
    for div, list in pairs(buckets) do table.sort(list, cmp) end


     -- render table with conditional styling
     -- render four separate tables
     local out = {
     local order = {
         "{| class=\"wikitable sortable\"",
         "Morovian Division","Southern Strait Division",
         "! Pos !! Team !! W !! D !! L !! Pts !! PF !! PA !! PD"
         "Western Highlands Division","Normarkian Division"
     }
     }
     for i, rec in ipairs(list) do
    local out = {}
        -- determine background color
     for _, div in ipairs(order) do
         local style = ""
         local list = buckets[div] or {}
         if i <= 8 then
         out[#out+1] = "== " .. div .. " Standings =="
            style = ' style="background-color:#ccffcc;"'    -- light green
        out[#out+1] = '{| class="wikitable sortable"'
        elseif i >= 9 and i <= 12 then
         out[#out+1] = "! Pos !! Team !! W !! D !! L !! Pts !! PF !! PA !! PD"
            style = ' style="background-color:#cce5ff;"'   -- light blue
        for i, rec in ipairs(list) do
         elseif i >= 27 and i <= 28 then
             out[#out+1] = "|-"
             style = ' style="background-color:#F1EB9C;"'    -- light yellow
            out[#out+1] =
        elseif i >= 29 and i <= 32 then
                "| " .. i
            style = ' style="background-color:#ffcccc;"'    -- light red
                .. " || " .. rec.team.logo .. " " .. rec.team.fullName
                .. " || " .. rec.W
                .. " || " .. rec.D
                .. " || " .. rec.L
                .. " || " .. rec.Pts
                .. " || " .. rec.PF
                .. " || " .. rec.PA
                .. " || " .. rec.PD
         end
         end
 
         out[#out+1] = "|}"
         -- insert row
         out[#out+1] = ""
        table.insert(out, "|-" .. style)
         table.insert(out,
            "| " .. i
            .. " || " .. rec.team.logo .. " " .. rec.team.fullName
            .. " || " .. rec.W
            .. " || " .. rec.D
            .. " || " .. rec.L
            .. " || " .. rec.Pts
            .. " || " .. rec.PF
            .. " || " .. rec.PA
            .. " || " .. rec.PD
        )
     end
     end
    table.insert(out, "|}")
     return table.concat(out, "\n")
     return table.concat(out, "\n")
end
end


 
------------------------------------------------------------------------
-- 8) PREVIOUS RESULTS: shows the last 5 matchdays
-- 7) PREVIOUS RESULTS (last 5 matchdays that actually exist)
------------------------------------------------------------------------
function p.renderPreviousResults(frame)
function p.renderPreviousResults(frame)
     local curr  = date.getCurrentDate()
     local curr  = date.getCurrentDate()
Line 287: Line 429:


     for d = today - 1, START, -1 do
     for d = today - 1, START, -1 do
         if dm[d] then table.insert(played, d) end
         if dm[d] then played[#played+1] = d end
    end
        if #played == 5 then break end
 
    local recent = {}
    for i = 1, math.min(5, #played) do
        table.insert(recent, played[i])
     end
     end


     local out = {
     local out = {
         "{| class=\"wikitable sortable\"",
         '{| class="wikitable sortable"',
         "! Date !! Home !! Score !! Away"
         '! Date !! Home !! Score !! Away'
     }
     }
     for _, d in ipairs(recent) do
     for _, d in ipairs(played) do
         local mon, dom  = getMonthDay(d)
         local mon, dom  = getMonthDay(d)
         local dateLabel = dom .. " " .. mon .. " " .. year .. " PSSC"
         local dateLabel = dom .. " " .. mon .. " " .. year .. " PSSC"
         for _, r in ipairs(simulateDayResults(d)) do
        local idx      = dm[d]
             local hs = "'''" .. r.hg .. "-" .. r.ag .. "'''"
         for k, m in ipairs(SCHED[idx]) do
             table.insert(out, "|-")
            local h, a = m[1], m[2]
             table.insert(out,
            local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
            local hg, ag = simScoreFor(h, a, salt)
             local hs = "'''" .. hg .. "-" .. ag .. "'''"
             out[#out+1] = "|-"
             out[#out+1] =
                 "| " .. dateLabel
                 "| " .. dateLabel
                 .. " || " .. r.home.logo .. " " .. r.home.fullName
                 .. " || " .. h.logo .. " " .. h.fullName
                 .. " || " .. hs
                 .. " || " .. hs
                 .. " || " .. r.away.logo .. " " .. r.away.fullName
                 .. " || " .. a.logo .. " " .. a.fullName
            )
         end
         end
     end
     end
     table.insert(out, "|}")
     out[#out+1] = "|}"
     return table.concat(out, "\n")
     return table.concat(out, "\n")
end
end


-- return export table
------------------------------------------------------------------------
------------------------------------------------------------------------
------------------------------------------------------------------------
-- 9)  12-TEAM POST-SEASON
-- 8) PLAYOFFS (kept from your original, lightly wired)
--    • Play-in (9–12) → 2 winners
--    • Still uses a static seed list; can be upgraded to pull top-12 per division.
--    • Round-of-8  (3–8 + 2) → 4
--    • Round-of-4  (re-seed) → 2 ⇢ join seeds 1-2 in SF
--    • Semi-finals (top-2 byes) → 2
--    • Final → Champion
------------------------------------------------------------------------
------------------------------------------------------------------------
-- calendar helpers ----------------------------------------------------
local DAYS_IN_YEAR = 183
local function md2day(m,d) return (m-1)*61 + d end    -- 61-day “months”
local function absDay(y,d) return y*DAYS_IN_YEAR + d end
local PO_YEAR  = 52              -- post-season year
local PO_YEAR  = 52              -- post-season year
local PO_FIRST = md2day(1,4)      -- 4 Atosiel 52 PSSC  (Play-in day)
local PO_FIRST = md2day(1,4)      -- 4 Atosiel 52 PSSC  (Play-in day)
local PO_IV    = 3                -- days between rounds
local PO_IV    = 3                -- days between rounds


-- seeding list (top-12 of final table)
-- seeding list (top-12 of final table) — unchanged
local seedTeams = {
local seedTeams = {
   teams[7], teams[17], teams[13], teams[19], teams[1],  teams[21],  -- 1-6
   teams[7], teams[17], teams[13], teams[19], teams[1],  teams[21],  -- 1-6
Line 343: Line 472:
}
}


-- play-in pairings  (9 v 12, 10 v 11)
-- play-in (9 v 12, 10 v 11)
local PLAYIN = {
local PLAYIN = {
   { seedTeams[9],  seedTeams[12] },
   { seedTeams[9],  seedTeams[12] },
Line 349: Line 478:
}
}


-- single–match score generator (mild title bias, 0-12 goals)
local function playScore(A,B,salt)
local function playScore(A,B,salt)
   math.randomseed(salt)
   math.randomseed(salt)
Line 361: Line 489:
end
end


-- advancing containers
local W = { PI={}, R8={}, R4={}, SF={} }
local W = { PI={}, R8={}, R4={}, SF={} }


-- wiki-row helper -----------------------------------------------------
local function mkRow(tag, absDate, H, A, salt, absNow)
local function mkRow(tag, absDate, H, A, salt, absNow)
   local hs, as, win = '—','—','—'
   local hs, as, win = '—','—','—'
   if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then
   if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then
     hs,as = playScore(H,A,salt)
     local h,a = playScore(H,A,salt)
     local Wn = (hs>as) and H or A
    hs,as = string.format("'''%d-%d'''",h,a), nil
     local Wn = (h>a) and H or A
     win = Wn.logo .. ' ' .. Wn.fullName
     win = Wn.logo .. ' ' .. Wn.fullName
     hs  = string.format("'''%d-%d'''",hs,as)
    as  = '' -- keep the combined cell already set above
     hs  = string.format("'''%d-%d'''",h,a)
   end
   end
   return string.format(
   return string.format(
     "|-\n| %s || %s %s || %s || %s %s || %s",
     "|-\n| %s || %s %s || %s || %s %s || %s",
     tag, H.logo,H.fullName, hs, A.logo,A.fullName, win)
     tag, H.logo,H.fullName, (hs or '—'), A.logo,A.fullName, win)
end
end


------------------------------------------------------------------------
-- 9.1  Play-off SCHEDULE (blank scores)
------------------------------------------------------------------------
function p.renderPlayoffSchedule(frame)
function p.renderPlayoffSchedule(frame)
   local out = {
   local out = {
Line 386: Line 511:
     '! Round !! Date !! Match-up'
     '! Round !! Date !! Match-up'
   }
   }
   local function dateCell(day)
   local function dateCell(day)
     local mon,dom = getMonthDay(day)
     local mon,dom = getMonthDay(day)
Line 399: Line 523:


   -- Play-in
   -- Play-in
   for i,p in ipairs(PLAYIN) do add('Play-in-'..i, PO_FIRST, p[1], p[2]) end
   for i,pair in ipairs(PLAYIN) do add('Play-in-'..i, PO_FIRST, pair[1], pair[2]) end


   -- banners for remaining rounds
   -- banners for remaining rounds
Line 419: Line 543:
end
end


------------------------------------------------------------------------
-- 9.2  Play-off RESULTS (auto-update each day)
------------------------------------------------------------------------
function p.renderPlayoffResults(frame)
function p.renderPlayoffResults(frame)
   local now = date.getCurrentDate()
   local now = date.getCurrentDate()
Line 433: Line 554:
   }
   }


   -- Play-in -----------------------------------------------------------
   -- Play-in
   local absPI = absDay(PO_YEAR, PO_FIRST)
   local absPI = absDay(PO_YEAR, PO_FIRST)
   for i,p in ipairs(PLAYIN) do
   for i,pair in ipairs(PLAYIN) do
     if absNow >= absPI then
     if absNow >= absPI then
       local g1,g2 = playScore(p[1],p[2],absPI*100+i)
       local g1,g2 = playScore(pair[1],pair[2],absPI*100+i)
       W.PI[i]    = (g1>g2) and p[1] or p[2]
       W.PI[i]    = (g1>g2) and pair[1] or pair[2]
     end
     end
     rows[#rows+1] = mkRow('PI-'..i, absPI, p[1], p[2], absPI*100+i, absNow)
     rows[#rows+1] = (function()
      local hs, as, win = '—','—','—'
      if absNow >= absPI then
        local g1,g2 = playScore(pair[1],pair[2],absPI*100+i)
        local Wn    = (g1>g2) and pair[1] or pair[2]
        win        = Wn.logo .. ' ' .. Wn.fullName
        hs          = string.format("'''%d-%d'''", g1, g2)
      end
      return string.format("|-\n| %s || %s %s || %s || %s %s || %s",
        'PI-'..i, pair[1].logo, pair[1].fullName, hs, pair[2].logo, pair[2].fullName, win)
    end)()
   end
   end


   -- Round-of-8 --------------------------------------------------------
   -- Round-of-8
   local absR8 = absPI + PO_IV
   local absR8 = absPI + PO_IV
   local pool = { seedTeams[3],seedTeams[4],seedTeams[5],seedTeams[6],
   local pool = { seedTeams[3],seedTeams[4],seedTeams[5],seedTeams[6],
Line 456: Line 587:
   local R8 = {}
   local R8 = {}
   for i=1,4 do R8[i] = { pool[i], pool[#pool - (i-1)] } end
   for i=1,4 do R8[i] = { pool[i], pool[#pool - (i-1)] } end
   for i,p in ipairs(R8) do
   for i,pair in ipairs(R8) do
     if absNow >= absR8 and p[2].fullName~='TBD' then
    local H,A = pair[1], pair[2]
       local g1,g2 = playScore(p[1],p[2],absR8*100+i)
    local hs, win = '—', '—'
       W.R8[i]    = (g1>g2) and p[1] or p[2]
     if absNow >= absR8 and A.fullName~='TBD' then
       local g1,g2 = playScore(H,A,absR8*100+i)
       W.R8[i]    = (g1>g2) and H or A
      hs          = string.format("'''%d-%d'''", g1, g2)
      win        = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
     end
     end
     rows[#rows+1] = mkRow('R-8-'..i, absR8, p[1], p[2], absR8*100+i, absNow)
     rows[#rows+1] = string.format("|-\n| %s || %s %s || %s || %s %s || %s",
      'R-8-'..i, H.logo,H.fullName, hs, A.logo,A.fullName, win)
   end
   end


   -- Round-of-4 --------------------------------------------------------
   -- Round-of-4 (re-seed)
   local absR4 = absR8 + PO_IV
   local absR4 = absR8 + PO_IV
   local R4 = {}
   local R4 = {}
Line 479: Line 615:
     local H = R4[i] and R4[i][1] or {logo='—',fullName='TBD'}
     local H = R4[i] and R4[i][1] or {logo='—',fullName='TBD'}
     local A = R4[i] and R4[i][2] or {logo='—',fullName='TBD'}
     local A = R4[i] and R4[i][2] or {logo='—',fullName='TBD'}
    local hs, win = '—', '—'
     if absNow >= absR4 and R4[i] then
     if absNow >= absR4 and R4[i] then
       local g1,g2 = playScore(H,A,absR4*100+i)
       local g1,g2 = playScore(H,A,absR4*100+i)
       W.R4[i]    = (g1>g2) and H or A
       W.R4[i]    = (g1>g2) and H or A
      hs          = string.format("'''%d-%d'''", g1, g2)
      win        = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
     end
     end
     rows[#rows+1] = mkRow('R-4-'..i, absR4, H, A, absR4*100+i, absNow)
     rows[#rows+1] = string.format("|-\n| %s || %s %s || %s || %s %s || %s",
      'R-4-'..i, H.logo,H.fullName, hs, A.logo,A.fullName, win)
   end
   end


   -- Semi-finals (Seeds 1-2 enter) ------------------------------------
   -- Semi-finals (Seeds 1–2 BYE)
   local absSF = absR4 + PO_IV
   local absSF = absR4 + PO_IV
   local SF = {
   local SF = {
Line 494: Line 634:
   for i=1,2 do
   for i=1,2 do
     local H,A = SF[i][1], SF[i][2]
     local H,A = SF[i][1], SF[i][2]
    local hs, win = '—', '—'
     if absNow >= absSF and A.fullName~='TBD' then
     if absNow >= absSF and A.fullName~='TBD' then
       local g1,g2 = playScore(H,A,absSF*100+i)
       local g1,g2 = playScore(H,A,absSF*100+i)
       W.SF[i]    = (g1>g2) and H or A
       W.SF[i]    = (g1>g2) and H or A
      hs          = string.format("'''%d-%d'''", g1, g2)
      win        = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
     end
     end
     rows[#rows+1] = mkRow('SF-'..i, absSF, H, A, absSF*100+i, absNow)
     rows[#rows+1] = string.format("|-\n| %s || %s %s || %s || %s %s || %s",
      'SF-'..i, H.logo,H.fullName, hs, A.logo,A.fullName, win)
   end
   end


   -- Final ------------------------------------------------------------
   -- Final
   local absF = absSF + PO_IV
   local absF = absSF + PO_IV
   local FH = W.SF[1] or {logo='—',fullName='TBD'}
   local FH = W.SF[1] or {logo='—',fullName='TBD'}
   local FA = W.SF[2] or {logo='—',fullName='TBD'}
   local FA = W.SF[2] or {logo='—',fullName='TBD'}
   rows[#rows+1] = mkRow('Final', absF, FH, FA, absF*100+1, absNow)
  local hs, win = '—', '—'
  if FH.fullName~='TBD' and FA.fullName~='TBD' and absNow >= absF then
    local g1,g2 = playScore(FH,FA,absF*100+1)
    hs          = string.format("'''%d-%d'''", g1, g2)
    win        = (g1>g2 and FH or FA).logo .. ' ' .. (g1>g2 and FH or FA).fullName
  end
   rows[#rows+1] = string.format("|-\n| %s || %s %s || %s || %s %s || %s",
    'Final', FH.logo,FH.fullName, hs, FA.logo,FA.fullName, win)


   rows[#rows+1] = '|}'
   rows[#rows+1] = '|}'
Line 511: Line 662:
end
end


-- END OF PLAY-OFF BLOCK
------------------------------------------------------------------------
-- EXPORT
------------------------------------------------------------------------
------------------------------------------------------------------------
return p
return p

Revision as of 00:18, 20 October 2025

Documentation for this module may be created at Module:PillSeasonSchedule/doc

-- Module:PillSeasonSchedule
local p = {}
local date = require('Module:BassaridianCalendar')

------------------------------------------------------------------------
-- 0) CONFIG / CALENDAR
------------------------------------------------------------------------
local START, END = 62, 183     -- season window within the PSSC year
local DAYS_IN_YEAR = 183

local function getMonthDay(d)
    if d <= 61 then
        return "Atosiel", d
    elseif d <= 122 then
        return "Thalassiel", d - 61
    else
        return "Opsitheiel", d - 122
    end
end

local function md2day(m,d) return (m-1)*61 + d end     -- 61-day “months”
local function absDay(y,d) return y*DAYS_IN_YEAR + d end

------------------------------------------------------------------------
-- 1) TEAMS (keep your logos/titles/championships exactly)
------------------------------------------------------------------------
local teams = {
    { code="Allegro Symphonara",      fullName="Allegro Symphonara",      logo="[[File:AllegrosymphonaraB.png|20px]]", championships=4 },
    { code="Ashguard Pyralis",        fullName="Ashguard Pyralis",        logo="[[File:AshguardpyralisB.png|20px]]", championships=1 },
    { code="Aurelia Auric",           fullName="Aurelia Auric",           logo="[[File:AureliaAuricB.png|20px]]", championships=2 },
    { code="Brightpath Aureum",       fullName="Brightpath Aureum",       logo="[[File:BrightpathaureumB.png|20px]]", championships=2 },
    { code="Celestial Sancta Lunaris",fullName="Celestial Sancta Lunaris", logo="[[File:CelestialsanctaB.png|20px]]", championships=1 },
    { code="Delphica Windborne",      fullName="Delphica Windborne",      logo="[[File:DelphicawindborneB.png|20px]]", championships=1 },
    { code="Emberstone Vaeringheim",  fullName="Emberstone Vaeringheim",  logo="[[File:EmberstonevaeringheimB.png|20px]]", championships=3 },
    { code="Flameborne Erythros",     fullName="Flameborne Erythros",     logo="[[File:FlameborneerythrosB.png|20px]]", championships=1 },
    { code="Forgeward Nexa",          fullName="Forgeward Nexa",          logo="[[File:ForgewardnexaB.png|20px]]", championships=0 },
    { code="Hearthkeeper Koinon",     fullName="Hearthkeeper Koinon",     logo="[[File:HearthkeeperkoinonB.png|20px]]", championships=0 },
    { code="Ironbough Sylvapolis",    fullName="Ironbough Sylvapolis",    logo="[[File:IronboughSylvapolisB.png|20px]]", championships=1 },
    { code="Redcliff Citadel",        fullName="Redcliff Citadel",        logo="[[File:RedcliffCitadel.png|20px]]", championships=1 },
    { code="Riftwarden Acheron",      fullName="Riftwarden Acheron",      logo="[[File:RiftwardenAcheronB.png|20px]]", championships=3 },
    { code="Saluria Skylight",        fullName="Saluria Skylight",        logo="[[File:SaluriaSkylightB.png|20px]]", championships=0 },
    { code="Sandveil Somniumpolis",   fullName="Sandveil Somniumpolis",   logo="[[File:SandVeilSomniumB.png|20px]]", championships=1 },
    { code="Serena Tidesong",         fullName="Serena Tidesong",         logo="[[File:Serena TidesongB.png|20px]]", championships=2 },
    { code="Steppe Luminaria",        fullName="Steppe Luminaria",        logo="[[File:Steppe LuminariaB.png|20px]]", championships=4 },
    { code="Strider Myrene",          fullName="Strider Myrene",          logo="[[File:StriderMyreneB.png|20px]]", championships=1 },
    { code="Vaeringheim Pillar",      fullName="Vaeringheim Pillar",      logo="[[File:VaeringheimPillarB.png|20px]]", championships=5 },
    { code="Imperial Delphica",       fullName="Imperial Delphica",       logo="[[File:ImperialDelphicaB.png|20px]]", championships=1 },
    { code="Ascendant Aetherium",     fullName="Ascendant Aetherium",     logo="[[File:AscendantAetheriumB.png|20px]]", championships=2 },
    { code="Sufriya Stormwake",       fullName="Sufriya Stormwake",       logo="[[File:SufriyaStormwake.png|20px]]", championships=1 },
    { code="Pillarion Club Suncliff", fullName="Pillarion Club Suncliff", logo="[[File:PCSuncliff.png|20px]]", championships=0 },
    { code="Jogi Regiment",           fullName="Jogi Regiment",           logo="[[File:JogiRegiment.png|20px]]", championships=0 },
    { code="Vine Fleet Mylecia",      fullName="Vine Fleet Mylecia",      logo="[[File:VineFleetMylecia.png|20px]]", championships=0 },
    { code="Port of Blore Heath",     fullName="Port of Blore Heath",     logo="[[File:PortBloreHeath.png|20px]]", championships=0 },
    { code="Free State Abeis",        fullName="Free State Abeis",        logo="[[File:FreeStateAbeis.png|20px]]", championships=0 },
    { code="Jezeraah City",           fullName="Jezeraah City",           logo="[[File:JezeraahCity.png|20px]]", championships=0 },
    { code="Ourid Pegasi",            fullName="Ourid Pegasi",            logo="[[File:OuridPegasiLogo.png|20px]]", championships=0 },
    { code="Pillarion Club Caspazani",fullName="Pillarion Club Caspazani",logo="[[File:PCCaspazani.png|20px]]", championships=0 },
    { code="Amberwatch Slevik",       fullName="Amberwatch Slevik",       logo="[[File:AmberwatchSlevik.png|20px]]", championships=0 },
    { code="Fanghorn Rein",           fullName="Fanghorn Rein",           logo="[[File:FanghornRein.png|20px]]", championships=0 },
}
local TEAM_BY_CODE = {}
for _,t in ipairs(teams) do TEAM_BY_CODE[t.code] = t end

------------------------------------------------------------------------
-- 2) DIVISIONS (your final four quads of eight)
------------------------------------------------------------------------
local DIVISIONS = {
  ["Morovian Division"] = {
    "Emberstone Vaeringheim","Vaeringheim Pillar","Imperial Delphica","Delphica Windborne",
    "Allegro Symphonara","Steppe Luminaria","Jezeraah City","Saluria Skylight"
  },
  ["Southern Strait Division"] = {
    "Sufriya Stormwake","Vine Fleet Mylecia","Port of Blore Heath","Jogi Regiment",
    "Free State Abeis","Sandveil Somniumpolis","Brightpath Aureum","Flameborne Erythros"
  },
  ["Western Highlands Division"] = {
    "Ashguard Pyralis","Aurelia Auric","Celestial Sancta Lunaris","Forgeward Nexa",
    "Hearthkeeper Koinon","Ironbough Sylvapolis","Riftwarden Acheron","Serena Tidesong"
  },
  ["Normarkian Division"] = {
    "Redcliff Citadel","Strider Myrene","Ascendant Aetherium","Pillarion Club Suncliff",
    "Ourid Pegasi","Pillarion Club Caspazani","Amberwatch Slevik","Fanghorn Rein"
  }
}
local DIV_OF = {}
for div, list in pairs(DIVISIONS) do
  for _, code in ipairs(list) do DIV_OF[code] = div end
end

------------------------------------------------------------------------
-- 3) DIVISION-WEIGHTED SCHEDULER
--    • In-division: each of 7 rivals twice (H/A)  -> 14 matches
--    • Cross-division: the other 24 once          -> 24 matches
--    = 38 total per club
------------------------------------------------------------------------
local function homeFirst(codeA, codeB)
  -- deterministic “hash” to pick a home side for cross-division fixtures
  local sA, sB = 0, 0
  for i=1,#codeA do sA = (sA + string.byte(codeA,i)) % 9973 end
  for i=1,#codeB do sB = (sB + string.byte(codeB,i)) % 9973 end
  return sA > sB
end

local function buildPairings()
  local intra, inter = {}, {}

  -- in-division double round robin (H/A)
  for _, list in pairs(DIVISIONS) do
    for i=1,#list-1 do
      for j=i+1,#list do
        local A, B = TEAM_BY_CODE[list[i]], TEAM_BY_CODE[list[j]]
        table.insert(intra, {home=A, away=B})
        table.insert(intra, {home=B, away=A})
      end
    end
  end

  -- cross-division single round robin
  local allCodes = {}
  for _,t in ipairs(teams) do table.insert(allCodes, t.code) end
  table.sort(allCodes) -- stable order

  for ai=1,#allCodes-1 do
    for bi=ai+1,#allCodes do
      local ca, cb = allCodes[ai], allCodes[bi]
      if DIV_OF[ca] ~= DIV_OF[cb] then
        local a, b = TEAM_BY_CODE[ca], TEAM_BY_CODE[cb]
        local hFirst = homeFirst(ca, cb)
        table.insert(inter, {home = hFirst and a or b, away = hFirst and b or a})
      end
    end
  end

  -- sort (deterministic grouping for nicer pacing)
  table.sort(intra, function(a,b)
    if DIV_OF[a.home.code] ~= DIV_OF[b.home.code] then
      return DIV_OF[a.home.code] < DIV_OF[b.home.code]
    end
    return a.home.code .. a.away.code < b.home.code .. b.away.code
  end)
  table.sort(inter, function(a,b)
    local ka = DIV_OF[a.home.code] .. "|" .. DIV_OF[a.away.code]
    local kb = DIV_OF[b.home.code] .. "|" .. DIV_OF[b.away.code]
    if ka ~= kb then return ka < kb end
    return a.home.code .. a.away.code < b.home.code .. b.away.code
  end)

  return intra, inter
end

local function packRounds(matches)
  -- greedy packer so no club appears twice in a round
  local rounds, used = {}, {}
  local function canPlace(r, m)
    return not (used[r] and (used[r][m.home.code] or used[r][m.away.code]))
  end
  for _, m in ipairs(matches) do
    local placed = false
    for r=1,#rounds do
      if canPlace(r, m) then
        rounds[r][#rounds[r]+1] = { m.home, m.away }
        used[r] = used[r] or {}
        used[r][m.home.code] = true
        used[r][m.away.code] = true
        placed = true
        break
      end
    end
    if not placed then
      rounds[#rounds+1] = { { m.home, m.away } }
      used[#rounds] = {}
      used[#rounds][m.home.code] = true
      used[#rounds][m.away.code] = true
    end
  end
  return rounds
end

-- Build the master schedule once (deterministic)
local SCHED = (function()
  local intra, inter = buildPairings()

  -- simple interleave: one intra block, one inter block, repeat
  local function chunk(arr, size)
    local out = {}
    for i=1,#arr,size do
      local b = {}
      for j=i, math.min(i+size-1, #arr) do b[#b+1] = arr[j] end
      out[#out+1] = b
    end
    return out
  end

  -- block sizes chosen to avoid very long runs of a single type
  local intraBlocks = chunk(intra, 64)
  local interBlocks = chunk(inter, 128)

  local merged = {}
  local ia, ib = 1, 1
  while ia <= #intraBlocks or ib <= #interBlocks do
    if ia <= #intraBlocks then
      for _,m in ipairs(intraBlocks[ia]) do merged[#merged+1] = m end
      ia = ia + 1
    end
    if ib <= #interBlocks then
      for _,m in ipairs(interBlocks[ib]) do merged[#merged+1] = m end
      ib = ib + 1
    end
  end

  return packRounds(merged)
end)()

local function generateSchedule() return SCHED end

-- Map rounds to PSSC dates across the season window
local function mapDays()
  local totalDays  = END - START + 1
  local totalRnds  = #SCHED
  local iv         = math.floor(totalDays / totalRnds)
  local ex         = totalDays % totalRnds
  local m, d       = {}, START
  for i=1,totalRnds do
    m[d] = i
    d = d + iv + (ex > 0 and 1 or 0)
    if ex > 0 then ex = ex - 1 end
  end
  return m
end

------------------------------------------------------------------------
-- 4) MATCH SIMULATION (deterministic by day & pairing)
------------------------------------------------------------------------
local function simScoreFor(h, a, seed)
  math.randomseed(seed)
  local p = math.random()
  local hg, ag
  if p < 0.1 then
    hg = math.random(0, 12); ag = hg
  else
    local wh, wa = h.championships + 1, a.championships + 1
    local rem    = (p - 0.1) / 0.9
    local homeWin= rem < (wh / (wh + wa))
    local winG   = math.random(1, 12)
    local loseG  = math.random(0, math.min(winG - 1, 12))
    if homeWin then hg, ag = winG, loseG else hg, ag = loseG, winG end
  end
  return hg, ag
end

local function simulateDayResults(day)
  local dm = mapDays()
  local idx = dm[day]
  if not idx then return {} end
  local out = {}
  for k, m in ipairs(SCHED[idx]) do
    local h, a = m[1], m[2]
    local salt = day*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
    local hg, ag = simScoreFor(h, a, salt)
    out[#out+1] = { home=h, away=a, hg=hg, ag=ag }
  end
  return out
end

------------------------------------------------------------------------
-- 5) TODAY / NEXT MATCHDAY RENDER
------------------------------------------------------------------------
local function renderRound(curDay, year)
    local dm      = mapDays()
    local target  = curDay
    local upcoming= false

    if not dm[target] then
        for d = curDay + 1, END do
            if dm[d] then target = d; upcoming = true; break end
        end
    end
    if not dm[target] then
        return "'''No more matches this season.'''"
    end

    local prefix = upcoming and "'''Next matchday:''' " or ""
    local mon, dom = getMonthDay(target)
    local label    = dom .. " " .. mon .. " " .. year .. " PSSC"
    local rows     = {}

    if not upcoming then
        for _, r in ipairs(simulateDayResults(target)) do
            local h, a, hg, ag = r.home, r.away, r.hg, r.ag
            local hc = (hg > ag and "green") or (hg < ag and "red") or "yellow"
            local ac = (ag > hg and "green") or (ag < hg and "red") or "yellow"
            local hs = string.format("<span style='color:%s;'>'''%d'''</span>", hc, hg)
            local as = string.format("<span style='color:%s;'>'''%d'''</span>", ac, ag)
            rows[#rows+1] =
                string.format("%s %s %s vs %s %s %s",
                    h.logo, hs, h.fullName,
                    a.logo, as, a.fullName
                )
        end
    else
        local idx = dm[target]
        for _, m in ipairs(SCHED[idx]) do
            local h, a = m[1], m[2]
            rows[#rows+1] =
                string.format("%s %s vs %s %s",
                    h.logo, h.fullName,
                    a.logo, a.fullName
                )
        end
    end

    return string.format(
        "|-\n| %s%d || %s || %s",
        prefix, target, label, table.concat(rows, "<br>")
    )
end

function p.renderSchedule(frame)
    local curr = date.getCurrentDate()
    local day  = tonumber(curr:match('^(%d+),')) or 0
    local year = tonumber(curr:match(', (%d+) PSSC')) or 0
    return table.concat({
        '{| class="wikitable sortable"',
        '! Day !! Date !! Matches',
        renderRound(day, year),
        '|}'
    }, "\n")
end

------------------------------------------------------------------------
-- 6) DIVISION STANDINGS
------------------------------------------------------------------------
function p.renderStandings(frame)
    local curr   = date.getCurrentDate()
    local today  = tonumber(curr:match("^(%d+),")) or 0
    local last   = math.min(today, END)

    -- init records
    local recs   = {}
    for _, t in ipairs(teams) do
        recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0 }
    end

    -- accumulate results up to 'last'
    local dm = mapDays()
    for d = START, last do
        local idx = dm[d]
        if idx then
            for k, m in ipairs(SCHED[idx]) do
                local h, a = m[1], m[2]
                local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
                local hg, ag = simScoreFor(h, a, salt)

                local H, A = recs[h.code], recs[a.code]
                H.PF, H.PA = H.PF + hg, H.PA + ag
                A.PF, A.PA = A.PF + ag, A.PA + hg

                if hg > ag then
                    H.W, H.Pts = H.W + 1, H.Pts + 3
                    A.L = A.L + 1
                elseif hg < ag then
                    A.W, A.Pts = A.W + 1, A.Pts + 3
                    H.L = H.L + 1
                else
                    H.D, H.Pts = H.D + 1, H.Pts + 1
                    A.D, A.Pts = A.D + 1, A.Pts + 1
                end
            end
        end
    end

    -- bucket by division
    local buckets = {}
    for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
    for code, r in pairs(recs) do
        r.PD = r.PF - r.PA
        local div = DIV_OF[code] or "—"
        buckets[div] = buckets[div] or {}
        table.insert(buckets[div], r)
    end

    local function cmp(a,b)
        if a.Pts ~= b.Pts then return a.Pts > b.Pts end
        if a.PD  ~= b.PD  then return a.PD  > b.PD  end
        if a.W   ~= b.W   then return a.W   > b.W   end
        return a.team.fullName < b.team.fullName
    end
    for div, list in pairs(buckets) do table.sort(list, cmp) end

    -- render four separate tables
    local order = {
        "Morovian Division","Southern Strait Division",
        "Western Highlands Division","Normarkian Division"
    }
    local out = {}
    for _, div in ipairs(order) do
        local list = buckets[div] or {}
        out[#out+1] = "== " .. div .. " Standings =="
        out[#out+1] = '{| class="wikitable sortable"'
        out[#out+1] = "! Pos !! Team !! W !! D !! L !! Pts !! PF !! PA !! PD"
        for i, rec in ipairs(list) do
            out[#out+1] = "|-"
            out[#out+1] =
                "| " .. i
                .. " || " .. rec.team.logo .. " " .. rec.team.fullName
                .. " || " .. rec.W
                .. " || " .. rec.D
                .. " || " .. rec.L
                .. " || " .. rec.Pts
                .. " || " .. rec.PF
                .. " || " .. rec.PA
                .. " || " .. rec.PD
        end
        out[#out+1] = "|}"
        out[#out+1] = ""
    end
    return table.concat(out, "\n")
end

------------------------------------------------------------------------
-- 7) PREVIOUS RESULTS (last 5 matchdays that actually exist)
------------------------------------------------------------------------
function p.renderPreviousResults(frame)
    local curr   = date.getCurrentDate()
    local today  = tonumber(curr:match("^(%d+),")) or 0
    local year   = tonumber(curr:match(", (%d+) PSSC")) or 0
    local dm     = mapDays()
    local played = {}

    for d = today - 1, START, -1 do
        if dm[d] then played[#played+1] = d end
        if #played == 5 then break end
    end

    local out = {
        '{| class="wikitable sortable"',
        '! Date !! Home !! Score !! Away'
    }
    for _, d in ipairs(played) do
        local mon, dom  = getMonthDay(d)
        local dateLabel = dom .. " " .. mon .. " " .. year .. " PSSC"
        local idx       = dm[d]
        for k, m in ipairs(SCHED[idx]) do
            local h, a = m[1], m[2]
            local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
            local hg, ag = simScoreFor(h, a, salt)
            local hs = "'''" .. hg .. "-" .. ag .. "'''"
            out[#out+1] = "|-"
            out[#out+1] =
                "| " .. dateLabel
                .. " || " .. h.logo .. " " .. h.fullName
                .. " || " .. hs
                .. " || " .. a.logo .. " " .. a.fullName
        end
    end
    out[#out+1] = "|}"
    return table.concat(out, "\n")
end

------------------------------------------------------------------------
-- 8) PLAYOFFS (kept from your original, lightly wired)
--     • Still uses a static seed list; can be upgraded to pull top-12 per division.
------------------------------------------------------------------------
local PO_YEAR  = 52               -- post-season year
local PO_FIRST = md2day(1,4)      -- 4 Atosiel 52 PSSC  (Play-in day)
local PO_IV    = 3                -- days between rounds

-- seeding list (top-12 of final table) — unchanged
local seedTeams = {
  teams[7], teams[17], teams[13], teams[19], teams[1],  teams[21],  -- 1-6
  teams[12],teams[4],  teams[3],  teams[18], teams[16], teams[5]    -- 7-12
}

-- play-in (9 v 12, 10 v 11)
local PLAYIN = {
  { seedTeams[9],  seedTeams[12] },
  { seedTeams[10], seedTeams[11] }
}

local function playScore(A,B,salt)
  math.randomseed(salt)
  local p = math.random()
  if p < 0.10 then local g=math.random(0,12); return g,g end
  local wa,wb = A.championships+1,B.championships+1
  local home  = (p-0.10)/0.90 < wa/(wa+wb)
  local hi    = math.random(1,12)
  local lo    = math.random(0, math.min(hi-1,12))
  return home and hi or lo, home and lo or hi
end

local W = { PI={}, R8={}, R4={}, SF={} }

local function mkRow(tag, absDate, H, A, salt, absNow)
  local hs, as, win = '—','—','—'
  if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then
    local h,a = playScore(H,A,salt)
    hs,as = string.format("'''%d-%d'''",h,a), nil
    local Wn = (h>a) and H or A
    win = Wn.logo .. ' ' .. Wn.fullName
    as  = '' -- keep the combined cell already set above
    hs  = string.format("'''%d-%d'''",h,a)
  end
  return string.format(
    "|-\n| %s || %s %s || %s || %s %s || %s",
    tag, H.logo,H.fullName, (hs or '—'), A.logo,A.fullName, win)
end

function p.renderPlayoffSchedule(frame)
  local out = {
    '{| class="wikitable sortable"',
    '! Round !! Date !! Match-up'
  }
  local function dateCell(day)
    local mon,dom = getMonthDay(day)
    return dom .. ' ' .. mon .. ' ' .. PO_YEAR .. '&nbsp;PSSC'
  end
  local function add(tag, day, H, A)
    out[#out+1] = '|-'
    out[#out+1] = string.format(
      '| %s || %s || %s %s vs %s %s',
      tag, dateCell(day), H.logo,H.fullName, A.logo,A.fullName)
  end

  -- Play-in
  for i,pair in ipairs(PLAYIN) do add('Play-in-'..i, PO_FIRST, pair[1], pair[2]) end

  -- banners for remaining rounds
  local banners = {
    { 'Round-of-8', 1 },
    { 'Round-of-4', 2 },
    { 'Semi-finals (Seeds 1-2 BYE)', 3 },
    { 'Final', 4 }
  }
  for _,b in ipairs(banners) do
    local d = PO_FIRST + PO_IV * b[2]
    out[#out+1] = '|-'
    out[#out+1] = '| colspan="3" | ' .. "'''" .. b[1] .. ":'''" ..
                  ' ' .. dateCell(d)
  end

  out[#out+1] = '|}'
  return table.concat(out, '\n')
end

function p.renderPlayoffResults(frame)
  local now = date.getCurrentDate()
  local dNow = tonumber(now:match('^(%d+),')) or 0
  local yNow = tonumber(now:match(', (%d+) PSSC')) or 0
  local absNow = absDay(yNow,dNow)

  local rows = {
    '{| class="wikitable sortable"',
    '! Round !! Home !! Score !! Away !! Winner'
  }

  -- Play-in
  local absPI = absDay(PO_YEAR, PO_FIRST)
  for i,pair in ipairs(PLAYIN) do
    if absNow >= absPI then
      local g1,g2 = playScore(pair[1],pair[2],absPI*100+i)
      W.PI[i]     = (g1>g2) and pair[1] or pair[2]
    end
    rows[#rows+1] = (function()
      local hs, as, win = '—','—','—'
      if absNow >= absPI then
        local g1,g2 = playScore(pair[1],pair[2],absPI*100+i)
        local Wn    = (g1>g2) and pair[1] or pair[2]
        win         = Wn.logo .. ' ' .. Wn.fullName
        hs          = string.format("'''%d-%d'''", g1, g2)
      end
      return string.format("|-\n| %s || %s %s || %s || %s %s || %s",
        'PI-'..i, pair[1].logo, pair[1].fullName, hs, pair[2].logo, pair[2].fullName, win)
    end)()
  end

  -- Round-of-8
  local absR8 = absPI + PO_IV
  local pool = { seedTeams[3],seedTeams[4],seedTeams[5],seedTeams[6],
                 seedTeams[7],seedTeams[8],
                 W.PI[1] or {logo='—',fullName='TBD'},
                 W.PI[2] or {logo='—',fullName='TBD'} }
  table.sort(pool, function(a,b)
      for _,t in ipairs(seedTeams) do
        if t==a then return true elseif t==b then return false end
      end
    end)
  local R8 = {}
  for i=1,4 do R8[i] = { pool[i], pool[#pool - (i-1)] } end
  for i,pair in ipairs(R8) do
    local H,A = pair[1], pair[2]
    local hs, win = '—', '—'
    if absNow >= absR8 and A.fullName~='TBD' then
      local g1,g2 = playScore(H,A,absR8*100+i)
      W.R8[i]     = (g1>g2) and H or A
      hs          = string.format("'''%d-%d'''", g1, g2)
      win         = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
    end
    rows[#rows+1] = string.format("|-\n| %s || %s %s || %s || %s %s || %s",
      'R-8-'..i, H.logo,H.fullName, hs, A.logo,A.fullName, win)
  end

  -- Round-of-4 (re-seed)
  local absR4 = absR8 + PO_IV
  local R4 = {}
  if W.R8[1] and W.R8[4] then
    local w = { W.R8[1],W.R8[2],W.R8[3],W.R8[4] }
    table.sort(w, function(a,b)
        for _,t in ipairs(seedTeams) do
          if t==a then return true elseif t==b then return false end
        end
      end)
    R4 = { {w[1],w[4]}, {w[2],w[3]} }
  end
  for i=1,2 do
    local H = R4[i] and R4[i][1] or {logo='—',fullName='TBD'}
    local A = R4[i] and R4[i][2] or {logo='—',fullName='TBD'}
    local hs, win = '—', '—'
    if absNow >= absR4 and R4[i] then
      local g1,g2 = playScore(H,A,absR4*100+i)
      W.R4[i]     = (g1>g2) and H or A
      hs          = string.format("'''%d-%d'''", g1, g2)
      win         = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
    end
    rows[#rows+1] = string.format("|-\n| %s || %s %s || %s || %s %s || %s",
      'R-4-'..i, H.logo,H.fullName, hs, A.logo,A.fullName, win)
  end

  -- Semi-finals (Seeds 1–2 BYE)
  local absSF = absR4 + PO_IV
  local SF = {
    { seedTeams[1], W.R4[2] or {logo='—',fullName='TBD'} },
    { seedTeams[2], W.R4[1] or {logo='—',fullName='TBD'} }
  }
  for i=1,2 do
    local H,A = SF[i][1], SF[i][2]
    local hs, win = '—', '—'
    if absNow >= absSF and A.fullName~='TBD' then
      local g1,g2 = playScore(H,A,absSF*100+i)
      W.SF[i]     = (g1>g2) and H or A
      hs          = string.format("'''%d-%d'''", g1, g2)
      win         = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
    end
    rows[#rows+1] = string.format("|-\n| %s || %s %s || %s || %s %s || %s",
      'SF-'..i, H.logo,H.fullName, hs, A.logo,A.fullName, win)
  end

  -- Final
  local absF = absSF + PO_IV
  local FH = W.SF[1] or {logo='—',fullName='TBD'}
  local FA = W.SF[2] or {logo='—',fullName='TBD'}
  local hs, win = '—', '—'
  if FH.fullName~='TBD' and FA.fullName~='TBD' and absNow >= absF then
    local g1,g2 = playScore(FH,FA,absF*100+1)
    hs          = string.format("'''%d-%d'''", g1, g2)
    win         = (g1>g2 and FH or FA).logo .. ' ' .. (g1>g2 and FH or FA).fullName
  end
  rows[#rows+1] = string.format("|-\n| %s || %s %s || %s || %s %s || %s",
    'Final', FH.logo,FH.fullName, hs, FA.logo,FA.fullName, win)

  rows[#rows+1] = '|}'
  return table.concat(rows, '\n')
end

------------------------------------------------------------------------
-- EXPORT
------------------------------------------------------------------------
return p