Module:PillSeasonSchedule: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
Undo revision 384312 by NewZimiaGov (talk)
Tag: Undo
NewZimiaGov (talk | contribs)
No edit summary
Line 8: Line 8:
local START, END = 62, 183    -- season window within the PSSC year
local START, END = 62, 183    -- season window within the PSSC year
local DAYS_IN_YEAR = 183
local DAYS_IN_YEAR = 183
local DEFAULT_SEASON = 52


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


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


Line 26: Line 27:
------------------------------------------------------------------------
------------------------------------------------------------------------
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 },
    { code="Ashguard Pyralis",       fullName="Ashguard Pyralis",         logo="[[File:AshguardpyralisB.png|20px]]", championships=1 },
  { 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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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 },
}
}


Line 63: Line 64:
for _,t in ipairs(teams) do TEAM_BY_CODE[t.code] = t end
for _,t in ipairs(teams) do TEAM_BY_CODE[t.code] = t end


-- Team display helpers (always show logo + name when available)
local function _asTeam(x)
local function _asTeam(x)
   if type(x) == "table" then return x end
   if type(x) == "table" then return x end
Line 74: Line 74:
   if bold then name = "'''" .. name .. "'''" end
   if bold then name = "'''" .. name .. "'''" end
   local logo = t.logo or ""
   local logo = t.logo or ""
   if logo ~= "" and logo ~= "—" then
   return (logo ~= "" and logo ~= "—") and (logo .. " " .. name) or name
    return logo .. " " .. name
  end
  return name
end
end


Line 108: Line 105:


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 3) DIVISION-WEIGHTED SCHEDULER
-- 3) SCHEDULE LOCK + BALANCED FUTURE
--    • In-division: 7 rivals twice (H/A)  -> 14 matches
--    • Cross-division: other 24 once      -> 24 matches
--    = 38 total per club
------------------------------------------------------------------------
------------------------------------------------------------------------
-- === LOCKED schedule: EXACT original logic (preserves history) ===
local function homeFirst(codeA, codeB)
local function homeFirst(codeA, codeB)
   local sA, sB = 0, 0
   local sA, sB = 0, 0
Line 192: Line 188:
end
end


local SCHED = (function()
local SCHED_LOCKED = (function()
   local intra, inter = buildPairings()
   local intra, inter = buildPairings()


Line 223: Line 219:
end)()
end)()


local function generateSchedule() return SCHED end
local function mapDaysLocked()
 
local function mapDays()
   local totalDays  = END - START + 1
   local totalDays  = END - START + 1
   local totalRnds  = #SCHED
   local totalRnds  = #SCHED_LOCKED
   local iv        = math.floor(totalDays / totalRnds)
   local iv        = math.floor(totalDays / totalRnds)
   local ex        = totalDays % totalRnds
   local ex        = totalDays % totalRnds
Line 239: Line 233:
end
end


------------------------------------------------------------------------
local DM_LOCKED = mapDaysLocked()
-- 4) MATCH SIMULATION (deterministic by day & pairing)
 
------------------------------------------------------------------------
local function lastPlayedLocked()
local function simScoreFor(h, a, seed)
   local curr = date.getCurrentDate()
  math.randomseed(seed)
   local today = tonumber(curr:match("^(%d+),")) or 0
   local p = math.random()
   local lastDay, lastMd = nil, 0
   local hg, ag
   for d=START, math.min(today, END) do
  if p < 0.1 then
     local md = DM_LOCKED[d]
    hg = math.random(0, 12); ag = hg
     if md and md > lastMd then lastMd = md; lastDay = d end
   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
   end
   return hg, ag
   return lastDay, lastMd
end
end


local function simulateDayResults(day)
-- === BALANCED schedule (38 rounds × 16 matches), used only AFTER pivot ===
   local dm = mapDays()
local DIV_ORDER = { "Morovian Division","Southern Strait Division","Western Highlands Division","Normarkian Division" }
   local idx = dm[day]
local TOTAL_ROUNDS = 38
  if not idx then return {} end
 
local function _copy(arr)
  local t = {}
  for i=1,#arr do t[i] = arr[i] end
  return t
end
 
local function roundRobin8(teamCodes)
   local n = #teamCodes
  if n ~= 8 then error("roundRobin8 expects exactly 8 teams") end
   local arr = _copy(teamCodes)
  local rounds = {}
  for r=1,7 do
    local matches = {}
    for i=1,4 do
      local a = TEAM_BY_CODE[arr[i]]
      local b = TEAM_BY_CODE[arr[n+1-i]]
      local home, away
      if (r+i) % 2 == 0 then home, away = a, b else home, away = b, a end
      matches[#matches+1] = { home, away }
    end
    rounds[#rounds+1] = matches
    local last = arr[n]
    for i=n,3,-1 do arr[i] = arr[i-1] end
    arr[2] = last
  end
  return rounds
end
 
local function swapHA(rounds)
   local out = {}
   local out = {}
   for k, m in ipairs(SCHED[idx]) do
   for r=1,#rounds do
     local h, a = m[1], m[2]
     out[r] = {}
     local salt = day*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
     for i=1,#rounds[r] do
    local hg, ag = simScoreFor(h, a, salt)
      local m = rounds[r][i]
    out[#out+1] = { home=h, away=a, hg=hg, ag=ag }
      out[r][i] = { m[2], m[1] }
    end
   end
   end
   return out
   return out
end
end


------------------------------------------------------------------------
local function interPairRounds(divA_codes, divB_codes, blockIndex)
-- 5) TODAY / NEXT MATCHDAY RENDER
  local rounds = {}
------------------------------------------------------------------------
  for t=0,7 do
local function renderRound(curDay, year)
     local matches = {}
    local dm      = mapDays()
     for i=1,8 do
     local target  = curDay
      local a = TEAM_BY_CODE[divA_codes[i]]
     local upcoming= false
      local b = TEAM_BY_CODE[divB_codes[((i+t-1) % 8) + 1]]
 
      local home, away
    if not dm[target] then
      if ((blockIndex + t) % 2 == 0) then home, away = a, b else home, away = b, a end
        for d = curDay + 1, END do
      matches[#matches+1] = { home, away }
            if dm[d] then target = d; upcoming = true; break end
        end
    end
    if not dm[target] then
        return "'''No more matches this season.'''"
     end
     end
    rounds[#rounds+1] = matches
  end
  return rounds
end


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


    if not upcoming then
  -- 14 intra rounds
        for _, r in ipairs(simulateDayResults(target)) do
  local intraByDiv = {}
            local h, a, hg, ag = r.home, r.away, r.hg, r.ag
  for _, divName in ipairs(DIV_ORDER) do
            local hc = (hg > ag and "green") or (hg < ag and "red") or "yellow"
    local rr7 = roundRobin8(DIVISIONS[divName])
            local ac = (ag > hg and "green") or (ag < hg and "red") or "yellow"
    local rr14 = {}
            local hs = string.format("<span style='color:%s;'>'''%d'''</span>", hc, hg)
    for i=1,7 do rr14[i] = rr7[i] end
            local as = string.format("<span style='color:%s;'>'''%d'''</span>", ac, ag)
    local rr7s = swapHA(rr7)
            rows[#rows+1] =
    for i=1,7 do rr14[7+i] = rr7s[i] end
                string.format("%s %s %s vs %s %s %s",
    intraByDiv[divName] = rr14
                    h.logo, hs, h.fullName,
  end
                    a.logo, as, a.fullName
 
                )
  for r=1,14 do
        end
     local full = {}
     else
    for _, divName in ipairs(DIV_ORDER) do
        local idx = dm[target]
      for _, m in ipairs(intraByDiv[divName][r]) do full[#full+1] = m end
        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
     end
    rounds[#rounds+1] = full
  end


    return string.format(
  -- 24 inter rounds = 3 blocks × 8 rounds
        "|-\n| %s%d || %s || %s",
  local A = DIVISIONS[DIV_ORDER[1]]
        prefix, target, label, table.concat(rows, "<br>")
  local B = DIVISIONS[DIV_ORDER[2]]
    )
  local C = DIVISIONS[DIV_ORDER[3]]
end
  local D = DIVISIONS[DIV_ORDER[4]]
 
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


------------------------------------------------------------------------
  local blocks = {
-- 6) DIVISION STANDINGS (color-coded by divisional position)
    { {A,B}, {C,D} },
------------------------------------------------------------------------
     { {A,C}, {B,D} },
function p.renderStandings(frame)
     { {A,D}, {B,C} },
     local curr  = date.getCurrentDate()
   }
     local today  = tonumber(curr:match("^(%d+),")) or 0
    local last   = math.min(today, END)


    local recs   = {}
   for blockIndex, pairset in ipairs(blocks) do
    for _, t in ipairs(teams) do
    local p1 = interPairRounds(pairset[1][1], pairset[1][2], blockIndex)
        recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0 }
    local p2 = interPairRounds(pairset[2][1], pairset[2][2], blockIndex)
    for t=1,8 do
      local full = {}
      for _,m in ipairs(p1[t]) do full[#full+1] = m end
      for _,m in ipairs(p2[t]) do full[#full+1] = m end
      rounds[#rounds+1] = full
     end
     end
  end


    local dm = mapDays()
  return rounds
    for d = START, last do
end)()
        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]
-- Composite mapDays: past days keep locked mapping; future days map to balanced rounds
                H.PF, H.PA = H.PF + hg, H.PA + ag
local function mapDays()
                A.PF, A.PA = A.PF + ag, A.PA + hg
  local pivotDay, pivotMd = lastPlayedLocked()


                if hg > ag then
  if not pivotDay or pivotMd == 0 then
                    H.W, H.Pts = H.W + 1, H.Pts + 3
    local totalDays  = END - START + 1
                    A.L = A.L + 1
    local iv        = math.floor(totalDays / TOTAL_ROUNDS)
                elseif hg < ag then
    local ex        = totalDays % TOTAL_ROUNDS
                    A.W, A.Pts = A.W + 1, A.Pts + 3
    local m, d      = {}, START
                    H.L = H.L + 1
    for i=1,TOTAL_ROUNDS do
                else
      m[d] = i
                    H.D, H.Pts = H.D + 1, H.Pts + 1
      d = d + iv + (ex > 0 and 1 or 0)
                    A.D, A.Pts = A.D + 1, A.Pts + 1
      if ex > 0 then ex = ex - 1 end
                end
            end
        end
     end
     end
    return m
  end


    local buckets = {}
  local dm = {}
    for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
  for d,md in pairs(DM_LOCKED) do
     for code, r in pairs(recs) do
     if d <= pivotDay then dm[d] = md end
        r.PD = r.PF - r.PA
  end
        local div = DIV_OF[code] or "—"
        buckets[div] = buckets[div] or {}
        table.insert(buckets[div], r)
    end


    local function cmp(a,b)
  local futureStartDay = pivotDay + 1
        if a.Pts ~= b.Pts then return a.Pts > b.Pts end
  local remainingDays = math.max(0, END - futureStartDay + 1)
        if a.PD  ~= b.PD  then return a.PD  > b.PD  end
  local remainingRnds = math.max(0, TOTAL_ROUNDS - pivotMd)
        if a.W  ~= b.W  then return a.W  > b.W  end
  if remainingDays == 0 or remainingRnds == 0 then return dm end
        return a.team.fullName < b.team.fullName
    end
    for div, list in pairs(buckets) do table.sort(list, cmp) end


    local order = {
  local iv = math.floor(remainingDays / remainingRnds)
        "Morovian Division","Southern Strait Division",
  local ex = remainingDays % remainingRnds
        "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
  local day = futureStartDay
            local style = ""
  for i=1,remainingRnds do
            if    i <= 2 then style = ' style="background-color:#ccffcc;"'
    local mdx = pivotMd + i
            elseif i <= 4 then style = ' style="background-color:#cce5ff;"'
    if mdx > TOTAL_ROUNDS then break end
            elseif i <= 6 then style = ' style="background-color:#F1EB9C;"'
     dm[day] = mdx
            else              style = ' style="background-color:#ffcccc;"'
    day = day + iv + (ex > 0 and 1 or 0)
            end
    if ex > 0 then ex = ex - 1 end
    if day > END then break end
  end


            out[#out+1] = "|-" .. style
  return dm
            out[#out+1] =
end
                "| " .. i
 
                .. " || " .. rec.team.logo .. " " .. rec.team.fullName
local function invertDayMap(dm)
                .. " || " .. rec.W
  local inv = {}
                .. " || " .. rec.D
  for day, md in pairs(dm) do inv[md] = day end
                .. " || " .. rec.L
  return inv
                .. " || " .. rec.Pts
                .. " || " .. rec.PF
                .. " || " .. rec.PA
                .. " || " .. rec.PD
        end
        out[#out+1] = "|}"
        out[#out+1] = ""
    end
    return table.concat(out, "\n")
end
end


------------------------------------------------------------------------
local function schedForRound(roundIdx)
-- 7) PREVIOUS RESULTS (last 5 matchdays)
   local _, pivotMd = lastPlayedLocked()
------------------------------------------------------------------------
   if roundIdx <= pivotMd then return SCHED_LOCKED end
function p.renderPreviousResults(frame)
  return SCHED_BALANCED
    local curr   = date.getCurrentDate()
end
    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
local function getRoundMatches(roundIdx)
        if dm[d] then played[#played+1] = d end
  local sched = schedForRound(roundIdx)
        if #played == 5 then break end
  return sched[roundIdx] or {}
    end
end


    local out = {
local function _currentPlayedMatchday()
        '{| class="wikitable sortable"',
  local curr = date.getCurrentDate()
        '! Date !! Home !! Score !! Away'
  local today = tonumber(curr:match("^(%d+),")) or 0
    }
  local dm = mapDays()
    for _, d in ipairs(played) do
  local lastMd = 0
        local mon, dom  = getMonthDay(d)
  for d=START, math.min(today, END) do
        local dateLabel = dom .. " " .. mon .. " " .. year .. " PSSC"
    if dm[d] and dm[d] > lastMd then lastMd = dm[d] end
        local idx      = dm[d]
  end
        for k, m in ipairs(SCHED[idx]) do
  return lastMd
            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
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 8) PLAYOFFS (12 teams seeded from divisions; unchanged seeding block)
-- 4) MATCH SIMULATION
------------------------------------------------------------------------
------------------------------------------------------------------------
local PO_YEAR  = 52
local function simScoreFor(h, a, seed)
local PO_FIRST = md2day(1,4)
  math.randomseed(seed)
local PO_IV    = 3
  local p = math.random()
 
  local hg, ag
local function tb_cmp(a,b)
  if p < 0.1 then
   if a.Pts ~= b.Pts then return a.Pts > b.Pts end
    hg = math.random(0, 12); ag = hg
   if a.PD ~= b.PD  then return a.PD  > b.PD  end
   else
  if a.W  ~= b.W  then return a.W  > b.W   end
    local wh, wa = (h.championships or 0) + 1, (a.championships or 0) + 1
   return a.team.fullName < b.team.fullName
    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
end


local function computeRecordsUpTo(dayEnd)
local function simulateDayResults(day)
  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, Div=DIV_OF[t.code] }
  end
   local dm = mapDays()
   local dm = mapDays()
   for d = START, math.min(dayEnd, END) do
   local idx = dm[day]
    local idx = dm[d]
  if not idx then return {} end
    if idx then
  local matches = getRoundMatches(idx)
      for k, m in ipairs(SCHED[idx]) do
  local out = {}
        local h, a = m[1], m[2]
  for k, m in ipairs(matches) do
        local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
    local h, a = m[1], m[2]
        local hg, ag = simScoreFor(h, a, salt)
    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


        local H, A = recs[h.code], recs[a.code]
------------------------------------------------------------------------
        H.PF, H.PA = H.PF + hg, H.PA + ag
-- 5) TODAY / NEXT MATCHDAY RENDER
        A.PF, A.PA = A.PF + ag, A.PA + hg
------------------------------------------------------------------------
local function renderRound(curDay, year)
  local dm      = mapDays()
  local target  = curDay
  local upcoming= false


        if hg > ag then
  if not dm[target] then
          H.W, H.Pts = H.W + 1, H.Pts + 3
    for d = curDay + 1, END do
          A.L = A.L + 1
      if dm[d] then target = d; upcoming = true; break end
        elseif hg < ag then
    end
          A.W, A.Pts = A.W + 1, A.Pts + 3
  end
           H.L = H.L + 1
  if not dm[target] then
         else
    return "'''No more matches this season.'''"
          H.D, H.Pts = H.D + 1, H.Pts + 1
  end
           A.D, A.Pts = A.D + 1, A.Pts + 1
 
         end
  local prefix = upcoming and "'''Next matchday:''' " or ""
      end
  local mon, dom = getMonthDay(target)
  local label    = dom .. " " .. mon .. " " .. year .. " PSSC"
  local rows    = {}
 
  local idx = dm[target]
  local matches = getRoundMatches(idx)
 
  if not upcoming then
    for k, m in ipairs(matches) do
      local h, a = m[1], m[2]
      local salt = target*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
      local hg, ag = simScoreFor(h, a, salt)
      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
    for _, m in ipairs(matches) 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
   end
   end
   for _, r in pairs(recs) do r.PD = r.PF - r.PA end
 
  return recs
   return string.format("|-\n| %s%d || %s || %s", prefix, target, label, table.concat(rows, "<br>"))
end
end


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


  local buckets = {}
------------------------------------------------------------------------
  for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
-- 6) DIVISION STANDINGS
   for _, r in pairs(recs) do
------------------------------------------------------------------------
    buckets[r.Div] = buckets[r.Div] or {}
function p.renderStandings(frame)
    table.insert(buckets[r.Div], r)
   local curr  = date.getCurrentDate()
   end
  local today  = tonumber(curr:match("^(%d+),")) or 0
   for div, list in pairs(buckets) do table.sort(list, tb_cmp) end
   local last   = math.min(today, END)


   local winners, runners = {}, {}
   local recs  = {}
   for _, div in ipairs({
   for _, t in ipairs(teams) do
    "Morovian Division","Southern Strait Division",
     recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0 }
    "Western Highlands Division","Normarkian Division"
  }) do
     local list = buckets[div] or {}
    if list[1] then table.insert(winners, list[1]) end
    if list[2] then table.insert(runners, list[2]) end
   end
   end
  table.sort(winners, tb_cmp)
  table.sort(runners, tb_cmp)


   local chosen = {}
   local dm = mapDays()
   for _,r in ipairs(winners) do chosen[r.team.code] = true end
   for d = START, last do
  for _,r in ipairs(runners) do chosen[r.team.code] = true end
    local idx = dm[d]
    if idx then
      local matches = getRoundMatches(idx)
      for k, m in ipairs(matches) 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 allList = {}
        local H, A = recs[h.code], recs[a.code]
  for _, r in pairs(recs) do table.insert(allList, r) end
        H.PF, H.PA = H.PF + hg, H.PA + ag
  table.sort(allList, tb_cmp)
        A.PF, A.PA = A.PF + ag, A.PA + hg


  local wild = {}
        if hg > ag then
  for _,r in ipairs(allList) do
          H.W, H.Pts = H.W + 1, H.Pts + 3
    if not chosen[r.team.code] then
          A.L = A.L + 1
      table.insert(wild, r)
        elseif hg < ag then
      if #wild == 4 then break end
          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


   local seeds = {}
   local buckets = {}
   for i=1,#winners do seeds[#seeds+1] = winners[i].team end
   for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
   for i=1,#runners do seeds[#seeds+1] = runners[i].team end
   for code, r in pairs(recs) do
  for i=1,#wild    do seeds[#seeds+1] = wild[i].team    end
    r.PD = r.PF - r.PA
   return seeds
    local div = DIV_OF[code] or "—"
end
    buckets[div] = buckets[div] or {}
    table.insert(buckets[div], r)
   end


local function playScore(A,B,salt)
  local function cmp(a,b)
  math.randomseed(salt)
    if a.Pts ~= b.Pts then return a.Pts > b.Pts end
  local p = math.random()
    if a.PD  ~= b.PD  then return a.PD  > b.PD  end
  if p < 0.10 then local g=math.random(0,12); return g,g end
    if a.W   ~= b.W  then return a.W  > b.W  end
   local wa,wb = A.championships+1,B.championships+1
    return a.team.fullName < b.team.fullName
  local home  = (p-0.10)/0.90 < wa/(wa+wb)
   end
   local hi    = math.random(1,12)
   for div, list in pairs(buckets) do table.sort(list, cmp) end
   local lo    = math.random(0, math.min(hi-1,12))
  return home and hi or lo, home and lo or hi
end


local function mkRow(tag, absDate, H, A, salt, absNow)
  local order = DIV_ORDER
  local hs, win = '—','—'
  local out = {}
  if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then
  for _, div in ipairs(order) do
     local g1,g2 = playScore(H,A,salt)
    local list = buckets[div] or {}
     hs  = string.format("'''%d-%d'''", g1, g2)
    out[#out+1] = "== " .. div .. " Standings =="
    win = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
    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
      local style = ""
      if     i <= 2 then style = ' style="background-color:#ccffcc;"'
      elseif i <= 4 then style = ' style="background-color:#cce5ff;"'
      elseif i <= 6 then style = ' style="background-color:#F1EB9C;"'
      else              style = ' style="background-color:#ffcccc;"'
      end
      out[#out+1] = "|-" .. style
      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
   end
   return string.format(
   return table.concat(out, "\n")
    "|-\n| %s || %s %s || %s || %s %s || %s",
    tag, H.logo,H.fullName, hs, A.logo,A.fullName, win)
end
end


function p.renderPlayoffSchedule(frame)
------------------------------------------------------------------------
   local seeds = getSeeds12()
-- 7) PREVIOUS RESULTS (last 5 matchdays)
------------------------------------------------------------------------
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 = {
   local out = {
     '{| class="wikitable sortable"',
     '{| class="wikitable sortable"',
     '! Round !! Date !! Match-up'
     '! Date !! Home !! Score !! Away'
   }
   }
   local function dateCell(day)
 
     local mon,dom = getMonthDay(day)
   for _, d in ipairs(played) do
     return dom .. ' ' .. mon .. ' ' .. PO_YEAR .. '&nbsp;PSSC'
     local mon, dom = getMonthDay(d)
  end
     local dateLabel = dom .. " " .. mon .. " " .. year .. " PSSC"
  local function add(tag, day, H, A)
    local idx = dm[d]
    out[#out+1] = '|-'
    local matches = getRoundMatches(idx)
    out[#out+1] = string.format(
    for k, m in ipairs(matches) do
      '| %s || %s || %s %s vs %s %s',
      local h, a = m[1], m[2]
      tag, dateCell(day), H.logo,H.fullName, A.logo,A.fullName)
      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
   end


   local p1, p2 = seeds[9], seeds[12]
   out[#out+1] = "|}"
  local p3, p4 = seeds[10], seeds[11]
   return table.concat(out, "\n")
   add('Play-in-1', PO_FIRST, p1, p2)
end
  add('Play-in-2', PO_FIRST, p3, p4)


  local banners = {
------------------------------------------------------------------------
    { 'Round-of-8', 1 },
-- 8) PLAYOFFS (12 teams seeded from divisions)
    { 'Round-of-4', 2 },
------------------------------------------------------------------------
    { 'Semi-finals (Seeds 1–2 BYE)', 3 },
local PO_YEAR  = 52
    { 'Final', 4 }
local PO_FIRST = md2day(1,4)
  }
local PO_IV    = 3
  for _,b in ipairs(banners) do
 
    local d = PO_FIRST + PO_IV * b[2]
local function tb_cmp(a,b)
    out[#out+1] = '|-'
  if a.Pts ~= b.Pts then return a.Pts > b.Pts end
    out[#out+1] = '| colspan="3" | ' .. "'''" .. b[1] .. ":'''" ..
  if a.PD  ~= b.PD  then return a.PD  > b.PD  end
                  ' ' .. dateCell(d)
  if a.W  ~= b.W  then return a.W  > b.W   end
   end
   return a.team.fullName < b.team.fullName
  out[#out+1] = '|}'
   return table.concat(out, '\n')
end
end


function p.renderPlayoffResults(frame)
local function computeRecordsUpTo(dayEnd)
   local seeds = getSeeds12()
   local recs = {}
   local now  = date.getCurrentDate()
   for _, t in ipairs(teams) do
  local dNow  = tonumber(now:match('^(%d+),')) or 0
    recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0, Div=DIV_OF[t.code] }
  local yNow  = tonumber(now:match(', (%d+) PSSC')) or 0
   end
   local absNow= absDay(yNow,dNow)


   local rows = {
   local dm = mapDays()
     '{| class="wikitable sortable"',
  for d = START, math.min(dayEnd, END) do
    '! Round !! Home !! Score !! Away !! Winner'
    local idx = dm[d]
  }
     if idx then
      local matches = getRoundMatches(idx)
      for k, m in ipairs(matches) 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 absPI = absDay(PO_YEAR, PO_FIRST)
        local H, A = recs[h.code], recs[a.code]
  local PI = {
        H.PF, H.PA = H.PF + hg, H.PA + ag
    { seeds[9],  seeds[12] },
        A.PF, A.PA = A.PF + ag, A.PA + hg
    { seeds[10], seeds[11] }
  }
  local W_PI = {}
  for i,pair in ipairs(PI) do
    local H,A = pair[1], pair[2]
    if absNow >= absPI then
      local g1,g2 = playScore(H,A,absPI*100+i)
      W_PI[i]    = (g1>g2) and H or A
    end
    rows[#rows+1] = mkRow('PI-'..i, absPI, H, A, absPI*100+i, absNow)
  end


  local absR8 = absPI + PO_IV
        if hg > ag then
  local pool = { seeds[3],seeds[4],seeds[5],seeds[6],seeds[7],seeds[8],
          H.W, H.Pts = H.W + 1, H.Pts + 3
                W_PI[1] or {logo='—',fullName='TBD'}, W_PI[2] or {logo='—',fullName='TBD'} }
          A.L = A.L + 1
  local R8 = { {pool[3], pool[6]}, {pool[4], pool[5]}, {pool[1], pool[8]}, {pool[2], pool[7]} }
        elseif hg < ag then
 
          A.W, A.Pts = A.W + 1, A.Pts + 3
  local W_R8 = {}
          H.L = H.L + 1
  for i,pair in ipairs(R8) do
        else
    local H,A = pair[1], pair[2]
          H.D, H.Pts = H.D + 1, H.Pts + 1
    if absNow >= absR8 and A.fullName~='TBD' then
          A.D, A.Pts = A.D + 1, A.Pts + 1
      local g1,g2 = playScore(H,A,absR8*100+i)
        end
       W_R8[i]    = (g1>g2) and H or A
       end
     end
     end
    rows[#rows+1] = mkRow('R-8-'..i, absR8, H, A, absR8*100+i, absNow)
   end
   end


   local absR4 = absR8 + PO_IV
   for _, r in pairs(recs) do r.PD = r.PF - r.PA end
  local R4 = {}
  return recs
  if #W_R8 == 4 then
end
    local idxByCode = {}
    for i,t in ipairs(seeds) do idxByCode[t.code] = i end
    table.sort(W_R8, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end)
    R4 = { { W_R8[1], W_R8[4] }, { W_R8[2], W_R8[3] } }
  end


  local W_R4 = {}
local function getSeeds12()
   for i=1,2 do
   local curr  = date.getCurrentDate()
    local H = R4[i] and R4[i][1] or {logo='—',fullName='TBD'}
  local today  = tonumber(curr:match("^(%d+),")) or END
    local A = R4[i] and R4[i][2] or {logo='—',fullName='TBD'}
  local recs  = computeRecordsUpTo(today)
    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
    end
    rows[#rows+1] = mkRow('R-4-'..i, absR4, H, A, absR4*100+i, absNow)
  end


   local absSF = absR4 + PO_IV
   local buckets = {}
  local SF = {}
   for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
   if #W_R4 == 2 then
  for _, r in pairs(recs) do
    local idxByCode = {}
    buckets[r.Div] = buckets[r.Div] or {}
    for i,t in ipairs(seeds) do idxByCode[t.code] = i end
     table.insert(buckets[r.Div], r)
    table.sort(W_R4, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end)
    SF = { { seeds[1], W_R4[2] }, { seeds[2], W_R4[1] } }
  else
     SF = { { seeds[1], {logo='—',fullName='TBD'} }, { seeds[2], {logo='—',fullName='TBD'} } }
   end
   end
  for div, list in pairs(buckets) do table.sort(list, tb_cmp) end


   local W_SF = {}
   local winners, runners = {}, {}
   for i=1,2 do
   for _, div in ipairs(DIV_ORDER) do
     local H,A = SF[i][1], SF[i][2]
     local list = buckets[div] or {}
    if absNow >= absSF and A.fullName~='TBD' then
    if list[1] then table.insert(winners, list[1]) end
      local g1,g2 = playScore(H,A,absSF*100+i)
     if list[2] then table.insert(runners, list[2]) end
      W_SF[i]     = (g1>g2) and H or A
    end
     rows[#rows+1] = mkRow('SF-'..i, absSF, H, A, absSF*100+i, absNow)
   end
   end
  table.sort(winners, tb_cmp)
  table.sort(runners, tb_cmp)


   local absF = absSF + PO_IV
   local chosen = {}
  local FH = W_SF[1] or {logo='—',fullName='TBD'}
   for _,r in ipairs(winners) do chosen[r.team.code] = true end
   local FA = W_SF[2] or {logo='—',fullName='TBD'}
   for _,r in ipairs(runners) do chosen[r.team.code] = true end
   rows[#rows+1] = mkRow('Final', absF, FH, FA, absF*100+1, absNow)


   rows[#rows+1] = '|}'
   local allList = {}
   return table.concat(rows, '\n')
   for _, r in pairs(recs) do table.insert(allList, r) end
end
  table.sort(allList, tb_cmp)


------------------------------------------------------------------------
  local wild = {}
-- 9)  PER-MATCH STATS (compute only on match day)
   for _,r in ipairs(allList) do
--    Guarantees: Final = 3*Goals + PossessionPts
     if not chosen[r.team.code] then
--    Adds column-wise highlight of higher value (light green)
      table.insert(wild, r)
------------------------------------------------------------------------
      if #wild == 4 then break end
local function computeMatchStats(h, a, hg, ag, seed)
     end
   local function splitScore(total, salt)
    local maxG = math.floor(total / 3)
     if maxG == 0 then return 0, total end
    math.randomseed(salt)
    local g = maxG
    if maxG >= 1 and math.random() < 0.40 then g = maxG - 1 end
     local p = total - 3*g
    return g, p
   end
   end


   local h_goals, h_poss = splitScore(hg, seed + 101)
   local seeds = {}
   local a_goals, a_poss = splitScore(ag, seed + 102)
  for i=1,#winners do seeds[#seeds+1] = winners[i].team end
  for i=1,#runners do seeds[#seeds+1] = runners[i].team end
   for i=1,#wild    do seeds[#seeds+1] = wild[i].team    end
  return seeds
end


  local function rrange(s, lo, hi) math.randomseed(s); return math.random(lo, hi) end
local function playScore(A,B,salt)
   local h_sh_on = (h_goals > 0) and rrange(seed+201, h_goals, h_goals+2) or rrange(seed+201, 0, 2)
  math.randomseed(salt)
   local a_sh_on = (a_goals > 0) and rrange(seed+202, a_goals, a_goals+2) or rrange(seed+202, 0, 2)
  local p = math.random()
  if p < 0.10 then local g=math.random(0,12); return g,g end
   local wa,wb = (A.championships or 0)+1,(B.championships or 0)+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 h_sh_off = rrange(seed+203, 0, 3)
local function mkRow(tag, absDate, H, A, salt, absNow)
   local a_sh_off = rrange(seed+204, 0, 3)
  local hs, win = '—','—'
   if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then
    local g1,g2 = playScore(H,A,salt)
    hs  = string.format("'''%d-%d'''", g1, g2)
    win = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
   end
  return string.format("|-\n| %s || %s %s || %s || %s %s || %s",
    tag, H.logo,H.fullName, hs, A.logo,A.fullName, win)
end


  local h_fouls = rrange(seed+205, 0, 3)
function p.renderPlayoffSchedule(frame)
   local a_fouls = rrange(seed+206, 0, 3)
   local seeds = getSeeds12()
   local h_fks  = a_fouls
   local out = { '{| class="wikitable sortable"', '! Round !! Date !! Match-up' }
  local a_fks  = h_fouls


   local h_throws = rrange(seed+207, 0, 3)
   local function dateCell(day)
  local a_throws = rrange(seed+208, 0, 3)
    local mon,dom = getMonthDay(day)
   local h_oob    = rrange(seed+209, 0, 2)
    return dom .. ' ' .. mon .. ' ' .. PO_YEAR .. '&nbsp;PSSC'
  local a_oob    = rrange(seed+210, 0, 2)
  end
  local h_gk     = rrange(seed+211, 0, 2)
   local function add(tag, day, H, A)
  local a_gk    = rrange(seed+212, 0, 2)
    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


   local base = 50
   add('Play-in-1', PO_FIRST, seeds[9], seeds[12])
  local swing = rrange(seed+213, 3, 6)
   add('Play-in-2', PO_FIRST, seeds[10], seeds[11])
   local h_poss_pct, a_poss_pct
  if hg > ag then
    h_poss_pct, a_poss_pct = base + swing, base - swing
  elseif hg < ag then
    h_poss_pct, a_poss_pct = base - swing, base + swing
  else
    local tilt = rrange(seed+214, -3, 3)
    h_poss_pct, a_poss_pct = base + tilt, base - tilt
  end


   return {
   local banners = {
     [h.code] = {
     { 'Round-of-8', 1 },
      final = hg, goals = h_goals, poss_points = h_poss,
    { 'Round-of-4', 2 },
      shots_on = h_sh_on, shots_off = h_sh_off,
    { 'Semi-finals (Seeds 1–2 BYE)', 3 },
      fouls = h_fouls, fks = h_fks, throws = h_throws,
     { 'Final', 4 }
      gk = h_gk, oob = h_oob, poss_pct = h_poss_pct
    },
     [a.code] = {
      final = ag, goals = a_goals, poss_points = a_poss,
      shots_on = a_sh_on, shots_off = a_sh_off,
      fouls = a_fouls, fks = a_fks, throws = a_throws,
      gk = a_gk, oob = a_oob, poss_pct = a_poss_pct
    }
   }
   }
end
  for _,b in ipairs(banners) do
 
    local d = PO_FIRST + PO_IV * b[2]
-- highlight helper: returns HTML-wrapped cells for the higher value
     out[#out+1] = '|-'
local function hiPair(hVal, aVal)
    out[#out+1] = '| colspan="3" | ' .. "'''" .. b[1] .. ":'''" .. ' ' .. dateCell(d)
  local function cell(v, isHigh)
     if isHigh then
      return string.format('<span style="background-color:#ccffcc;">%s</span>', v)
    else
      return tostring(v)
    end
   end
   end
   local hHigh = (hVal > aVal)
   out[#out+1] = '|}'
  local aHigh = (aVal > hVal)
   return table.concat(out, '\n')
   return cell(hVal, hHigh), cell(aVal, aHigh)
end
end


function p.renderMatchStatsForDay(frame)
function p.renderPlayoffResults(frame)
   local curr   = date.getCurrentDate()
   local seeds = getSeeds12()
   local dayNow = tonumber(curr:match('^(%d+),')) or 0
  local now   = date.getCurrentDate()
   local year  = tonumber(curr:match(', (%d+) PSSC')) or 0
   local dNow  = tonumber(now:match('^(%d+),')) or 0
   local dm    = mapDays()
   local yNow  = tonumber(now:match(', (%d+) PSSC')) or 0
   local absNow= absDay(yNow,dNow)
 
  local rows = { '{| class="wikitable sortable"', '! Round !! Home !! Score !! Away !! Winner' }
 
  local absPI = absDay(PO_YEAR, PO_FIRST)
  local PI = { { seeds[9],  seeds[12] }, { seeds[10], seeds[11] } }
  local W_PI = {}


   local target = dm[dayNow] and dayNow or nil
   for i,pair in ipairs(PI) do
  if not target then
    local H,A = pair[1], pair[2]
    for d = dayNow + 1, END do
    if absNow >= absPI then
       if dm[d] then target = d; break end
      local g1,g2 = playScore(H,A,absPI*100+i)
       W_PI[i]     = (g1>g2) and H or A
     end
     end
    rows[#rows+1] = mkRow('PI-'..i, absPI, H, A, absPI*100+i, absNow)
   end
   end
  if not target then return "'''No scheduled matches in window.'''" end


   local idx    = dm[target]
   local absR8 = absPI + PO_IV
   local isToday = (target == dayNow)
  local pool = { seeds[3],seeds[4],seeds[5],seeds[6],seeds[7],seeds[8],
                W_PI[1] or {logo='—',fullName='TBD'}, W_PI[2] or {logo='—',fullName='TBD'} }
   local R8 = { {pool[3], pool[6]}, {pool[4], pool[5]}, {pool[1], pool[8]}, {pool[2], pool[7]} }


   local mon, dom = getMonthDay(target)
   local W_R8 = {}
   local label    = dom .. " " .. mon .. " " .. year .. " PSSC"
   for i,pair in ipairs(R8) do
 
     local H,A = pair[1], pair[2]
  -- highlight helper: numeric compare, optional suffix for display
     if absNow >= absR8 and A.fullName~='TBD' then
  local function hiPairNum(hVal, aVal, suffix)
      local g1,g2 = playScore(H,A,absR8*100+i)
     hVal = tonumber(hVal) or 0
       W_R8[i]    = (g1>g2) and H or A
     aVal = tonumber(aVal) or 0
    suffix = suffix or ""
 
    local function cell(v, isHigh)
       local txt = tostring(v) .. suffix
      if isHigh then
        return string.format('<span style="background-color:#ccffcc;">%s</span>', txt)
      end
      return txt
     end
     end
    rows[#rows+1] = mkRow('R-8-'..i, absR8, H, A, absR8*100+i, absNow)
  end


    local hHigh = (hVal > aVal)
  local absR4 = absR8 + PO_IV
    local aHigh = (aVal > hVal)
  local R4, W_R4 = {}, {}
    return cell(hVal, hHigh), cell(aVal, aHigh)
  end


   -- highlight helper for already-formatted strings (no suffix, no tonumber)
   if #W_R8 == 4 then
  local function hiPairStr(hVal, aVal)
    local idxByCode = {}
    local function cell(v, isHigh)
    for i,t in ipairs(seeds) do idxByCode[t.code] = i end
      if isHigh then
    table.sort(W_R8, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end)
        return string.format('<span style="background-color:#ccffcc;">%s</span>', tostring(v))
     R4 = { { W_R8[1], W_R8[4] }, { W_R8[2], W_R8[3] } }
      end
      return tostring(v)
    end
    local hn = tonumber(hVal) or -1
     local an = tonumber(aVal) or -1
    local hHigh = (hn > an)
    local aHigh = (an > hn)
    return cell(hVal, hHigh), cell(aVal, aHigh)
   end
   end


   local out = {
   for i=1,2 do
     '{| class="wikitable sortable"',
     local H = R4[i] and R4[i][1] or {logo='—',fullName='TBD'}
     '|+ Match Statistics — ' .. label .. (isToday and '' or ' (fixtures; stats available day-of)'),
     local A = R4[i] and R4[i][2] or {logo='—',fullName='TBD'}
     '! Match !! Team !! Final !! Goals (Pillar) !! Possession Pts !! Shots on !! Shots off !! Fouls !! Free Kicks !! Throw-Ins !! Goal Kicks !! OOB !! Poss %'
    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
    end
     rows[#rows+1] = mkRow('R-4-'..i, absR4, H, A, absR4*100+i, absNow)
   end


   if isToday then
   local absSF = absR4 + PO_IV
    local results = simulateDayResults(target)
  local SF = {}
    for k, r in ipairs(results) do
      local seed = target*1000 + k + string.byte(r.home.code,1) + string.byte(r.away.code,1)
      local S    = computeMatchStats(r.home, r.away, r.hg, r.ag, seed)
      local H, A = S[r.home.code], S[r.away.code]


      local matchLabel = r.home.logo .. ' ' .. r.home.fullName .. ' vs ' .. r.away.logo .. ' ' .. r.away.fullName
  if #W_R4 == 2 then
    local idxByCode = {}
    for i,t in ipairs(seeds) do idxByCode[t.code] = i end
    table.sort(W_R4, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end)
    SF = { { seeds[1], W_R4[2] }, { seeds[2], W_R4[1] } }
  else
    SF = { { seeds[1], {logo='—',fullName='TBD'} }, { seeds[2], {logo='—',fullName='TBD'} } }
  end


      local hF,  aF   = hiPairNum(H.final,       A.final)
  local W_SF = {}
      local hG,   aG  = hiPairNum(H.goals,       A.goals)
   for i=1,2 do
       local hP,   aP  = hiPairNum(H.poss_points, A.poss_points)
    local H,A = SF[i][1], SF[i][2]
       local hSON, aSON = hiPairNum(H.shots_on,    A.shots_on)
    if absNow >= absSF and A.fullName~='TBD' then
      local hSOF, aSOF = hiPairNum(H.shots_off,   A.shots_off)
       local g1,g2 = playScore(H,A,absSF*100+i)
      local hFO, aFO  = hiPairNum(H.fouls,       A.fouls)
       W_SF[i]    = (g1>g2) and H or A
      local hFK,  aFK  = hiPairNum(H.fks,        A.fks)
    end
      local hTH, aTH  = hiPairNum(H.throws,      A.throws)
    rows[#rows+1] = mkRow('SF-'..i, absSF, H, A, absSF*100+i, absNow)
      local hGK, aGK  = hiPairNum(H.gk,          A.gk)
  end
      local hOOB, aOOB = hiPairNum(H.oob,         A.oob)
 
      local hPOS, aPOS = hiPairNum(H.poss_pct,   A.poss_pct, '%')
  local absF = absSF + PO_IV
  local FH = W_SF[1] 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)


      -- HOME ROW (Match cell rowspan=2)
  rows[#rows+1] = '|}'
      out[#out+1] = '|-'
  return table.concat(rows, '\n')
      out[#out+1] =
end
        '| rowspan="2" | ' .. matchLabel ..
        ' || ' .. r.home.logo .. ' ' .. r.home.fullName ..
        ' || ' .. hF ..
        ' || ' .. hG ..
        ' || ' .. hP ..
        ' || ' .. hSON ..
        ' || ' .. hSOF ..
        ' || ' .. hFO ..
        ' || ' .. hFK ..
        ' || ' .. hTH ..
        ' || ' .. hGK ..
        ' || ' .. hOOB ..
        ' || ' .. hPOS


      -- AWAY ROW (no Match cell; it’s covered by rowspan)
------------------------------------------------------------------------
      out[#out+1] = '|-'
-- 9) PER-MATCH STATS
      out[#out+1] =
------------------------------------------------------------------------
        '| ' .. r.away.logo .. ' ' .. r.away.fullName ..
local function computeMatchStats(h, a, hg, ag, seed)
        ' || ' .. aF ..
  local function splitScore(total, salt)
        ' || ' .. aG ..
    local maxG = math.floor(total / 3)
        ' || ' .. aP ..
    if maxG == 0 then return 0, total end
        ' || ' .. aSON ..
    math.randomseed(salt)
        ' || ' .. aSOF ..
    local g = maxG
        ' || ' .. aFO ..
    if maxG >= 1 and math.random() < 0.40 then g = maxG - 1 end
        ' || ' .. aFK ..
     local p = total - 3*g
        ' || ' .. aTH ..
     return g, p
        ' || ' .. aGK ..
  end
        ' || ' .. aOOB ..
        ' || ' .. aPOS
    end
  else
     -- Fixture-only view (no stats yet)
     for _, m in ipairs(SCHED[idx]) do
      local matchLabel = m[1].logo .. ' ' .. m[1].fullName .. ' vs ' .. m[2].logo .. ' ' .. m[2].fullName


      out[#out+1] = '|-'
  local h_goals, h_poss = splitScore(hg, seed + 101)
      out[#out+1] =
  local a_goals, a_poss = splitScore(ag, seed + 102)
        '| rowspan="2" | ' .. matchLabel ..
        ' || ' .. m[1].logo .. ' ' .. m[1].fullName ..
        ' || — || — || — || — || — || — || — || — || — || — || —'


      out[#out+1] = '|-'
  local function rrange(s, lo, hi) math.randomseed(s); return math.random(lo, hi) end
      out[#out+1] =
  local h_sh_on = (h_goals > 0) and rrange(seed+201, h_goals, h_goals+2) or rrange(seed+201, 0, 2)
        '| ' .. m[2].logo .. ' ' .. m[2].fullName ..
   local a_sh_on = (a_goals > 0) and rrange(seed+202, a_goals, a_goals+2) or rrange(seed+202, 0, 2)
        ' || — || — || — || — || — || — || — || — || — || — || —'
    end
   end


   out[#out+1] = '|}'
   local h_sh_off = rrange(seed+203, 0, 3)
   return table.concat(out, '\n')
   local a_sh_off = rrange(seed+204, 0, 3)
end


  local h_fouls = rrange(seed+205, 0, 3)
  local a_fouls = rrange(seed+206, 0, 3)
  local h_fks  = a_fouls
  local a_fks  = h_fouls


------------------------------------------------------------------------
  local h_throws = rrange(seed+207, 0, 3)
------------------------------------------------------------------------
  local a_throws = rrange(seed+208, 0, 3)
-- 10) ROSTERS + TRANSFERS + PLAYER STATS (embedded, deterministic)
  local h_oob    = rrange(seed+209, 0, 2)
--     Includes wide-variation Bassaridian-style name generation.
  local a_oob    = rrange(seed+210, 0, 2)
------------------------------------------------------------------------
  local h_gk    = rrange(seed+211, 0, 2)
  local a_gk     = rrange(seed+212, 0, 2)


-- Config
  local base = 50
local ROSTER_SIZE = 12
  local swing = rrange(seed+213, 3, 6)
local TRANSFER_MD_START = 20
  local h_poss_pct, a_poss_pct
local TRANSFER_MD_END   = 30
  if hg > ag then
local DEFAULT_SEASON = 52
    h_poss_pct, a_poss_pct = base + swing, base - swing
   elseif hg < ag then
    h_poss_pct, a_poss_pct = base - swing, base + swing
  else
    local tilt = rrange(seed+214, -3, 3)
    h_poss_pct, a_poss_pct = base + tilt, base - tilt
  end


-- Positions (edit to match in-world naming later if you want)
  return {
local POSITIONS = {
    [h.code] = {
  "Striker","Striker",
      final = hg, goals = h_goals, poss_points = h_poss,
  "Playmaker","Playmaker","Playmaker",
      shots_on = h_sh_on, shots_off = h_sh_off,
  "Defender","Defender","Defender",
      fouls = h_fouls, fks = h_fks, throws = h_throws,
  "Keeper",
      gk = h_gk, oob = h_oob, poss_pct = h_poss_pct
  "Utility","Utility","Utility"
    },
}
    [a.code] = {
 
      final = ag, goals = a_goals, poss_points = a_poss,
-----------------------------------------------------------------------
      shots_on = a_sh_on, shots_off = a_sh_off,
-- Deterministic PRNG (does NOT touch math.random / randomseed)
      fouls = a_fouls, fks = a_fks, throws = a_throws,
-----------------------------------------------------------------------
      gk = a_gk, oob = a_oob, poss_pct = a_poss_pct
local function _seedFromString(s)
    }
  local h = 0
   }
  for i=1,#s do h = (h*31 + string.byte(s,i)) % 2147483647 end
   return (h == 0) and 1 or h
end
end


local function _rngNext(st)
function p.renderMatchStatsForDay(frame)
   st = (1103515245 * st + 12345) % 2147483647
  local curr  = date.getCurrentDate()
   return st, st / 2147483647
   local dayNow = tonumber(curr:match('^(%d+),')) or 0
end
   local year  = tonumber(curr:match(', (%d+) PSSC')) or 0
  local dm    = mapDays()


local function _pick(st, arr)
  local target = dm[dayNow] and dayNow or nil
   if not arr or #arr == 0 then return st, nil end
   if not target then
  local r; st, r = _rngNext(st)
    for d = dayNow + 1, END do
   local idx = math.floor(r * #arr) + 1
      if dm[d] then target = d; break end
   return st, arr[idx]
    end
end
   end
   if not target then return "'''No scheduled matches in window.'''" end


local function _normKey(s)
  local idx    = dm[target]
  if not s or s == "" then return nil end
   local isToday = (target == dayNow)
   s = mw.ustring.lower(s)
  s = mw.ustring.gsub(s, "%s+", "_")
  s = mw.ustring.gsub(s, "[^%w_]", "")
  return s
end


-----------------------------------------------------------------------
  local mon, dom = getMonthDay(target)
-- Wide-variation name generator (syllables + light diacritics)
  local label    = dom .. " " .. mon .. " " .. year .. " PSSC"
-----------------------------------------------------------------------
local NAME_STYLES = {
  bassarid = {
    firstA = {"Ka","Tha","Py","De","Ar","Lo","Ni","Sa","Eo","Ae","Ky","My","Ga","Do","Sy","Va"},
    firstB = {"li","the","ra","me","ri","phi","no","lo","re","so","te","xa","ro","na","di","mos"},
    firstC = {"thros","rios","nax","dros","lion","menos","kar","thes","sos","lios","zian","phoros","kris","dor","thes","ron"},
    lastA  = {"Amin","Delph","Kalli","Vareng","Caspaz","Myren","Aurel","Koin","Therm","Skyr","Erythr","Loth","Chrys","Silen","Nexa","Morov"},
    lastB  = {"a","i","o","e","u","y","ae","io","ou","ea"},
    lastC  = {"dis","kos","tron","ides","aris","eas","ion","oros","anos","inos","elis","akis","eth","on","eus","yr"}
  },


   haifan = {
   local function hiPairNum(hVal, aVal, suffix)
     firstA = {"Se","Or","Ke","Ha","Yu","Az","Le","Mi","De","Sa","Fa","Ri","Ta","Na","Su","Ba"},
    hVal = tonumber(hVal) or 0
     firstB = {"lim","han","mal","kan","suf","iz","yla","na","rya","fi","ru","hir","mir","zir","yif","sar"},
     aVal = tonumber(aVal) or 0
    firstC = {"","oğlu","an","ir","em","et","in","a","e","u",""},
     suffix = suffix or ""
    lastA  = {"Yıldır","Dem","Kay","Arsl","Çel","Ayd","Gün","Korkm","Sarı","Taş","Karad","Özt","Ak","Boz","Şah"},
    local function cell(v, hi)
     lastB  = {"im","ir","a","i","u","ü","o","ö",""},
      local txt = tostring(v) .. suffix
     lastC  = {"maz","er","kan","soy","taş","kaya","han","gül","lı","ci","oğlu",""}
      return hi and ('<span style="background-color:#ccffcc;">'..txt..'</span>') or txt
   },
     end
     return cell(hVal, hVal>aVal), cell(aVal, aVal>hVal)
   end


   normark = {
   local out = {
     firstA = {"Ei","Ha","Le","Bj","Si","As","In","To","Ka","Sk","Ry","Ul","Va","Sa","Jo","Th"},
     '{| class="wikitable sortable"',
     firstB = {"rik","kon","if","orn","grid","trid","ga","rsten","ri","ald","var","lva","ren","mund","nar","or"},
     '|+ Match Statistics — ' .. label .. (isToday and '' or ' (fixtures; stats available day-of)'),
    firstC = {"","r","d","n","k","s",""},
     '! Match !! Team !! Final !! Goals (Pillar) !! Possession Pts !! Shots on !! Shots off !! Fouls !! Free Kicks !! Throw-Ins !! Goal Kicks !! OOB !! Poss %'
    lastA  = {"Fjell","Ravn","Skog","Sund","Vinter","Knut","Thors","Haldor","Eirik","Bjorn","Isen","Storm","Sol","Hav","Ulv"},
    lastB  = {"sen","son","sson","vik","lund","by","heim","holt","dal","gard","berg","mark"},
    lastC  = {"","","",""}
  },
 
  imperial = {
    firstA = {"Mar","Jul","Cas","Oct","Luc","Tib","Sab","Aurel","Flav","Dec","Val","Cor","Dom","Sev","Faust","Cris"},
    firstB = {"cus","ia","sian","avia","ian","er","ina","ius","ia","imus","eri","vin","itian","eran","inus","pus"},
    firstC = {"","us","a","","us","a","us","a","us","a"},
     lastA  = {"Valer","Marcell","Aquil","Sever","Corvin","Drus","Faustin","Liv","Domit","Crisp","Aurel","Cass","Octav","Lucan","Tiber","Flav"},
    lastB  = {"ius","us","a","an","inus","ian","ensis","orum","atus","aris"},
    lastC  = {"","","",""}
   }
   }
}


local NAME_STYLE_KEYS = {"bassarid","haifan","normark","imperial"}
  local matches = getRoundMatches(idx)


local function _cap(s)
  if isToday then
  if not s or s == "" then return s end
    local results = simulateDayResults(target)
  return mw.ustring.upper(mw.ustring.sub(s,1,1)) .. mw.ustring.sub(s,2)
    for k, r in ipairs(results) do
end
      local seed = target*1000 + k + string.byte(r.home.code,1) + string.byte(r.away.code,1)
      local S    = computeMatchStats(r.home, r.away, r.hg, r.ag, seed)
      local H, A = S[r.home.code], S[r.away.code]


local function _maybeDiacritics(st, s)
      local matchLabel = r.home.logo .. ' ' .. r.home.fullName .. ' vs ' .. r.away.logo .. ' ' .. r.away.fullName
  -- very light touch
  local r; st, r = _rngNext(st)
  if r < 0.08 then
    s = mw.ustring.gsub(s, "a", "ä", 1)
  elseif r < 0.16 then
    s = mw.ustring.gsub(s, "i", "ï", 1)
  elseif r < 0.24 then
    s = mw.ustring.gsub(s, "o", "ö", 1)
  elseif r < 0.32 then
    s = mw.ustring.gsub(s, "u", "ü", 1)
  end
  return st, s
end


local function _buildNameFrom(style, st, isLast)
      local hF,  aF  = hiPairNum(H.final,      A.final)
   local t = NAME_STYLES[style] or NAME_STYLES.bassarid
      local hG,   aG  = hiPairNum(H.goals,       A.goals)
  local a,b,c
      local hP,   aP  = hiPairNum(H.poss_points, A.poss_points)
   if not isLast then
      local hSON, aSON = hiPairNum(H.shots_on,    A.shots_on)
    st, a = _pick(st, t.firstA); st, b = _pick(st, t.firstB); st, c = _pick(st, t.firstC)
      local hSOF, aSOF = hiPairNum(H.shots_off,  A.shots_off)
  else
      local hFO, aFO  = hiPairNum(H.fouls,       A.fouls)
    st, a = _pick(st, t.lastA);  st, b = _pick(st, t.lastB);  st, c = _pick(st, t.lastC)
      local hFK, aFK  = hiPairNum(H.fks,         A.fks)
  end
      local hTH, aTH  = hiPairNum(H.throws,     A.throws)
      local hGK, aGK  = hiPairNum(H.gk,         A.gk)
      local hOOB, aOOB = hiPairNum(H.oob,         A.oob)
      local hPOS, aPOS = hiPairNum(H.poss_pct,   A.poss_pct, '%')


  local name = _cap((a or "") .. (b or "") .. (c or ""))
      out[#out+1] = '|-'
      out[#out+1] =
        '| rowspan="2" | ' .. matchLabel ..
        ' || ' .. r.home.logo .. ' ' .. r.home.fullName ..
        ' || ' .. hF ..
        ' || ' .. hG ..
        ' || ' .. hP ..
        ' || ' .. hSON ..
        ' || ' .. hSOF ..
        ' || ' .. hFO ..
        ' || ' .. hFK ..
        ' || ' .. hTH ..
        ' || ' .. hGK ..
        ' || ' .. hOOB ..
        ' || ' .. hPOS


  -- rare hyphen/apostrophe
      out[#out+1] = '|-'
  local r; st, r = _rngNext(st)
      out[#out+1] =
  if r < 0.05 and #name >= 6 then
        '| ' .. r.away.logo .. ' ' .. r.away.fullName ..
    local cut = math.floor(#name/2)
        ' || ' .. aF ..
    name = mw.ustring.sub(name,1,cut) .. "-" .. mw.ustring.sub(name,cut+1)
        ' || ' .. aG ..
  elseif r < 0.08 and #name >= 6 then
        ' || ' .. aP ..
    local cut = math.floor(#name/2)
        ' || ' .. aSON ..
    name = mw.ustring.sub(name,1,cut) .. "’" .. mw.ustring.sub(name,cut+1)
        ' || ' .. aSOF ..
  end
        ' || ' .. aFO ..
 
        ' || ' .. aFK ..
  st, name = _maybeDiacritics(st, name)
        ' || ' .. aTH ..
  return st, name
        ' || ' .. aGK ..
end
        ' || ' .. aOOB ..
 
        ' || ' .. aPOS
local function _chooseStyleForTeam(teamCode, season)
    end
  local st = _seedFromString(tostring(season).."|"..tostring(teamCode).."|STYLE")
  else
   local key; st, key = _pick(st, NAME_STYLE_KEYS)
    for _, m in ipairs(matches) do
  return key or "bassarid"
      local matchLabel = m[1].logo .. ' ' .. m[1].fullName .. ' vs ' .. m[2].logo .. ' ' .. m[2].fullName
      out[#out+1] = '|-'
      out[#out+1] =
        '| rowspan="2" | ' .. matchLabel ..
        ' || ' .. m[1].logo .. ' ' .. m[1].fullName ..
        ' || — || — || — || — || — || — || — || — || — || — || —'
      out[#out+1] = '|-'
      out[#out+1] =
        '| ' .. m[2].logo .. ' ' .. m[2].fullName ..
        ' || — || — || — || — || — || — || — || — || — || — || —'
    end
  end
 
   out[#out+1] = '|}'
  return table.concat(out, '\n')
end
end


-----------------------------------------------------------------------
function p.renderMatchStatsSingle(frame)
-- Base roster generation (stable per team+season)
  local args = frame.args or {}
-----------------------------------------------------------------------
   local day  = tonumber(args.day or '') or 0
local function _genBaseRoster(teamCode, season)
   local home = args.home
   local key = _normKey(teamCode) or teamCode
  local away = args.away
   local st = _seedFromString(tostring(season) .. "|" .. key .. "|ROSTER")
  if day == 0 or not home or not away then
   local style = _chooseStyleForTeam(teamCode, season)
    return "'''Pass day, home, and away:''' day=###, home=Team, away=Team"
   end


   local roster, used = {}, {}
   local curr  = date.getCurrentDate()
   for i=1, ROSTER_SIZE do
  local dayNow = tonumber(curr:match('^(%d+),')) or 0
     local id = key .. "-" .. string.format("%02d", i)
  local year  = tonumber(curr:match(', (%d+) PSSC')) or 0
  local dm    = mapDays()
  local idx    = dm[day]
   if not idx then return "'''No round mapped for that day.'''" end
 
  local matches = getRoundMatches(idx)
  local matchIdx
  for k, m in ipairs(matches) do
     if m[1].code == home and m[2].code == away then matchIdx = k; break end
  end
  if not matchIdx then return "'''Match not found on that day with that home/away.'''" end
 
  local m = matches[matchIdx]
  local mon, dom = getMonthDay(day)
  local dateLabel= dom .. " " .. mon .. " " .. year .. " PSSC"
 
  local out = {
    '{| class="wikitable"',
    '|+ Match Statistics — ' .. dateLabel .. ((day == dayNow) and '' or ' (fixture; stats available day-of)'),
    '! Statistic !! ' .. _teamWithLogo(m[1]) .. ' !! ' .. _teamWithLogo(m[2])
  }
 
  if day == dayNow then
    local resList = simulateDayResults(day)
    local r = resList[matchIdx]
    local seed = day*1000 + matchIdx + string.byte(home,1) + string.byte(away,1)
    local S = computeMatchStats(m[1], m[2], r.hg, r.ag, seed)
    local H, A = S[home], S[away]


     local full
     local function hi(hv, av, suffix)
    for _=1, 10 do
       hv = tonumber(hv) or 0
       local first, last
       av = tonumber(av) or 0
       st, first = _buildNameFrom(style, st, false)
       suffix = suffix or ""
       st, last  = _buildNameFrom(style, st, true)
      local h = tostring(hv) .. suffix
       full = first .. " " .. last
       local a = tostring(av) .. suffix
       if not used[full] then used[full] = true; break end
      if hv > av then h = '<span style="background-color:#ccffcc;">'..h..'</span>'
       elseif av > hv then a = '<span style="background-color:#ccffcc;">'..a..'</span>' end
      return h, a
     end
     end


     roster[i] = {
     local hF,aF    = hi(H.final,      A.final)
       id = id,
    local hG,aG    = hi(H.goals,       A.goals)
       name = full or ("Player " .. id),
    local hP,aP    = hi(H.poss_points, A.poss_points)
      pos = POSITIONS[i] or "Utility",
    local hSON,aSON = hi(H.shots_on,    A.shots_on)
      no  = i
    local hSOF,aSOF = hi(H.shots_off,   A.shots_off)
     }
    local hFO,aFO  = hi(H.fouls,       A.fouls)
    local hFK,aFK  = hi(H.fks,        A.fks)
    local hTH,aTH  = hi(H.throws,      A.throws)
    local hGK,aGK  = hi(H.gk,          A.gk)
    local hOOB,aOOB = hi(H.oob,        A.oob)
    local hPOS,aPOS = hi(H.poss_pct,    A.poss_pct, '%')
 
    out[#out+1] = '|-'; out[#out+1] = '| Final Score || ' .. hF .. ' || ' .. aF
    out[#out+1] = '|-'; out[#out+1] = '| Goals (Pillar Strikes) || ' .. hG .. ' || ' .. aG
    out[#out+1] = '|-'; out[#out+1] = '| Possession Points || ' .. hP .. ' || ' .. aP
    out[#out+1] = '|-'; out[#out+1] = '| Shots on Target || ' .. hSON .. ' || ' .. aSON
    out[#out+1] = '|-'; out[#out+1] = '| Shots off Target || ' .. hSOF .. ' || ' .. aSOF
    out[#out+1] = '|-'; out[#out+1] = '| Fouls || ' .. hFO .. ' || ' .. aFO
    out[#out+1] = '|-'; out[#out+1] = '| Free Kicks Awarded || ' .. hFK .. ' || ' .. aFK
    out[#out+1] = '|-'; out[#out+1] = '| Throw-Ins || ' .. hTH .. ' || ' .. aTH
    out[#out+1] = '|-'; out[#out+1] = '| Goal Kicks || ' .. hGK .. ' || ' .. aGK
    out[#out+1] = '|-'; out[#out+1] = '| Out of Bounds || ' .. hOOB .. ' || ' .. aOOB
    out[#out+1] = '|-'; out[#out+1] = '| Ball Possession || ' .. hPOS .. ' || ' .. aPOS
  else
    local function blank(row) out[#out+1] = '|-'; out[#out+1] = row end
     blank('| Final Score || — || —')
    blank('| Goals (Pillar Strikes) || — || —')
    blank('| Possession Points || — || —')
    blank('| Shots on Target || — || —')
    blank('| Shots off Target || — || —')
    blank('| Fouls || — || —')
    blank('| Free Kicks Awarded || — || —')
    blank('| Throw-Ins || — || —')
    blank('| Goal Kicks || — || —')
    blank('| Out of Bounds || — || —')
    blank('| Ball Possession || — || —')
   end
   end
  roster[1].notes = "Captain"
  return roster
end


local function _buildBaseRosters(season)
   out[#out+1] = '|}'
   local rosters = {}
   return table.concat(out, '\n')
   for _,t in ipairs(teams) do
    rosters[t.code] = _genBaseRoster(t.code, season)
  end
  return rosters
end
end


-----------------------------------------------------------------------
------------------------------------------------------------------------
-- Transfer market (deterministic 1-for-1 swaps, MD20..MD30)
-- 10) ROSTERS + TRANSFERS + PLAYER STATS
-----------------------------------------------------------------------
------------------------------------------------------------------------
local function _indexById(roster)
local ROSTER_SIZE = 12
   local m = {}
local TRANSFER_MD_START = 20
   for i,p in ipairs(roster) do m[p.id] = i end
local TRANSFER_MD_END   = 30
   return m
 
end
local POSITIONS = {
   "Striker","Striker",
  "Playmaker","Playmaker","Playmaker",
  "Defender","Defender","Defender",
  "Keeper",
   "Utility","Utility","Utility"
}


local function _findSwapCandidate(st, roster, wantPos)
-- deterministic PRNG (does NOT touch math.randomseed)
   local candidates = {}
local function _seedFromString(s)
   for _,p in ipairs(roster) do
   local h = 0
    if p.pos == wantPos then candidates[#candidates+1] = p end
   for i=1,#s do h = (h*31 + string.byte(s,i)) % 2147483647 end
   end
   return (h == 0) and 1 or h
  if #candidates == 0 then candidates = roster end
  return _pick(st, candidates)
end
end


local function _genTransferDeals(season)
local function _rngNext(st)
   local st = _seedFromString(tostring(season) .. "|TRANSFERWINDOW|MD" .. TRANSFER_MD_START .. "-" .. TRANSFER_MD_END)
   st = (1103515245 * st + 12345) % 2147483647
  return st, st / 2147483647
end


  local deals, teamCodes = {}, {}
local function _pick(st, arr)
   for _,t in ipairs(teams) do teamCodes[#teamCodes+1] = t.code end
  if not arr or #arr == 0 then return st, nil end
   local r; st, r = _rngNext(st)
  local idx = math.floor(r * #arr) + 1
  return st, arr[idx]
end


   for md = TRANSFER_MD_START, TRANSFER_MD_END do
local function _normKey(s)
    local r; st, r = _rngNext(st)
   if not s or s == "" then return nil end
    local dealCount = 1 + math.floor(r * 3) -- 1–3 per matchday
  s = mw.ustring.lower(s)
  s = mw.ustring.gsub(s, "%s+", "_")
  s = mw.ustring.gsub(s, "[^%w_]", "")
  return s
end


     for _=1, dealCount do
local NAME_STYLES = {
      local A,B
  bassarid = {
      st, A = _pick(st, teamCodes)
     firstA = {"Ka","Tha","Py","De","Ar","Lo","Ni","Sa","Eo","Ae","Ky","My","Ga","Do","Sy","Va"},
      repeat st, B = _pick(st, teamCodes) until B ~= A
    firstB = {"li","the","ra","me","ri","phi","no","lo","re","so","te","xa","ro","na","di","mos"},
 
    firstC = {"thros","rios","nax","dros","lion","menos","kar","thes","sos","lios","zian","phoros","kris","dor","thes","ron"},
      local baseA = _genBaseRoster(A, season)
    lastA  = {"Amin","Delph","Kalli","Vareng","Caspaz","Myren","Aurel","Koin","Therm","Skyr","Erythr","Loth","Chrys","Silen","Nexa","Morov"},
      local baseB = _genBaseRoster(B, season)
    lastB  = {"a","i","o","e","u","y","ae","io","ou","ea"},
 
    lastC  = {"dis","kos","tron","ides","aris","eas","ion","oros","anos","inos","elis","akis","eth","on","eus","yr"}
      local pA; st, pA = _pick(st, baseA)
  },
      if pA and pA.pos == "Keeper" then
  haifan = {
        local rr; st, rr = _rngNext(st)
    firstA = {"Se","Or","Ke","Ha","Yu","Az","Le","Mi","De","Sa","Fa","Ri","Ta","Na","Su","Ba"},
        if rr < 0.75 then st, pA = _pick(st, baseA) end
    firstB = {"lim","han","mal","kan","suf","iz","yla","na","rya","fi","ru","hir","mir","zir","yif","sar"},
      end
    firstC = {"","oğlu","an","ir","em","et","in","a","e","u",""},
 
    lastA  = {"Yıldır","Dem","Kay","Arsl","Çel","Ayd","Gün","Korkm","Sarı","Taş","Karad","Özt","Ak","Boz","Şah"},
      local pB; st, pB = _findSwapCandidate(st, baseB, (pA and pA.pos) or "Utility")
    lastB  = {"im","ir","a","i","u","ü","o","ö",""},
 
    lastC  = {"maz","er","kan","soy","taş","kaya","han","gül","lı","ci","oğlu",""}
      if A and B and pA and pB then
  },
        deals[#deals+1] = { md=md, A=A, B=B, A_id=pA.id, B_id=pB.id, pos=pA.pos }
  normark = {
      end
    firstA = {"Ei","Ha","Le","Bj","Si","As","In","To","Ka","Sk","Ry","Ul","Va","Sa","Jo","Th"},
    end
    firstB = {"rik","kon","if","orn","grid","trid","ga","rsten","ri","ald","var","lva","ren","mund","nar","or"},
  end
    firstC = {"","r","d","n","k","s",""},
    lastA  = {"Fjell","Ravn","Skog","Sund","Vinter","Knut","Thors","Haldor","Eirik","Bjorn","Isen","Storm","Sol","Hav","Ulv"},
    lastB  = {"sen","son","sson","vik","lund","by","heim","holt","dal","gard","berg","mark"},
    lastC  = {"","","",""}
  },
  imperial = {
    firstA = {"Mar","Jul","Cas","Oct","Luc","Tib","Sab","Aurel","Flav","Dec","Val","Cor","Dom","Sev","Faust","Cris"},
    firstB = {"cus","ia","sian","avia","ian","er","ina","ius","ia","imus","eri","vin","itian","eran","inus","pus"},
    firstC = {"","us","a","","us","a","us","a","us","a"},
    lastA  = {"Valer","Marcell","Aquil","Sever","Corvin","Drus","Faustin","Liv","Domit","Crisp","Aurel","Cass","Octav","Lucan","Tiber","Flav"},
    lastB  = {"ius","us","a","an","inus","ian","ensis","orum","atus","aris"},
    lastC  = {"","","",""}
  }
}
local NAME_STYLE_KEYS = {"bassarid","haifan","normark","imperial"}


   return deals
local function _cap(s)
   if not s or s == "" then return s end
  return mw.ustring.upper(mw.ustring.sub(s,1,1)) .. mw.ustring.sub(s,2)
end
end


local function _applyTransfersUpTo(baseRosters, deals, upToMd)
local function _maybeDiacritics(st, s)
   local rosters = {}
   local r; st, r = _rngNext(st)
   for code, r in pairs(baseRosters) do
   if r < 0.08 then s = mw.ustring.gsub(s, "a", "ä", 1)
    rosters[code] = {}
  elseif r < 0.16 then s = mw.ustring.gsub(s, "i", "ï", 1)
    for i,p in ipairs(r) do
  elseif r < 0.24 then s = mw.ustring.gsub(s, "o", "ö", 1)
      rosters[code][i] = { id=p.id, name=p.name, pos=p.pos, no=p.no, notes=p.notes }
  elseif r < 0.32 then s = mw.ustring.gsub(s, "u", "ü", 1)
    end
   end
   end
  return st, s
end


  for _,d in ipairs(deals) do
local function _buildNameFrom(style, st, isLast)
    if d.md <= upToMd then
  local t = NAME_STYLES[style] or NAME_STYLES.bassarid
      local RA, RB = rosters[d.A], rosters[d.B]
  local a,b,c
      if RA and RB then
  if not isLast then
        local ia = _indexById(RA)[d.A_id]
    st, a = _pick(st, t.firstA); st, b = _pick(st, t.firstB); st, c = _pick(st, t.firstC)
        local ib = _indexById(RB)[d.B_id]
  else
        if ia and ib then
    st, a = _pick(st, t.lastA);  st, b = _pick(st, t.lastB);  st, c = _pick(st, t.lastC)
          local tmp = RA[ia]
          RA[ia] = RB[ib]
          RB[ib] = tmp
        end
      end
    end
   end
   end


   return rosters
  local name = _cap((a or "") .. (b or "") .. (c or ""))
 
  local r; st, r = _rngNext(st)
  if r < 0.05 and #name >= 6 then
    local cut = math.floor(#name/2)
    name = mw.ustring.sub(name,1,cut) .. "-" .. mw.ustring.sub(name,cut+1)
  elseif r < 0.08 and #name >= 6 then
    local cut = math.floor(#name/2)
    name = mw.ustring.sub(name,1,cut) .. "’" .. mw.ustring.sub(name,cut+1)
  end
 
  st, name = _maybeDiacritics(st, name)
   return st, name
end
end


-----------------------------------------------------------------------
local function _chooseStyleForTeam(teamCode, season)
-- Matchday helpers
   local st = _seedFromString(tostring(season).."|"..tostring(teamCode).."|STYLE")
-----------------------------------------------------------------------
   local key; st, key = _pick(st, NAME_STYLE_KEYS)
local function _invertDayMap(dm)
   return key or "bassarid"
   local inv = {}
   for day, md in pairs(dm) do inv[md] = day end
   return inv
end
end


local function _currentPlayedMatchday()
local function _genBaseRoster(teamCode, season)
   local curr = date.getCurrentDate()
   local key = _normKey(teamCode) or teamCode
   local today = tonumber(curr:match("^(%d+),")) or 0
   local st = _seedFromString(tostring(season) .. "|" .. key .. "|ROSTER")
   local dm = mapDays()
   local style = _chooseStyleForTeam(teamCode, season)
  local lastMd = 0
  for d=START, math.min(today, END) do
    if dm[d] and dm[d] > lastMd then lastMd = dm[d] end
  end
  return lastMd
end


-----------------------------------------------------------------------
  local roster, used = {}, {}
-- Allocate per-player events that SUM to computeMatchStats outputs
   for i=1, ROSTER_SIZE do
-----------------------------------------------------------------------
     local id = key .. "-" .. string.format("%02d", i)
local function _alloc(st, roster, posWeights)
    local full
  local pool = {}
     for _=1, 10 do
   for _,p in ipairs(roster) do
      local first, last
     local w = posWeights[p.pos] or 1
      st, first = _buildNameFrom(style, st, false)
     for _=1,w do pool[#pool+1] = p end
      st, last  = _buildNameFrom(style, st, true)
      full = first .. " " .. last
      if not used[full] then used[full] = true; break end
    end
    roster[i] = { id=id, name=full or ("Player "..id), pos=POSITIONS[i] or "Utility", no=i }
   end
   end
   return _pick(st, pool)
  roster[1].notes = "Captain"
   return roster
end
end


local function _ensure(map, pid)
local function _buildBaseRosters(season)
   if not map[pid] then
   local rosters = {}
    map[pid] = { goals=0, assists=0, poss=0, son=0, sof=0, fouls=0, apps=0 }
  for _,t in ipairs(teams) do rosters[t.code] = _genBaseRoster(t.code, season) end
  end
   return rosters
   return map[pid]
end
end


local function _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster)
local function _indexById(roster)
   local salt = day*1000 + matchIndex + string.byte(homeTeam.code,1) + string.byte(awayTeam.code,1)
   local m = {}
   local hg, ag = simScoreFor(homeTeam, awayTeam, salt)
  for i,p in ipairs(roster) do m[p.id] = i end
   return m
end


  local S = computeMatchStats(homeTeam, awayTeam, hg, ag, salt)
local function _findSwapCandidate(st, roster, wantPos)
   local H = S[homeTeam.code]
   local candidates = {}
   local A = S[awayTeam.code]
   for _,p in ipairs(roster) do if p.pos == wantPos then candidates[#candidates+1] = p end end
   local stats = { home = H, away = A }
   if #candidates == 0 then candidates = roster end
  return _pick(st, candidates)
end


   local st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)
local function _genTransferDeals(season)
   local events = { home={}, away={} }
   local st = _seedFromString(tostring(season) .. "|TRANSFERWINDOW|MD" .. TRANSFER_MD_START .. "-" .. TRANSFER_MD_END)
   local deals, teamCodes = {}, {}
  for _,t in ipairs(teams) do teamCodes[#teamCodes+1] = t.code end


   -- appearances: count everyone on roster as 1 appearance (simple)
   for md = TRANSFER_MD_START, TRANSFER_MD_END do
  for _,p in ipairs(homeRoster) do _ensure(events.home, p.id).apps = 1 end
    local r; st, r = _rngNext(st)
  for _,p in ipairs(awayRoster) do _ensure(events.away, p.id).apps = 1 end
    local dealCount = 1 + math.floor(r * 3)
    for _=1, dealCount do
      local A,B
      st, A = _pick(st, teamCodes)
      repeat st, B = _pick(st, teamCodes) until B ~= A
 
      local baseA = _genBaseRoster(A, season)
      local baseB = _genBaseRoster(B, season)


  local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
      local pA; st, pA = _pick(st, baseA)
  local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
      if pA and pA.pos == "Keeper" then
  local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
        local rr; st, rr = _rngNext(st)
        if rr < 0.75 then st, pA = _pick(st, baseA) end
      end


  -- goals + assists
      local pB; st, pB = _findSwapCandidate(st, baseB, (pA and pA.pos) or "Utility")
  for _=1, stats.home.goals do
      if A and B and pA and pB then
    local p; st, p = _alloc(st, homeRoster, W_GOAL)
        deals[#deals+1] = { md=md, A=A, B=B, A_id=pA.id, B_id=pB.id, pos=pA.pos }
    _ensure(events.home, p.id).goals = _ensure(events.home, p.id).goals + 1
      end
    local r; st, r = _rngNext(st)
    if r < 0.70 then
      local a; st, a = _alloc(st, homeRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
      if a and a.id ~= p.id then _ensure(events.home, a.id).assists = _ensure(events.home, a.id).assists + 1 end
     end
     end
   end
   end
   for _=1, stats.away.goals do
   return deals
    local p; st, p = _alloc(st, awayRoster, W_GOAL)
end
    _ensure(events.away, p.id).goals = _ensure(events.away, p.id).goals + 1
 
    local r; st, r = _rngNext(st)
local function _applyTransfersUpTo(baseRosters, deals, upToMd)
     if r < 0.70 then
  local rosters = {}
      local a; st, a = _alloc(st, awayRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
  for code, r in pairs(baseRosters) do
      if a and a.id ~= p.id then _ensure(events.away, a.id).assists = _ensure(events.away, a.id).assists + 1 end
     rosters[code] = {}
    end
    for i,p in ipairs(r) do rosters[code][i] = { id=p.id, name=p.name, pos=p.pos, no=p.no, notes=p.notes } end
   end
   end


  -- possession points
   for _,d in ipairs(deals) do
   for _=1, stats.home.poss_points do
     if d.md <= upToMd then
     local p; st, p = _alloc(st, homeRoster, W_POSS)
      local RA, RB = rosters[d.A], rosters[d.B]
    _ensure(events.home, p.id).poss = _ensure(events.home, p.id).poss + 1
      if RA and RB then
  end
        local ia = _indexById(RA)[d.A_id]
  for _=1, stats.away.poss_points do
        local ib = _indexById(RB)[d.B_id]
    local p; st, p = _alloc(st, awayRoster, W_POSS)
        if ia and ib then
    _ensure(events.away, p.id).poss = _ensure(events.away, p.id).poss + 1
          local tmp = RA[ia]
  end
          RA[ia] = RB[ib]
 
          RB[ib] = tmp
  -- shots on/off
        end
  local function distributeShots(side, roster, on, off)
       end
    for _=1, on do
      local p; st, p = _alloc(st, roster, {Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0})
      _ensure(side, p.id).son = _ensure(side, p.id).son + 1
    end
    for _=1, off do
       local p; st, p = _alloc(st, roster, {Striker=4, Playmaker=3, Utility=2, Defender=1, Keeper=0})
      _ensure(side, p.id).sof = _ensure(side, p.id).sof + 1
     end
     end
   end
   end
  distributeShots(events.home, homeRoster, stats.home.shots_on, stats.home.shots_off)
  distributeShots(events.away, awayRoster, stats.away.shots_on, stats.away.shots_off)


  -- fouls
   return rosters
  for _=1, stats.home.fouls do
    local p; st, p = _alloc(st, homeRoster, W_FOUL)
    _ensure(events.home, p.id).fouls = _ensure(events.home, p.id).fouls + 1
  end
  for _=1, stats.away.fouls do
    local p; st, p = _alloc(st, awayRoster, W_FOUL)
    _ensure(events.away, p.id).fouls = _ensure(events.away, p.id).fouls + 1
  end
 
   return events
end
end


-----------------------------------------------------------------------
local function _alloc(st, roster, posWeights)
-- Season totals (recomputed on view; "auto-updates" with matchday)
   local pool = {}
-----------------------------------------------------------------------
  for _,p in ipairs(roster) do
local function _computePlayerSeasonTotals(season, upToMd)
    local w = posWeights[p.pos] or 1
   local dm = mapDays()
    for _=1,w do pool[#pool+1] = p end
   local inv = _invertDayMap(dm)
   end
  return _pick(st, pool)
end
 
local function _ensure(map, pid)
  if not map[pid] then map[pid] = { goals=0, assists=0, poss=0, son=0, sof=0, fouls=0, apps=0 } end
  return map[pid]
end


   local deals = _genTransferDeals(season)
local function _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster)
   local base = _buildBaseRosters(season)
   local salt = day*1000 + matchIndex + string.byte(homeTeam.code,1) + string.byte(awayTeam.code,1)
  local hg, ag = simScoreFor(homeTeam, awayTeam, salt)
   local S = computeMatchStats(homeTeam, awayTeam, hg, ag, salt)
  local H = S[homeTeam.code] or {}
  local A = S[awayTeam.code] or {}
  local stats = { home = H, away = A }


   local totals = {} -- pid -> totals row
   local st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)
  local events = { home={}, away={} }


   local function ensurePlayer(pid, name, pos)
   for _,p in ipairs(homeRoster) do _ensure(events.home, p.id).apps = 1 end
    if not totals[pid] then
  for _,p in ipairs(awayRoster) do _ensure(events.away, p.id).apps = 1 end
      totals[pid] = { id=pid, name=name or pid, pos=pos or "—", team="—",
                      apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0 }
    end
    return totals[pid]
  end


   for md=1, math.min(upToMd, #SCHED) do
   local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
    local day = inv[md]
  local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
    if day then
  local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
      local rosters = _applyTransfersUpTo(base, deals, md)


      for matchIndex, m in ipairs(SCHED[md]) do
  for _=1, (stats.home.goals or 0) do
        local h, a = m[1], m[2]
    local pz; st, pz = _alloc(st, homeRoster, W_GOAL)
        local hr, ar = rosters[h.code], rosters[a.code]
    _ensure(events.home, pz.id).goals = _ensure(events.home, pz.id).goals + 1
        local ev = _matchPlayerEvents(season, md, day, matchIndex, h, a, hr, ar)
    local r; st, r = _rngNext(st)
 
     if r < 0.70 then
        -- home
      local a; st, a = _alloc(st, homeRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
        for pid, st in pairs(ev.home) do
      if a and a.id ~= pz.id then _ensure(events.home, a.id).assists = _ensure(events.home, a.id).assists + 1 end
          local name, pos = pid, "—"
          for _,pp in ipairs(hr) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
          local row = ensurePlayer(pid, name, pos)
          row.team    = h.code
          row.apps    = row.apps + (st.apps or 0)
          row.goals  = row.goals + (st.goals or 0)
          row.assists = row.assists + (st.assists or 0)
          row.poss    = row.poss + (st.poss or 0)
          row.son     = row.son + (st.son or 0)
          row.sof    = row.sof + (st.sof or 0)
          row.fouls  = row.fouls + (st.fouls or 0)
        end
 
        -- away
        for pid, st in pairs(ev.away) do
          local name, pos = pid, "—"
          for _,pp in ipairs(ar) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
          local row = ensurePlayer(pid, name, pos)
          row.team    = a.code
          row.apps    = row.apps + (st.apps or 0)
          row.goals  = row.goals + (st.goals or 0)
          row.assists = row.assists + (st.assists or 0)
          row.poss    = row.poss + (st.poss or 0)
          row.son    = row.son + (st.son or 0)
          row.sof    = row.sof + (st.sof or 0)
          row.fouls  = row.fouls + (st.fouls or 0)
        end
      end
     end
     end
   end
   end


   return totals, deals, base
   for _=1, (stats.away.goals or 0) do
end
    local pz; st, pz = _alloc(st, awayRoster, W_GOAL)
    _ensure(events.away, pz.id).goals = _ensure(events.away, pz.id).goals + 1
    local r; st, r = _rngNext(st)
    if r < 0.70 then
      local a; st, a = _alloc(st, awayRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
      if a and a.id ~= pz.id then _ensure(events.away, a.id).assists = _ensure(events.away, a.id).assists + 1 end
    end
  end


-----------------------------------------------------------------------
  for _=1, (stats.home.poss_points or 0) do
-- PUBLIC: Single roster
    local pz; st, pz = _alloc(st, homeRoster, W_POSS)
-- {{#invoke:PillSeasonSchedule|renderRoster|team=Vaeringheim Pillar|season=52}}
    _ensure(events.home, pz.id).poss = _ensure(events.home, pz.id).poss + 1
-----------------------------------------------------------------------
   end
function p.renderRoster(frame)
  for _=1, (stats.away.poss_points or 0) do
   local args = frame.args or {}
    local pz; st, pz = _alloc(st, awayRoster, W_POSS)
  local team = args.team or args[1]
     _ensure(events.away, pz.id).poss = _ensure(events.away, pz.id).poss + 1
  if not team or not TEAM_BY_CODE[team] then
     return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''"
   end
   end


   local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
   local function distributeShots(side, roster, on, off)
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    for _=1, (on or 0) do
      local pz; st, pz = _alloc(st, roster, {Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0})
      _ensure(side, pz.id).son = _ensure(side, pz.id).son + 1
    end
    for _=1, (off or 0) do
      local pz; st, pz = _alloc(st, roster, {Striker=4, Playmaker=3, Utility=2, Defender=1, Keeper=0})
      _ensure(side, pz.id).sof = _ensure(side, pz.id).sof + 1
    end
  end
  distributeShots(events.home, homeRoster, stats.home.shots_on or 0, stats.home.shots_off or 0)
  distributeShots(events.away, awayRoster, stats.away.shots_on or 0, stats.away.shots_off or 0)


   local deals = _genTransferDeals(season)
   for _=1, (stats.home.fouls or 0) do
   local base  = _buildBaseRosters(season)
    local pz; st, pz = _alloc(st, homeRoster, W_FOUL)
  local rosters = _applyTransfersUpTo(base, deals, upToMd)
    _ensure(events.home, pz.id).fouls = _ensure(events.home, pz.id).fouls + 1
   local r = rosters[team] or {}
   end
  for _=1, (stats.away.fouls or 0) do
    local pz; st, pz = _alloc(st, awayRoster, W_FOUL)
    _ensure(events.away, pz.id).fouls = _ensure(events.away, pz.id).fouls + 1
   end


   local out = {
   return events
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
end
    '! # !! Player !! Position !! ID !! Notes'
  }
  for _,pl in ipairs(r) do
    out[#out+1] = '|-'
    out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> || %s',
      pl.no or 0, pl.name or '—', pl.pos or '—', pl.id or '—', pl.notes or '—'
    )
  end
  out[#out+1] = '|}'


  return (TEAM_BY_CODE[team].logo or "") ..
local function _computePlayerSeasonTotals(season, upToMd)
    " '''" .. team .. "''' (Roster as of Matchday " .. tostring(upToMd) .. ")\n" ..
  local dm = mapDays()
    table.concat(out, "\n")
  local inv = invertDayMap(dm)
end


-----------------------------------------------------------------------
  local deals = _genTransferDeals(season)
-- PUBLIC: All rosters (collapsible per team)
   local base = _buildBaseRosters(season)
-- {{#invoke:PillSeasonSchedule|renderAllRosters|season=52}}
   local totals = {}
-----------------------------------------------------------------------
function p.renderAllRosters(frame)
   local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
   local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()


   local out = {}
   local function ensurePlayer(pid, name, pos)
  for _,t in ipairs(teams) do
     if not totals[pid] then
     local title = (t.logo or "") .. " <b>" .. t.code .. "</b>"
      totals[pid] = { id=pid, name=name or pid, pos=pos or "—", team="—",
    out[#out+1] =
                      apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0 }
      '<div class="mw-collapsible mw-collapsed" data-expandtext="Show roster" data-collapsetext="Hide roster" style="margin:0.6em 0;">' ..
    end
        '<div>' .. title .. '</div>' ..
    return totals[pid]
        '<div class="mw-collapsible-content">' ..
          p.renderRoster({ args = { team = t.code, season = season, md = upToMd } }) ..
        '</div>' ..
      '</div>'
   end
   end
  return table.concat(out, "\n")
end


-----------------------------------------------------------------------
  for md=1, math.min(upToMd, TOTAL_ROUNDS) do
-- PUBLIC: Transfer log (up to current matchday unless md provided)
    local day = inv[md]
-- {{#invoke:PillSeasonSchedule|renderTransferLog|season=52}}
    if day then
-----------------------------------------------------------------------
      local rosters = _applyTransfersUpTo(base, deals, md)
function p.renderTransferLog(frame)
      local fixtures = getRoundMatches(md)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()


  local deals = _genTransferDeals(season)
      for matchIndex, m in ipairs(fixtures) do
        local h, a = m[1], m[2]
        local hr, ar = rosters[h.code] or {}, rosters[a.code] or {}
        local ev = _matchPlayerEvents(season, md, day, matchIndex, h, a, hr, ar)


  local out = {
        for pid, stt in pairs(ev.home) do
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
          local name, pos = pid, "—"
     '! Matchday !! From !! To !! Swap (pos)'
          for _,pp in ipairs(hr) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
   }
          local row = ensurePlayer(pid, name, pos)
  for _,d in ipairs(deals) do
          row.team    = h.code
    if d.md <= upToMd then
          row.apps    = row.apps + (stt.apps or 0)
      out[#out+1] = '|-'
          row.goals  = row.goals + (stt.goals or 0)
      out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> ⇄ <code>%s</code> (%s)',
          row.assists = row.assists + (stt.assists or 0)
        d.md, _teamWithLogo(d.A), _teamWithLogo(d.B), d.A_id, d.B_id, d.pos or '—'
          row.poss    = row.poss + (stt.poss or 0)
       )
          row.son    = row.son + (stt.son or 0)
          row.sof     = row.sof + (stt.sof or 0)
          row.fouls   = row.fouls + (stt.fouls or 0)
        end
 
        for pid, stt in pairs(ev.away) do
          local name, pos = pid, "—"
          for _,pp in ipairs(ar) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
          local row = ensurePlayer(pid, name, pos)
          row.team    = a.code
          row.apps    = row.apps + (stt.apps or 0)
          row.goals  = row.goals + (stt.goals or 0)
          row.assists = row.assists + (stt.assists or 0)
          row.poss    = row.poss + (stt.poss or 0)
          row.son    = row.son + (stt.son or 0)
          row.sof    = row.sof + (stt.sof or 0)
          row.fouls  = row.fouls + (stt.fouls or 0)
        end
       end
     end
     end
   end
   end
  out[#out+1] = '|}'


   return "'''Transfer window (Matchdays " .. TRANSFER_MD_START .. "–" .. TRANSFER_MD_END ..
   return totals, deals, base
    ") — season " .. season .. " (through Matchday " .. tostring(upToMd) .. ")'''\n" ..
    table.concat(out, "\n")
end
end


-----------------------------------------------------------------------
function p.renderRoster(frame)
-- PUBLIC: Team player totals (team roster as of md, totals through md)
-- {{#invoke:PillSeasonSchedule|renderPlayerStats|team=Vaeringheim Pillar|season=52}}
-----------------------------------------------------------------------
function p.renderPlayerStats(frame)
   local args = frame.args or {}
   local args = frame.args or {}
   local team = args.team or args[1]
   local team = args.team or args[1]
Line 1,488: Line 1,542:
   local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
   local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()


   local totals, deals, base = _computePlayerSeasonTotals(season, upToMd)
   local deals = _genTransferDeals(season)
   local rostersNow = _applyTransfersUpTo(base, deals, upToMd)
  local base = _buildBaseRosters(season)
   local rosterNow = rostersNow[team] or {}
   local rosters = _applyTransfersUpTo(base, deals, upToMd)
   local r = rosters[team] or {}


   local out = {
   local out = {
     '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
     '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
     '! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'
     '! # !! Player !! Position !! ID !! Notes'
   }
   }
 
   for _,pl in ipairs(r) do
   for _,pl in ipairs(rosterNow) do
    local r = totals[pl.id]
     out[#out+1] = '|-'
     out[#out+1] = '|-'
     out[#out+1] = string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d',
     out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> || %s',
       pl.name, pl.pos,
       pl.no or 0, pl.name or '—', pl.pos or '—', pl.id or '—', pl.notes or '—'
      r and r.apps or 0,
      r and r.goals or 0,
      r and r.assists or 0,
      r and r.poss or 0,
      r and r.son or 0,
      r and r.sof or 0,
      r and r.fouls or 0
     )
     )
   end
   end
   out[#out+1] = '|}'
   out[#out+1] = '|}'


   return (TEAM_BY_CODE[team].logo or "") ..
   return (TEAM_BY_CODE[team].logo or "") ..
     " '''" .. team .. "''' — Player totals through Matchday " .. tostring(upToMd) .. "\n" ..
     " '''" .. team .. "''' (Roster as of Matchday " .. tostring(upToMd) .. ")\n" ..
     table.concat(out, "\n")
     table.concat(out, "\n")
end
end


-----------------------------------------------------------------------
function p.renderAllRosters(frame)
-- PUBLIC: League leaders (top 20)
-- {{#invoke:PillSeasonSchedule|renderLeaders|stat=goals|season=52}}
-----------------------------------------------------------------------
function p.renderLeaders(frame)
   local args = frame.args or {}
   local args = frame.args or {}
   local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
   local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
   local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
   local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()


   local stat = (args.stat or args[2] or "goals")
   local out = {}
  local ok = { goals=true, assists=true, poss=true, son=true, sof=true, fouls=true, apps=true }
  for _,t in ipairs(teams) do
  if not ok[stat] then
    local title = (t.logo or "") .. " <b>" .. t.code .. "</b>"
    return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''"
    out[#out+1] =
      '<div class="mw-collapsible mw-collapsed" data-expandtext="Show roster" data-collapsetext="Hide roster" style="margin:0.6em 0;">' ..
        '<div>' .. title .. '</div>' ..
        '<div class="mw-collapsible-content">' ..
          p.renderRoster({ args = { team = t.code, season = season, md = upToMd } }) ..
        '</div>' ..
      '</div>'
   end
   end
  return table.concat(out, "\n")
end


  local totals = (_computePlayerSeasonTotals(season, upToMd))
function p.renderTransferLog(frame)
   local list = {}
   local args = frame.args or {}
   for _,r in pairs(totals) do list[#list+1] = r end
   local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
 
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
  table.sort(list, function(a,b)
   local deals = _genTransferDeals(season)
    if (a[stat] or 0) ~= (b[stat] or 0) then return (a[stat] or 0) > (b[stat] or 0) end
    return (a.name or "") < (b.name or "")
   end)


   local out = {
   local out = {
     '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
     '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
     '! Rank !! Player !! Team (current) !! Pos !! ' .. stat
     '! Matchday !! From !! To !! Swap (pos)'
   }
   }
 
   for _,d in ipairs(deals) do
   for i=1, math.min(20, #list) do
     if d.md <= upToMd then
     local r = list[i]
      out[#out+1] = '|-'
    out[#out+1] = '|-'
      out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> ⇄ <code>%s</code> (%s)',
    out[#out+1] = string.format('| %d || %s || %s || %s || %d',
        d.md, _teamWithLogo(d.A), _teamWithLogo(d.B), d.A_id, d.B_id, d.pos or '—'
      i, r.name or "—", _teamWithLogo(r.team or "—"), r.pos or "—", r[stat] or 0
      )
     )
     end
   end
   end
   out[#out+1] = '|}'
   out[#out+1] = '|}'


   return "'''League leaders (" .. stat .. ") — season " .. season ..
   return "'''Transfer window (Matchdays " .. TRANSFER_MD_START .. "–" .. TRANSFER_MD_END ..
    " through Matchday " .. tostring(upToMd) .. "'''\n" ..
    ") — season " .. season .. " (through Matchday " .. tostring(upToMd) .. ")'''\n" ..
     table.concat(out, "\n")
     table.concat(out, "\n")
end
end


-----------------------------------------------------------------------
function p.renderPlayerStats(frame)
-- MATCH REPORTS: Match of the Week + single match report (WITH DERBY TAG)
   local args = frame.args or {}
-----------------------------------------------------------------------
   local team = args.team or args[1]
local function _fmtTime(seconds)
   if not team or not TEAM_BY_CODE[team] then
   local m = math.floor(seconds / 60)
     return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''"
   local s = seconds % 60
  return string.format("%02d:%02d", m, s)
end
 
local function _abbr(teamName)
  local letters = {}
   for w in mw.ustring.gmatch(teamName or "", "%S+") do
     letters[#letters+1] = mw.ustring.upper(mw.ustring.sub(w, 1, 1))
   end
   end
  local a = table.concat(letters, "")
  if #a >= 2 then return mw.ustring.sub(a, 1, 2) end
  return (a ~= "" and a) or "XX"
end


local function _getN(t, k) return (t and tonumber(t[k])) or 0 end
  local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()


local function _timesList(times, maxn)
  local totals, deals, base = _computePlayerSeasonTotals(season, upToMd)
  if not times or #times == 0 then return "" end
   local rostersNow = _applyTransfersUpTo(base, deals, upToMd)
  maxn = maxn or 3
   local rosterNow = rostersNow[team] or {}
   local out = {}
  for i = 1, math.min(maxn, #times) do out[#out+1] = _fmtTime(times[i]) end
   if #times > maxn then out[#out+1] = "…" end
  return " (" .. table.concat(out, ", ") .. ")"
end


local function _randTime(st, startSec, endSec)
   local out = {
   local r; st, r = _rngNext(st)
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
  local span = math.max(1, endSec - startSec - 2)
    '! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'
   local sec = startSec + 1 + math.floor(r * span)
   }
  return st, sec
end


local function _genTimes(st, n, firstStart, firstEnd, secondStart, secondEnd)
  for _,pl in ipairs(rosterNow) do
  local times = {}
    local r = totals[pl.id]
  if n <= 0 then return st, times end
    out[#out+1] = '|-'
  local split = math.ceil(n / 2)
    out[#out+1] = string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d',
  for i=1, n do
      pl.name, pl.pos,
    if i <= split then
      r and r.apps or 0,
       st, times[i] = _randTime(st, firstStart, firstEnd)
       r and r.goals or 0,
    else
      r and r.assists or 0,
       st, times[i] = _randTime(st, secondStart, secondEnd)
      r and r.poss or 0,
     end
      r and r.son or 0,
       r and r.sof or 0,
      r and r.fouls or 0
     )
   end
   end
  table.sort(times)
  return st, times
end


local function _pickPlayer(st, roster, posA, posB, posC)
   out[#out+1] = '|}'
   local candidates = {}
 
   for _,p in ipairs(roster or {}) do
   return (TEAM_BY_CODE[team].logo or "") ..
     if p.pos == posA or p.pos == posB or p.pos == posC then
     " '''" .. team .. "''' — Player totals through Matchday " .. tostring(upToMd) .. "\n" ..
      candidates[#candidates+1] = p
     table.concat(out, "\n")
     end
  end
  if #candidates == 0 then candidates = roster end
  local pl; st, pl = _pick(st, candidates)
  return st, pl
end
end


-- Rivalry tag: same division => derby
function p.renderLeaders(frame)
local function _rivalryTag(homeCode, awayCode)
   local args = frame.args or {}
   local dh, da = DIV_OF[homeCode], DIV_OF[awayCode]
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
   if dh and da and dh == da then
   local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    return " (Divisional derby — " .. dh .. ")"
  end
  return ""
end


-- Append derby tag INSIDE trailing bold quotes if title ends with '''
  local stat = (args.stat or args[2] or "goals")
local function _appendToBoldTitle(title, suffix)
   local ok = { goals=true, assists=true, poss=true, son=true, sof=true, fouls=true, apps=true }
   if not suffix or suffix == "" then return title end
   if not ok[stat] then
   if not title then return suffix end
    return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''"
  if mw.ustring.match(title, "'''%s*$") then
    return mw.ustring.gsub(title, "'''%s*$", suffix .. "'''", 1)
   end
   end
  return title .. suffix
end


local function _pickMatchIndexForDay(season, md, pickMode)
  local totals = (_computePlayerSeasonTotals(season, upToMd))
   pickMode = pickMode or "best"
   local list = {}
   local fixtures = SCHED[md]
   for _,r in pairs(totals) do list[#list+1] = r end
  if not fixtures then return nil, "No fixtures found for this matchday." end


   local inv = _invertDayMap(mapDays())
   table.sort(list, function(a,b)
  local day = inv[md] or (START + md)
    if (a[stat] or 0) ~= (b[stat] or 0) then return (a[stat] or 0) > (b[stat] or 0) end
    return (a.name or "") < (b.name or "")
  end)


   local bestI, bestScore = 1, -1e18
   local out = {
  for i, m in ipairs(fixtures) do
     '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
     local home, away = m[1], m[2]
     '! Rank !! Player !! Team (current) !! Pos !! ' .. stat
     local salt = day * 1000 + i + string.byte(home.code,1) + string.byte(away.code,1)
  }
    local hs, as = simScoreFor(home, away, salt)
    local S = computeMatchStats(home, away, hs, as, salt)
    local H = S[home.code] or {}
    local A = S[away.code] or {}


    local diff  = math.abs(hs - as)
  for i=1, math.min(20, #list) do
     local total = hs + as
     local r = list[i]
     local chaos = (_getN(H,"fouls") + _getN(A,"fouls"))
    out[#out+1] = '|-'
                + (_getN(H,"shots_on") + _getN(A,"shots_on"))
     out[#out+1] = string.format('| %d || %s || %s || %s || %d',
                + (_getN(H,"shots_off") + _getN(A,"shots_off"))
      i, r.name or "—", _teamWithLogo(r.team or "—"), r.pos or "—", r[stat] or 0
 
     )
    local q
  end
    if pickMode == "close" then
      q = (50 - diff * 10) + total + chaos * 0.25
    elseif pickMode == "high" then
      q = total * 12 - diff * 2 + chaos * 0.20
     elseif pickMode == "chaos" then
      q = chaos * 10 + total * 2 - diff
    else
      q = (20 - diff * 6) + total * 8 + chaos * 0.35
    end


    if q > bestScore then bestScore, bestI = q, i end
  out[#out+1] = '|}'
  end


   return bestI
   return "'''League leaders (" .. stat .. ") — season " .. season ..
    " through Matchday " .. tostring(upToMd) .. "'''\n" ..
    table.concat(out, "\n")
end
end


local function _renderMatchReport(season, md, matchIndex, titleOverride)
------------------------------------------------------------------------
   local fixtures = SCHED[md]
-- 11) MATCH REPORTS + DERBY TAG
   if not fixtures or not fixtures[matchIndex] then
------------------------------------------------------------------------
    return "'''No such match (md=" .. tostring(md) .. ", match=" .. tostring(matchIndex) .. ").'''"
local function _fmtTime(seconds)
   local m = math.floor(seconds / 60)
   local s = seconds % 60
  return string.format("%02d:%02d", m, s)
end
 
local function _abbr(teamName)
  local letters = {}
  for w in mw.ustring.gmatch(teamName or "", "%S+") do
    letters[#letters+1] = mw.ustring.upper(mw.ustring.sub(w, 1, 1))
   end
   end
  local a = table.concat(letters, "")
  if #a >= 2 then return mw.ustring.sub(a, 1, 2) end
  return (a ~= "" and a) or "XX"
end


   local inv = _invertDayMap(mapDays())
local function _randTime2(st, startSec, endSec)
   local day = inv[md] or (START + md)
   local r; st, r = _rngNext(st)
  local span = math.max(1, endSec - startSec - 2)
   local sec = startSec + 1 + math.floor(r * span)
  return st, sec
end


  local home, away = fixtures[matchIndex][1], fixtures[matchIndex][2]
local function _genTimes2(st, n, firstStart, firstEnd, secondStart, secondEnd)
   local derby = _rivalryTag(home.code, away.code)
   local times = {}
 
  if n <= 0 then return st, times end
   local salt = day * 1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
   local split = math.ceil(n / 2)
  local hs, as = simScoreFor(home, away, salt)
  for i=1, n do
    if i <= split then
      st, times[i] = _randTime2(st, firstStart, firstEnd)
    else
      st, times[i] = _randTime2(st, secondStart, secondEnd)
    end
  end
  table.sort(times)
  return st, times
end


  local S = computeMatchStats(home, away, hs, as, salt)
local function _timesList2(times, maxn)
   local H = S[home.code] or {}
  if not times or #times == 0 then return "" end
   local A = S[away.code] or {}
  maxn = maxn or 3
   local out = {}
   for i = 1, math.min(maxn, #times) do out[#out+1] = _fmtTime(times[i]) end
  if #times > maxn then out[#out+1] = "…" end
  return " (" .. table.concat(out, ", ") .. ")"
end


  -- Rosters (transfer-aware for this matchday)
local function _rivalryTag(homeCode, awayCode)
   local deals = _genTransferDeals(season)
   local dh, da = DIV_OF[homeCode], DIV_OF[awayCode]
   local base  = _buildBaseRosters(season)
   if dh and da and dh == da then
   local rosters = _applyTransfersUpTo(base, deals, md)
    return " (Divisional derby — " .. dh .. ")"
   local homeRoster = rosters[home.code] or {}
   end
  local awayRoster = rosters[away.code] or {}
   return ""
end


  -- Times
local function _appendToBoldTitle(title, suffix)
   local st = _seedFromString(tostring(season).."|REPORT|MD"..md.."|M"..matchIndex.."|"..home.code.."|"..away.code)
   if not suffix or suffix == "" then return title end
   local FIRST_START, FIRST_END   = 0, 35*60
  if not title then return suffix end
  local SECOND_START, SECOND_END = 35*60, 70*60
  if mw.ustring.match(title, "'''%s*$") then
    return mw.ustring.gsub(title, "'''%s*$", suffix .. "'''", 1)
   end
   return title .. suffix
end


   local homeTag, awayTag = _abbr(home.code), _abbr(away.code)
local function _pickMatchIndexForDay(season, md, pickMode)
  pickMode = pickMode or "best"
   local fixtures = getRoundMatches(md)
  if not fixtures or #fixtures == 0 then return nil, "No fixtures found for this matchday." end


  -- scoring components (GOAL = 3 points, POSS = 1 point)
   local inv = invertDayMap(mapDays())
   local homePP, awayPP, homeG, awayG
   local day = inv[md] or (START + md)
  st, homePP = _genTimes(st, _getN(H,"poss_points"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   st, awayPP = _genTimes(st, _getN(A,"poss_points"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, homeG  = _genTimes(st, _getN(H,"goals"),      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayG  = _genTimes(st, _getN(A,"goals"),      FIRST_START, FIRST_END, SECOND_START, SECOND_END)


  -- highlights
   local bestI, bestScore = 1, -1e18
   local homeSON, awaySON, homeFOUL, awayFOUL, homeOOB, awayOOB
   for i, m in ipairs(fixtures) do
   st, homeSON  = _genTimes(st, _getN(H,"shots_on"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
    local home, away = m[1], m[2]
  st, awaySON  = _genTimes(st, _getN(A,"shots_on"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
    local salt = day * 1000 + i + string.byte(home.code,1) + string.byte(away.code,1)
  st, homeFOUL = _genTimes(st, _getN(H,"fouls"),    FIRST_START, FIRST_END, SECOND_START, SECOND_END)
    local hs, as = simScoreFor(home, away, salt)
  st, awayFOUL = _genTimes(st, _getN(A,"fouls"),    FIRST_START, FIRST_END, SECOND_START, SECOND_END)
    local S = computeMatchStats(home, away, hs, as, salt)
  st, homeOOB  = _genTimes(st, _getN(H,"oob"),     FIRST_START, FIRST_END, SECOND_START, SECOND_END)
    local H = S[home.code] or {}
  st, awayOOB  = _genTimes(st, _getN(A,"oob"),      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
    local A = S[away.code] or {}


  -- Scoring timeline: running score must add +3 for goals, +1 for possession
    local diff  = math.abs(hs - as)
  local scoreEvents = {}
     local total = hs + as
  local function queueScore(sec, which, kind)
    local chaos = (tonumber(H.fouls) or 0) + (tonumber(A.fouls) or 0)
     scoreEvents[#scoreEvents+1] = { t=sec, which=which, kind=kind } -- kind="PP" or "G"
                + (tonumber(H.shots_on) or 0) + (tonumber(A.shots_on) or 0)
  end
                + (tonumber(H.shots_off) or 0) + (tonumber(A.shots_off) or 0)
  for _,sec in ipairs(homePP) do queueScore(sec, "H", "PP") end
  for _,sec in ipairs(awayPP) do queueScore(sec, "A", "PP") end
  for _,sec in ipairs(homeG)  do queueScore(sec, "H", "G") end
  for _,sec in ipairs(awayG)  do queueScore(sec, "A", "G") end
  table.sort(scoreEvents, function(a,b) return a.t < b.t end)


  local events = {}
    local q
  local function addEvent(sec, text) events[#events+1] = { t=sec, text=text } end
    if pickMode == "close" then
      q = (50 - diff * 10) + total + chaos * 0.25
    elseif pickMode == "high" then
      q = total * 12 - diff * 2 + chaos * 0.20
    elseif pickMode == "chaos" then
      q = chaos * 10 + total * 2 - diff
    else
      q = (20 - diff * 6) + total * 8 + chaos * 0.35
    end


  local hRun, aRun = 0, 0
    if q > bestScore then bestScore, bestI = q, i end
   local hHalf, aHalf = 0, 0
  end
   return bestI
end


   for _,e in ipairs(scoreEvents) do
local function _renderMatchReport(season, md, matchIndex, titleOverride)
     local add = (e.kind == "G") and 3 or 1
   local fixtures = getRoundMatches(md)
    if e.which == "H" then hRun = hRun + add else aRun = aRun + add end
  if not fixtures or not fixtures[matchIndex] then
    if e.t < 35*60 then hHalf, aHalf = hRun, aRun end
     return "'''No such match (md=" .. tostring(md) .. ", match=" .. tostring(matchIndex) .. ").'''"
  end
 
  local inv = invertDayMap(mapDays())
  local day = inv[md] or (START + md)


    local tag    = (e.which=="H") and homeTag or awayTag
  local home, away = fixtures[matchIndex][1], fixtures[matchIndex][2]
    local roster = (e.which=="H") and homeRoster or awayRoster
  local derby = _rivalryTag(home.code, away.code)
    local scoreLine = string.format("%s %d–%d %s", _teamWithLogo(home), hRun, aRun, _teamWithLogo(away))


    if e.kind == "G" then
  local salt = day * 1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
      local pz; st, pz = _pickPlayer(st, roster, "Striker", "Utility", "Playmaker")
  local hs, as = simScoreFor(home, away, salt)
      addEvent(e.t, string.format("%s – Goal (Pillar Strike) (%s): %s converts a direct pillar strike. (%s)",
        _fmtTime(e.t), tag, (pz and pz.name or "a forward"), scoreLine))
    else
      local pp; st, pp = _pickPlayer(st, roster, "Playmaker", "Utility", "Striker")
      addEvent(e.t, string.format("%s – Possession Play (%s): %s completes a 10-second controlled hold in the scoring zone. (%s)",
        _fmtTime(e.t), tag, (pp and pp.name or "a midfielder"), scoreLine))
    end
  end


  -- A few non-scoring highlights (kept small)
   local S = computeMatchStats(home, away, hs, as, salt)
   local function addHighlights(times, roster, tag, label, posA, posB, posC, template)
  local H = S[home.code] or {}
    local cap = math.min(2, #times)
  local A = S[away.code] or {}
    for i=1, cap do
      local sec = times[i]
      local px; st, px = _pickPlayer(st, roster, posA, posB, posC)
      addEvent(sec, string.format("%s – %s (%s): " .. template,
        _fmtTime(sec), label, tag, (px and px.name or "a player")))
    end
  end


   addHighlights(homeFOUL, homeRoster, homeTag, "Foul", "Defender", "Utility", "Playmaker",
   local deals = _genTransferDeals(season)
    "%s clips a runner during a shielded carry; restart taken quickly.")
   local base  = _buildBaseRosters(season)
   addHighlights(awayFOUL, awayRoster, awayTag, "Foul", "Defender", "Utility", "Playmaker",
   local rosters = _applyTransfersUpTo(base, deals, md)
    "%s commits a tactical pull in transition; shape preserved on the restart.")
   local homeRoster = rosters[home.code] or {}
  addHighlights(homeSON, homeRoster, homeTag, "Shot on Target", "Striker", "Utility", "Playmaker",
   local awayRoster = rosters[away.code] or {}
    "%s forces a controlled save off a pillar-side angle; no strike awarded.")
   addHighlights(awaySON, awayRoster, awayTag, "Shot on Target", "Striker", "Utility", "Playmaker",
    "%s drives a low effort that glances the casing; play continues.")
   addHighlights(homeOOB, homeRoster, homeTag, "Out of Bounds", "Utility", "Defender", "Playmaker",
    "%s overhits a diagonal switch; possession turns over at the boundary.")
   addHighlights(awayOOB, awayRoster, awayTag, "Out of Bounds", "Utility", "Defender", "Playmaker",
    "%s sends a pressured clearance long; reset follows.")


   table.sort(events, function(a,b) return a.t < b.t end)
   local st = _seedFromString(tostring(season).."|REPORT|MD"..md.."|M"..matchIndex.."|"..home.code.."|"..away.code)
  local FIRST_START, FIRST_END  = 0, 35*60
  local SECOND_START, SECOND_END = 35*60, 70*60


   local firstHalf, secondHalf = {}, {}
   local homeTag, awayTag = _abbr(home.code), _abbr(away.code)
   for _,e in ipairs(events) do
 
    if e.t < 35*60 then firstHalf[#firstHalf+1] = e.text else secondHalf[#secondHalf+1] = e.text end
   local homePP, awayPP, homeG, awayG
   end
  st, homePP = _genTimes2(st, tonumber(H.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayPP = _genTimes2(st, tonumber(A.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, homeG  = _genTimes2(st, tonumber(H.goals) or 0,      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   st, awayG  = _genTimes2(st, tonumber(A.goals) or 0,      FIRST_START, FIRST_END, SECOND_START, SECOND_END)


   local diff = math.abs(hs - as)
   local homeSON, awaySON, homeFOUL, awayFOUL, homeOOB, awayOOB
   local mood = (diff <= 1) and "a tight, tactical contest" or "a high-variance clash"
  st, homeSON  = _genTimes2(st, tonumber(H.shots_on) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   local summary = string.format(
   st, awaySON  = _genTimes2(st, tonumber(A.shots_on) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
    "Matchday %d featured %s defined by controlled zone entries and disciplined restarts. %s edged %s %d–%d, with decisive moments coming from timed possession holds and selective pillar pressure rather than sustained long-range attempts.",
   st, homeFOUL = _genTimes2(st, tonumber(H.fouls) or 0,    FIRST_START, FIRST_END, SECOND_START, SECOND_END)
    md, mood, _teamWithLogo(home), _teamWithLogo(away), hs, as
  st, awayFOUL = _genTimes2(st, tonumber(A.fouls) or 0,    FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   )
  st, homeOOB  = _genTimes2(st, tonumber(H.oob) or 0,     FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   st, awayOOB  = _genTimes2(st, tonumber(A.oob) or 0,      FIRST_START, FIRST_END, SECOND_START, SECOND_END)


   -- Title with derby tag (works for both override and default)
   -- scoring timeline (+3 goal, +1 possession)
   local baseTitle = titleOverride or string.format("'''Match of the Week — %d PSSC (Matchday %d)'''", season, md)
   local scoreEvents = {}
   local title = _appendToBoldTitle(baseTitle, derby)
  local function queueScore(sec, which, kind) scoreEvents[#scoreEvents+1] = { t=sec, which=which, kind=kind } end
  for _,sec in ipairs(homePP) do queueScore(sec, "H", "PP") end
  for _,sec in ipairs(awayPP) do queueScore(sec, "A", "PP") end
  for _,sec in ipairs(homeG) do queueScore(sec, "H", "G")  end
  for _,sec in ipairs(awayG)  do queueScore(sec, "A", "G") end
   table.sort(scoreEvents, function(a,b) return a.t < b.t end)


  -- Output
   local events = {}
   local out = {}
   local function addEvent(sec, text) events[#events+1] = { t=sec, text=text } end
   out[#out+1] = title
 
   out[#out+1] = ""
   local hRun, aRun = 0, 0
  out[#out+1] = "=== Match Summary ==="
  local hHalf, aHalf = 0, 0
  out[#out+1] = summary
 
  out[#out+1] = ""
  for _,e in ipairs(scoreEvents) do
  out[#out+1] = "=== Key Events by Time ==="
    local add = (e.kind == "G") and 3 or 1
  out[#out+1] = ""
    if e.which == "H" then hRun = hRun + add else aRun = aRun + add end
  out[#out+1] = "==== First Half (0:00–35:00) ===="
    if e.t < 35*60 then hHalf, aHalf = hRun, aRun end
  if #firstHalf == 0 then
 
     out[#out+1] = "—"
    local tag    = (e.which=="H") and homeTag or awayTag
  else
    local roster = (e.which=="H") and homeRoster or awayRoster
    for _,ln in ipairs(firstHalf) do out[#out+1] = "* " .. ln end
    local scoreLine = string.format("%s %d–%d %s", _teamWithLogo(home), hRun, aRun, _teamWithLogo(away))
 
    if e.kind == "G" then
      local pz; st, pz = _pickPlayer(st, roster, "Striker", "Utility", "Playmaker")
      addEvent(e.t, string.format("%s – Goal (Pillar Strike) (%s): %s converts a direct pillar strike. (%s)",
        _fmtTime(e.t), tag, (pz and pz.name or "a forward"), scoreLine))
     else
      local pp; st, pp = _pickPlayer(st, roster, "Playmaker", "Utility", "Striker")
      addEvent(e.t, string.format("%s – Possession Play (%s): %s completes a 10-second controlled hold in the scoring zone. (%s)",
        _fmtTime(e.t), tag, (pp and pp.name or "a midfielder"), scoreLine))
    end
   end
   end
   out[#out+1] = ""
 
  out[#out+1] = string.format("35:00 – Halftime: %s %d–%d %s.", _teamWithLogo(home), hHalf, aHalf, _teamWithLogo(away))
   local function addHighlights(times, roster, tag, label, posA, posB, posC, template)
  out[#out+1] = ""
    local cap = math.min(2, #times)
  out[#out+1] = "==== Second Half (35:00–70:00) ===="
    for i=1, cap do
  if #secondHalf == 0 then
      local sec = times[i]
     out[#out+1] = "—"
      local px; st, px = _pickPlayer(st, roster, posA, posB, posC)
  else
      addEvent(sec, string.format("%s – %s (%s): " .. template,
    for _,ln in ipairs(secondHalf) do out[#out+1] = "* " .. ln end
        _fmtTime(sec), label, tag, (px and px.name or "a player")))
     end
   end
   end
   out[#out+1] = ""
 
   out[#out+1] = string.format("70:00 – Final Whistle: %s %d–%d %s.", _teamWithLogo(home), hs, as, _teamWithLogo(away))
   addHighlights(homeFOUL, homeRoster, homeTag, "Foul", "Defender", "Utility", "Playmaker",
   out[#out+1] = ""
    "%s clips a runner during a shielded carry; restart taken quickly.")
  out[#out+1] = "=== Match Stats ==="
   addHighlights(awayFOUL, awayRoster, awayTag, "Foul", "Defender", "Utility", "Playmaker",
   out[#out+1] = '{| class="wikitable" style="width:100%; font-size:90%;"'
    "%s commits a tactical pull in transition; shape preserved on the restart.")
   out[#out+1] = string.format('! Statistic !! %s !! %s', _teamWithLogo(home), _teamWithLogo(away))
  addHighlights(homeSON, homeRoster, homeTag, "Shot on Target", "Striker", "Utility", "Playmaker",
  out[#out+1] = '|-'
    "%s forces a controlled save off a pillar-side angle; no strike awarded.")
  out[#out+1] = string.format('| Final Score || %d || %d', hs, as)
  addHighlights(awaySON, awayRoster, awayTag, "Shot on Target", "Striker", "Utility", "Playmaker",
   out[#out+1] = '|-'
    "%s drives a low effort that glances the casing; play continues.")
   out[#out+1] = string.format('| Goals (Pillar Strikes) || %d%s || %d%s',
   addHighlights(homeOOB, homeRoster, homeTag, "Out of Bounds", "Utility", "Defender", "Playmaker",
     _getN(H,"goals"), _timesList(homeG, 3),
    "%s overhits a diagonal switch; possession turns over at the boundary.")
    _getN(A,"goals"), _timesList(awayG, 3)
   addHighlights(awayOOB, awayRoster, awayTag, "Out of Bounds", "Utility", "Defender", "Playmaker",
    "%s sends a pressured clearance long; reset follows.")
 
   table.sort(events, function(a,b) return a.t < b.t end)
 
  local firstHalf, secondHalf = {}, {}
  for _,e in ipairs(events) do
    if e.t < 35*60 then firstHalf[#firstHalf+1] = e.text else secondHalf[#secondHalf+1] = e.text end
  end
 
  local diff = math.abs(hs - as)
   local mood = (diff <= 1) and "a tight, tactical contest" or "a high-variance clash"
   local summary = string.format(
    "Matchday %d featured %s defined by controlled zone entries and disciplined restarts. %s edged %s %d–%d, with decisive moments coming from timed possession holds and selective pillar pressure rather than sustained long-range attempts.",
     md, mood, _teamWithLogo(home), _teamWithLogo(away), hs, as
   )
   )
  local baseTitle = titleOverride or string.format("'''Match of the Week — %d PSSC (Matchday %d)'''", season, md)
  local title = _appendToBoldTitle(baseTitle, derby)
  local out = {}
  out[#out+1] = title
  out[#out+1] = ""
  out[#out+1] = "=== Match Summary ==="
  out[#out+1] = summary
  out[#out+1] = ""
  out[#out+1] = "=== Key Events by Time ==="
  out[#out+1] = ""
  out[#out+1] = "==== First Half (0:00–35:00) ===="
  if #firstHalf == 0 then out[#out+1] = "—" else for _,ln in ipairs(firstHalf) do out[#out+1] = "* " .. ln end end
  out[#out+1] = ""
  out[#out+1] = string.format("35:00 – Halftime: %s %d–%d %s.", _teamWithLogo(home), hHalf, aHalf, _teamWithLogo(away))
  out[#out+1] = ""
  out[#out+1] = "==== Second Half (35:00–70:00) ===="
  if #secondHalf == 0 then out[#out+1] = "—" else for _,ln in ipairs(secondHalf) do out[#out+1] = "* " .. ln end end
  out[#out+1] = ""
  out[#out+1] = string.format("70:00 – Final Whistle: %s %d–%d %s.", _teamWithLogo(home), hs, as, _teamWithLogo(away))
  out[#out+1] = ""
  out[#out+1] = "=== Match Stats ==="
  out[#out+1] = '{| class="wikitable" style="width:100%; font-size:90%;"'
  out[#out+1] = string.format('! Statistic !! %s !! %s', _teamWithLogo(home), _teamWithLogo(away))
  out[#out+1] = '|-'; out[#out+1] = string.format('| Final Score || %d || %d', hs, as)
   out[#out+1] = '|-'
   out[#out+1] = '|-'
   out[#out+1] = string.format('| Possession Points || %d%s || %d%s',
   out[#out+1] = string.format('| Goals (Pillar Strikes) || %d%s || %d%s',
     _getN(H,"poss_points"), _timesList(homePP, 4),
     tonumber(H.goals) or 0, _timesList2(homeG, 3),
     _getN(A,"poss_points"), _timesList(awayPP, 4)
     tonumber(A.goals) or 0, _timesList2(awayG, 3)
  )
   )
  out[#out+1] = '|-'
  out[#out+1] = string.format('| Shots on Target || %d || %d', _getN(H,"shots_on"), _getN(A,"shots_on"))
  out[#out+1] = '|-'
  out[#out+1] = string.format('| Shots off Target || %d || %d', _getN(H,"shots_off"), _getN(A,"shots_off"))
  out[#out+1] = '|-'
  out[#out+1] = string.format('| Fouls || %d || %d', _getN(H,"fouls"), _getN(A,"fouls"))
   out[#out+1] = '|-'
  out[#out+1] = string.format('| Free Kicks Awarded || %d || %d', _getN(H,"fks"), _getN(A,"fks"))
   out[#out+1] = '|-'
   out[#out+1] = '|-'
   out[#out+1] = string.format('| Out of Bounds || %d || %d', _getN(H,"oob"), _getN(A,"oob"))
   out[#out+1] = string.format('| Possession Points || %d%s || %d%s',
   out[#out+1] = '|-'
    tonumber(H.poss_points) or 0, _timesList2(homePP, 4),
  out[#out+1] = string.format('| Throw-Ins || %d || %d', _getN(H,"throws"), _getN(A,"throws"))
    tonumber(A.poss_points) or 0, _timesList2(awayPP, 4)
   out[#out+1] = '|-'
  )
  out[#out+1] = string.format('| Goal Kicks || %d || %d', _getN(H,"gk"), _getN(A,"gk"))
  out[#out+1] = '|-'; out[#out+1] = string.format('| Shots on Target || %d || %d', tonumber(H.shots_on) or 0, tonumber(A.shots_on) or 0)
   out[#out+1] = '|-'
  out[#out+1] = '|-'; out[#out+1] = string.format('| Shots off Target || %d || %d', tonumber(H.shots_off) or 0, tonumber(A.shots_off) or 0)
  out[#out+1] = string.format('| Ball Possession || %d%% || %d%%', _getN(H,"poss_pct"), _getN(A,"poss_pct"))
  out[#out+1] = '|-'; out[#out+1] = string.format('| Fouls || %d || %d', tonumber(H.fouls) or 0, tonumber(A.fouls) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Free Kicks Awarded || %d || %d', tonumber(H.fks) or 0, tonumber(A.fks) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Out of Bounds || %d || %d', tonumber(H.oob) or 0, tonumber(A.oob) or 0)
   out[#out+1] = '|-'; out[#out+1] = string.format('| Throw-Ins || %d || %d', tonumber(H.throws) or 0, tonumber(A.throws) or 0)
   out[#out+1] = '|-'; out[#out+1] = string.format('| Goal Kicks || %d || %d', tonumber(H.gk) or 0, tonumber(A.gk) or 0)
   out[#out+1] = '|-'; out[#out+1] = string.format('| Ball Possession || %d%% || %d%%', tonumber(H.poss_pct) or 0, tonumber(A.poss_pct) or 0)
   out[#out+1] = '|}'
   out[#out+1] = '|}'
   return table.concat(out, "\n")
   return table.concat(out, "\n")
end
end


-- PUBLIC: Match of the Week (auto-pick match on a matchday)
function p.renderMatchOfWeek(frame)
function p.renderMatchOfWeek(frame)
   local args = frame.args or {}
   local args = frame.args or {}
Line 1,885: Line 1,967:
   if md < 1 then return "'''No matchday has been played yet.'''"
   if md < 1 then return "'''No matchday has been played yet.'''"
   end
   end
   if not SCHED[md] then return "'''No fixtures found for matchday " .. tostring(md) .. ".'''" end
  local fixtures = getRoundMatches(md)
   if not fixtures or #fixtures == 0 then return "'''No fixtures found for matchday " .. tostring(md) .. ".'''" end


   local idx, err = _pickMatchIndexForDay(season, md, pick)
   local idx, err = _pickMatchIndexForDay(season, md, pick)
Line 1,892: Line 1,975:
end
end


-- PUBLIC: Force a specific match report (md + match index)
function p.renderChaosOfWeek(frame)
  local args = frame.args or {}
  args.pick = args.pick or "chaos"
  args.md = nil
  return p.renderMatchOfWeek({ args = args })
end
 
function p.renderMatchReport(frame)
function p.renderMatchReport(frame)
   local args = frame.args or {}
   local args = frame.args or {}
Line 1,898: Line 1,987:
   local md = tonumber(args.md or args.matchday or args[2] or "") or _currentPlayedMatchday()
   local md = tonumber(args.md or args.matchday or args[2] or "") or _currentPlayedMatchday()
   local matchIndex = tonumber(args.match or args[3] or "1") or 1
   local matchIndex = tonumber(args.match or args[3] or "1") or 1
   local titleOverride = string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''", season, md, matchIndex)
   local titleOverride = string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''", season, md, matchIndex)
   return _renderMatchReport(season, md, matchIndex, titleOverride)
   return _renderMatchReport(season, md, matchIndex, titleOverride)
end
end


-- MATCHDAY CAPSULE (safe: will not error if renderWeeklyAwards isn't present)
------------------------------------------------------------------------
function p.renderMatchdayCapsule(frame)
-- 12) WEEKLY AWARDS + MATCHDAY CAPSULE
------------------------------------------------------------------------
function p.renderWeeklyAwards(frame)
   local args = frame.args or {}
   local args = frame.args or {}
   local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
   local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
   local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
   local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
   local pick = args.pick or "chaos"  -- default to chaos
  if md < 1 then return "'''No matchday has been played yet.'''" end
 
  local inv = invertDayMap(mapDays())
   local day = inv[md] or (START + md)


   if md < 1 or not SCHED[md] then
   local deals = _genTransferDeals(season)
    return "'''No matchday has been played yet (or no fixtures found).'''"
  local base  = _buildBaseRosters(season)
   end
   local rosters = _applyTransfersUpTo(base, deals, md)


  -- Convert matchday -> PSSC day (needed for simulateDayResults)
   local function clamp(x, lo, hi)
   local inv = _invertDayMap(mapDays())
    if x < lo then return lo end
  local day = inv[md]
    if x > hi then return hi end
  if not day then
     return x
     return "'''Unable to resolve PSSC day for matchday " .. tostring(md) .. ".'''"
   end
   end


   local results = simulateDayResults(day)
   local function scorePlayer(line, pos, oppShotsOn, oppGoals)
    local goals  = line.goals or 0
    local assists = line.assists or 0
    local poss    = line.poss or 0
    local son    = line.son or 0
    local sof    = line.sof or 0
    local fouls  = line.fouls or 0


  local out = {}
    local rating = 6
  out[#out+1] = "== Matchday " .. tostring(md) .. " Capsule =="
      + goals*1.25
  out[#out+1] = ""
      + assists*0.85
      + poss*0.05
      + son*0.15
      - sof*0.08
      - fouls*0.16


  -- Featured match (auto recap + player names)
    if pos == "Keeper" then
  out[#out+1] = p.renderMatchOfWeek({ args = { season = season, md = md, pick = pick } })
      local saves = math.max(0, (oppShotsOn or 0) - (oppGoals or 0))
  out[#out+1] = ""
      rating = 6 + saves*0.25 - fouls*0.10
      if (oppGoals or 0) == 0 then rating = rating + 1.0 end
    end


  -- Full results list
     return clamp(rating, 0, 10)
  out[#out+1] = "=== Full Results ==="
  for _, r in ipairs(results or {}) do
     out[#out+1] = string.format("* %s '''%d''' %s vs %s '''%d''' %s",
      (r.home and r.home.logo) or "",
      tonumber(r.hg) or 0,
      (r.home and (r.home.fullName or r.home.code)) or "Home",
      (r.away and r.away.logo) or "",
      tonumber(r.ag) or 0,
      (r.away and (r.away.fullName or r.away.code)) or "Away"
    )
   end
   end


   -- Weekly awards (optional)
   local best = { overall=nil, striker=nil, playmaker=nil, defender=nil, keeper=nil }
  if type(p.renderWeeklyAwards) == "function" then
 
    out[#out+1] = ""
   local function consider(entry, bucket)
    out[#out+1] = p.renderWeeklyAwards({ args = { season = season, md = md } })
     if not best[bucket] or entry.rating > best[bucket].rating then best[bucket] = entry end
   else
     out[#out+1] = ""
    out[#out+1] = "''(Weekly awards not enabled yet.)''"
   end
   end


   return table.concat(out, "\n")
   local fixtures = getRoundMatches(md)
end
  for matchIndex, m in ipairs(fixtures) do
    local home, away = m[1], m[2]
    local hr = rosters[home.code] or {}
    local ar = rosters[away.code] or {}


-----------------------------------------------------------------------
    local salt = day*1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
-- WEEKLY AWARDS (drop-in)
    local hs, as = simScoreFor(home, away, salt)
-----------------------------------------------------------------------
    local S = computeMatchStats(home, away, hs, as, salt)
function p.renderWeeklyAwards(frame)
    local H = S[home.code] or {}
  local args = frame.args or {}
    local A = S[away.code] or {}


  -- LOCAL clamp so it can’t go missing / global-nil
    -- allocate simple per-player contributions deterministically for awards
  local function _clamp(x, lo, hi)
    local st = _seedFromString(tostring(season).."|AWARDS|MD"..md.."|D"..day.."|M"..matchIndex.."|"..home.code.."|"..away.code)
     if x < lo then return lo end
     local linesH, linesA = {}, {}
    if x > hi then return hi end
    return x
  end


  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
    local function ensure(map, pid)
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
      if not map[pid] then map[pid] = {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0} end
      return map[pid]
    end


  if md < 1 or not SCHED[md] then
    local function pickWeighted(stt, roster, weights)
    return "'''No fixtures found for matchday " .. tostring(md) .. ".'''"
      local pool = {}
  end
      for _,p in ipairs(roster or {}) do
        local w = weights[p.pos] or 1
        for _=1,w do pool[#pool+1] = p end
      end
      return _pick(stt, pool)
    end


  -- Resolve PSSC day for this matchday
    local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
  local inv = {}
    local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
  for d, mday in pairs(mapDays()) do inv[mday] = d end
    local W_SHOT = { Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
  local day = inv[md] or (START + md)
    local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }


  -- Rosters (transfer-aware if available)
    for _=1, tonumber(H.goals) or 0 do
  local rosters
      local pl; st, pl = pickWeighted(st, hr, W_GOAL)
  if type(_genTransferDeals) == "function" and type(_buildBaseRosters) == "function" and type(_applyTransfersUpTo) == "function" then
      ensure(linesH, pl.id).goals = ensure(linesH, pl.id).goals + 1
    local deals = _genTransferDeals(season)
      local r; st, r = _rngNext(st)
    local base  = _buildBaseRosters(season)
      if r < 0.70 then
    rosters = _applyTransfersUpTo(base, deals, md)
        local a; st, a = pickWeighted(st, hr, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
  else
        if a and a.id ~= pl.id then ensure(linesH, a.id).assists = ensure(linesH, a.id).assists + 1 end
     rosters = type(_buildBaseRosters) == "function" and _buildBaseRosters(season) or {}
      end
  end
    end
 
     for _=1, tonumber(A.goals) or 0 do
  local function ensure(map, pid)
      local pl; st, pl = pickWeighted(st, ar, W_GOAL)
    if not map[pid] then map[pid] = {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0} end
      ensure(linesA, pl.id).goals = ensure(linesA, pl.id).goals + 1
    return map[pid]
      local r; st, r = _rngNext(st)
  end
      if r < 0.70 then
 
        local a; st, a = pickWeighted(st, ar, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
  local function pickWeighted(st, roster, weights)
        if a and a.id ~= pl.id then ensure(linesA, a.id).assists = ensure(linesA, a.id).assists + 1 end
    local pool = {}
       end
    for _,p in ipairs(roster or {}) do
      local w = weights[p.pos] or 1
       for _=1,w do pool[#pool+1] = p end
     end
     end
    return _pick(st, pool)
  end


  local function scorePlayer(line, pos, oppShotsOn, oppGoals)
     for _=1, tonumber(H.poss_points) or 0 do
     local goals  = line.goals or 0
      local pl; st, pl = pickWeighted(st, hr, W_POSS)
    local assists = line.assists or 0
      ensure(linesH, pl.id).poss = ensure(linesH, pl.id).poss + 1
    local poss   = line.poss or 0
     end
     local son     = line.son or 0
     for _=1, tonumber(A.poss_points) or 0 do
    local sof    = line.sof or 0
      local pl; st, pl = pickWeighted(st, ar, W_POSS)
     local fouls  = line.fouls or 0
      ensure(linesA, pl.id).poss = ensure(linesA, pl.id).poss + 1
     end


     local rating = 6
     for _=1, tonumber(H.shots_on) or 0 do
       + goals*1.25
      local pl; st, pl = pickWeighted(st, hr, W_SHOT)
       + assists*0.85
       ensure(linesH, pl.id).son = ensure(linesH, pl.id).son + 1
       + poss*0.05
    end
       + son*0.15
    for _=1, tonumber(H.shots_off) or 0 do
       - sof*0.08
       local pl; st, pl = pickWeighted(st, hr, W_SHOT)
      - fouls*0.16
       ensure(linesH, pl.id).sof = ensure(linesH, pl.id).sof + 1
 
    end
     if pos == "Keeper" then
    for _=1, tonumber(A.shots_on) or 0 do
       local saves = math.max(0, (oppShotsOn or 0) - (oppGoals or 0))
       local pl; st, pl = pickWeighted(st, ar, W_SHOT)
      rating = 6 + saves*0.25 - fouls*0.10
       ensure(linesA, pl.id).son = ensure(linesA, pl.id).son + 1
       if (oppGoals or 0) == 0 then rating = rating + 1.0 end
    end
     for _=1, tonumber(A.shots_off) or 0 do
       local pl; st, pl = pickWeighted(st, ar, W_SHOT)
      ensure(linesA, pl.id).sof = ensure(linesA, pl.id).sof + 1
    end
 
    for _=1, tonumber(H.fouls) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_FOUL)
      ensure(linesH, pl.id).fouls = ensure(linesH, pl.id).fouls + 1
    end
    for _=1, tonumber(A.fouls) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_FOUL)
       ensure(linesA, pl.id).fouls = ensure(linesA, pl.id).fouls + 1
     end
     end


     return _clamp(rating, 0, 10)
     local function evalTeam(teamObj, roster, lines, oppStats)
   end
      local oppShotsOn = tonumber(oppStats.shots_on) or 0
 
      local oppGoals   = tonumber(oppStats.goals) or 0
  local best = { overall=nil, striker=nil, playmaker=nil, defender=nil, keeper=nil }
      for _,pp in ipairs(roster or {}) do
 
        local line = lines[pp.id] or {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0}
  local function consider(entry, bucket)
        local rating = scorePlayer(line, pp.pos, oppShotsOn, oppGoals)
    if not best[bucket] or entry.rating > best[bucket].rating then best[bucket] = entry end
  end


  for matchIndex, m in ipairs(SCHED[md]) do
        local saves, clean = 0, false
    local home, away = m[1], m[2]
        if pp.pos == "Keeper" then
    local hr = (rosters and rosters[home.code]) or {}
          saves = math.max(0, oppShotsOn - oppGoals)
    local ar = (rosters and rosters[away.code]) or {}
          clean = (oppGoals == 0)
        end


    -- match stats (truth source)
        local entry = {
    local salt = day*1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
          rating=rating, name=pp.name or pp.id, pos=pp.pos or "—",
    local hs, as = simScoreFor(home, away, salt)
          team=teamObj.code, goals=line.goals or 0, assists=line.assists or 0,
    local S = computeMatchStats(home, away, hs, as, salt)
          poss=line.poss or 0, son=line.son or 0, sof=line.sof or 0, fouls=line.fouls or 0,
    local H = S[home.code] or {}
          saves=saves, clean=clean
    local A = S[away.code] or {}
        }


    -- deterministic per-match RNG
        consider(entry, "overall")
    local st = _seedFromString(tostring(season).."|AWARDS|MD"..md.."|D"..day.."|M"..matchIndex.."|"..home.code.."|"..away.code)
        if pp.pos == "Striker"  then consider(entry, "striker") end
        if pp.pos == "Playmaker" then consider(entry, "playmaker") end
        if pp.pos == "Defender" then consider(entry, "defender") end
        if pp.pos == "Keeper"   then consider(entry, "keeper") end
      end
    end


     local linesH, linesA = {}, {}
     evalTeam(home, hr, linesH, A)
    evalTeam(away, ar, linesA, H)
  end


    local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
  local out = {}
    local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
  out[#out+1] = "=== Weekly Awards (Matchday " .. tostring(md) .. ") ==="
    local W_SHOT = { Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
  out[#out+1] = '{| class="wikitable sortable" style="width:100%; font-size:90%;"'
    local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
  out[#out+1] = "! Award !! Winner !! Team !! Pos !! G !! A !! Poss !! S-on !! S-off !! Fouls !! Saves/CS !! Rating"


    -- allocate goals (+ assists)
  local function row(label, e)
    for _=1, (tonumber(H.goals) or 0) do
    if not e then return end
      local pl; st, pl = pickWeighted(st, hr, W_GOAL)
    local sc = "—"
      ensure(linesH, pl.id).goals = ensure(linesH, pl.id).goals + 1
    if e.pos == "Keeper" then sc = tostring(e.saves or 0) .. ((e.clean and " / CS") or "") end
      local r; st, r = _rngNext(st)
     out[#out+1] = "|-"
      if r < 0.70 then
     out[#out+1] = string.format("| %s || %s || %s || %s || %d || %d || %d || %d || %d || %d || %s || %.2f",
        local a; st, a = pickWeighted(st, hr, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
       label, e.name or "—", _teamWithLogo(e.team or "—"), e.pos or "—",
        if a and a.id ~= pl.id then ensure(linesH, a.id).assists = ensure(linesH, a.id).assists + 1 end
       e.goals or 0, e.assists or 0, e.poss or 0, e.son or 0, e.sof or 0, e.fouls or 0, sc, e.rating or 0
      end
    )
     end
  end
     for _=1, (tonumber(A.goals) or 0) do
       local pl; st, pl = pickWeighted(st, ar, W_GOAL)
      ensure(linesA, pl.id).goals = ensure(linesA, pl.id).goals + 1
      local r; st, r = _rngNext(st)
       if r < 0.70 then
        local a; st, a = pickWeighted(st, ar, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
        if a and a.id ~= pl.id then ensure(linesA, a.id).assists = ensure(linesA, a.id).assists + 1 end
      end
    end


    -- possession points
  row("Player of the Week", best.overall)
    for _=1, (tonumber(H.poss_points) or 0) do
  row("Striker of the Week", best.striker)
      local pl; st, pl = pickWeighted(st, hr, W_POSS)
  row("Playmaker of the Week", best.playmaker)
      ensure(linesH, pl.id).poss = ensure(linesH, pl.id).poss + 1
  row("Defender of the Week", best.defender)
    end
  row("Keeper of the Week", best.keeper)
    for _=1, (tonumber(A.poss_points) or 0) do
      local pl; st, pl = pickWeighted(st, ar, W_POSS)
      ensure(linesA, pl.id).poss = ensure(linesA, pl.id).poss + 1
    end


    -- shots on/off
  out[#out+1] = "|}"
    for _=1, (tonumber(H.shots_on) or 0) do
  return table.concat(out, "\n")
      local pl; st, pl = pickWeighted(st, hr, W_SHOT)
end
      ensure(linesH, pl.id).son = ensure(linesH, pl.id).son + 1
    end
    for _=1, (tonumber(H.shots_off) or 0) do
      local pl; st, pl = pickWeighted(st, hr, W_SHOT)
      ensure(linesH, pl.id).sof = ensure(linesH, pl.id).sof + 1
    end
    for _=1, (tonumber(A.shots_on) or 0) do
      local pl; st, pl = pickWeighted(st, ar, W_SHOT)
      ensure(linesA, pl.id).son = ensure(linesA, pl.id).son + 1
    end
    for _=1, (tonumber(A.shots_off) or 0) do
      local pl; st, pl = pickWeighted(st, ar, W_SHOT)
      ensure(linesA, pl.id).sof = ensure(linesA, pl.id).sof + 1
    end


    -- fouls
function p.renderMatchdayCapsule(frame)
    for _=1, (tonumber(H.fouls) or 0) do
  local args = frame.args or {}
      local pl; st, pl = pickWeighted(st, hr, W_FOUL)
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
      ensure(linesH, pl.id).fouls = ensure(linesH, pl.id).fouls + 1
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    end
  local pick = args.pick or "chaos"
    for _=1, (tonumber(A.fouls) or 0) do
      local pl; st, pl = pickWeighted(st, ar, W_FOUL)
      ensure(linesA, pl.id).fouls = ensure(linesA, pl.id).fouls + 1
    end


    local function mkEntry(teamObj, pp, line, oppStats)
  if md < 1 then return "'''No matchday has been played yet.'''"
      local oppShotsOn = tonumber(oppStats.shots_on) or 0
   end
      local oppGoals   = tonumber(oppStats.goals) or 0
      local rating = scorePlayer(line, pp.pos, oppShotsOn, oppGoals)


      local saves = 0
  local inv = invertDayMap(mapDays())
      local clean = false
  local day = inv[md]
      if pp.pos == "Keeper" then
  if not day then return "'''Unable to resolve PSSC day for matchday " .. tostring(md) .. ".'''" end
        saves = math.max(0, oppShotsOn - oppGoals)
        clean = (oppGoals == 0)
      end


      return {
   local results = simulateDayResults(day)
        rating = rating,
        name = pp.name or pp.id,
        pos  = pp.pos or "—",
        team = teamObj.code,
        logo = teamObj.logo or "",
        goals  = line.goals or 0,
        assists = line.assists or 0,
        poss    = line.poss or 0,
        son    = line.son or 0,
        sof    = line.sof or 0,
        fouls  = line.fouls or 0,
        saves  = saves,
        clean   = clean
      }
    end


    local function evalTeam(teamObj, roster, lines, oppStats)
   local out = {}
      for _,pp in ipairs(roster or {}) do
   out[#out+1] = "== Matchday " .. tostring(md) .. " Capsule =="
        local line = lines[pp.id] or {goals=0,assists=0,poss=0,son=0,sof=0,fouls=0}
   out[#out+1] = ""
        local e = mkEntry(teamObj, pp, line, oppStats)
   out[#out+1] = p.renderMatchOfWeek({ args = { season = season, md = md, pick = pick } })
 
  out[#out+1] = ""
        consider(e, "overall")
  out[#out+1] = "=== Full Results ==="
        if pp.pos == "Striker"  then consider(e, "striker") end
  for _, r in ipairs(results or {}) do
        if pp.pos == "Playmaker" then consider(e, "playmaker") end
     out[#out+1] = string.format("* %s '''%d''' %s vs %s '''%d''' %s",
        if pp.pos == "Defender"  then consider(e, "defender") end
       (r.home and r.home.logo) or "",
        if pp.pos == "Keeper"    then consider(e, "keeper") end
       tonumber(r.hg) or 0,
      end
       (r.home and (r.home.fullName or r.home.code)) or "Home",
    end
       (r.away and r.away.logo) or "",
 
       tonumber(r.ag) or 0,
    evalTeam(home, hr, linesH, A)
       (r.away and (r.away.fullName or r.away.code)) or "Away"
    evalTeam(away, ar, linesA, H)
     )
  end
   end
 
  out[#out+1] = ""
   local out = {}
  out[#out+1] = p.renderWeeklyAwards({ args = { season = season, md = md } })
   out[#out+1] = "=== Weekly Awards (Matchday " .. tostring(md) .. ") ==="
   out[#out+1] = '{| class="wikitable sortable" style="width:100%; font-size:90%;"'
   out[#out+1] = "! Award !! Winner !! Team !! Pos !! G !! A !! Poss !! S-on !! S-off !! Fouls !! Saves/CS !! Rating"
 
  local function row(label, e)
    if not e then return end
    local sc = "—"
    if e.pos == "Keeper" then
      sc = tostring(e.saves) .. (e.clean and " / CS" or "")
    end
    out[#out+1] = "|-"
     out[#out+1] = string.format("| %s || %s || %s || %s || %d || %d || %d || %d || %d || %d || %s || %.2f",
       label,
      e.name,
      _teamWithLogo(e.team or "—"),
      e.pos or "—",
       e.goals or 0,
       e.assists or 0,
      e.poss or 0,
       e.son or 0,
       e.sof or 0,
       e.fouls or 0,
      sc,
      e.rating or 0
     )
   end


  row("Player of the Week", best.overall)
  row("Striker of the Week", best.striker)
  row("Playmaker of the Week", best.playmaker)
  row("Defender of the Week", best.defender)
  row("Keeper of the Week", best.keeper)
  out[#out+1] = "|}"
   return table.concat(out, "\n")
   return table.concat(out, "\n")
end
end

Revision as of 17:57, 15 December 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 DEFAULT_SEASON = 52

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
local function absDay(y,d) return y*DAYS_IN_YEAR + d end

------------------------------------------------------------------------
-- 1) TEAMS
------------------------------------------------------------------------
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

local function _asTeam(x)
  if type(x) == "table" then return x end
  return TEAM_BY_CODE[x] or { code=tostring(x), fullName=tostring(x), logo="" }
end

local function _teamWithLogo(x, bold)
  local t = _asTeam(x)
  local name = t.fullName or t.code or tostring(x)
  if bold then name = "'''" .. name .. "'''" end
  local logo = t.logo or ""
  return (logo ~= "" and logo ~= "—") and (logo .. " " .. name) or name
end

------------------------------------------------------------------------
-- 2) DIVISIONS (4 x 8)
------------------------------------------------------------------------
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) SCHEDULE LOCK + BALANCED FUTURE
------------------------------------------------------------------------

-- === LOCKED schedule: EXACT original logic (preserves history) ===
local function homeFirst(codeA, codeB)
  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 = {}, {}

  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

  local allCodes = {}
  for _,t in ipairs(teams) do table.insert(allCodes, t.code) end
  table.sort(allCodes)

  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

  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)
  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

local SCHED_LOCKED = (function()
  local intra, inter = buildPairings()

  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

  local intraBlocks = chunk(intra, 64)
  local interBlocks = chunk(inter, 128)

  local merged, 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 mapDaysLocked()
  local totalDays  = END - START + 1
  local totalRnds  = #SCHED_LOCKED
  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

local DM_LOCKED = mapDaysLocked()

local function lastPlayedLocked()
  local curr = date.getCurrentDate()
  local today = tonumber(curr:match("^(%d+),")) or 0
  local lastDay, lastMd = nil, 0
  for d=START, math.min(today, END) do
    local md = DM_LOCKED[d]
    if md and md > lastMd then lastMd = md; lastDay = d end
  end
  return lastDay, lastMd
end

-- === BALANCED schedule (38 rounds × 16 matches), used only AFTER pivot ===
local DIV_ORDER = { "Morovian Division","Southern Strait Division","Western Highlands Division","Normarkian Division" }
local TOTAL_ROUNDS = 38

local function _copy(arr)
  local t = {}
  for i=1,#arr do t[i] = arr[i] end
  return t
end

local function roundRobin8(teamCodes)
  local n = #teamCodes
  if n ~= 8 then error("roundRobin8 expects exactly 8 teams") end
  local arr = _copy(teamCodes)
  local rounds = {}
  for r=1,7 do
    local matches = {}
    for i=1,4 do
      local a = TEAM_BY_CODE[arr[i]]
      local b = TEAM_BY_CODE[arr[n+1-i]]
      local home, away
      if (r+i) % 2 == 0 then home, away = a, b else home, away = b, a end
      matches[#matches+1] = { home, away }
    end
    rounds[#rounds+1] = matches
    local last = arr[n]
    for i=n,3,-1 do arr[i] = arr[i-1] end
    arr[2] = last
  end
  return rounds
end

local function swapHA(rounds)
  local out = {}
  for r=1,#rounds do
    out[r] = {}
    for i=1,#rounds[r] do
      local m = rounds[r][i]
      out[r][i] = { m[2], m[1] }
    end
  end
  return out
end

local function interPairRounds(divA_codes, divB_codes, blockIndex)
  local rounds = {}
  for t=0,7 do
    local matches = {}
    for i=1,8 do
      local a = TEAM_BY_CODE[divA_codes[i]]
      local b = TEAM_BY_CODE[divB_codes[((i+t-1) % 8) + 1]]
      local home, away
      if ((blockIndex + t) % 2 == 0) then home, away = a, b else home, away = b, a end
      matches[#matches+1] = { home, away }
    end
    rounds[#rounds+1] = matches
  end
  return rounds
end

local SCHED_BALANCED = (function()
  local rounds = {}

  -- 14 intra rounds
  local intraByDiv = {}
  for _, divName in ipairs(DIV_ORDER) do
    local rr7 = roundRobin8(DIVISIONS[divName])
    local rr14 = {}
    for i=1,7 do rr14[i] = rr7[i] end
    local rr7s = swapHA(rr7)
    for i=1,7 do rr14[7+i] = rr7s[i] end
    intraByDiv[divName] = rr14
  end

  for r=1,14 do
    local full = {}
    for _, divName in ipairs(DIV_ORDER) do
      for _, m in ipairs(intraByDiv[divName][r]) do full[#full+1] = m end
    end
    rounds[#rounds+1] = full
  end

  -- 24 inter rounds = 3 blocks × 8 rounds
  local A = DIVISIONS[DIV_ORDER[1]]
  local B = DIVISIONS[DIV_ORDER[2]]
  local C = DIVISIONS[DIV_ORDER[3]]
  local D = DIVISIONS[DIV_ORDER[4]]

  local blocks = {
    { {A,B}, {C,D} },
    { {A,C}, {B,D} },
    { {A,D}, {B,C} },
  }

  for blockIndex, pairset in ipairs(blocks) do
    local p1 = interPairRounds(pairset[1][1], pairset[1][2], blockIndex)
    local p2 = interPairRounds(pairset[2][1], pairset[2][2], blockIndex)
    for t=1,8 do
      local full = {}
      for _,m in ipairs(p1[t]) do full[#full+1] = m end
      for _,m in ipairs(p2[t]) do full[#full+1] = m end
      rounds[#rounds+1] = full
    end
  end

  return rounds
end)()

-- Composite mapDays: past days keep locked mapping; future days map to balanced rounds
local function mapDays()
  local pivotDay, pivotMd = lastPlayedLocked()

  if not pivotDay or pivotMd == 0 then
    local totalDays  = END - START + 1
    local iv         = math.floor(totalDays / TOTAL_ROUNDS)
    local ex         = totalDays % TOTAL_ROUNDS
    local m, d       = {}, START
    for i=1,TOTAL_ROUNDS 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

  local dm = {}
  for d,md in pairs(DM_LOCKED) do
    if d <= pivotDay then dm[d] = md end
  end

  local futureStartDay = pivotDay + 1
  local remainingDays = math.max(0, END - futureStartDay + 1)
  local remainingRnds = math.max(0, TOTAL_ROUNDS - pivotMd)
  if remainingDays == 0 or remainingRnds == 0 then return dm end

  local iv = math.floor(remainingDays / remainingRnds)
  local ex = remainingDays % remainingRnds

  local day = futureStartDay
  for i=1,remainingRnds do
    local mdx = pivotMd + i
    if mdx > TOTAL_ROUNDS then break end
    dm[day] = mdx
    day = day + iv + (ex > 0 and 1 or 0)
    if ex > 0 then ex = ex - 1 end
    if day > END then break end
  end

  return dm
end

local function invertDayMap(dm)
  local inv = {}
  for day, md in pairs(dm) do inv[md] = day end
  return inv
end

local function schedForRound(roundIdx)
  local _, pivotMd = lastPlayedLocked()
  if roundIdx <= pivotMd then return SCHED_LOCKED end
  return SCHED_BALANCED
end

local function getRoundMatches(roundIdx)
  local sched = schedForRound(roundIdx)
  return sched[roundIdx] or {}
end

local function _currentPlayedMatchday()
  local curr = date.getCurrentDate()
  local today = tonumber(curr:match("^(%d+),")) or 0
  local dm = mapDays()
  local lastMd = 0
  for d=START, math.min(today, END) do
    if dm[d] and dm[d] > lastMd then lastMd = dm[d] end
  end
  return lastMd
end

------------------------------------------------------------------------
-- 4) MATCH SIMULATION
------------------------------------------------------------------------
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 or 0) + 1, (a.championships or 0) + 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 matches = getRoundMatches(idx)
  local out = {}
  for k, m in ipairs(matches) 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     = {}

  local idx = dm[target]
  local matches = getRoundMatches(idx)

  if not upcoming then
    for k, m in ipairs(matches) do
      local h, a = m[1], m[2]
      local salt = target*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
      local hg, ag = simScoreFor(h, a, salt)
      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
    for _, m in ipairs(matches) 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)

  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

  local dm = mapDays()
  for d = START, last do
    local idx = dm[d]
    if idx then
      local matches = getRoundMatches(idx)
      for k, m in ipairs(matches) 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

  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

  local order = DIV_ORDER
  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
      local style = ""
      if     i <= 2 then style = ' style="background-color:#ccffcc;"'
      elseif i <= 4 then style = ' style="background-color:#cce5ff;"'
      elseif i <= 6 then style = ' style="background-color:#F1EB9C;"'
      else               style = ' style="background-color:#ffcccc;"'
      end
      out[#out+1] = "|-" .. style
      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)
------------------------------------------------------------------------
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]
    local matches = getRoundMatches(idx)
    for k, m in ipairs(matches) 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 (12 teams seeded from divisions)
------------------------------------------------------------------------
local PO_YEAR  = 52
local PO_FIRST = md2day(1,4)
local PO_IV    = 3

local function tb_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

local function computeRecordsUpTo(dayEnd)
  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, Div=DIV_OF[t.code] }
  end

  local dm = mapDays()
  for d = START, math.min(dayEnd, END) do
    local idx = dm[d]
    if idx then
      local matches = getRoundMatches(idx)
      for k, m in ipairs(matches) 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

  for _, r in pairs(recs) do r.PD = r.PF - r.PA end
  return recs
end

local function getSeeds12()
  local curr   = date.getCurrentDate()
  local today  = tonumber(curr:match("^(%d+),")) or END
  local recs   = computeRecordsUpTo(today)

  local buckets = {}
  for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
  for _, r in pairs(recs) do
    buckets[r.Div] = buckets[r.Div] or {}
    table.insert(buckets[r.Div], r)
  end
  for div, list in pairs(buckets) do table.sort(list, tb_cmp) end

  local winners, runners = {}, {}
  for _, div in ipairs(DIV_ORDER) do
    local list = buckets[div] or {}
    if list[1] then table.insert(winners, list[1]) end
    if list[2] then table.insert(runners,  list[2]) end
  end
  table.sort(winners, tb_cmp)
  table.sort(runners, tb_cmp)

  local chosen = {}
  for _,r in ipairs(winners) do chosen[r.team.code] = true end
  for _,r in ipairs(runners) do chosen[r.team.code] = true end

  local allList = {}
  for _, r in pairs(recs) do table.insert(allList, r) end
  table.sort(allList, tb_cmp)

  local wild = {}
  for _,r in ipairs(allList) do
    if not chosen[r.team.code] then
      table.insert(wild, r)
      if #wild == 4 then break end
    end
  end

  local seeds = {}
  for i=1,#winners do seeds[#seeds+1] = winners[i].team end
  for i=1,#runners do seeds[#seeds+1] = runners[i].team end
  for i=1,#wild    do seeds[#seeds+1] = wild[i].team    end
  return seeds
end

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 or 0)+1,(B.championships or 0)+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 function mkRow(tag, absDate, H, A, salt, absNow)
  local hs, win = '—','—'
  if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then
    local g1,g2 = playScore(H,A,salt)
    hs  = string.format("'''%d-%d'''", g1, g2)
    win = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
  end
  return string.format("|-\n| %s || %s %s || %s || %s %s || %s",
    tag, H.logo,H.fullName, hs, A.logo,A.fullName, win)
end

function p.renderPlayoffSchedule(frame)
  local seeds = getSeeds12()
  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

  add('Play-in-1', PO_FIRST, seeds[9], seeds[12])
  add('Play-in-2', PO_FIRST, seeds[10], seeds[11])

  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 seeds = getSeeds12()
  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' }

  local absPI = absDay(PO_YEAR, PO_FIRST)
  local PI = { { seeds[9],  seeds[12] }, { seeds[10], seeds[11] } }
  local W_PI = {}

  for i,pair in ipairs(PI) do
    local H,A = pair[1], pair[2]
    if absNow >= absPI then
      local g1,g2 = playScore(H,A,absPI*100+i)
      W_PI[i]     = (g1>g2) and H or A
    end
    rows[#rows+1] = mkRow('PI-'..i, absPI, H, A, absPI*100+i, absNow)
  end

  local absR8 = absPI + PO_IV
  local pool = { seeds[3],seeds[4],seeds[5],seeds[6],seeds[7],seeds[8],
                 W_PI[1] or {logo='—',fullName='TBD'}, W_PI[2] or {logo='—',fullName='TBD'} }
  local R8 = { {pool[3], pool[6]}, {pool[4], pool[5]}, {pool[1], pool[8]}, {pool[2], pool[7]} }

  local W_R8 = {}
  for i,pair in ipairs(R8) do
    local H,A = pair[1], pair[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
    end
    rows[#rows+1] = mkRow('R-8-'..i, absR8, H, A, absR8*100+i, absNow)
  end

  local absR4 = absR8 + PO_IV
  local R4, W_R4 = {}, {}

  if #W_R8 == 4 then
    local idxByCode = {}
    for i,t in ipairs(seeds) do idxByCode[t.code] = i end
    table.sort(W_R8, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end)
    R4 = { { W_R8[1], W_R8[4] }, { W_R8[2], W_R8[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'}
    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
    end
    rows[#rows+1] = mkRow('R-4-'..i, absR4, H, A, absR4*100+i, absNow)
  end

  local absSF = absR4 + PO_IV
  local SF = {}

  if #W_R4 == 2 then
    local idxByCode = {}
    for i,t in ipairs(seeds) do idxByCode[t.code] = i end
    table.sort(W_R4, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end)
    SF = { { seeds[1], W_R4[2] }, { seeds[2], W_R4[1] } }
  else
    SF = { { seeds[1], {logo='—',fullName='TBD'} }, { seeds[2], {logo='—',fullName='TBD'} } }
  end

  local W_SF = {}
  for i=1,2 do
    local H,A = SF[i][1], SF[i][2]
    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
    end
    rows[#rows+1] = mkRow('SF-'..i, absSF, H, A, absSF*100+i, absNow)
  end

  local absF = absSF + PO_IV
  local FH = W_SF[1] 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)

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

------------------------------------------------------------------------
-- 9) PER-MATCH STATS
------------------------------------------------------------------------
local function computeMatchStats(h, a, hg, ag, seed)
  local function splitScore(total, salt)
    local maxG = math.floor(total / 3)
    if maxG == 0 then return 0, total end
    math.randomseed(salt)
    local g = maxG
    if maxG >= 1 and math.random() < 0.40 then g = maxG - 1 end
    local p = total - 3*g
    return g, p
  end

  local h_goals, h_poss = splitScore(hg, seed + 101)
  local a_goals, a_poss = splitScore(ag, seed + 102)

  local function rrange(s, lo, hi) math.randomseed(s); return math.random(lo, hi) end
  local h_sh_on = (h_goals > 0) and rrange(seed+201, h_goals, h_goals+2) or rrange(seed+201, 0, 2)
  local a_sh_on = (a_goals > 0) and rrange(seed+202, a_goals, a_goals+2) or rrange(seed+202, 0, 2)

  local h_sh_off = rrange(seed+203, 0, 3)
  local a_sh_off = rrange(seed+204, 0, 3)

  local h_fouls = rrange(seed+205, 0, 3)
  local a_fouls = rrange(seed+206, 0, 3)
  local h_fks   = a_fouls
  local a_fks   = h_fouls

  local h_throws = rrange(seed+207, 0, 3)
  local a_throws = rrange(seed+208, 0, 3)
  local h_oob    = rrange(seed+209, 0, 2)
  local a_oob    = rrange(seed+210, 0, 2)
  local h_gk     = rrange(seed+211, 0, 2)
  local a_gk     = rrange(seed+212, 0, 2)

  local base = 50
  local swing = rrange(seed+213, 3, 6)
  local h_poss_pct, a_poss_pct
  if hg > ag then
    h_poss_pct, a_poss_pct = base + swing, base - swing
  elseif hg < ag then
    h_poss_pct, a_poss_pct = base - swing, base + swing
  else
    local tilt = rrange(seed+214, -3, 3)
    h_poss_pct, a_poss_pct = base + tilt, base - tilt
  end

  return {
    [h.code] = {
      final = hg, goals = h_goals, poss_points = h_poss,
      shots_on = h_sh_on, shots_off = h_sh_off,
      fouls = h_fouls, fks = h_fks, throws = h_throws,
      gk = h_gk, oob = h_oob, poss_pct = h_poss_pct
    },
    [a.code] = {
      final = ag, goals = a_goals, poss_points = a_poss,
      shots_on = a_sh_on, shots_off = a_sh_off,
      fouls = a_fouls, fks = a_fks, throws = a_throws,
      gk = a_gk, oob = a_oob, poss_pct = a_poss_pct
    }
  }
end

function p.renderMatchStatsForDay(frame)
  local curr   = date.getCurrentDate()
  local dayNow = tonumber(curr:match('^(%d+),')) or 0
  local year   = tonumber(curr:match(', (%d+) PSSC')) or 0
  local dm     = mapDays()

  local target = dm[dayNow] and dayNow or nil
  if not target then
    for d = dayNow + 1, END do
      if dm[d] then target = d; break end
    end
  end
  if not target then return "'''No scheduled matches in window.'''" end

  local idx     = dm[target]
  local isToday = (target == dayNow)

  local mon, dom = getMonthDay(target)
  local label    = dom .. " " .. mon .. " " .. year .. " PSSC"

  local function hiPairNum(hVal, aVal, suffix)
    hVal = tonumber(hVal) or 0
    aVal = tonumber(aVal) or 0
    suffix = suffix or ""
    local function cell(v, hi)
      local txt = tostring(v) .. suffix
      return hi and ('<span style="background-color:#ccffcc;">'..txt..'</span>') or txt
    end
    return cell(hVal, hVal>aVal), cell(aVal, aVal>hVal)
  end

  local out = {
    '{| class="wikitable sortable"',
    '|+ Match Statistics — ' .. label .. (isToday and '' or ' (fixtures; stats available day-of)'),
    '! Match !! Team !! Final !! Goals (Pillar) !! Possession Pts !! Shots on !! Shots off !! Fouls !! Free Kicks !! Throw-Ins !! Goal Kicks !! OOB !! Poss %'
  }

  local matches = getRoundMatches(idx)

  if isToday then
    local results = simulateDayResults(target)
    for k, r in ipairs(results) do
      local seed = target*1000 + k + string.byte(r.home.code,1) + string.byte(r.away.code,1)
      local S    = computeMatchStats(r.home, r.away, r.hg, r.ag, seed)
      local H, A = S[r.home.code], S[r.away.code]

      local matchLabel = r.home.logo .. ' ' .. r.home.fullName .. ' vs ' .. r.away.logo .. ' ' .. r.away.fullName

      local hF,   aF   = hiPairNum(H.final,       A.final)
      local hG,   aG   = hiPairNum(H.goals,       A.goals)
      local hP,   aP   = hiPairNum(H.poss_points, A.poss_points)
      local hSON, aSON = hiPairNum(H.shots_on,    A.shots_on)
      local hSOF, aSOF = hiPairNum(H.shots_off,   A.shots_off)
      local hFO,  aFO  = hiPairNum(H.fouls,       A.fouls)
      local hFK,  aFK  = hiPairNum(H.fks,         A.fks)
      local hTH,  aTH  = hiPairNum(H.throws,      A.throws)
      local hGK,  aGK  = hiPairNum(H.gk,          A.gk)
      local hOOB, aOOB = hiPairNum(H.oob,         A.oob)
      local hPOS, aPOS = hiPairNum(H.poss_pct,    A.poss_pct, '%')

      out[#out+1] = '|-'
      out[#out+1] =
        '| rowspan="2" | ' .. matchLabel ..
        ' || ' .. r.home.logo .. ' ' .. r.home.fullName ..
        ' || ' .. hF ..
        ' || ' .. hG ..
        ' || ' .. hP ..
        ' || ' .. hSON ..
        ' || ' .. hSOF ..
        ' || ' .. hFO ..
        ' || ' .. hFK ..
        ' || ' .. hTH ..
        ' || ' .. hGK ..
        ' || ' .. hOOB ..
        ' || ' .. hPOS

      out[#out+1] = '|-'
      out[#out+1] =
        '| ' .. r.away.logo .. ' ' .. r.away.fullName ..
        ' || ' .. aF ..
        ' || ' .. aG ..
        ' || ' .. aP ..
        ' || ' .. aSON ..
        ' || ' .. aSOF ..
        ' || ' .. aFO ..
        ' || ' .. aFK ..
        ' || ' .. aTH ..
        ' || ' .. aGK ..
        ' || ' .. aOOB ..
        ' || ' .. aPOS
    end
  else
    for _, m in ipairs(matches) do
      local matchLabel = m[1].logo .. ' ' .. m[1].fullName .. ' vs ' .. m[2].logo .. ' ' .. m[2].fullName
      out[#out+1] = '|-'
      out[#out+1] =
        '| rowspan="2" | ' .. matchLabel ..
        ' || ' .. m[1].logo .. ' ' .. m[1].fullName ..
        ' || — || — || — || — || — || — || — || — || — || — || —'
      out[#out+1] = '|-'
      out[#out+1] =
        '| ' .. m[2].logo .. ' ' .. m[2].fullName ..
        ' || — || — || — || — || — || — || — || — || — || — || —'
    end
  end

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

function p.renderMatchStatsSingle(frame)
  local args = frame.args or {}
  local day  = tonumber(args.day or '') or 0
  local home = args.home
  local away = args.away
  if day == 0 or not home or not away then
    return "'''Pass day, home, and away:''' day=###, home=Team, away=Team"
  end

  local curr   = date.getCurrentDate()
  local dayNow = tonumber(curr:match('^(%d+),')) or 0
  local year   = tonumber(curr:match(', (%d+) PSSC')) or 0
  local dm     = mapDays()
  local idx    = dm[day]
  if not idx then return "'''No round mapped for that day.'''" end

  local matches = getRoundMatches(idx)
  local matchIdx
  for k, m in ipairs(matches) do
    if m[1].code == home and m[2].code == away then matchIdx = k; break end
  end
  if not matchIdx then return "'''Match not found on that day with that home/away.'''" end

  local m = matches[matchIdx]
  local mon, dom = getMonthDay(day)
  local dateLabel= dom .. " " .. mon .. " " .. year .. " PSSC"

  local out = {
    '{| class="wikitable"',
    '|+ Match Statistics — ' .. dateLabel .. ((day == dayNow) and '' or ' (fixture; stats available day-of)'),
    '! Statistic !! ' .. _teamWithLogo(m[1]) .. ' !! ' .. _teamWithLogo(m[2])
  }

  if day == dayNow then
    local resList = simulateDayResults(day)
    local r = resList[matchIdx]
    local seed = day*1000 + matchIdx + string.byte(home,1) + string.byte(away,1)
    local S = computeMatchStats(m[1], m[2], r.hg, r.ag, seed)
    local H, A = S[home], S[away]

    local function hi(hv, av, suffix)
      hv = tonumber(hv) or 0
      av = tonumber(av) or 0
      suffix = suffix or ""
      local h = tostring(hv) .. suffix
      local a = tostring(av) .. suffix
      if hv > av then h = '<span style="background-color:#ccffcc;">'..h..'</span>'
      elseif av > hv then a = '<span style="background-color:#ccffcc;">'..a..'</span>' end
      return h, a
    end

    local hF,aF     = hi(H.final,       A.final)
    local hG,aG     = hi(H.goals,       A.goals)
    local hP,aP     = hi(H.poss_points, A.poss_points)
    local hSON,aSON = hi(H.shots_on,    A.shots_on)
    local hSOF,aSOF = hi(H.shots_off,   A.shots_off)
    local hFO,aFO   = hi(H.fouls,       A.fouls)
    local hFK,aFK   = hi(H.fks,         A.fks)
    local hTH,aTH   = hi(H.throws,      A.throws)
    local hGK,aGK   = hi(H.gk,          A.gk)
    local hOOB,aOOB = hi(H.oob,         A.oob)
    local hPOS,aPOS = hi(H.poss_pct,    A.poss_pct, '%')

    out[#out+1] = '|-'; out[#out+1] = '| Final Score || ' .. hF .. ' || ' .. aF
    out[#out+1] = '|-'; out[#out+1] = '| Goals (Pillar Strikes) || ' .. hG .. ' || ' .. aG
    out[#out+1] = '|-'; out[#out+1] = '| Possession Points || ' .. hP .. ' || ' .. aP
    out[#out+1] = '|-'; out[#out+1] = '| Shots on Target || ' .. hSON .. ' || ' .. aSON
    out[#out+1] = '|-'; out[#out+1] = '| Shots off Target || ' .. hSOF .. ' || ' .. aSOF
    out[#out+1] = '|-'; out[#out+1] = '| Fouls || ' .. hFO .. ' || ' .. aFO
    out[#out+1] = '|-'; out[#out+1] = '| Free Kicks Awarded || ' .. hFK .. ' || ' .. aFK
    out[#out+1] = '|-'; out[#out+1] = '| Throw-Ins || ' .. hTH .. ' || ' .. aTH
    out[#out+1] = '|-'; out[#out+1] = '| Goal Kicks || ' .. hGK .. ' || ' .. aGK
    out[#out+1] = '|-'; out[#out+1] = '| Out of Bounds || ' .. hOOB .. ' || ' .. aOOB
    out[#out+1] = '|-'; out[#out+1] = '| Ball Possession || ' .. hPOS .. ' || ' .. aPOS
  else
    local function blank(row) out[#out+1] = '|-'; out[#out+1] = row end
    blank('| Final Score || — || —')
    blank('| Goals (Pillar Strikes) || — || —')
    blank('| Possession Points || — || —')
    blank('| Shots on Target || — || —')
    blank('| Shots off Target || — || —')
    blank('| Fouls || — || —')
    blank('| Free Kicks Awarded || — || —')
    blank('| Throw-Ins || — || —')
    blank('| Goal Kicks || — || —')
    blank('| Out of Bounds || — || —')
    blank('| Ball Possession || — || —')
  end

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

------------------------------------------------------------------------
-- 10) ROSTERS + TRANSFERS + PLAYER STATS
------------------------------------------------------------------------
local ROSTER_SIZE = 12
local TRANSFER_MD_START = 20
local TRANSFER_MD_END   = 30

local POSITIONS = {
  "Striker","Striker",
  "Playmaker","Playmaker","Playmaker",
  "Defender","Defender","Defender",
  "Keeper",
  "Utility","Utility","Utility"
}

-- deterministic PRNG (does NOT touch math.randomseed)
local function _seedFromString(s)
  local h = 0
  for i=1,#s do h = (h*31 + string.byte(s,i)) % 2147483647 end
  return (h == 0) and 1 or h
end

local function _rngNext(st)
  st = (1103515245 * st + 12345) % 2147483647
  return st, st / 2147483647
end

local function _pick(st, arr)
  if not arr or #arr == 0 then return st, nil end
  local r; st, r = _rngNext(st)
  local idx = math.floor(r * #arr) + 1
  return st, arr[idx]
end

local function _normKey(s)
  if not s or s == "" then return nil end
  s = mw.ustring.lower(s)
  s = mw.ustring.gsub(s, "%s+", "_")
  s = mw.ustring.gsub(s, "[^%w_]", "")
  return s
end

local NAME_STYLES = {
  bassarid = {
    firstA = {"Ka","Tha","Py","De","Ar","Lo","Ni","Sa","Eo","Ae","Ky","My","Ga","Do","Sy","Va"},
    firstB = {"li","the","ra","me","ri","phi","no","lo","re","so","te","xa","ro","na","di","mos"},
    firstC = {"thros","rios","nax","dros","lion","menos","kar","thes","sos","lios","zian","phoros","kris","dor","thes","ron"},
    lastA  = {"Amin","Delph","Kalli","Vareng","Caspaz","Myren","Aurel","Koin","Therm","Skyr","Erythr","Loth","Chrys","Silen","Nexa","Morov"},
    lastB  = {"a","i","o","e","u","y","ae","io","ou","ea"},
    lastC  = {"dis","kos","tron","ides","aris","eas","ion","oros","anos","inos","elis","akis","eth","on","eus","yr"}
  },
  haifan = {
    firstA = {"Se","Or","Ke","Ha","Yu","Az","Le","Mi","De","Sa","Fa","Ri","Ta","Na","Su","Ba"},
    firstB = {"lim","han","mal","kan","suf","iz","yla","na","rya","fi","ru","hir","mir","zir","yif","sar"},
    firstC = {"","oğlu","an","ir","em","et","in","a","e","u",""},
    lastA  = {"Yıldır","Dem","Kay","Arsl","Çel","Ayd","Gün","Korkm","Sarı","Taş","Karad","Özt","Ak","Boz","Şah"},
    lastB  = {"im","ir","a","i","u","ü","o","ö",""},
    lastC  = {"maz","er","kan","soy","taş","kaya","han","gül","lı","ci","oğlu",""}
  },
  normark = {
    firstA = {"Ei","Ha","Le","Bj","Si","As","In","To","Ka","Sk","Ry","Ul","Va","Sa","Jo","Th"},
    firstB = {"rik","kon","if","orn","grid","trid","ga","rsten","ri","ald","var","lva","ren","mund","nar","or"},
    firstC = {"","r","d","n","k","s",""},
    lastA  = {"Fjell","Ravn","Skog","Sund","Vinter","Knut","Thors","Haldor","Eirik","Bjorn","Isen","Storm","Sol","Hav","Ulv"},
    lastB  = {"sen","son","sson","vik","lund","by","heim","holt","dal","gard","berg","mark"},
    lastC  = {"","","",""}
  },
  imperial = {
    firstA = {"Mar","Jul","Cas","Oct","Luc","Tib","Sab","Aurel","Flav","Dec","Val","Cor","Dom","Sev","Faust","Cris"},
    firstB = {"cus","ia","sian","avia","ian","er","ina","ius","ia","imus","eri","vin","itian","eran","inus","pus"},
    firstC = {"","us","a","","us","a","us","a","us","a"},
    lastA  = {"Valer","Marcell","Aquil","Sever","Corvin","Drus","Faustin","Liv","Domit","Crisp","Aurel","Cass","Octav","Lucan","Tiber","Flav"},
    lastB  = {"ius","us","a","an","inus","ian","ensis","orum","atus","aris"},
    lastC  = {"","","",""}
  }
}
local NAME_STYLE_KEYS = {"bassarid","haifan","normark","imperial"}

local function _cap(s)
  if not s or s == "" then return s end
  return mw.ustring.upper(mw.ustring.sub(s,1,1)) .. mw.ustring.sub(s,2)
end

local function _maybeDiacritics(st, s)
  local r; st, r = _rngNext(st)
  if r < 0.08 then s = mw.ustring.gsub(s, "a", "ä", 1)
  elseif r < 0.16 then s = mw.ustring.gsub(s, "i", "ï", 1)
  elseif r < 0.24 then s = mw.ustring.gsub(s, "o", "ö", 1)
  elseif r < 0.32 then s = mw.ustring.gsub(s, "u", "ü", 1)
  end
  return st, s
end

local function _buildNameFrom(style, st, isLast)
  local t = NAME_STYLES[style] or NAME_STYLES.bassarid
  local a,b,c
  if not isLast then
    st, a = _pick(st, t.firstA); st, b = _pick(st, t.firstB); st, c = _pick(st, t.firstC)
  else
    st, a = _pick(st, t.lastA);  st, b = _pick(st, t.lastB);  st, c = _pick(st, t.lastC)
  end

  local name = _cap((a or "") .. (b or "") .. (c or ""))

  local r; st, r = _rngNext(st)
  if r < 0.05 and #name >= 6 then
    local cut = math.floor(#name/2)
    name = mw.ustring.sub(name,1,cut) .. "-" .. mw.ustring.sub(name,cut+1)
  elseif r < 0.08 and #name >= 6 then
    local cut = math.floor(#name/2)
    name = mw.ustring.sub(name,1,cut) .. "’" .. mw.ustring.sub(name,cut+1)
  end

  st, name = _maybeDiacritics(st, name)
  return st, name
end

local function _chooseStyleForTeam(teamCode, season)
  local st = _seedFromString(tostring(season).."|"..tostring(teamCode).."|STYLE")
  local key; st, key = _pick(st, NAME_STYLE_KEYS)
  return key or "bassarid"
end

local function _genBaseRoster(teamCode, season)
  local key = _normKey(teamCode) or teamCode
  local st = _seedFromString(tostring(season) .. "|" .. key .. "|ROSTER")
  local style = _chooseStyleForTeam(teamCode, season)

  local roster, used = {}, {}
  for i=1, ROSTER_SIZE do
    local id = key .. "-" .. string.format("%02d", i)
    local full
    for _=1, 10 do
      local first, last
      st, first = _buildNameFrom(style, st, false)
      st, last  = _buildNameFrom(style, st, true)
      full = first .. " " .. last
      if not used[full] then used[full] = true; break end
    end
    roster[i] = { id=id, name=full or ("Player "..id), pos=POSITIONS[i] or "Utility", no=i }
  end
  roster[1].notes = "Captain"
  return roster
end

local function _buildBaseRosters(season)
  local rosters = {}
  for _,t in ipairs(teams) do rosters[t.code] = _genBaseRoster(t.code, season) end
  return rosters
end

local function _indexById(roster)
  local m = {}
  for i,p in ipairs(roster) do m[p.id] = i end
  return m
end

local function _findSwapCandidate(st, roster, wantPos)
  local candidates = {}
  for _,p in ipairs(roster) do if p.pos == wantPos then candidates[#candidates+1] = p end end
  if #candidates == 0 then candidates = roster end
  return _pick(st, candidates)
end

local function _genTransferDeals(season)
  local st = _seedFromString(tostring(season) .. "|TRANSFERWINDOW|MD" .. TRANSFER_MD_START .. "-" .. TRANSFER_MD_END)
  local deals, teamCodes = {}, {}
  for _,t in ipairs(teams) do teamCodes[#teamCodes+1] = t.code end

  for md = TRANSFER_MD_START, TRANSFER_MD_END do
    local r; st, r = _rngNext(st)
    local dealCount = 1 + math.floor(r * 3)
    for _=1, dealCount do
      local A,B
      st, A = _pick(st, teamCodes)
      repeat st, B = _pick(st, teamCodes) until B ~= A

      local baseA = _genBaseRoster(A, season)
      local baseB = _genBaseRoster(B, season)

      local pA; st, pA = _pick(st, baseA)
      if pA and pA.pos == "Keeper" then
        local rr; st, rr = _rngNext(st)
        if rr < 0.75 then st, pA = _pick(st, baseA) end
      end

      local pB; st, pB = _findSwapCandidate(st, baseB, (pA and pA.pos) or "Utility")
      if A and B and pA and pB then
        deals[#deals+1] = { md=md, A=A, B=B, A_id=pA.id, B_id=pB.id, pos=pA.pos }
      end
    end
  end
  return deals
end

local function _applyTransfersUpTo(baseRosters, deals, upToMd)
  local rosters = {}
  for code, r in pairs(baseRosters) do
    rosters[code] = {}
    for i,p in ipairs(r) do rosters[code][i] = { id=p.id, name=p.name, pos=p.pos, no=p.no, notes=p.notes } end
  end

  for _,d in ipairs(deals) do
    if d.md <= upToMd then
      local RA, RB = rosters[d.A], rosters[d.B]
      if RA and RB then
        local ia = _indexById(RA)[d.A_id]
        local ib = _indexById(RB)[d.B_id]
        if ia and ib then
          local tmp = RA[ia]
          RA[ia] = RB[ib]
          RB[ib] = tmp
        end
      end
    end
  end

  return rosters
end

local function _alloc(st, roster, posWeights)
  local pool = {}
  for _,p in ipairs(roster) do
    local w = posWeights[p.pos] or 1
    for _=1,w do pool[#pool+1] = p end
  end
  return _pick(st, pool)
end

local function _ensure(map, pid)
  if not map[pid] then map[pid] = { goals=0, assists=0, poss=0, son=0, sof=0, fouls=0, apps=0 } end
  return map[pid]
end

local function _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster)
  local salt = day*1000 + matchIndex + string.byte(homeTeam.code,1) + string.byte(awayTeam.code,1)
  local hg, ag = simScoreFor(homeTeam, awayTeam, salt)
  local S = computeMatchStats(homeTeam, awayTeam, hg, ag, salt)
  local H = S[homeTeam.code] or {}
  local A = S[awayTeam.code] or {}
  local stats = { home = H, away = A }

  local st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)
  local events = { home={}, away={} }

  for _,p in ipairs(homeRoster) do _ensure(events.home, p.id).apps = 1 end
  for _,p in ipairs(awayRoster) do _ensure(events.away, p.id).apps = 1 end

  local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
  local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
  local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }

  for _=1, (stats.home.goals or 0) do
    local pz; st, pz = _alloc(st, homeRoster, W_GOAL)
    _ensure(events.home, pz.id).goals = _ensure(events.home, pz.id).goals + 1
    local r; st, r = _rngNext(st)
    if r < 0.70 then
      local a; st, a = _alloc(st, homeRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
      if a and a.id ~= pz.id then _ensure(events.home, a.id).assists = _ensure(events.home, a.id).assists + 1 end
    end
  end

  for _=1, (stats.away.goals or 0) do
    local pz; st, pz = _alloc(st, awayRoster, W_GOAL)
    _ensure(events.away, pz.id).goals = _ensure(events.away, pz.id).goals + 1
    local r; st, r = _rngNext(st)
    if r < 0.70 then
      local a; st, a = _alloc(st, awayRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
      if a and a.id ~= pz.id then _ensure(events.away, a.id).assists = _ensure(events.away, a.id).assists + 1 end
    end
  end

  for _=1, (stats.home.poss_points or 0) do
    local pz; st, pz = _alloc(st, homeRoster, W_POSS)
    _ensure(events.home, pz.id).poss = _ensure(events.home, pz.id).poss + 1
  end
  for _=1, (stats.away.poss_points or 0) do
    local pz; st, pz = _alloc(st, awayRoster, W_POSS)
    _ensure(events.away, pz.id).poss = _ensure(events.away, pz.id).poss + 1
  end

  local function distributeShots(side, roster, on, off)
    for _=1, (on or 0) do
      local pz; st, pz = _alloc(st, roster, {Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0})
      _ensure(side, pz.id).son = _ensure(side, pz.id).son + 1
    end
    for _=1, (off or 0) do
      local pz; st, pz = _alloc(st, roster, {Striker=4, Playmaker=3, Utility=2, Defender=1, Keeper=0})
      _ensure(side, pz.id).sof = _ensure(side, pz.id).sof + 1
    end
  end
  distributeShots(events.home, homeRoster, stats.home.shots_on or 0, stats.home.shots_off or 0)
  distributeShots(events.away, awayRoster, stats.away.shots_on or 0, stats.away.shots_off or 0)

  for _=1, (stats.home.fouls or 0) do
    local pz; st, pz = _alloc(st, homeRoster, W_FOUL)
    _ensure(events.home, pz.id).fouls = _ensure(events.home, pz.id).fouls + 1
  end
  for _=1, (stats.away.fouls or 0) do
    local pz; st, pz = _alloc(st, awayRoster, W_FOUL)
    _ensure(events.away, pz.id).fouls = _ensure(events.away, pz.id).fouls + 1
  end

  return events
end

local function _computePlayerSeasonTotals(season, upToMd)
  local dm = mapDays()
  local inv = invertDayMap(dm)

  local deals = _genTransferDeals(season)
  local base = _buildBaseRosters(season)
  local totals = {}

  local function ensurePlayer(pid, name, pos)
    if not totals[pid] then
      totals[pid] = { id=pid, name=name or pid, pos=pos or "—", team="—",
                      apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0 }
    end
    return totals[pid]
  end

  for md=1, math.min(upToMd, TOTAL_ROUNDS) do
    local day = inv[md]
    if day then
      local rosters = _applyTransfersUpTo(base, deals, md)
      local fixtures = getRoundMatches(md)

      for matchIndex, m in ipairs(fixtures) do
        local h, a = m[1], m[2]
        local hr, ar = rosters[h.code] or {}, rosters[a.code] or {}
        local ev = _matchPlayerEvents(season, md, day, matchIndex, h, a, hr, ar)

        for pid, stt in pairs(ev.home) do
          local name, pos = pid, "—"
          for _,pp in ipairs(hr) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
          local row = ensurePlayer(pid, name, pos)
          row.team    = h.code
          row.apps    = row.apps + (stt.apps or 0)
          row.goals   = row.goals + (stt.goals or 0)
          row.assists = row.assists + (stt.assists or 0)
          row.poss    = row.poss + (stt.poss or 0)
          row.son     = row.son + (stt.son or 0)
          row.sof     = row.sof + (stt.sof or 0)
          row.fouls   = row.fouls + (stt.fouls or 0)
        end

        for pid, stt in pairs(ev.away) do
          local name, pos = pid, "—"
          for _,pp in ipairs(ar) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
          local row = ensurePlayer(pid, name, pos)
          row.team    = a.code
          row.apps    = row.apps + (stt.apps or 0)
          row.goals   = row.goals + (stt.goals or 0)
          row.assists = row.assists + (stt.assists or 0)
          row.poss    = row.poss + (stt.poss or 0)
          row.son     = row.son + (stt.son or 0)
          row.sof     = row.sof + (stt.sof or 0)
          row.fouls   = row.fouls + (stt.fouls or 0)
        end
      end
    end
  end

  return totals, deals, base
end

function p.renderRoster(frame)
  local args = frame.args or {}
  local team = args.team or args[1]
  if not team or not TEAM_BY_CODE[team] then
    return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''"
  end

  local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()

  local deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local rosters = _applyTransfersUpTo(base, deals, upToMd)
  local r = rosters[team] or {}

  local out = {
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    '! # !! Player !! Position !! ID !! Notes'
  }
  for _,pl in ipairs(r) do
    out[#out+1] = '|-'
    out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> || %s',
      pl.no or 0, pl.name or '—', pl.pos or '—', pl.id or '—', pl.notes or '—'
    )
  end
  out[#out+1] = '|}'

  return (TEAM_BY_CODE[team].logo or "") ..
    " '''" .. team .. "''' (Roster as of Matchday " .. tostring(upToMd) .. ")\n" ..
    table.concat(out, "\n")
end

function p.renderAllRosters(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()

  local out = {}
  for _,t in ipairs(teams) do
    local title = (t.logo or "") .. " <b>" .. t.code .. "</b>"
    out[#out+1] =
      '<div class="mw-collapsible mw-collapsed" data-expandtext="Show roster" data-collapsetext="Hide roster" style="margin:0.6em 0;">' ..
        '<div>' .. title .. '</div>' ..
        '<div class="mw-collapsible-content">' ..
          p.renderRoster({ args = { team = t.code, season = season, md = upToMd } }) ..
        '</div>' ..
      '</div>'
  end
  return table.concat(out, "\n")
end

function p.renderTransferLog(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
  local deals = _genTransferDeals(season)

  local out = {
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    '! Matchday !! From !! To !! Swap (pos)'
  }
  for _,d in ipairs(deals) do
    if d.md <= upToMd then
      out[#out+1] = '|-'
      out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> ⇄ <code>%s</code> (%s)',
        d.md, _teamWithLogo(d.A), _teamWithLogo(d.B), d.A_id, d.B_id, d.pos or '—'
      )
    end
  end
  out[#out+1] = '|}'

  return "'''Transfer window (Matchdays " .. TRANSFER_MD_START .. "–" .. TRANSFER_MD_END ..
    ") — season " .. season .. " (through Matchday " .. tostring(upToMd) .. ")'''\n" ..
    table.concat(out, "\n")
end

function p.renderPlayerStats(frame)
  local args = frame.args or {}
  local team = args.team or args[1]
  if not team or not TEAM_BY_CODE[team] then
    return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''"
  end

  local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()

  local totals, deals, base = _computePlayerSeasonTotals(season, upToMd)
  local rostersNow = _applyTransfersUpTo(base, deals, upToMd)
  local rosterNow = rostersNow[team] or {}

  local out = {
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    '! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'
  }

  for _,pl in ipairs(rosterNow) do
    local r = totals[pl.id]
    out[#out+1] = '|-'
    out[#out+1] = string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d',
      pl.name, pl.pos,
      r and r.apps or 0,
      r and r.goals or 0,
      r and r.assists or 0,
      r and r.poss or 0,
      r and r.son or 0,
      r and r.sof or 0,
      r and r.fouls or 0
    )
  end

  out[#out+1] = '|}'

  return (TEAM_BY_CODE[team].logo or "") ..
    " '''" .. team .. "''' — Player totals through Matchday " .. tostring(upToMd) .. "\n" ..
    table.concat(out, "\n")
end

function p.renderLeaders(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()

  local stat = (args.stat or args[2] or "goals")
  local ok = { goals=true, assists=true, poss=true, son=true, sof=true, fouls=true, apps=true }
  if not ok[stat] then
    return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''"
  end

  local totals = (_computePlayerSeasonTotals(season, upToMd))
  local list = {}
  for _,r in pairs(totals) do list[#list+1] = r end

  table.sort(list, function(a,b)
    if (a[stat] or 0) ~= (b[stat] or 0) then return (a[stat] or 0) > (b[stat] or 0) end
    return (a.name or "") < (b.name or "")
  end)

  local out = {
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    '! Rank !! Player !! Team (current) !! Pos !! ' .. stat
  }

  for i=1, math.min(20, #list) do
    local r = list[i]
    out[#out+1] = '|-'
    out[#out+1] = string.format('| %d || %s || %s || %s || %d',
      i, r.name or "—", _teamWithLogo(r.team or "—"), r.pos or "—", r[stat] or 0
    )
  end

  out[#out+1] = '|}'

  return "'''League leaders (" .. stat .. ") — season " .. season ..
    " through Matchday " .. tostring(upToMd) .. "'''\n" ..
    table.concat(out, "\n")
end

------------------------------------------------------------------------
-- 11) MATCH REPORTS + DERBY TAG
------------------------------------------------------------------------
local function _fmtTime(seconds)
  local m = math.floor(seconds / 60)
  local s = seconds % 60
  return string.format("%02d:%02d", m, s)
end

local function _abbr(teamName)
  local letters = {}
  for w in mw.ustring.gmatch(teamName or "", "%S+") do
    letters[#letters+1] = mw.ustring.upper(mw.ustring.sub(w, 1, 1))
  end
  local a = table.concat(letters, "")
  if #a >= 2 then return mw.ustring.sub(a, 1, 2) end
  return (a ~= "" and a) or "XX"
end

local function _randTime2(st, startSec, endSec)
  local r; st, r = _rngNext(st)
  local span = math.max(1, endSec - startSec - 2)
  local sec = startSec + 1 + math.floor(r * span)
  return st, sec
end

local function _genTimes2(st, n, firstStart, firstEnd, secondStart, secondEnd)
  local times = {}
  if n <= 0 then return st, times end
  local split = math.ceil(n / 2)
  for i=1, n do
    if i <= split then
      st, times[i] = _randTime2(st, firstStart, firstEnd)
    else
      st, times[i] = _randTime2(st, secondStart, secondEnd)
    end
  end
  table.sort(times)
  return st, times
end

local function _timesList2(times, maxn)
  if not times or #times == 0 then return "" end
  maxn = maxn or 3
  local out = {}
  for i = 1, math.min(maxn, #times) do out[#out+1] = _fmtTime(times[i]) end
  if #times > maxn then out[#out+1] = "…" end
  return " (" .. table.concat(out, ", ") .. ")"
end

local function _rivalryTag(homeCode, awayCode)
  local dh, da = DIV_OF[homeCode], DIV_OF[awayCode]
  if dh and da and dh == da then
    return " (Divisional derby — " .. dh .. ")"
  end
  return ""
end

local function _appendToBoldTitle(title, suffix)
  if not suffix or suffix == "" then return title end
  if not title then return suffix end
  if mw.ustring.match(title, "'''%s*$") then
    return mw.ustring.gsub(title, "'''%s*$", suffix .. "'''", 1)
  end
  return title .. suffix
end

local function _pickMatchIndexForDay(season, md, pickMode)
  pickMode = pickMode or "best"
  local fixtures = getRoundMatches(md)
  if not fixtures or #fixtures == 0 then return nil, "No fixtures found for this matchday." end

  local inv = invertDayMap(mapDays())
  local day = inv[md] or (START + md)

  local bestI, bestScore = 1, -1e18
  for i, m in ipairs(fixtures) do
    local home, away = m[1], m[2]
    local salt = day * 1000 + i + string.byte(home.code,1) + string.byte(away.code,1)
    local hs, as = simScoreFor(home, away, salt)
    local S = computeMatchStats(home, away, hs, as, salt)
    local H = S[home.code] or {}
    local A = S[away.code] or {}

    local diff  = math.abs(hs - as)
    local total = hs + as
    local chaos = (tonumber(H.fouls) or 0) + (tonumber(A.fouls) or 0)
                + (tonumber(H.shots_on) or 0) + (tonumber(A.shots_on) or 0)
                + (tonumber(H.shots_off) or 0) + (tonumber(A.shots_off) or 0)

    local q
    if pickMode == "close" then
      q = (50 - diff * 10) + total + chaos * 0.25
    elseif pickMode == "high" then
      q = total * 12 - diff * 2 + chaos * 0.20
    elseif pickMode == "chaos" then
      q = chaos * 10 + total * 2 - diff
    else
      q = (20 - diff * 6) + total * 8 + chaos * 0.35
    end

    if q > bestScore then bestScore, bestI = q, i end
  end
  return bestI
end

local function _renderMatchReport(season, md, matchIndex, titleOverride)
  local fixtures = getRoundMatches(md)
  if not fixtures or not fixtures[matchIndex] then
    return "'''No such match (md=" .. tostring(md) .. ", match=" .. tostring(matchIndex) .. ").'''"
  end

  local inv = invertDayMap(mapDays())
  local day = inv[md] or (START + md)

  local home, away = fixtures[matchIndex][1], fixtures[matchIndex][2]
  local derby = _rivalryTag(home.code, away.code)

  local salt = day * 1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
  local hs, as = simScoreFor(home, away, salt)

  local S = computeMatchStats(home, away, hs, as, salt)
  local H = S[home.code] or {}
  local A = S[away.code] or {}

  local deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local rosters = _applyTransfersUpTo(base, deals, md)
  local homeRoster = rosters[home.code] or {}
  local awayRoster = rosters[away.code] or {}

  local st = _seedFromString(tostring(season).."|REPORT|MD"..md.."|M"..matchIndex.."|"..home.code.."|"..away.code)
  local FIRST_START, FIRST_END   = 0, 35*60
  local SECOND_START, SECOND_END = 35*60, 70*60

  local homeTag, awayTag = _abbr(home.code), _abbr(away.code)

  local homePP, awayPP, homeG, awayG
  st, homePP = _genTimes2(st, tonumber(H.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayPP = _genTimes2(st, tonumber(A.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, homeG  = _genTimes2(st, tonumber(H.goals) or 0,       FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayG  = _genTimes2(st, tonumber(A.goals) or 0,       FIRST_START, FIRST_END, SECOND_START, SECOND_END)

  local homeSON, awaySON, homeFOUL, awayFOUL, homeOOB, awayOOB
  st, homeSON  = _genTimes2(st, tonumber(H.shots_on) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awaySON  = _genTimes2(st, tonumber(A.shots_on) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, homeFOUL = _genTimes2(st, tonumber(H.fouls) or 0,    FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayFOUL = _genTimes2(st, tonumber(A.fouls) or 0,    FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, homeOOB  = _genTimes2(st, tonumber(H.oob) or 0,      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayOOB  = _genTimes2(st, tonumber(A.oob) or 0,      FIRST_START, FIRST_END, SECOND_START, SECOND_END)

  -- scoring timeline (+3 goal, +1 possession)
  local scoreEvents = {}
  local function queueScore(sec, which, kind) scoreEvents[#scoreEvents+1] = { t=sec, which=which, kind=kind } end
  for _,sec in ipairs(homePP) do queueScore(sec, "H", "PP") end
  for _,sec in ipairs(awayPP) do queueScore(sec, "A", "PP") end
  for _,sec in ipairs(homeG)  do queueScore(sec, "H", "G")  end
  for _,sec in ipairs(awayG)  do queueScore(sec, "A", "G")  end
  table.sort(scoreEvents, function(a,b) return a.t < b.t end)

  local events = {}
  local function addEvent(sec, text) events[#events+1] = { t=sec, text=text } end

  local hRun, aRun = 0, 0
  local hHalf, aHalf = 0, 0

  for _,e in ipairs(scoreEvents) do
    local add = (e.kind == "G") and 3 or 1
    if e.which == "H" then hRun = hRun + add else aRun = aRun + add end
    if e.t < 35*60 then hHalf, aHalf = hRun, aRun end

    local tag    = (e.which=="H") and homeTag or awayTag
    local roster = (e.which=="H") and homeRoster or awayRoster
    local scoreLine = string.format("%s %d–%d %s", _teamWithLogo(home), hRun, aRun, _teamWithLogo(away))

    if e.kind == "G" then
      local pz; st, pz = _pickPlayer(st, roster, "Striker", "Utility", "Playmaker")
      addEvent(e.t, string.format("%s – Goal (Pillar Strike) (%s): %s converts a direct pillar strike. (%s)",
        _fmtTime(e.t), tag, (pz and pz.name or "a forward"), scoreLine))
    else
      local pp; st, pp = _pickPlayer(st, roster, "Playmaker", "Utility", "Striker")
      addEvent(e.t, string.format("%s – Possession Play (%s): %s completes a 10-second controlled hold in the scoring zone. (%s)",
        _fmtTime(e.t), tag, (pp and pp.name or "a midfielder"), scoreLine))
    end
  end

  local function addHighlights(times, roster, tag, label, posA, posB, posC, template)
    local cap = math.min(2, #times)
    for i=1, cap do
      local sec = times[i]
      local px; st, px = _pickPlayer(st, roster, posA, posB, posC)
      addEvent(sec, string.format("%s – %s (%s): " .. template,
        _fmtTime(sec), label, tag, (px and px.name or "a player")))
    end
  end

  addHighlights(homeFOUL, homeRoster, homeTag, "Foul", "Defender", "Utility", "Playmaker",
    "%s clips a runner during a shielded carry; restart taken quickly.")
  addHighlights(awayFOUL, awayRoster, awayTag, "Foul", "Defender", "Utility", "Playmaker",
    "%s commits a tactical pull in transition; shape preserved on the restart.")
  addHighlights(homeSON, homeRoster, homeTag, "Shot on Target", "Striker", "Utility", "Playmaker",
    "%s forces a controlled save off a pillar-side angle; no strike awarded.")
  addHighlights(awaySON, awayRoster, awayTag, "Shot on Target", "Striker", "Utility", "Playmaker",
    "%s drives a low effort that glances the casing; play continues.")
  addHighlights(homeOOB, homeRoster, homeTag, "Out of Bounds", "Utility", "Defender", "Playmaker",
    "%s overhits a diagonal switch; possession turns over at the boundary.")
  addHighlights(awayOOB, awayRoster, awayTag, "Out of Bounds", "Utility", "Defender", "Playmaker",
    "%s sends a pressured clearance long; reset follows.")

  table.sort(events, function(a,b) return a.t < b.t end)

  local firstHalf, secondHalf = {}, {}
  for _,e in ipairs(events) do
    if e.t < 35*60 then firstHalf[#firstHalf+1] = e.text else secondHalf[#secondHalf+1] = e.text end
  end

  local diff = math.abs(hs - as)
  local mood = (diff <= 1) and "a tight, tactical contest" or "a high-variance clash"
  local summary = string.format(
    "Matchday %d featured %s defined by controlled zone entries and disciplined restarts. %s edged %s %d–%d, with decisive moments coming from timed possession holds and selective pillar pressure rather than sustained long-range attempts.",
    md, mood, _teamWithLogo(home), _teamWithLogo(away), hs, as
  )

  local baseTitle = titleOverride or string.format("'''Match of the Week — %d PSSC (Matchday %d)'''", season, md)
  local title = _appendToBoldTitle(baseTitle, derby)

  local out = {}
  out[#out+1] = title
  out[#out+1] = ""
  out[#out+1] = "=== Match Summary ==="
  out[#out+1] = summary
  out[#out+1] = ""
  out[#out+1] = "=== Key Events by Time ==="
  out[#out+1] = ""
  out[#out+1] = "==== First Half (0:00–35:00) ===="
  if #firstHalf == 0 then out[#out+1] = "—" else for _,ln in ipairs(firstHalf) do out[#out+1] = "* " .. ln end end
  out[#out+1] = ""
  out[#out+1] = string.format("35:00 – Halftime: %s %d–%d %s.", _teamWithLogo(home), hHalf, aHalf, _teamWithLogo(away))
  out[#out+1] = ""
  out[#out+1] = "==== Second Half (35:00–70:00) ===="
  if #secondHalf == 0 then out[#out+1] = "—" else for _,ln in ipairs(secondHalf) do out[#out+1] = "* " .. ln end end
  out[#out+1] = ""
  out[#out+1] = string.format("70:00 – Final Whistle: %s %d–%d %s.", _teamWithLogo(home), hs, as, _teamWithLogo(away))
  out[#out+1] = ""
  out[#out+1] = "=== Match Stats ==="
  out[#out+1] = '{| class="wikitable" style="width:100%; font-size:90%;"'
  out[#out+1] = string.format('! Statistic !! %s !! %s', _teamWithLogo(home), _teamWithLogo(away))
  out[#out+1] = '|-'; out[#out+1] = string.format('| Final Score || %d || %d', hs, as)
  out[#out+1] = '|-'
  out[#out+1] = string.format('| Goals (Pillar Strikes) || %d%s || %d%s',
    tonumber(H.goals) or 0, _timesList2(homeG, 3),
    tonumber(A.goals) or 0, _timesList2(awayG, 3)
  )
  out[#out+1] = '|-'
  out[#out+1] = string.format('| Possession Points || %d%s || %d%s',
    tonumber(H.poss_points) or 0, _timesList2(homePP, 4),
    tonumber(A.poss_points) or 0, _timesList2(awayPP, 4)
  )
  out[#out+1] = '|-'; out[#out+1] = string.format('| Shots on Target || %d || %d', tonumber(H.shots_on) or 0, tonumber(A.shots_on) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Shots off Target || %d || %d', tonumber(H.shots_off) or 0, tonumber(A.shots_off) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Fouls || %d || %d', tonumber(H.fouls) or 0, tonumber(A.fouls) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Free Kicks Awarded || %d || %d', tonumber(H.fks) or 0, tonumber(A.fks) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Out of Bounds || %d || %d', tonumber(H.oob) or 0, tonumber(A.oob) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Throw-Ins || %d || %d', tonumber(H.throws) or 0, tonumber(A.throws) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Goal Kicks || %d || %d', tonumber(H.gk) or 0, tonumber(A.gk) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Ball Possession || %d%% || %d%%', tonumber(H.poss_pct) or 0, tonumber(A.poss_pct) or 0)
  out[#out+1] = '|}'
  return table.concat(out, "\n")
end

function p.renderMatchOfWeek(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
  local pick = args.pick or "best"

  if md < 1 then return "'''No matchday has been played yet.'''"
  end
  local fixtures = getRoundMatches(md)
  if not fixtures or #fixtures == 0 then return "'''No fixtures found for matchday " .. tostring(md) .. ".'''" end

  local idx, err = _pickMatchIndexForDay(season, md, pick)
  if not idx then return "'''" .. (err or "Unable to select match.") .. "'''" end
  return _renderMatchReport(season, md, idx)
end

function p.renderChaosOfWeek(frame)
  local args = frame.args or {}
  args.pick = args.pick or "chaos"
  args.md = nil
  return p.renderMatchOfWeek({ args = args })
end

function p.renderMatchReport(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local md = tonumber(args.md or args.matchday or args[2] or "") or _currentPlayedMatchday()
  local matchIndex = tonumber(args.match or args[3] or "1") or 1
  local titleOverride = string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''", season, md, matchIndex)
  return _renderMatchReport(season, md, matchIndex, titleOverride)
end

------------------------------------------------------------------------
-- 12) WEEKLY AWARDS + MATCHDAY CAPSULE
------------------------------------------------------------------------
function p.renderWeeklyAwards(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
  if md < 1 then return "'''No matchday has been played yet.'''" end

  local inv = invertDayMap(mapDays())
  local day = inv[md] or (START + md)

  local deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local rosters = _applyTransfersUpTo(base, deals, md)

  local function clamp(x, lo, hi)
    if x < lo then return lo end
    if x > hi then return hi end
    return x
  end

  local function scorePlayer(line, pos, oppShotsOn, oppGoals)
    local goals   = line.goals or 0
    local assists = line.assists or 0
    local poss    = line.poss or 0
    local son     = line.son or 0
    local sof     = line.sof or 0
    local fouls   = line.fouls or 0

    local rating = 6
      + goals*1.25
      + assists*0.85
      + poss*0.05
      + son*0.15
      - sof*0.08
      - fouls*0.16

    if pos == "Keeper" then
      local saves = math.max(0, (oppShotsOn or 0) - (oppGoals or 0))
      rating = 6 + saves*0.25 - fouls*0.10
      if (oppGoals or 0) == 0 then rating = rating + 1.0 end
    end

    return clamp(rating, 0, 10)
  end

  local best = { overall=nil, striker=nil, playmaker=nil, defender=nil, keeper=nil }

  local function consider(entry, bucket)
    if not best[bucket] or entry.rating > best[bucket].rating then best[bucket] = entry end
  end

  local fixtures = getRoundMatches(md)
  for matchIndex, m in ipairs(fixtures) do
    local home, away = m[1], m[2]
    local hr = rosters[home.code] or {}
    local ar = rosters[away.code] or {}

    local salt = day*1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
    local hs, as = simScoreFor(home, away, salt)
    local S = computeMatchStats(home, away, hs, as, salt)
    local H = S[home.code] or {}
    local A = S[away.code] or {}

    -- allocate simple per-player contributions deterministically for awards
    local st = _seedFromString(tostring(season).."|AWARDS|MD"..md.."|D"..day.."|M"..matchIndex.."|"..home.code.."|"..away.code)
    local linesH, linesA = {}, {}

    local function ensure(map, pid)
      if not map[pid] then map[pid] = {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0} end
      return map[pid]
    end

    local function pickWeighted(stt, roster, weights)
      local pool = {}
      for _,p in ipairs(roster or {}) do
        local w = weights[p.pos] or 1
        for _=1,w do pool[#pool+1] = p end
      end
      return _pick(stt, pool)
    end

    local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
    local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
    local W_SHOT = { Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
    local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }

    for _=1, tonumber(H.goals) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_GOAL)
      ensure(linesH, pl.id).goals = ensure(linesH, pl.id).goals + 1
      local r; st, r = _rngNext(st)
      if r < 0.70 then
        local a; st, a = pickWeighted(st, hr, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
        if a and a.id ~= pl.id then ensure(linesH, a.id).assists = ensure(linesH, a.id).assists + 1 end
      end
    end
    for _=1, tonumber(A.goals) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_GOAL)
      ensure(linesA, pl.id).goals = ensure(linesA, pl.id).goals + 1
      local r; st, r = _rngNext(st)
      if r < 0.70 then
        local a; st, a = pickWeighted(st, ar, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
        if a and a.id ~= pl.id then ensure(linesA, a.id).assists = ensure(linesA, a.id).assists + 1 end
      end
    end

    for _=1, tonumber(H.poss_points) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_POSS)
      ensure(linesH, pl.id).poss = ensure(linesH, pl.id).poss + 1
    end
    for _=1, tonumber(A.poss_points) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_POSS)
      ensure(linesA, pl.id).poss = ensure(linesA, pl.id).poss + 1
    end

    for _=1, tonumber(H.shots_on) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_SHOT)
      ensure(linesH, pl.id).son = ensure(linesH, pl.id).son + 1
    end
    for _=1, tonumber(H.shots_off) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_SHOT)
      ensure(linesH, pl.id).sof = ensure(linesH, pl.id).sof + 1
    end
    for _=1, tonumber(A.shots_on) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_SHOT)
      ensure(linesA, pl.id).son = ensure(linesA, pl.id).son + 1
    end
    for _=1, tonumber(A.shots_off) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_SHOT)
      ensure(linesA, pl.id).sof = ensure(linesA, pl.id).sof + 1
    end

    for _=1, tonumber(H.fouls) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_FOUL)
      ensure(linesH, pl.id).fouls = ensure(linesH, pl.id).fouls + 1
    end
    for _=1, tonumber(A.fouls) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_FOUL)
      ensure(linesA, pl.id).fouls = ensure(linesA, pl.id).fouls + 1
    end

    local function evalTeam(teamObj, roster, lines, oppStats)
      local oppShotsOn = tonumber(oppStats.shots_on) or 0
      local oppGoals   = tonumber(oppStats.goals) or 0
      for _,pp in ipairs(roster or {}) do
        local line = lines[pp.id] or {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0}
        local rating = scorePlayer(line, pp.pos, oppShotsOn, oppGoals)

        local saves, clean = 0, false
        if pp.pos == "Keeper" then
          saves = math.max(0, oppShotsOn - oppGoals)
          clean = (oppGoals == 0)
        end

        local entry = {
          rating=rating, name=pp.name or pp.id, pos=pp.pos or "—",
          team=teamObj.code, goals=line.goals or 0, assists=line.assists or 0,
          poss=line.poss or 0, son=line.son or 0, sof=line.sof or 0, fouls=line.fouls or 0,
          saves=saves, clean=clean
        }

        consider(entry, "overall")
        if pp.pos == "Striker"   then consider(entry, "striker") end
        if pp.pos == "Playmaker" then consider(entry, "playmaker") end
        if pp.pos == "Defender"  then consider(entry, "defender") end
        if pp.pos == "Keeper"    then consider(entry, "keeper") end
      end
    end

    evalTeam(home, hr, linesH, A)
    evalTeam(away, ar, linesA, H)
  end

  local out = {}
  out[#out+1] = "=== Weekly Awards (Matchday " .. tostring(md) .. ") ==="
  out[#out+1] = '{| class="wikitable sortable" style="width:100%; font-size:90%;"'
  out[#out+1] = "! Award !! Winner !! Team !! Pos !! G !! A !! Poss !! S-on !! S-off !! Fouls !! Saves/CS !! Rating"

  local function row(label, e)
    if not e then return end
    local sc = "—"
    if e.pos == "Keeper" then sc = tostring(e.saves or 0) .. ((e.clean and " / CS") or "") end
    out[#out+1] = "|-"
    out[#out+1] = string.format("| %s || %s || %s || %s || %d || %d || %d || %d || %d || %d || %s || %.2f",
      label, e.name or "—", _teamWithLogo(e.team or "—"), e.pos or "—",
      e.goals or 0, e.assists or 0, e.poss or 0, e.son or 0, e.sof or 0, e.fouls or 0, sc, e.rating or 0
    )
  end

  row("Player of the Week", best.overall)
  row("Striker of the Week", best.striker)
  row("Playmaker of the Week", best.playmaker)
  row("Defender of the Week", best.defender)
  row("Keeper of the Week", best.keeper)

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

function p.renderMatchdayCapsule(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
  local pick = args.pick or "chaos"

  if md < 1 then return "'''No matchday has been played yet.'''"
  end

  local inv = invertDayMap(mapDays())
  local day = inv[md]
  if not day then return "'''Unable to resolve PSSC day for matchday " .. tostring(md) .. ".'''" end

  local results = simulateDayResults(day)

  local out = {}
  out[#out+1] = "== Matchday " .. tostring(md) .. " Capsule =="
  out[#out+1] = ""
  out[#out+1] = p.renderMatchOfWeek({ args = { season = season, md = md, pick = pick } })
  out[#out+1] = ""
  out[#out+1] = "=== Full Results ==="
  for _, r in ipairs(results or {}) do
    out[#out+1] = string.format("* %s '''%d''' %s vs %s '''%d''' %s",
      (r.home and r.home.logo) or "",
      tonumber(r.hg) or 0,
      (r.home and (r.home.fullName or r.home.code)) or "Home",
      (r.away and r.away.logo) or "",
      tonumber(r.ag) or 0,
      (r.away and (r.away.fullName or r.away.code)) or "Away"
    )
  end
  out[#out+1] = ""
  out[#out+1] = p.renderWeeklyAwards({ args = { season = season, md = md } })

  return table.concat(out, "\n")
end

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