Module:PillSeasonSchedule: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
NewZimiaGov (talk | contribs)
No edit summary
Line 10: Line 10:


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 26:
------------------------------------------------------------------------
------------------------------------------------------------------------
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 63:
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 108: Line 107:


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 3) DIVISION-WEIGHTED SCHEDULER
-- 3) SCHEDULE LOCK + BALANCED FUTURE SCHEDULE
--    • In-division: 7 rivals twice (H/A) -> 14 matches
--    • SCHED_LOCKED preserves history (your existing generator)
--    • Cross-division: other 24 once      -> 24 matches
--    • SCHED_BALANCED ensures equal games going forward (38 full rounds)
--    = 38 total per club
--    • mapDays() locks past days and remaps future days to balanced rounds
------------------------------------------------------------------------
------------------------------------------------------------------------
-- ====== Existing schedule generator (history) ======
local function homeFirst(codeA, codeB)
local function homeFirst(codeA, codeB)
   local sA, sB = 0, 0
   local sA, sB = 0, 0
Line 120: Line 121:
end
end


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


Line 127: Line 128:
       for j=i+1,#list do
       for j=i+1,#list do
         local A, B = TEAM_BY_CODE[list[i]], TEAM_BY_CODE[list[j]]
         local A, B = TEAM_BY_CODE[list[i]], TEAM_BY_CODE[list[j]]
         table.insert(intra, {home=A, away=B})
         intra[#intra+1] = {home=A, away=B}
         table.insert(intra, {home=B, away=A})
         intra[#intra+1] = {home=B, away=A}
       end
       end
     end
     end
Line 134: Line 135:


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


Line 143: Line 144:
         local a, b = TEAM_BY_CODE[ca], TEAM_BY_CODE[cb]
         local a, b = TEAM_BY_CODE[ca], TEAM_BY_CODE[cb]
         local hFirst = homeFirst(ca, cb)
         local hFirst = homeFirst(ca, cb)
         table.insert(inter, {home = hFirst and a or b, away = hFirst and b or a})
         inter[#inter+1] = {home = hFirst and a or b, away = hFirst and b or a}
       end
       end
     end
     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
   return intra, inter
end
end


local function packRounds(matches)
local function packRounds_greedy(matches)
   local rounds, used = {}, {}
   local rounds, used = {}, {}
   local function canPlace(r, m)
   local function canPlace(r, m)
Line 184: Line 171:
     if not placed then
     if not placed then
       rounds[#rounds+1] = { { m.home, m.away } }
       rounds[#rounds+1] = { { m.home, m.away } }
       used[#rounds] = {}
       used[#rounds] = { [m.home.code]=true, [m.away.code]=true }
      used[#rounds][m.home.code] = true
      used[#rounds][m.away.code] = true
     end
     end
   end
   end
Line 192: Line 177:
end
end


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


   local function chunk(arr, size)
   local function chunk(arr, size)
Line 220: Line 205:
   end
   end


   return packRounds(merged)
   return packRounds_greedy(merged)
end)()
end)()


local function generateSchedule() return SCHED end
-- ====== Balanced schedule (38 full rounds, no byes) ======
local DIV_ORDER = {
  "Morovian Division",
  "Southern Strait Division",
  "Western Highlands Division",
  "Normarkian Division"
}


local function mapDays()
local function _copy(arr)
   local totalDays  = END - START + 1
   local t = {}
   local totalRnds  = #SCHED
  for i=1,#arr do t[i] = arr[i] end
   local iv        = math.floor(totalDays / totalRnds)
  return t
   local ex        = totalDays % totalRnds
end
   local m, d      = {}, START
 
  for i=1,totalRnds do
local function roundRobin8(teamCodes)
    m[d] = i
   local n = #teamCodes
    d = d + iv + (ex > 0 and 1 or 0)
  if n ~= 8 then error("roundRobin8 expects exactly 8 teams") end
     if ex > 0 then ex = ex - 1 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
   end
   return m
   return rounds
end
end


------------------------------------------------------------------------
local function swapHA(rounds)
-- 4) MATCH SIMULATION (deterministic by day & pairing)
   local out = {}
------------------------------------------------------------------------
   for r=1,#rounds do
local function simScoreFor(h, a, seed)
     out[r] = {}
  math.randomseed(seed)
     for i=1,#rounds[r] do
   local p = math.random()
      local m = rounds[r][i]
   local hg, ag
      out[r][i] = { m[2], m[1] }
  if p < 0.1 then
     end
     hg = math.random(0, 12); ag = hg
  else
     local wh, wa = h.championships + 1, a.championships + 1
    local rem    = (p - 0.1) / 0.9
    local homeWin= rem < (wh / (wh + wa))
    local winG  = math.random(1, 12)
    local loseG  = math.random(0, math.min(winG - 1, 12))
     if homeWin then hg, ag = winG, loseG else hg, ag = loseG, winG end
   end
   end
   return hg, ag
   return out
end
end


local function simulateDayResults(day)
local function interPairRounds(divA_codes, divB_codes, blockIndex)
   local dm = mapDays()
   local rounds = {}
   local idx = dm[day]
   for t=0,7 do
  if not idx then return {} end
    local matches = {}
  local out = {}
    for i=1,8 do
  for k, m in ipairs(SCHED[idx]) do
      local a = TEAM_BY_CODE[divA_codes[i]]
    local h, a = m[1], m[2]
      local b = TEAM_BY_CODE[divB_codes[((i+t-1) % 8) + 1]]
    local salt = day*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
      local home, away
    local hg, ag = simScoreFor(h, a, salt)
      if ((blockIndex + t) % 2 == 0) then home, away = a, b else home, away = b, a end
    out[#out+1] = { home=h, away=a, hg=hg, ag=ag }
      matches[#matches+1] = { home, away }
    end
    rounds[#rounds+1] = matches
   end
   end
   return out
   return rounds
end
end


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


     if not dm[target] then
  -- Intra: 14 rounds (4 matches per division per round = 16 total)
        for d = curDay + 1, END do
  local intraByDiv = {}
            if dm[d] then target = d; upcoming = true; break end
  for _, divName in ipairs(DIV_ORDER) do
        end
     local rr7 = roundRobin8(DIVISIONS[divName])
    end
    local rr14 = {}
    if not dm[target] then
    for i=1,7 do rr14[i] = rr7[i] end
        return "'''No more matches this season.'''"
     local rr7s = swapHA(rr7)
    end
     for i=1,7 do rr14[7+i] = rr7s[i] end
 
     intraByDiv[divName] = rr14
     local prefix = upcoming and "'''Next matchday:''' " or ""
  end
     local mon, dom = getMonthDay(target)
     local label    = dom .. " " .. mon .. " " .. year .. " PSSC"
    local rows    = {}


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


    return string.format(
  -- Inter: 24 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()
  if #rounds ~= 38 then error("Balanced schedule should have 38 rounds, got "..tostring(#rounds)) end
    for d = START, last do
  for i=1,#rounds do
        local idx = dm[d]
    if #rounds[i] ~= 16 then error("Balanced round "..i.." has "..tostring(#rounds[i]).." matches") end
        if idx then
  end
            for k, m in ipairs(SCHED[idx]) do
  return rounds
                local h, a = m[1], m[2]
end)()
                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]
local TOTAL_ROUNDS = 38
                H.PF, H.PA = H.PF + hg, H.PA + ag
                A.PF, A.PA = A.PF + ag, A.PA + hg


                if hg > ag then
-- Day map builder for a schedule length
                    H.W, H.Pts = H.W + 1, H.Pts + 3
local function mapDaysFor(schedule)
                    A.L = A.L + 1
  local totalDays  = END - START + 1
                elseif hg < ag then
  local totalRnds  = #schedule
                    A.W, A.Pts = A.W + 1, A.Pts + 3
  local iv        = math.floor(totalDays / totalRnds)
                    H.L = H.L + 1
  local ex        = totalDays % totalRnds
                else
  local m, d      = {}, START
                    H.D, H.Pts = H.D + 1, H.Pts + 1
  for i=1,totalRnds do
                    A.D, A.Pts = A.D + 1, A.Pts + 1
    m[d] = i
                end
    d = d + iv + (ex > 0 and 1 or 0)
            end
    if ex > 0 then ex = ex - 1 end
        end
  end
    end
  return m
end
 
local DM_LOCKED = mapDaysFor(SCHED_LOCKED)


    local buckets = {}
-- Find last played matchday under locked mapping (history boundary)
    for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
local function lastPlayedLocked()
    for code, r in pairs(recs) do
  local curr = date.getCurrentDate()
        r.PD = r.PF - r.PA
  local today = tonumber(curr:match("^(%d+),")) or 0
        local div = DIV_OF[code] or "—"
  local lastDay, lastMd = nil, 0
        buckets[div] = buckets[div] or {}
  for d=START, math.min(today, END) do
        table.insert(buckets[div], r)
    local md = DM_LOCKED[d]
    if md and md > lastMd then
      lastMd = md
      lastDay = d
     end
     end
  end
  return lastDay, lastMd
end


    local function cmp(a,b)
-- Composite mapDays: locked in the past, balanced in the future
        if a.Pts ~= b.Pts then return a.Pts > b.Pts end
local function mapDays()
        if a.PD  ~= b.PD  then return a.PD  > b.PD  end
   local pivotDay, pivotMd = lastPlayedLocked()
        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 = {
  if not pivotDay or pivotMd == 0 then
        "Morovian Division","Southern Strait Division",
     return mapDaysFor(SCHED_BALANCED)
        "Western Highlands Division","Normarkian Division"
  end
    }
    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 dm = {}
            local style = ""
  for d,md in pairs(DM_LOCKED) do
            if     i <= 2 then style = ' style="background-color:#ccffcc;"'
    if d <= pivotDay then dm[d] = md end
            elseif i <= 4 then style = ' style="background-color:#cce5ff;"'
  end
            elseif i <= 6 then style = ' style="background-color:#F1EB9C;"'
            else              style = ' style="background-color:#ffcccc;"'
            end


            out[#out+1] = "|-" .. style
  local futureStartDay = pivotDay + 1
            out[#out+1] =
  local remainingDays = math.max(0, END - futureStartDay + 1)
                "| " .. i
  local remainingRnds = math.max(0, TOTAL_ROUNDS - pivotMd)
                .. " || " .. 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


------------------------------------------------------------------------
   if remainingDays == 0 or remainingRnds == 0 then
-- 7) PREVIOUS RESULTS (last 5 matchdays)
     return dm
------------------------------------------------------------------------
   end
function p.renderPreviousResults(frame)
 
    local curr   = date.getCurrentDate()
  local iv = math.floor(remainingDays / remainingRnds)
    local today  = tonumber(curr:match("^(%d+),")) or 0
  local ex = remainingDays % remainingRnds
     local year   = tonumber(curr:match(", (%d+) PSSC")) or 0
    local dm    = mapDays()
    local played = {}


    for d = today - 1, START, -1 do
  local day = futureStartDay
        if dm[d] then played[#played+1] = d end
  for i=1,remainingRnds do
        if #played == 5 then break end
    local mdx = pivotMd + i
    end
    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


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


------------------------------------------------------------------------
local function invertDayMap(dm)
-- 8) PLAYOFFS (12 teams seeded from divisions; unchanged seeding block)
  local inv = {}
------------------------------------------------------------------------
  for day, md in pairs(dm) do inv[md] = day end
local PO_YEAR  = 52
   return inv
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
end


local function computeRecordsUpTo(dayEnd)
local function _currentPlayedMatchday()
   local recs = {}
   local curr = date.getCurrentDate()
   for _, t in ipairs(teams) do
   local today = tonumber(curr: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 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
   end
   local dm = mapDays()
   return lastMd
   for d = START, math.min(dayEnd, END) do
end
    local idx = dm[d]
 
    if idx then
local function schedForRound(roundIdx)
      for k, m in ipairs(SCHED[idx]) do
   local _, pivotMd = lastPlayedLocked()
        local h, a = m[1], m[2]
  if roundIdx <= pivotMd then return SCHED_LOCKED end
        local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
  return SCHED_BALANCED
        local hg, ag = simScoreFor(h, a, salt)
end


        local H, A = recs[h.code], recs[a.code]
local function getRoundMatches(roundIdx)
        H.PF, H.PA = H.PF + hg, H.PA + ag
  local sched = schedForRound(roundIdx)
        A.PF, A.PA = A.PF + ag, A.PA + hg
  return sched[roundIdx] or {}
end


        if hg > ag then
------------------------------------------------------------------------
          H.W, H.Pts = H.W + 1, H.Pts + 3
-- 4) MATCH SIMULATION (deterministic by day & pairing)
          A.L = A.L + 1
------------------------------------------------------------------------
        elseif hg < ag then
local function simScoreFor(h, a, seed)
          A.W, A.Pts = A.W + 1, A.Pts + 3
  math.randomseed(seed)
          H.L = H.L + 1
  local p = math.random()
        else
  local hg, ag
          H.D, H.Pts = H.D + 1, H.Pts + 1
  if p < 0.1 then
          A.D, A.Pts = A.D + 1, A.Pts + 1
    hg = math.random(0, 12); ag = hg
        end
  else
      end
    local wh, wa = (h.championships or 0) + 1, (a.championships or 0) + 1
     end
    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
   for _, r in pairs(recs) do r.PD = r.PF - r.PA end
   return hg, ag
  return recs
end
end


local function getSeeds12()
local function simulateDayResults(day)
   local curr   = date.getCurrentDate()
   local dm = mapDays()
   local today  = tonumber(curr:match("^(%d+),")) or END
   local idx = dm[day]
   local recs   = computeRecordsUpTo(today)
  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


   local buckets = {}
   if not dm[target] then
  for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
    for d = curDay + 1, END do
   for _, r in pairs(recs) do
      if dm[d] then target = d; upcoming = true; break end
    buckets[r.Div] = buckets[r.Div] or {}
    end
     table.insert(buckets[r.Div], r)
   end
  if not dm[target] then
     return "'''No more matches this season.'''"
   end
   end
  for div, list in pairs(buckets) do table.sort(list, tb_cmp) end


   local winners, runners = {}, {}
   local prefix = upcoming and "'''Next matchday:''' " or ""
   for _, div in ipairs({
   local mon, dom = getMonthDay(target)
    "Morovian Division","Southern Strait Division",
  local label    = dom .. " " .. mon .. " " .. year .. " PSSC"
    "Western Highlands Division","Normarkian Division"
   local rows     = {}
   }) 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 = {}
   local idx = dm[target]
  for _,r in ipairs(winners) do chosen[r.team.code] = true end
   local matches = getRoundMatches(idx)
   for _,r in ipairs(runners) do chosen[r.team.code] = true end


   local allList = {}
   if not upcoming then
  for _, r in pairs(recs) do table.insert(allList, r) end
    for k, m in ipairs(matches) do
  table.sort(allList, tb_cmp)
      local h, a = m[1], m[2]
 
      local salt = target*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
  local wild = {}
      local hg, ag = simScoreFor(h, a, salt)
   for _,r in ipairs(allList) do
      local hc = (hg > ag and "green") or (hg < ag and "red") or "yellow"
    if not chosen[r.team.code] then
      local ac = (ag > hg and "green") or (ag < hg and "red") or "yellow"
       table.insert(wild, r)
      local hs = string.format("<span style='color:%s;'>'''%d'''</span>", hc, hg)
      if #wild == 4 then break end
      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


   local seeds = {}
   return string.format("|-\n| %s%d || %s || %s", prefix, target, label, table.concat(rows, "<br>"))
  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
end


local function playScore(A,B,salt)
function p.renderSchedule(frame)
  math.randomseed(salt)
   local curr = date.getCurrentDate()
   local p = math.random()
   local day  = tonumber(curr:match('^(%d+),')) or 0
   if p < 0.10 then local g=math.random(0,12); return g,g end
   local year = tonumber(curr:match(', (%d+) PSSC')) or 0
   local wa,wb = A.championships+1,B.championships+1
   return table.concat({
   local home  = (p-0.10)/0.90 < wa/(wa+wb)
    '{| class="wikitable sortable"',
  local hi    = math.random(1,12)
    '! Day !! Date !! Matches',
  local lo    = math.random(0, math.min(hi-1,12))
    renderRound(day, year),
   return home and hi or lo, home and lo or hi
    '|}'
   }, "\n")
end
end


local function mkRow(tag, absDate, H, A, salt, absNow)
------------------------------------------------------------------------
   local hs, win = '—','—'
-- 6) DIVISION STANDINGS
   if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then
------------------------------------------------------------------------
    local g1,g2 = playScore(H,A,salt)
function p.renderStandings(frame)
    hs = string.format("'''%d-%d'''", g1, g2)
   local curr   = date.getCurrentDate()
    win = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
  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
   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 dm = mapDays()
   local seeds = getSeeds12()
   for d = START, last do
   local out = {
     local idx = dm[d]
     '{| class="wikitable sortable"',
     if idx then
     '! Round !! Date !! Match-up'
      local matches = getRoundMatches(idx)
  }
      for k, m in ipairs(matches) do
  local function dateCell(day)
        local h, a = m[1], m[2]
    local mon,dom = getMonthDay(day)
        local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
    return dom .. ' ' .. mon .. ' ' .. PO_YEAR .. '&nbsp;PSSC'
        local hg, ag = simScoreFor(h, a, salt)
  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


  local p1, p2 = seeds[9], seeds[12]
        local H, A = recs[h.code], recs[a.code]
  local p3, p4 = seeds[10], seeds[11]
        H.PF, H.PA = H.PF + hg, H.PA + ag
  add('Play-in-1', PO_FIRST, p1, p2)
        A.PF, A.PA = A.PF + ag, A.PA + hg
  add('Play-in-2', PO_FIRST, p3, p4)


  local banners = {
        if hg > ag then
    { 'Round-of-8', 1 },
          H.W, H.Pts = H.W + 1, H.Pts + 3
    { 'Round-of-4', 2 },
          A.L = A.L + 1
    { 'Semi-finals (Seeds 1–2 BYE)', 3 },
        elseif hg < ag then
     { 'Final', 4 }
          A.W, A.Pts = A.W + 1, A.Pts + 3
   }
          H.L = H.L + 1
   for _,b in ipairs(banners) do
        else
     local d = PO_FIRST + PO_IV * b[2]
          H.D, H.Pts = H.D + 1, H.Pts + 1
     out[#out+1] = '|-'
          A.D, A.Pts = A.D + 1, A.Pts + 1
     out[#out+1] = '| colspan="3" | ' .. "'''" .. b[1] .. ":'''" ..
        end
                  ' ' .. dateCell(d)
      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
   end
  out[#out+1] = '|}'
  return table.concat(out, '\n')
end


function p.renderPlayoffResults(frame)
  local function cmp(a,b)
   local seeds = getSeeds12()
    if a.Pts ~= b.Pts then return a.Pts > b.Pts end
   local now   = date.getCurrentDate()
    if a.PD  ~= b.PD  then return a.PD  > b.PD  end
   local dNow  = tonumber(now:match('^(%d+),')) or 0
    if a.W   ~= b.W  then return a.W   > b.W   end
   local yNow  = tonumber(now:match(', (%d+) PSSC')) or 0
    return a.team.fullName < b.team.fullName
  local absNow= absDay(yNow,dNow)
   end
   for div, list in pairs(buckets) do table.sort(list, cmp) end


   local rows = {
   local order = {
     '{| class="wikitable sortable"',
     "Morovian Division","Southern Strait Division",
     '! Round !! Home !! Score !! Away !! Winner'
     "Western Highlands Division","Normarkian Division"
   }
   }


   local absPI = absDay(PO_YEAR, PO_FIRST)
   local out = {}
  local PI = {
  for _, div in ipairs(order) do
     { seeds[9],  seeds[12] },
    local list = buckets[div] or {}
     { seeds[10], seeds[11] }
     out[#out+1] = "== " .. div .. " Standings =="
  }
    out[#out+1] = '{| class="wikitable sortable"'
  local W_PI = {}
     out[#out+1] = "! Pos !! Team !! W !! D !! L !! Pts !! PF !! PA !! PD"
  for i,pair in ipairs(PI) do
 
     local H,A = pair[1], pair[2]
    for i, rec in ipairs(list) do
    if absNow >= absPI then
      local style = ""
       local g1,g2 = playScore(H,A,absPI*100+i)
      if     i <= 2 then style = ' style="background-color:#ccffcc;"'
       W_PI[i]     = (g1>g2) and H or A
      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
     end
     rows[#rows+1] = mkRow('PI-'..i, absPI, H, A, absPI*100+i, absNow)
     out[#out+1] = "|}"
    out[#out+1] = ""
   end
   end


   local absR8 = absPI + PO_IV
  return table.concat(out, "\n")
   local pool = { seeds[3],seeds[4],seeds[5],seeds[6],seeds[7],seeds[8],
end
                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]} }
------------------------------------------------------------------------
-- 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 = {}


   local W_R8 = {}
   for d = today - 1, START, -1 do
  for i,pair in ipairs(R8) do
     if dm[d] then played[#played+1] = d end
     local H,A = pair[1], pair[2]
     if #played == 5 then break end
     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
   end


   local absR4 = absR8 + PO_IV
   local out = {
  local R4 = {}
     '{| class="wikitable sortable"',
  if #W_R8 == 4 then
     '! Date !! Home !! Score !! Away'
     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 = {}
   for _, d in ipairs(played) do
  for i=1,2 do
    local mon, dom  = getMonthDay(d)
     local H = R4[i] and R4[i][1] or {logo='—',fullName='TBD'}
    local dateLabel = dom .. " " .. mon .. " " .. year .. " PSSC"
    local A = R4[i] and R4[i][2] or {logo='—',fullName='TBD'}
     local idx = dm[d]
    if absNow >= absR4 and R4[i] then
    local matches = getRoundMatches(idx)
       local g1,g2 = playScore(H,A,absR4*100+i)
    for k, m in ipairs(matches) do
       W_R4[i]     = (g1>g2) and H or A
      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
    rows[#rows+1] = mkRow('R-4-'..i, absR4, H, A, absR4*100+i, absNow)
   end
   end


   local absSF = absR4 + PO_IV
   out[#out+1] = "|}"
  local SF = {}
   return table.concat(out, "\n")
   if #W_R4 == 2 then
end
    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
-- 8) PLAYOFFS (12 teams seeded from divisions)
    local H,A = SF[i][1], SF[i][2]
------------------------------------------------------------------------
    if absNow >= absSF and A.fullName~='TBD' then
local PO_YEAR  = 52
      local g1,g2 = playScore(H,A,absSF*100+i)
local PO_FIRST = md2day(1,4)
      W_SF[i]    = (g1>g2) and H or A
local PO_IV    = 3
    end
    rows[#rows+1] = mkRow('SF-'..i, absSF, H, A, absSF*100+i, absNow)
  end


  local absF = absSF + PO_IV
local function tb_cmp(a,b)
   local FH = W_SF[1] or {logo='—',fullName='TBD'}
   if a.Pts ~= b.Pts then return a.Pts > b.Pts end
   local FA = W_SF[2] or {logo='—',fullName='TBD'}
   if a.PD  ~= b.PD  then return a.PD  > b.PD  end
   rows[#rows+1] = mkRow('Final', absF, FH, FA, absF*100+1, absNow)
   if a.W  ~= b.W  then return a.W  > b.W   end
 
   return a.team.fullName < b.team.fullName
   rows[#rows+1] = '|}'
   return table.concat(rows, '\n')
end
end


------------------------------------------------------------------------
local function computeRecordsUpTo(dayEnd)
-- 9)  PER-MATCH STATS (compute only on match day)
   local recs = {}
--    Guarantees: Final = 3*Goals + PossessionPts
  for _, t in ipairs(teams) do
--    Adds column-wise highlight of higher value (light green)
     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 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
   end


   local h_goals, h_poss = splitScore(hg, seed + 101)
   local dm = mapDays()
  local a_goals, a_poss = splitScore(ag, seed + 102)
  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 function rrange(s, lo, hi) math.randomseed(s); return math.random(lo, hi) end
        local H, A = recs[h.code], recs[a.code]
  local h_sh_on = (h_goals > 0) and rrange(seed+201, h_goals, h_goals+2) or rrange(seed+201, 0, 2)
        H.PF, H.PA = H.PF + hg, H.PA + ag
  local a_sh_on = (a_goals > 0) and rrange(seed+202, a_goals, a_goals+2) or rrange(seed+202, 0, 2)
        A.PF, A.PA = A.PF + ag, A.PA + hg


  local h_sh_off = rrange(seed+203, 0, 3)
        if hg > ag then
  local a_sh_off = rrange(seed+204, 0, 3)
          H.W, H.Pts = H.W + 1, H.Pts + 3
 
          A.L = A.L + 1
  local h_fouls = rrange(seed+205, 0, 3)
        elseif hg < ag then
  local a_fouls = rrange(seed+206, 0, 3)
          A.W, A.Pts = A.W + 1, A.Pts + 3
  local h_fks  = a_fouls
          H.L = H.L + 1
   local a_fks  = h_fouls
        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 h_throws = rrange(seed+207, 0, 3)
   for _, r in pairs(recs) do r.PD = r.PF - r.PA end
  local a_throws = rrange(seed+208, 0, 3)
   return recs
  local h_oob    = rrange(seed+209, 0, 2)
end
   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 function getSeeds12()
   local swing = rrange(seed+213, 3, 6)
   local curr  = date.getCurrentDate()
   local h_poss_pct, a_poss_pct
   local today  = tonumber(curr:match("^(%d+),")) or END
   if hg > ag then
   local recs  = computeRecordsUpTo(today)
    h_poss_pct, a_poss_pct = base + swing, base - swing
 
   elseif hg < ag then
   local buckets = {}
     h_poss_pct, a_poss_pct = base - swing, base + swing
  for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
  else
   for _, r in pairs(recs) do
     local tilt = rrange(seed+214, -3, 3)
     buckets[r.Div] = buckets[r.Div] or {}
    h_poss_pct, a_poss_pct = base + tilt, base - tilt
     table.insert(buckets[r.Div], r)
   end
   end
  for div, list in pairs(buckets) do table.sort(list, tb_cmp) end


   return {
   local winners, runners = {}, {}
     [h.code] = {
  for _, div in ipairs(DIV_ORDER) do
      final = hg, goals = h_goals, poss_points = h_poss,
     local list = buckets[div] or {}
      shots_on = h_sh_on, shots_off = h_sh_off,
    if list[1] then table.insert(winners, list[1]) end
      fouls = h_fouls, fks = h_fks, throws = h_throws,
    if list[2] then table.insert(runners, list[2]) end
      gk = h_gk, oob = h_oob, poss_pct = h_poss_pct
  end
    },
  table.sort(winners, tb_cmp)
    [a.code] = {
  table.sort(runners, tb_cmp)
      final = ag, goals = a_goals, poss_points = a_poss,
 
      shots_on = a_sh_on, shots_off = a_sh_off,
  local chosen = {}
      fouls = a_fouls, fks = a_fks, throws = a_throws,
  for _,r in ipairs(winners) do chosen[r.team.code] = true end
      gk = a_gk, oob = a_oob, poss_pct = a_poss_pct
  for _,r in ipairs(runners) do chosen[r.team.code] = true end
    }
 
   }
  local allList = {}
end
  for _, r in pairs(recs) do table.insert(allList, r) end
   table.sort(allList, tb_cmp)


-- highlight helper: returns HTML-wrapped cells for the higher value
  local wild = {}
local function hiPair(hVal, aVal)
   for _,r in ipairs(allList) do
   local function cell(v, isHigh)
     if not chosen[r.team.code] then
     if isHigh then
       table.insert(wild, r)
       return string.format('<span style="background-color:#ccffcc;">%s</span>', v)
       if #wild == 4 then break end
    else
       return tostring(v)
     end
     end
   end
   end
  local hHigh = (hVal > aVal)
  local aHigh = (aVal > hVal)
  return cell(hVal, hHigh), cell(aVal, aHigh)
end


function p.renderMatchStatsForDay(frame)
   local seeds = {}
   local curr   = date.getCurrentDate()
   for i=1,#winners do seeds[#seeds+1] = winners[i].team end
   local dayNow = tonumber(curr:match('^(%d+),')) or 0
   for i=1,#runners do seeds[#seeds+1] = runners[i].team end
   local year  = tonumber(curr:match(', (%d+) PSSC')) or 0
   for i=1,#wild    do seeds[#seeds+1] = wild[i].team    end
   local dm    = mapDays()
   return seeds
end


   local target = dm[dayNow] and dayNow or nil
local function playScore(A,B,salt)
   if not target then
  math.randomseed(salt)
    for d = dayNow + 1, END do
   local p = math.random()
      if dm[d] then target = d; break end
   if p < 0.10 then local g=math.random(0,12); return g,g end
    end
  local wa,wb = (A.championships or 0)+1,(B.championships or 0)+1
   end
  local home  = (p-0.10)/0.90 < wa/(wa+wb)
   if not target then return "'''No scheduled matches in window.'''" end
  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 idx    = dm[target]
local function mkRow(tag, absDate, H, A, salt, absNow)
  local isToday = (target == dayNow)
  local hs, win = '—','—'
 
  if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then
  local mon, dom = getMonthDay(target)
     local g1,g2 = playScore(H,A,salt)
  local label    = dom .. " " .. mon .. " " .. year .. " PSSC"
     hs  = string.format("'''%d-%d'''", g1, g2)
 
     win = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
  -- highlight helper: numeric compare, optional suffix for display
  local function hiPairNum(hVal, aVal, suffix)
    hVal = tonumber(hVal) or 0
    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
 
     local hHigh = (hVal > aVal)
    local aHigh = (aVal > hVal)
    return cell(hVal, hHigh), cell(aVal, aHigh)
  end
 
  -- highlight helper for already-formatted strings (no suffix, no tonumber)
  local function hiPairStr(hVal, aVal)
     local function cell(v, isHigh)
      if isHigh then
        return string.format('<span style="background-color:#ccffcc;">%s</span>', tostring(v))
      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
  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 = {
   local out = {
     '{| class="wikitable sortable"',
     '{| class="wikitable sortable"',
     '|+ Match Statistics — ' .. label .. (isToday and '' or ' (fixtures; stats available day-of)'),
     '! Round !! Date !! Match-up'
    '! Match !! Team !! Final !! Goals (Pillar) !! Possession Pts !! Shots on !! Shots off !! Fouls !! Free Kicks !! Throw-Ins !! Goal Kicks !! OOB !! Poss %'
   }
   }
  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


   if isToday then
   local p1, p2 = seeds[9], seeds[12]
    local results = simulateDayResults(target)
  local p3, p4 = seeds[10], seeds[11]
    for k, r in ipairs(results) do
  add('Play-in-1', PO_FIRST, p1, p2)
      local seed = target*1000 + k + string.byte(r.home.code,1) + string.byte(r.away.code,1)
  add('Play-in-2', PO_FIRST, p3, p4)
      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 banners = {
 
    { 'Round-of-8', 1 },
      local hF,   aF  = hiPairNum(H.final,       A.final)
    { 'Round-of-4', 2 },
      local hGaG   = hiPairNum(H.goals,      A.goals)
    { 'Semi-finals (Seeds 1–2 BYE)', 3 },
      local hP,  aP  = hiPairNum(H.poss_points, A.poss_points)
    { 'Final', 4 }
      local hSON, aSON = hiPairNum(H.shots_on,    A.shots_on)
   }
      local hSOF, aSOF = hiPairNum(H.shots_off,  A.shots_off)
   for _,b in ipairs(banners) do
      local hFO,  aFO  = hiPairNum(H.fouls,      A.fouls)
    local d = PO_FIRST + PO_IV * b[2]
      local hFK,  aFK  = hiPairNum(H.fks,        A.fks)
    out[#out+1] = '|-'
      local hTH,  aTH  = hiPairNum(H.throws,      A.throws)
    out[#out+1] = '| colspan="3" | ' .. "'''" .. b[1] .. ":'''" .. ' ' .. dateCell(d)
      local hGK,  aGK  = hiPairNum(H.gk,          A.gk)
  end
      local hOOB, aOOB = hiPairNum(H.oob,        A.oob)
  out[#out+1] = '|}'
      local hPOS, aPOS = hiPairNum(H.poss_pct,    A.poss_pct, '%')
  return table.concat(out, '\n')
end


      -- HOME ROW (Match cell rowspan=2)
function p.renderPlayoffResults(frame)
      out[#out+1] = '|-'
  local seeds = getSeeds12()
      out[#out+1] =
  local now  = date.getCurrentDate()
        '| rowspan="2" | ' .. matchLabel ..
  local dNow  = tonumber(now:match('^(%d+),')) or 0
        ' || ' .. r.home.logo .. ' ' .. r.home.fullName ..
  local yNow  = tonumber(now:match(', (%d+) PSSC')) or 0
        ' || ' .. hF ..
  local absNow= absDay(yNow,dNow)
        ' || ' .. hG ..
 
        ' || ' .. hP ..
  local rows = {
        ' || ' .. hSON ..
    '{| class="wikitable sortable"',
        ' || ' .. hSOF ..
    '! Round !! Home !! Score !! Away !! Winner'
        ' || ' .. hFO ..
  }
        ' || ' .. hFK ..
 
        ' || ' .. hTH ..
  local absPI = absDay(PO_YEAR, PO_FIRST)
        ' || ' .. hGK ..
  local PI = { { seeds[9],  seeds[12] }, { seeds[10], seeds[11] } }
        ' || ' .. hOOB ..
  local W_PI = {}
        ' || ' .. hPOS


      -- AWAY ROW (no Match cell; it’s covered by rowspan)
  for i,pair in ipairs(PI) do
      out[#out+1] = '|-'
    local H,A = pair[1], pair[2]
       out[#out+1] =
    if absNow >= absPI then
        '| ' .. r.away.logo .. ' ' .. r.away.fullName ..
       local g1,g2 = playScore(H,A,absPI*100+i)
        ' || ' .. aF ..
      W_PI[i]     = (g1>g2) and H or A
        ' || ' .. aG ..
        ' || ' .. aP ..
        ' || ' .. aSON ..
        ' || ' .. aSOF ..
        ' || ' .. aFO ..
        ' || ' .. aFK ..
        ' || ' .. aTH ..
        ' || ' .. aGK ..
        ' || ' .. aOOB ..
        ' || ' .. aPOS
     end
     end
  else
     rows[#rows+1] = mkRow('PI-'..i, absPI, H, A, absPI*100+i, absNow)
     -- Fixture-only view (no stats yet)
  end
    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 absR8 = absPI + PO_IV
      out[#out+1] =
  local pool = { seeds[3],seeds[4],seeds[5],seeds[6],seeds[7],seeds[8],
        '| rowspan="2" | ' .. matchLabel ..
                W_PI[1] or {logo='—',fullName='TBD'}, W_PI[2] or {logo='—',fullName='TBD'} }
        ' || ' .. m[1].logo .. ' ' .. m[1].fullName ..
  local R8 = { {pool[3], pool[6]}, {pool[4], pool[5]}, {pool[1], pool[8]}, {pool[2], pool[7]} }
        ' || — || — || — || — || — || — || — || — || — || — || —'


      out[#out+1] = '|-'
  local W_R8 = {}
       out[#out+1] =
  for i,pair in ipairs(R8) do
        '| ' .. m[2].logo .. ' ' .. m[2].fullName ..
    local H,A = pair[1], pair[2]
        ' || — || — || — || — || — || — || — || — || — || — || —'
    if absNow >= absR8 and A.fullName~='TBD' then
    end
       local g1,g2 = playScore(H,A,absR8*100+i)
   end
      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


   out[#out+1] = '|}'
   local absR4 = absR8 + PO_IV
  return table.concat(out, '\n')
  local R4, W_R4 = {}, {}
end


  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'}
-- 10) ROSTERS + TRANSFERS + PLAYER STATS (embedded, deterministic)
    local A = R4[i] and R4[i][2] or {logo='—',fullName='TBD'}
--     Includes wide-variation Bassaridian-style name generation.
    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
-- Config
    end
local ROSTER_SIZE = 12
    rows[#rows+1] = mkRow('R-4-'..i, absR4, H, A, absR4*100+i, absNow)
local TRANSFER_MD_START = 20
   end
local TRANSFER_MD_END   = 30
local DEFAULT_SEASON = 52


-- Positions (edit to match in-world naming later if you want)
  local absSF = absR4 + PO_IV
local POSITIONS = {
  local SF = {}
  "Striker","Striker",
  "Playmaker","Playmaker","Playmaker",
  "Defender","Defender","Defender",
  "Keeper",
  "Utility","Utility","Utility"
}


-----------------------------------------------------------------------
  if #W_R4 == 2 then
-- Deterministic PRNG (does NOT touch math.random / randomseed)
    local idxByCode = {}
-----------------------------------------------------------------------
    for i,t in ipairs(seeds) do idxByCode[t.code] = i end
local function _seedFromString(s)
    table.sort(W_R4, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end)
   local h = 0
    SF = { { seeds[1], W_R4[2] }, { seeds[2], W_R4[1] } }
   for i=1,#s do h = (h*31 + string.byte(s,i)) % 2147483647 end
  else
   return (h == 0) and 1 or h
    SF = { { seeds[1], {logo='—',fullName='TBD'} }, { seeds[2], {logo='—',fullName='TBD'} } }
end
  end
 
 
local function _rngNext(st)
   local W_SF = {}
   st = (1103515245 * st + 12345) % 2147483647
   for i=1,2 do
  return st, st / 2147483647
    local H,A = SF[i][1], SF[i][2]
end
    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)


local function _pick(st, arr)
   rows[#rows+1] = '|}'
   if not arr or #arr == 0 then return st, nil end
   return table.concat(rows, '\n')
   local r; st, r = _rngNext(st)
  local idx = math.floor(r * #arr) + 1
  return st, arr[idx]
end
end


local function _normKey(s)
------------------------------------------------------------------------
   if not s or s == "" then return nil end
-- 9) PER-MATCH STATS
  s = mw.ustring.lower(s)
------------------------------------------------------------------------
   s = mw.ustring.gsub(s, "%s+", "_")
local function computeMatchStats(h, a, hg, ag, seed)
   s = mw.ustring.gsub(s, "[^%w_]", "")
   local function splitScore(total, salt)
  return s
    local maxG = math.floor(total / 3)
end
    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
-- Wide-variation name generator (syllables + light diacritics)
  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 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 h_sh_off = rrange(seed+203, 0, 3)
    firstA = {"Se","Or","Ke","Ha","Yu","Az","Le","Mi","De","Sa","Fa","Ri","Ta","Na","Su","Ba"},
  local a_sh_off = rrange(seed+204, 0, 3)
    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 = {
   local h_fouls = rrange(seed+205, 0, 3)
    firstA = {"Ei","Ha","Le","Bj","Si","As","In","To","Ka","Sk","Ry","Ul","Va","Sa","Jo","Th"},
  local a_fouls = rrange(seed+206, 0, 3)
    firstB = {"rik","kon","if","orn","grid","trid","ga","rsten","ri","ald","var","lva","ren","mund","nar","or"},
  local h_fks  = a_fouls
    firstC = {"","r","d","n","k","s",""},
  local a_fks  = h_fouls
    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 = {
   local h_throws = rrange(seed+207, 0, 3)
    firstA = {"Mar","Jul","Cas","Oct","Luc","Tib","Sab","Aurel","Flav","Dec","Val","Cor","Dom","Sev","Faust","Cris"},
  local a_throws = rrange(seed+208, 0, 3)
    firstB = {"cus","ia","sian","avia","ian","er","ina","ius","ia","imus","eri","vin","itian","eran","inus","pus"},
  local h_oob    = rrange(seed+209, 0, 2)
    firstC = {"","us","a","","us","a","us","a","us","a"},
  local a_oob    = rrange(seed+210, 0, 2)
    lastA  = {"Valer","Marcell","Aquil","Sever","Corvin","Drus","Faustin","Liv","Domit","Crisp","Aurel","Cass","Octav","Lucan","Tiber","Flav"},
  local h_gk     = rrange(seed+211, 0, 2)
     lastB  = {"ius","us","a","an","inus","ian","ensis","orum","atus","aris"},
  local a_gk     = rrange(seed+212, 0, 2)
     lastC  = {"","","",""}
  }
}


local NAME_STYLE_KEYS = {"bassarid","haifan","normark","imperial"}
  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


local function _cap(s)
  return {
  if not s or s == "" then return s end
    [h.code] = {
  return mw.ustring.upper(mw.ustring.sub(s,1,1)) .. mw.ustring.sub(s,2)
      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
end


local function _maybeDiacritics(st, s)
function p.renderMatchStatsForDay(frame)
   -- very light touch
   local curr  = date.getCurrentDate()
   local r; st, r = _rngNext(st)
   local dayNow = tonumber(curr:match('^(%d+),')) or 0
   if r < 0.08 then
   local year  = tonumber(curr:match(', (%d+) PSSC')) or 0
    s = mw.ustring.gsub(s, "a", "ä", 1)
   local dm     = mapDays()
   elseif r < 0.16 then
 
     s = mw.ustring.gsub(s, "i", "ï", 1)
   local target = dm[dayNow] and dayNow or nil
   elseif r < 0.24 then
  if not target then
     s = mw.ustring.gsub(s, "o", "ö", 1)
     for d = dayNow + 1, END do
  elseif r < 0.32 then
      if dm[d] then target = d; break end
     s = mw.ustring.gsub(s, "u", "ü", 1)
     end
   end
   end
   return st, s
   if not target then return "'''No scheduled matches in window.'''" end
end


local function _buildNameFrom(style, st, isLast)
   local idx    = dm[target]
   local t = NAME_STYLES[style] or NAME_STYLES.bassarid
   local isToday = (target == dayNow)
   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 mon, dom = getMonthDay(target)
  local label    = dom .. " " .. mon .. " " .. year .. " PSSC"


  -- rare hyphen/apostrophe
   local function hiPairNum(hVal, aVal, suffix)
   local r; st, r = _rngNext(st)
    hVal = tonumber(hVal) or 0
  if r < 0.05 and #name >= 6 then
    aVal = tonumber(aVal) or 0
     local cut = math.floor(#name/2)
    suffix = suffix or ""
    name = mw.ustring.sub(name,1,cut) .. "-" .. mw.ustring.sub(name,cut+1)
     local function cell(v, hi)
  elseif r < 0.08 and #name >= 6 then
      local txt = tostring(v) .. suffix
     local cut = math.floor(#name/2)
      return hi and ('<span style="background-color:#ccffcc;">'..txt..'</span>') or txt
     name = mw.ustring.sub(name,1,cut) .. "’" .. mw.ustring.sub(name,cut+1)
     end
     return cell(hVal, hVal>aVal), cell(aVal, aVal>hVal)
   end
   end


   st, name = _maybeDiacritics(st, name)
   local out = {
   return st, name
    '{| class="wikitable sortable"',
end
    '|+ 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 function _chooseStyleForTeam(teamCode, season)
   local matches = getRoundMatches(idx)
   local st = _seedFromString(tostring(season).."|"..tostring(teamCode).."|STYLE")
  local key; st, key = _pick(st, NAME_STYLE_KEYS)
  return key or "bassarid"
end


-----------------------------------------------------------------------
  if isToday then
-- Base roster generation (stable per team+season)
    local results = simulateDayResults(target)
-----------------------------------------------------------------------
    for k, r in ipairs(results) do
local function _genBaseRoster(teamCode, season)
      local seed = target*1000 + k + string.byte(r.home.code,1) + string.byte(r.away.code,1)
  local key = _normKey(teamCode) or teamCode
      local S    = computeMatchStats(r.home, r.away, r.hg, r.ag, seed)
  local st = _seedFromString(tostring(season) .. "|" .. key .. "|ROSTER")
      local H, A = S[r.home.code], S[r.away.code]
  local style = _chooseStyleForTeam(teamCode, season)


  local roster, used = {}, {}
      local matchLabel = r.home.logo .. ' ' .. r.home.fullName .. ' vs ' .. r.away.logo .. ' ' .. r.away.fullName
  for i=1, ROSTER_SIZE do
    local id = key .. "-" .. string.format("%02d", i)


    local full
      local hF,  aF  = hiPairNum(H.final,      A.final)
    for _=1, 10 do
      local hG,  aG  = hiPairNum(H.goals,       A.goals)
       local first, last
       local hP,   aP  = hiPairNum(H.poss_points, A.poss_points)
       st, first = _buildNameFrom(style, st, false)
       local hSON, aSON = hiPairNum(H.shots_on,   A.shots_on)
       st, last = _buildNameFrom(style, st, true)
      local hSOF, aSOF = hiPairNum(H.shots_off,  A.shots_off)
       full = first .. " " .. last
       local hFO, aFO = hiPairNum(H.fouls,       A.fouls)
       if not used[full] then used[full] = true; break end
      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, '%')


    roster[i] = {
      out[#out+1] = '|-'
       id = id,
       out[#out+1] =
      name = full or ("Player " .. id),
        '| rowspan="2" | ' .. matchLabel ..
      pos = POSITIONS[i] or "Utility",
        ' || ' .. r.home.logo .. ' ' .. r.home.fullName ..
      no  = i
        ' || ' .. hF ..
    }
        ' || ' .. hG ..
  end
        ' || ' .. hP ..
  roster[1].notes = "Captain"
        ' || ' .. hSON ..
  return roster
        ' || ' .. hSOF ..
end
        ' || ' .. hFO ..
        ' || ' .. hFK ..
        ' || ' .. hTH ..
        ' || ' .. hGK ..
        ' || ' .. hOOB ..
        ' || ' .. hPOS


local function _buildBaseRosters(season)
      out[#out+1] = '|-'
  local rosters = {}
      out[#out+1] =
   for _,t in ipairs(teams) do
        '| ' .. r.away.logo .. ' ' .. r.away.fullName ..
    rosters[t.code] = _genBaseRoster(t.code, season)
        ' || ' .. aF ..
   end
        ' || ' .. aG ..
   return rosters
        ' || ' .. 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
end


-----------------------------------------------------------------------
function p.renderMatchStatsSingle(frame)
-- Transfer market (deterministic 1-for-1 swaps, MD20..MD30)
  local args = frame.args or {}
-----------------------------------------------------------------------
  local day  = tonumber(args.day or '') or 0
local function _indexById(roster)
  local home = args.home
   local m = {}
  local away = args.away
   for i,p in ipairs(roster) do m[p.id] = i end
  if day == 0 or not home or not away then
   return m
    return "'''Pass day, home, and away:''' day=###, home=Team, away=Team"
end
  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 function _findSwapCandidate(st, roster, wantPos)
  local matches = getRoundMatches(idx)
   local candidates = {}
   local matchIdx
   for _,p in ipairs(roster) do
   for k, m in ipairs(matches) do
     if p.pos == wantPos then candidates[#candidates+1] = p end
     if m[1].code == home and m[2].code == away then matchIdx = k; break end
   end
   end
   if #candidates == 0 then candidates = roster end
   if not matchIdx then return "'''Match not found on that day with that home/away.'''" end
  return _pick(st, candidates)
end


local function _genTransferDeals(season)
  local m = matches[matchIdx]
   local st = _seedFromString(tostring(season) .. "|TRANSFERWINDOW|MD" .. TRANSFER_MD_START .. "-" .. TRANSFER_MD_END)
  local mon, dom = getMonthDay(day)
   local dateLabel= dom .. " " .. mon .. " " .. year .. " PSSC"


   local deals, teamCodes = {}, {}
   local out = {
  for _,t in ipairs(teams) do teamCodes[#teamCodes+1] = t.code end
    '{| class="wikitable"',
    '|+ Match Statistics — ' .. dateLabel .. ((day == dayNow) and '' or ' (fixture; stats available day-of)'),
    '! Statistic !! ' .. _teamWithLogo(m[1]) .. ' !! ' .. _teamWithLogo(m[2])
  }


   for md = TRANSFER_MD_START, TRANSFER_MD_END do
   if day == dayNow then
     local r; st, r = _rngNext(st)
     local resList = simulateDayResults(day)
     local dealCount = 1 + math.floor(r * 3) -- 1–3 per matchday
     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]


     for _=1, dealCount do
     local function hi(hv, av, suffix)
       local A,B
      hv = tonumber(hv) or 0
       st, A = _pick(st, teamCodes)
      av = tonumber(av) or 0
       repeat st, B = _pick(st, teamCodes) until B ~= A
       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 baseA = _genBaseRoster(A, season)
    local hF,aF    = hi(H.final,       A.final)
       local baseB = _genBaseRoster(B, season)
    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, '%')


      local pA; st, pA = _pick(st, baseA)
    out[#out+1] = '|-'; out[#out+1] = '| Final Score || ' .. hF .. ' || ' .. aF
      if pA and pA.pos == "Keeper" then
    out[#out+1] = '|-'; out[#out+1] = '| Goals (Pillar Strikes) || ' .. hG .. ' || ' .. aG
        local rr; st, rr = _rngNext(st)
    out[#out+1] = '|-'; out[#out+1] = '| Possession Points || ' .. hP .. ' || ' .. aP
        if rr < 0.75 then st, pA = _pick(st, baseA) end
    out[#out+1] = '|-'; out[#out+1] = '| Shots on Target || ' .. hSON .. ' || ' .. aSON
      end
    out[#out+1] = '|-'; out[#out+1] = '| Shots off Target || ' .. hSOF .. ' || ' .. aSOF
 
    out[#out+1] = '|-'; out[#out+1] = '| Fouls || ' .. hFO .. ' || ' .. aFO
      local pB; st, pB = _findSwapCandidate(st, baseB, (pA and pA.pos) or "Utility")
    out[#out+1] = '|-'; out[#out+1] = '| Free Kicks Awarded || ' .. hFK .. ' || ' .. aFK
 
    out[#out+1] = '|-'; out[#out+1] = '| Throw-Ins || ' .. hTH .. ' || ' .. aTH
      if A and B and pA and pB then
    out[#out+1] = '|-'; out[#out+1] = '| Goal Kicks || ' .. hGK .. ' || ' .. aGK
        deals[#deals+1] = { md=md, A=A, B=B, A_id=pA.id, B_id=pB.id, pos=pA.pos }
    out[#out+1] = '|-'; out[#out+1] = '| Out of Bounds || ' .. hOOB .. ' || ' .. aOOB
      end
    out[#out+1] = '|-'; out[#out+1] = '| Ball Possession || ' .. hPOS .. ' || ' .. aPOS
     end
  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


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


local function _applyTransfersUpTo(baseRosters, deals, upToMd)
------------------------------------------------------------------------
  local rosters = {}
-- 10) ROSTERS + TRANSFERS + PLAYER STATS (embedded)
  for code, r in pairs(baseRosters) do
------------------------------------------------------------------------
    rosters[code] = {}
local DEFAULT_SEASON = 52
    for i,p in ipairs(r) do
local ROSTER_SIZE = 12
      rosters[code][i] = { id=p.id, name=p.name, pos=p.pos, no=p.no, notes=p.notes }
local TRANSFER_MD_START = 20
    end
local TRANSFER_MD_END   = 30
   end


   for _,d in ipairs(deals) do
local POSITIONS = {
    if d.md <= upToMd then
   "Striker","Striker",
      local RA, RB = rosters[d.A], rosters[d.B]
  "Playmaker","Playmaker","Playmaker",
      if RA and RB then
  "Defender","Defender","Defender",
        local ia = _indexById(RA)[d.A_id]
  "Keeper",
        local ib = _indexById(RB)[d.B_id]
  "Utility","Utility","Utility"
        if ia and ib then
}
          local tmp = RA[ia]
          RA[ia] = RB[ib]
          RB[ib] = tmp
        end
      end
    end
  end


   return rosters
-- 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
end


-----------------------------------------------------------------------
local function _rngNext(st)
-- Matchday helpers
   st = (1103515245 * st + 12345) % 2147483647
-----------------------------------------------------------------------
   return st, st / 2147483647
local function _invertDayMap(dm)
   local inv = {}
  for day, md in pairs(dm) do inv[md] = day end
   return inv
end
end


local function _currentPlayedMatchday()
local function _pick(st, arr)
   local curr = date.getCurrentDate()
   if not arr or #arr == 0 then return st, nil end
  local today = tonumber(curr:match("^(%d+),")) or 0
   local r; st, r = _rngNext(st)
   local dm = mapDays()
   local idx = math.floor(r * #arr) + 1
   local lastMd = 0
  return st, arr[idx]
  for d=START, math.min(today, END) do
    if dm[d] and dm[d] > lastMd then lastMd = dm[d] end
  end
  return lastMd
end
end


-----------------------------------------------------------------------
local function _normKey(s)
-- Allocate per-player events that SUM to computeMatchStats outputs
   if not s or s == "" then return nil end
-----------------------------------------------------------------------
   s = mw.ustring.lower(s)
local function _alloc(st, roster, posWeights)
  s = mw.ustring.gsub(s, "%s+", "_")
   local pool = {}
  s = mw.ustring.gsub(s, "[^%w_]", "")
   for _,p in ipairs(roster) do
   return s
    local w = posWeights[p.pos] or 1
    for _=1,w do pool[#pool+1] = p end
  end
   return _pick(st, pool)
end
end


local function _ensure(map, pid)
-- Wide-variation name generator
   if not map[pid] then
local NAME_STYLES = {
     map[pid] = { goals=0, assists=0, poss=0, son=0, sof=0, fouls=0, apps=0 }
   bassarid = {
   end
     firstA = {"Ka","Tha","Py","De","Ar","Lo","Ni","Sa","Eo","Ae","Ky","My","Ga","Do","Sy","Va"},
   return map[pid]
    firstB = {"li","the","ra","me","ri","phi","no","lo","re","so","te","xa","ro","na","di","mos"},
end
    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"},
local function _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster)
    lastB  = {"a","i","o","e","u","y","ae","io","ou","ea"},
  local salt = day*1000 + matchIndex + string.byte(homeTeam.code,1) + string.byte(awayTeam.code,1)
    lastC  = {"dis","kos","tron","ides","aris","eas","ion","oros","anos","inos","elis","akis","eth","on","eus","yr"}
   local hg, ag = simScoreFor(homeTeam, awayTeam, salt)
  },
 
  haifan = {
  local S = computeMatchStats(homeTeam, awayTeam, hg, ag, salt)
    firstA = {"Se","Or","Ke","Ha","Yu","Az","Le","Mi","De","Sa","Fa","Ri","Ta","Na","Su","Ba"},
  local H = S[homeTeam.code]
    firstB = {"lim","han","mal","kan","suf","iz","yla","na","rya","fi","ru","hir","mir","zir","yif","sar"},
  local A = S[awayTeam.code]
    firstC = {"","oğlu","an","ir","em","et","in","a","e","u",""},
   local stats = { home = H, away = A }
    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 st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)
local function _cap(s)
  local events = { home={}, away={} }
  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


  -- appearances: count everyone on roster as 1 appearance (simple)
local function _maybeDiacritics(st, s)
   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
  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 W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
local function _buildNameFrom(style, st, isLast)
   local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
   local t = NAME_STYLES[style] or NAME_STYLES.bassarid
   local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
   local a,b,c
 
   if not isLast then
   -- goals + assists
     st, a = _pick(st, t.firstA); st, b = _pick(st, t.firstB); st, c = _pick(st, t.firstC)
  for _=1, stats.home.goals do
   else
     local p; st, p = _alloc(st, homeRoster, W_GOAL)
     st, a = _pick(st, t.lastA); st, b = _pick(st, t.lastB); st, c = _pick(st, t.lastC)
    _ensure(events.home, p.id).goals = _ensure(events.home, p.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 ~= p.id then _ensure(events.home, a.id).assists = _ensure(events.home, a.id).assists + 1 end
    end
   end
  for _=1, stats.away.goals do
     local p; st, p = _alloc(st, awayRoster, W_GOAL)
    _ensure(events.away, p.id).goals = _ensure(events.away, p.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 ~= p.id then _ensure(events.away, a.id).assists = _ensure(events.away, a.id).assists + 1 end
    end
   end
   end


   -- possession points
   local name = _cap((a or "") .. (b or "") .. (c or ""))
  for _=1, stats.home.poss_points do
    local p; st, p = _alloc(st, homeRoster, W_POSS)
    _ensure(events.home, p.id).poss = _ensure(events.home, p.id).poss + 1
  end
  for _=1, stats.away.poss_points do
    local p; st, p = _alloc(st, awayRoster, W_POSS)
    _ensure(events.away, p.id).poss = _ensure(events.away, p.id).poss + 1
  end


  -- shots on/off
   local r; st, r = _rngNext(st)
   local function distributeShots(side, roster, on, off)
  if r < 0.05 and #name >= 6 then
    for _=1, on do
    local cut = math.floor(#name/2)
      local p; st, p = _alloc(st, roster, {Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0})
    name = mw.ustring.sub(name,1,cut) .. "-" .. mw.ustring.sub(name,cut+1)
      _ensure(side, p.id).son = _ensure(side, p.id).son + 1
  elseif r < 0.08 and #name >= 6 then
    end
     local cut = math.floor(#name/2)
     for _=1, off do
    name = mw.ustring.sub(name,1,cut) .. "’" .. mw.ustring.sub(name,cut+1)
      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
  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
   st, name = _maybeDiacritics(st, name)
  for _=1, stats.home.fouls do
   return st, name
    local p; st, p = _alloc(st, homeRoster, W_FOUL)
end
    _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
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
end


-----------------------------------------------------------------------
local function _genBaseRoster(teamCode, season)
-- Season totals (recomputed on view; "auto-updates" with matchday)
  local key = _normKey(teamCode) or teamCode
-----------------------------------------------------------------------
   local st = _seedFromString(tostring(season) .. "|" .. key .. "|ROSTER")
local function _computePlayerSeasonTotals(season, upToMd)
   local style = _chooseStyleForTeam(teamCode, season)
   local dm = mapDays()
   local inv = _invertDayMap(dm)


   local deals = _genTransferDeals(season)
   local roster, used = {}, {}
   local base = _buildBaseRosters(season)
   for i=1, ROSTER_SIZE do
 
    local id = key .. "-" .. string.format("%02d", i)
  local totals = {} -- pid -> totals row
    local full
 
    for _=1, 10 do
  local function ensurePlayer(pid, name, pos)
      local first, last
    if not totals[pid] then
      st, first = _buildNameFrom(style, st, false)
       totals[pid] = { id=pid, name=name or pid, pos=pos or "—", team="",
       st, last  = _buildNameFrom(style, st, true)
                      apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0 }
      full = first .. " " .. last
      if not used[full] then used[full] = true; break end
     end
     end
     return totals[pid]
     roster[i] = { id=id, name=full or ("Player "..id), pos=POSITIONS[i] or "Utility", no=i }
   end
   end
  roster[1].notes = "Captain"
  return roster
end


   for md=1, math.min(upToMd, #SCHED) do
local function _buildBaseRosters(season)
    local day = inv[md]
  local rosters = {}
    if day then
   for _,t in ipairs(teams) do rosters[t.code] = _genBaseRoster(t.code, season) end
      local rosters = _applyTransfersUpTo(base, deals, md)
  return rosters
end


      for matchIndex, m in ipairs(SCHED[md]) do
local function _indexById(roster)
        local h, a = m[1], m[2]
  local m = {}
        local hr, ar = rosters[h.code], rosters[a.code]
  for i,p in ipairs(roster) do m[p.id] = i end
        local ev = _matchPlayerEvents(season, md, day, matchIndex, h, a, hr, ar)
  return m
end


        -- home
local function _findSwapCandidate(st, roster, wantPos)
        for pid, st in pairs(ev.home) do
  local candidates = {}
          local name, pos = pid, "—"
  for _,p in ipairs(roster) do if p.pos == wantPos then candidates[#candidates+1] = p end end
          for _,pp in ipairs(hr) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
   if #candidates == 0 then candidates = roster end
          local row = ensurePlayer(pid, name, pos)
   return _pick(st, candidates)
          row.team    = h.code
end
          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
local function _genTransferDeals(season)
        for pid, st in pairs(ev.away) do
  local st = _seedFromString(tostring(season) .. "|TRANSFERWINDOW|MD" .. TRANSFER_MD_START .. "-" .. TRANSFER_MD_END)
          local name, pos = pid, "—"
   local deals, teamCodes = {}, {}
          for _,pp in ipairs(ar) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
  for _,t in ipairs(teams) do teamCodes[#teamCodes+1] = t.code 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


   return totals, deals, base
   for md = TRANSFER_MD_START, TRANSFER_MD_END do
end
    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)
-- PUBLIC: Single roster
      local baseB = _genBaseRoster(B, season)
-- {{#invoke:PillSeasonSchedule|renderRoster|team=Vaeringheim Pillar|season=52}}
-----------------------------------------------------------------------
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 pA; st, pA = _pick(st, baseA)
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
      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 deals = _genTransferDeals(season)
      local pB; st, pB = _findSwapCandidate(st, baseB, (pA and pA.pos) or "Utility")
  local base  = _buildBaseRosters(season)
      if A and B and pA and pB then
  local rosters = _applyTransfersUpTo(base, deals, upToMd)
        deals[#deals+1] = { md=md, A=A, B=B, A_id=pA.id, B_id=pB.id, pos=pA.pos }
   local r = rosters[team] or {}
      end
    end
  end
   return deals
end


   local out = {
local function _applyTransfersUpTo(baseRosters, deals, upToMd)
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
   local rosters = {}
     '! # !! Player !! Position !! ID !! Notes'
  for code, r in pairs(baseRosters) do
  }
     rosters[code] = {}
  for _,pl in ipairs(r) do
    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
    out[#out+1] = '|-'
  end
    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 '—'
  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
   end
  out[#out+1] = '|}'


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


-----------------------------------------------------------------------
local function _alloc(st, roster, posWeights)
-- PUBLIC: All rosters (collapsible per team)
   local pool = {}
-- {{#invoke:PillSeasonSchedule|renderAllRosters|season=52}}
   for _,p in ipairs(roster) do
-----------------------------------------------------------------------
     local w = posWeights[p.pos] or 1
function p.renderAllRosters(frame)
     for _=1,w do pool[#pool+1] = p end
  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
   end
   return table.concat(out, "\n")
   return _pick(st, pool)
end
end


-----------------------------------------------------------------------
local function _ensure(map, pid)
-- PUBLIC: Transfer log (up to current matchday unless md provided)
   if not map[pid] then map[pid] = { goals=0, assists=0, poss=0, son=0, sof=0, fouls=0, apps=0 } end
-- {{#invoke:PillSeasonSchedule|renderTransferLog|season=52}}
   return map[pid]
-----------------------------------------------------------------------
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 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 out = {
   local st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
   local events = { home={}, away={} }
    '! 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 ..
   for _,p in ipairs(homeRoster) do _ensure(events.home, p.id).apps = 1 end
    ") — season " .. season .. " (through Matchday " .. tostring(upToMd) .. ")'''\n" ..
  for _,p in ipairs(awayRoster) do _ensure(events.away, p.id).apps = 1 end
    table.concat(out, "\n")
end


-----------------------------------------------------------------------
  local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
-- PUBLIC: Team player totals (team roster as of md, totals through md)
  local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
-- {{#invoke:PillSeasonSchedule|renderPlayerStats|team=Vaeringheim Pillar|season=52}}
  local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
-----------------------------------------------------------------------
 
function p.renderPlayerStats(frame)
  for _=1, (stats.home.goals or 0) do
  local args = frame.args or {}
    local pz; st, pz = _alloc(st, homeRoster, W_GOAL)
  local team = args.team or args[1]
    _ensure(events.home, pz.id).goals = _ensure(events.home, pz.id).goals + 1
  if not team or not TEAM_BY_CODE[team] then
    local r; st, r = _rngNext(st)
    return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''"
    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
   end


   local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
   for _=1, (stats.away.goals or 0) do
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    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


   local totals, deals, base = _computePlayerSeasonTotals(season, upToMd)
   for _=1, (stats.home.poss_points or 0) do
   local rostersNow = _applyTransfersUpTo(base, deals, upToMd)
    local pz; st, pz = _alloc(st, homeRoster, W_POSS)
   local rosterNow = rostersNow[team] or {}
    _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 out = {
   local function distributeShots(side, roster, on, off)
     '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    for _=1, (on or 0) do
     '! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'
      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 _,pl in ipairs(rosterNow) do
   for _=1, (stats.home.fouls or 0) do
     local r = totals[pl.id]
     local pz; st, pz = _alloc(st, homeRoster, W_FOUL)
    out[#out+1] = '|-'
    _ensure(events.home, pz.id).fouls = _ensure(events.home, pz.id).fouls + 1
    out[#out+1] = string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d',
  end
      pl.name, pl.pos,
  for _=1, (stats.away.fouls or 0) do
      r and r.apps or 0,
    local pz; st, pz = _alloc(st, awayRoster, W_FOUL)
      r and r.goals or 0,
    _ensure(events.away, pz.id).fouls = _ensure(events.away, pz.id).fouls + 1
      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] = '|}'
   return events
end


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


-----------------------------------------------------------------------
  local deals = _genTransferDeals(season)
-- PUBLIC: League leaders (top 20)
   local base = _buildBaseRosters(season)
-- {{#invoke:PillSeasonSchedule|renderLeaders|stat=goals|season=52}}
 
-----------------------------------------------------------------------
   local totals = {}
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 function ensurePlayer(pid, name, pos)
  local ok = { goals=true, assists=true, poss=true, son=true, sof=true, fouls=true, apps=true }
    if not totals[pid] then
  if not ok[stat] then
      totals[pid] = { id=pid, name=name or pid, pos=pos or "—", team="",
     return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''"
                      apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0 }
    end
     return totals[pid]
   end
   end


   local totals = (_computePlayerSeasonTotals(season, upToMd))
   for md=1, math.min(upToMd, TOTAL_ROUNDS) do
  local list = {}
    local day = inv[md]
  for _,r in pairs(totals) do list[#list+1] = r end
    if day then
      local rosters = _applyTransfersUpTo(base, deals, md)
      local fixtures = getRoundMatches(md)


  table.sort(list, function(a,b)
      for matchIndex, m in ipairs(fixtures) do
    if (a[stat] or 0) ~= (b[stat] or 0) then return (a[stat] or 0) > (b[stat] or 0) end
        local h, a = m[1], m[2]
    return (a.name or "") < (b.name or "")
        local hr, ar = rosters[h.code] or {}, rosters[a.code] or {}
  end)
        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, "—"
     '! Rank !! Player !! Team (current) !! Pos !! ' .. stat
          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 i=1, math.min(20, #list) do
        for pid, stt in pairs(ev.away) do
    local r = list[i]
          local name, pos = pid, "—"
    out[#out+1] = '|-'
          for _,pp in ipairs(ar) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
    out[#out+1] = string.format('| %d || %s || %s || %s || %d',
          local row = ensurePlayer(pid, name, pos)
      i, r.name or "—", _teamWithLogo(r.team or "—"), r.pos or "—", r[stat] or 0
          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


  out[#out+1] = '|}'
   return totals, deals, base
 
   return "'''League leaders (" .. stat .. ") — season " .. season ..
    " through Matchday " .. tostring(upToMd) .. "'''\n" ..
    table.concat(out, "\n")
end
end


-----------------------------------------------------------------------
function p.renderRoster(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 deals = _genTransferDeals(season)
   if not times or #times == 0 then return "" end
   local base  = _buildBaseRosters(season)
   maxn = maxn or 3
  local rosters = _applyTransfersUpTo(base, deals, upToMd)
   local out = {}
   local r = rosters[team] or {}
   for i = 1, math.min(maxn, #times) do out[#out+1] = _fmtTime(times[i]) end
 
  if #times > maxn then out[#out+1] = "…" end
   local out = {
  return " (" .. table.concat(out, ", ") .. ")"
    '{| 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] = '|}'


local function _randTime(st, startSec, endSec)
  return (TEAM_BY_CODE[team].logo or "") ..
  local r; st, r = _rngNext(st)
    " '''" .. team .. "''' (Roster as of Matchday " .. tostring(upToMd) .. ")\n" ..
  local span = math.max(1, endSec - startSec - 2)
    table.concat(out, "\n")
  local sec = startSec + 1 + math.floor(r * span)
  return st, sec
end
end


local function _genTimes(st, n, firstStart, firstEnd, secondStart, secondEnd)
function p.renderAllRosters(frame)
   local times = {}
   local args = frame.args or {}
   if n <= 0 then return st, times end
   local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
   local split = math.ceil(n / 2)
   local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
   for i=1, n do
 
     if i <= split then
  local out = {}
       st, times[i] = _randTime(st, firstStart, firstEnd)
   for _,t in ipairs(teams) do
    else
     local title = (t.logo or "") .. " <b>" .. t.code .. "</b>"
      st, times[i] = _randTime(st, secondStart, secondEnd)
    out[#out+1] =
    end
       '<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
   table.sort(times)
   return table.concat(out, "\n")
  return st, times
end
end


local function _pickPlayer(st, roster, posA, posB, posC)
function p.renderTransferLog(frame)
   local candidates = {}
   local args = frame.args or {}
   for _,p in ipairs(roster or {}) do
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
     if p.pos == posA or p.pos == posB or p.pos == posC then
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
       candidates[#candidates+1] = p
  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
   end
   end
   if #candidates == 0 then candidates = roster end
   out[#out+1] = '|}'
   local pl; st, pl = _pick(st, candidates)
 
  return st, pl
   return "'''Transfer window (Matchdays " .. TRANSFER_MD_START .. "–" .. TRANSFER_MD_END ..
    ") — season " .. season .. " (through Matchday " .. tostring(upToMd) .. ")'''\n" ..
    table.concat(out, "\n")
end
end


-- Rivalry tag: same division => derby
function p.renderPlayerStats(frame)
local function _rivalryTag(homeCode, awayCode)
   local args = frame.args or {}
   local dh, da = DIV_OF[homeCode], DIV_OF[awayCode]
  local team = args.team or args[1]
   if dh and da and dh == da then
   if not team or not TEAM_BY_CODE[team] then
     return " (Divisional derby — " .. dh .. ")"
     return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''"
   end
   end
  return ""
end


-- Append derby tag INSIDE trailing bold quotes if title ends with '''
  local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
local function _appendToBoldTitle(title, suffix)
   local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
   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)
  local totals, deals, base = _computePlayerSeasonTotals(season, upToMd)
   pickMode = pickMode or "best"
   local rostersNow = _applyTransfersUpTo(base, deals, upToMd)
   local fixtures = SCHED[md]
   local rosterNow = rostersNow[team] or {}
  if not fixtures then return nil, "No fixtures found for this matchday." end


   local inv = _invertDayMap(mapDays())
   local out = {
   local day = inv[md] or (START + md)
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    '! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'
   }


  local bestI, bestScore = 1, -1e18
   for _,pl in ipairs(rosterNow) do
   for i, m in ipairs(fixtures) do
     local r = totals[pl.id]
     local home, away = m[1], m[2]
    out[#out+1] = '|-'
     local salt = day * 1000 + i + string.byte(home.code,1) + string.byte(away.code,1)
     out[#out+1] = string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d',
    local hs, as = simScoreFor(home, away, salt)
      pl.name, pl.pos,
    local S = computeMatchStats(home, away, hs, as, salt)
      r and r.apps or 0,
    local H = S[home.code] or {}
      r and r.goals or 0,
     local A = S[away.code] or {}
      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


    local diff  = math.abs(hs - as)
  out[#out+1] = '|}'
    local total = hs + as
    local chaos = (_getN(H,"fouls") + _getN(A,"fouls"))
                + (_getN(H,"shots_on") + _getN(A,"shots_on"))
                + (_getN(H,"shots_off") + _getN(A,"shots_off"))


    local q
  return (TEAM_BY_CODE[team].logo or "") ..
     if pickMode == "close" then
     " '''" .. team .. "''' — Player totals through Matchday " .. tostring(upToMd) .. "\n" ..
      q = (50 - diff * 10) + total + chaos * 0.25
     table.concat(out, "\n")
     elseif pickMode == "high" then
end
      q = total * 12 - diff * 2 + chaos * 0.20
 
    elseif pickMode == "chaos" then
function p.renderLeaders(frame)
      q = chaos * 10 + total * 2 - diff
  local args = frame.args or {}
    else
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
      q = (20 - diff * 6) + total * 8 + chaos * 0.35
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    end


    if q > bestScore then bestScore, bestI = q, i end
  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
   end


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


local function _renderMatchReport(season, md, matchIndex, titleOverride)
  table.sort(list, function(a,b)
  local fixtures = SCHED[md]
    if (a[stat] or 0) ~= (b[stat] or 0) then return (a[stat] or 0) > (b[stat] or 0) end
  if not fixtures or not fixtures[matchIndex] then
     return (a.name or "") < (b.name or "")
     return "'''No such match (md=" .. tostring(md) .. ", match=" .. tostring(matchIndex) .. ").'''"
   end)
   end


   local inv = _invertDayMap(mapDays())
   local out = {
   local day = inv[md] or (START + md)
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    '! Rank !! Player !! Team (current) !! Pos !! ' .. stat
   }


   local home, away = fixtures[matchIndex][1], fixtures[matchIndex][2]
   for i=1, math.min(20, #list) do
  local derby = _rivalryTag(home.code, away.code)
    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


   local salt = day * 1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
   out[#out+1] = '|}'
  local hs, as = simScoreFor(home, away, salt)


   local S = computeMatchStats(home, away, hs, as, salt)
   return "'''League leaders (" .. stat .. ") — season " .. season ..
  local H = S[home.code] or {}
    " through Matchday " .. tostring(upToMd) .. "'''\n" ..
  local A = S[away.code] or {}
    table.concat(out, "\n")
end


  -- Rosters (transfer-aware for this matchday)
------------------------------------------------------------------------
  local deals = _genTransferDeals(season)
-- 11) MATCH REPORTS (Match of the Week + Match Report) + DERBY TAG
   local base  = _buildBaseRosters(season)
------------------------------------------------------------------------
   local rosters = _applyTransfersUpTo(base, deals, md)
local function _fmtTime(seconds)
  local homeRoster = rosters[home.code] or {}
   local m = math.floor(seconds / 60)
  local awayRoster = rosters[away.code] or {}
   local s = seconds % 60
  return string.format("%02d:%02d", m, s)
end


  -- Times
local function _abbr(teamName)
   local st = _seedFromString(tostring(season).."|REPORT|MD"..md.."|M"..matchIndex.."|"..home.code.."|"..away.code)
   local letters = {}
   local FIRST_START, FIRST_END  = 0, 35*60
  for w in mw.ustring.gmatch(teamName or "", "%S+") do
   local SECOND_START, SECOND_END = 35*60, 70*60
    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 homeTag, awayTag = _abbr(home.code), _abbr(away.code)
local function _randTime(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


  -- scoring components (GOAL = 3 points, POSS = 1 point)
local function _genTimes(st, n, firstStart, firstEnd, secondStart, secondEnd)
  local homePP, awayPP, homeG, awayG
   local times = {}
  st, homePP = _genTimes(st, _getN(H,"poss_points"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   if n <= 0 then return st, times end
   st, awayPP = _genTimes(st, _getN(A,"poss_points"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   local split = math.ceil(n / 2)
   st, homeG  = _genTimes(st, _getN(H,"goals"),      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   for i=1, n do
  st, awayG  = _genTimes(st, _getN(A,"goals"),      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
    if i <= split then
 
      st, times[i] = _randTime(st, firstStart, firstEnd)
  -- highlights
    else
   local homeSON, awaySON, homeFOUL, awayFOUL, homeOOB, awayOOB
      st, times[i] = _randTime(st, secondStart, secondEnd)
  st, homeSON  = _genTimes(st, _getN(H,"shots_on"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
     end
   st, awaySON  = _genTimes(st, _getN(A,"shots_on"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, homeFOUL = _genTimes(st, _getN(H,"fouls"),    FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayFOUL = _genTimes(st, _getN(A,"fouls"),    FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, homeOOB  = _genTimes(st, _getN(H,"oob"),      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayOOB  = _genTimes(st, _getN(A,"oob"),      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
 
  -- Scoring timeline: running score must add +3 for goals, +1 for possession
  local scoreEvents = {}
  local function queueScore(sec, which, kind)
     scoreEvents[#scoreEvents+1] = { t=sec, which=which, kind=kind } -- kind="PP" or "G"
   end
   end
   for _,sec in ipairs(homePP) do queueScore(sec, "H", "PP") end
   table.sort(times)
   for _,sec in ipairs(awayPP) do queueScore(sec, "A", "PP") end
   return st, times
  for _,sec in ipairs(homeG)  do queueScore(sec, "H", "G")  end
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 _timesList(times, maxn)
   local function addEvent(sec, text) events[#events+1] = { t=sec, text=text } end
  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 hRun, aRun = 0, 0
local function _pickPlayer(st, roster, posA, posB, posC)
   local hHalf, aHalf = 0, 0
   local candidates = {}
  for _,p in ipairs(roster or {}) do
    if p.pos == posA or p.pos == posB or p.pos == posC then
      candidates[#candidates+1] = p
    end
  end
  if #candidates == 0 then candidates = roster end
   local pl; st, pl = _pick(st, candidates)
  return st, pl
end


  for _,e in ipairs(scoreEvents) do
local function _rivalryTag(homeCode, awayCode)
    local add = (e.kind == "G") and 3 or 1
  local dh, da = DIV_OF[homeCode], DIV_OF[awayCode]
    if e.which == "H" then hRun = hRun + add else aRun = aRun + add end
  if dh and da and dh == da then
    if e.t < 35*60 then hHalf, aHalf = hRun, aRun end
    return " (Divisional derby — " .. dh .. ")"
  end
  return ""
end


    local tag    = (e.which=="H") and homeTag or awayTag
local function _appendToBoldTitle(title, suffix)
    local roster = (e.which=="H") and homeRoster or awayRoster
  if not suffix or suffix == "" then return title end
    local scoreLine = string.format("%s %d–%d %s", _teamWithLogo(home), hRun, aRun, _teamWithLogo(away))
  if not title then return suffix end
 
  if mw.ustring.match(title, "'''%s*$") then
    if e.kind == "G" then
     return mw.ustring.gsub(title, "'''%s*$", suffix .. "'''", 1)
      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
  return title .. suffix
end


  -- A few non-scoring highlights (kept small)
local function _pickMatchIndexForDay(season, md, pickMode)
  local function addHighlights(times, roster, tag, label, posA, posB, posC, template)
  pickMode = pickMode or "best"
    local cap = math.min(2, #times)
  local fixtures = getRoundMatches(md)
    for i=1, cap do
  if not fixtures or #fixtures == 0 then return nil, "No fixtures found for this matchday." end
      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 inv = invertDayMap(mapDays())
    "%s clips a runner during a shielded carry; restart taken quickly.")
   local day = inv[md] or (START + md)
  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 bestI, bestScore = 1, -1e18
 
   for i, m in ipairs(fixtures) do
   local firstHalf, secondHalf = {}, {}
     local home, away = m[1], m[2]
   for _,e in ipairs(events) do
    local salt = day * 1000 + i + string.byte(home.code,1) + string.byte(away.code,1)
     if e.t < 35*60 then firstHalf[#firstHalf+1] = e.text else secondHalf[#secondHalf+1] = e.text end
    local hs, as = simScoreFor(home, away, salt)
  end
    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 diff = math.abs(hs - as)
  local mood = (diff <= 1) and "a tight, tactical contest" or "a high-variance clash"
    local total = hs + as
  local summary = string.format(
    local chaos = (tonumber(H.fouls) or 0) + (tonumber(A.fouls) or 0)
    "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.",
                + (tonumber(H.shots_on) or 0) + (tonumber(A.shots_on) or 0)
    md, mood, _teamWithLogo(home), _teamWithLogo(away), hs, as
                + (tonumber(H.shots_off) or 0) + (tonumber(A.shots_off) or 0)
  )


  -- Title with derby tag (works for both override and default)
    local q
  local baseTitle = titleOverride or string.format("'''Match of the Week — %d PSSC (Matchday %d)'''", season, md)
    if pickMode == "close" then
  local title = _appendToBoldTitle(baseTitle, derby)
      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


  -- Output
    if q > bestScore then bestScore, bestI = q, i end
  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
   end
   out[#out+1] = ""
   return bestI
  out[#out+1] = string.format("35:00 – Halftime: %s %d–%d %s.", _teamWithLogo(home), hHalf, aHalf, _teamWithLogo(away))
end
  out[#out+1] = ""
 
   out[#out+1] = "==== Second Half (35:00–70:00) ===="
local function _renderMatchReport(season, md, matchIndex, titleOverride)
   if #secondHalf == 0 then
   local fixtures = getRoundMatches(md)
     out[#out+1] = ""
   if not fixtures or not fixtures[matchIndex] then
  else
     return "'''No such match (md=" .. tostring(md) .. ", match=" .. tostring(matchIndex) .. ").'''"
    for _,ln in ipairs(secondHalf) do out[#out+1] = "* " .. ln 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))
  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',
    _getN(H,"goals"), _timesList(homeG, 3),
    _getN(A,"goals"), _timesList(awayG, 3)
  )
  out[#out+1] = '|-'
  out[#out+1] = string.format('| Possession Points || %d%s || %d%s',
    _getN(H,"poss_points"), _timesList(homePP, 4),
    _getN(A,"poss_points"), _timesList(awayPP, 4)
  )
  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] = string.format('| Out of Bounds || %d || %d', _getN(H,"oob"), _getN(A,"oob"))
  out[#out+1] = '|-'
  out[#out+1] = string.format('| Throw-Ins || %d || %d', _getN(H,"throws"), _getN(A,"throws"))
  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('| Ball Possession || %d%% || %d%%', _getN(H,"poss_pct"), _getN(A,"poss_pct"))
  out[#out+1] = '|}'
  return table.concat(out, "\n")
end


-- PUBLIC: Match of the Week (auto-pick match on a matchday)
  local inv = invertDayMap(mapDays())
function p.renderMatchOfWeek(frame)
   local day = inv[md] or (START + md)
   local args = frame.args or {}
 
   local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
   local home, away = fixtures[matchIndex][1], fixtures[matchIndex][2]
   local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
   local derby = _rivalryTag(home.code, away.code)
  local pick = args.pick or "best"


   if md < 1 then return "'''No matchday has been played yet.'''"
   local salt = day * 1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
   end
   local hs, as = simScoreFor(home, away, salt)
  if not SCHED[md] then return "'''No fixtures found for matchday " .. tostring(md) .. ".'''" end


   local idx, err = _pickMatchIndexForDay(season, md, pick)
   local S = computeMatchStats(home, away, hs, as, salt)
   if not idx then return "'''" .. (err or "Unable to select match.") .. "'''" end
   local H = S[home.code] or {}
   return _renderMatchReport(season, md, idx)
   local A = S[away.code] or {}
end


-- PUBLIC: Force a specific match report (md + match index)
  local deals = _genTransferDeals(season)
function p.renderMatchReport(frame)
   local base  = _buildBaseRosters(season)
   local args = frame.args or {}
   local rosters = _applyTransfersUpTo(base, deals, md)
   local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
   local homeRoster = rosters[home.code] or {}
   local md = tonumber(args.md or args.matchday or args[2] or "") or _currentPlayedMatchday()
   local awayRoster = rosters[away.code] or {}
   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 st = _seedFromString(tostring(season).."|REPORT|MD"..md.."|M"..matchIndex.."|"..home.code.."|"..away.code)
   return _renderMatchReport(season, md, matchIndex, titleOverride)
   local FIRST_START, FIRST_END  = 0, 35*60
end
  local SECOND_START, SECOND_END = 35*60, 70*60


-- MATCHDAY CAPSULE (safe: will not error if renderWeeklyAwards isn't present)
   local homeTag, awayTag = _abbr(home.code), _abbr(away.code)
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"  -- default to chaos


   if md < 1 or not SCHED[md] then
   local homePP, awayPP, homeG, awayG
    return "'''No matchday has been played yet (or no fixtures found).'''"
  st, homePP = _genTimes(st, tonumber(H.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   end
  st, awayPP = _genTimes(st, tonumber(A.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, homeG  = _genTimes(st, tonumber(H.goals) or 0,      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   st, awayG  = _genTimes(st, tonumber(A.goals) or 0,      FIRST_START, FIRST_END, SECOND_START, SECOND_END)


   -- Convert matchday -> PSSC day (needed for simulateDayResults)
   local homeSON, awaySON, homeFOUL, awayFOUL, homeOOB, awayOOB
   local inv = _invertDayMap(mapDays())
  st, homeSON  = _genTimes(st, tonumber(H.shots_on) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   local day = inv[md]
   st, awaySON  = _genTimes(st, tonumber(A.shots_on) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   if not day then
   st, homeFOUL = _genTimes(st, tonumber(H.fouls) or 0,    FIRST_START, FIRST_END, SECOND_START, SECOND_END)
    return "'''Unable to resolve PSSC day for matchday " .. tostring(md) .. ".'''"
   st, awayFOUL = _genTimes(st, tonumber(A.fouls) or 0,    FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   end
  st, homeOOB  = _genTimes(st, tonumber(H.oob) or 0,      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   st, awayOOB  = _genTimes(st, tonumber(A.oob) or 0,      FIRST_START, FIRST_END, SECOND_START, SECOND_END)


   local results = simulateDayResults(day)
   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 out = {}
   local events = {}
   out[#out+1] = "== Matchday " .. tostring(md) .. " Capsule =="
   local function addEvent(sec, text) events[#events+1] = { t=sec, text=text } end
  out[#out+1] = ""


   -- Featured match (auto recap + player names)
   local hRun, aRun = 0, 0
  out[#out+1] = p.renderMatchOfWeek({ args = { season = season, md = md, pick = pick } })
   local hHalf, aHalf = 0, 0
   out[#out+1] = ""


  -- Full results list
   for _,e in ipairs(scoreEvents) do
  out[#out+1] = "=== Full Results ==="
     local add = (e.kind == "G") and 3 or 1
   for _, r in ipairs(results or {}) do
    if e.which == "H" then hRun = hRun + add else aRun = aRun + add end
     out[#out+1] = string.format("* %s '''%d''' %s vs %s '''%d''' %s",
    if e.t < 35*60 then hHalf, aHalf = hRun, aRun end
      (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


  -- Weekly awards (optional)
    local tag    = (e.which=="H") and homeTag or awayTag
  if type(p.renderWeeklyAwards) == "function" then
     local roster = (e.which=="H") and homeRoster or awayRoster
     out[#out+1] = ""
     local scoreLine = string.format("%s %d–%d %s", _teamWithLogo(home), hRun, aRun, _teamWithLogo(away))
     out[#out+1] = p.renderWeeklyAwards({ args = { season = season, md = md } })
  else
    out[#out+1] = ""
    out[#out+1] = "''(Weekly awards not enabled yet.)''"
  end


  return table.concat(out, "\n")
    if e.kind == "G" then
end
      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))
-- WEEKLY AWARDS (drop-in)
    else
-----------------------------------------------------------------------
      local pp; st, pp = _pickPlayer(st, roster, "Playmaker", "Utility", "Striker")
function p.renderWeeklyAwards(frame)
      addEvent(e.t, string.format("%s – Possession Play (%s): %s completes a 10-second controlled hold in the scoring zone. (%s)",
  local args = frame.args or {}
        _fmtTime(e.t), tag, (pp and pp.name or "a midfielder"), scoreLine))
 
     end
  -- LOCAL clamp so it can’t go missing / global-nil
  local function _clamp(x, lo, hi)
     if x < lo then return lo end
    if x > hi then return hi end
    return x
   end
   end


   local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
   local function addHighlights(times, roster, tag, label, posA, posB, posC, template)
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    local cap = math.min(2, #times)
 
    for i=1, cap do
  if md < 1 or not SCHED[md] then
      local sec = times[i]
    return "'''No fixtures found for matchday " .. tostring(md) .. ".'''"
      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
   end


   -- Resolve PSSC day for this matchday
   addHighlights(homeFOUL, homeRoster, homeTag, "Foul", "Defender", "Utility", "Playmaker",
  local inv = {}
    "%s clips a runner during a shielded carry; restart taken quickly.")
   for d, mday in pairs(mapDays()) do inv[mday] = d end
   addHighlights(awayFOUL, awayRoster, awayTag, "Foul", "Defender", "Utility", "Playmaker",
   local day = inv[md] or (START + md)
    "%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.")


   -- Rosters (transfer-aware if available)
   table.sort(events, function(a,b) return a.t < b.t end)
  local rosters
  if type(_genTransferDeals) == "function" and type(_buildBaseRosters) == "function" and type(_applyTransfersUpTo) == "function" then
    local deals = _genTransferDeals(season)
    local base  = _buildBaseRosters(season)
    rosters = _applyTransfersUpTo(base, deals, md)
  else
    rosters = type(_buildBaseRosters) == "function" and _buildBaseRosters(season) or {}
  end


   local function ensure(map, pid)
   local firstHalf, secondHalf = {}, {}
     if not map[pid] then map[pid] = {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0} end
  for _,e in ipairs(events) do
    return map[pid]
     if e.t < 35*60 then firstHalf[#firstHalf+1] = e.text else secondHalf[#secondHalf+1] = e.text end
   end
   end


   local function pickWeighted(st, roster, weights)
   local diff = math.abs(hs - as)
    local pool = {}
  local mood = (diff <= 1) and "a tight, tactical contest" or "a high-variance clash"
    for _,p in ipairs(roster or {}) do
  local summary = string.format(
      local w = weights[p.pos] or 1
    "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.",
      for _=1,w do pool[#pool+1] = p end
     md, mood, _teamWithLogo(home), _teamWithLogo(away), hs, as
     end
   )
    return _pick(st, pool)
   end


   local function scorePlayer(line, pos, oppShotsOn, oppGoals)
   local baseTitle = titleOverride or string.format("'''Match of the Week — %d PSSC (Matchday %d)'''", season, md)
    local goals   = line.goals or 0
   local title = _appendToBoldTitle(baseTitle, derby)
    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
  local out = {}
      + goals*1.25
  out[#out+1] = title
      + assists*0.85
  out[#out+1] = ""
      + poss*0.05
  out[#out+1] = "=== Match Summary ==="
      + son*0.15
  out[#out+1] = summary
      - sof*0.08
  out[#out+1] = ""
      - fouls*0.16
  out[#out+1] = "=== Key Events by Time ==="
 
  out[#out+1] = ""
    if pos == "Keeper" then
  out[#out+1] = "==== First Half (0:00–35:00) ===="
      local saves = math.max(0, (oppShotsOn or 0) - (oppGoals or 0))
  if #firstHalf == 0 then
      rating = 6 + saves*0.25 - fouls*0.10
    out[#out+1] = "—"
      if (oppGoals or 0) == 0 then rating = rating + 1.0 end
  else
    end
     for _,ln in ipairs(firstHalf) do out[#out+1] = "* " .. ln end
 
     return _clamp(rating, 0, 10)
   end
   end
 
  out[#out+1] = ""
   local best = { overall=nil, striker=nil, playmaker=nil, defender=nil, keeper=nil }
   out[#out+1] = string.format("35:00 – Halftime: %s %d–%d %s.", _teamWithLogo(home), hHalf, aHalf, _teamWithLogo(away))
 
  out[#out+1] = ""
   local function consider(entry, bucket)
   out[#out+1] = "==== Second Half (35:00–70:00) ===="
     if not best[bucket] or entry.rating > best[bucket].rating then best[bucket] = entry end
  if #secondHalf == 0 then
     out[#out+1] = "—"
  else
    for _,ln in ipairs(secondHalf) do out[#out+1] = "* " .. ln 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))
  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, _timesList(homeG, 3),
    tonumber(A.goals) or 0, _timesList(awayG, 3)
  )
  out[#out+1] = '|-'
  out[#out+1] = string.format('| Possession Points || %d%s || %d%s',
    tonumber(H.poss_points) or 0, _timesList(homePP, 4),
    tonumber(A.poss_points) or 0, _timesList(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


  for matchIndex, m in ipairs(SCHED[md]) do
function p.renderMatchOfWeek(frame)
    local home, away = m[1], m[2]
  local args = frame.args or {}
    local hr = (rosters and rosters[home.code]) or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
    local ar = (rosters and rosters[away.code]) or {}
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
 
  local pick = args.pick or "best"
    -- match stats (truth source)
 
    local salt = day*1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
  if md < 1 then return "'''No matchday has been played yet.'''"
    local hs, as = simScoreFor(home, away, salt)
  end
    local S = computeMatchStats(home, away, hs, as, salt)
  local fixtures = getRoundMatches(md)
    local H = S[home.code] or {}
  if not fixtures or #fixtures == 0 then return "'''No fixtures found for matchday " .. tostring(md) .. ".'''" end
    local A = S[away.code] or {}
 
 
  local idx, err = _pickMatchIndexForDay(season, md, pick)
    -- deterministic per-match RNG
  if not idx then return "'''" .. (err or "Unable to select match.") .. "'''" end
    local st = _seedFromString(tostring(season).."|AWARDS|MD"..md.."|D"..day.."|M"..matchIndex.."|"..home.code.."|"..away.code)
  return _renderMatchReport(season, md, idx)
 
end
    local linesH, linesA = {}, {}


    local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
function p.renderChaosOfWeek(frame)
    local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
  local args = frame.args or {}
    local W_SHOT = { Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
  args.pick = args.pick or "chaos"
    local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
  args.md = nil
  return p.renderMatchOfWeek({ args = args })
end


    -- allocate goals (+ assists)
function p.renderMatchReport(frame)
    for _=1, (tonumber(H.goals) or 0) do
  local args = frame.args or {}
      local pl; st, pl = pickWeighted(st, hr, W_GOAL)
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
      ensure(linesH, pl.id).goals = ensure(linesH, pl.id).goals + 1
  local md = tonumber(args.md or args.matchday or args[2] or "") or _currentPlayedMatchday()
      local r; st, r = _rngNext(st)
  local matchIndex = tonumber(args.match or args[3] or "1") or 1
      if r < 0.70 then
  local titleOverride = string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''", season, md, matchIndex)
        local a; st, a = pickWeighted(st, hr, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
  return _renderMatchReport(season, md, matchIndex, titleOverride)
        if a and a.id ~= pl.id then ensure(linesH, a.id).assists = ensure(linesH, a.id).assists + 1 end
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
------------------------------------------------------------------------
    for _=1, (tonumber(H.poss_points) or 0) do
-- 12) WEEKLY AWARDS + MATCHDAY CAPSULE
      local pl; st, pl = pickWeighted(st, hr, W_POSS)
------------------------------------------------------------------------
      ensure(linesH, pl.id).poss = ensure(linesH, pl.id).poss + 1
function p.renderWeeklyAwards(frame)
    end
  local args = frame.args or {}
    for _=1, (tonumber(A.poss_points) or 0) do
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
      local pl; st, pl = pickWeighted(st, ar, W_POSS)
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
      ensure(linesA, pl.id).poss = ensure(linesA, pl.id).poss + 1
  if md < 1 then return "'''No matchday has been played yet.'''" end
    end


    -- shots on/off
  local inv = invertDayMap(mapDays())
    for _=1, (tonumber(H.shots_on) or 0) do
  local day = inv[md] or (START + md)
      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


    -- fouls
  local deals = _genTransferDeals(season)
    for _=1, (tonumber(H.fouls) or 0) do
  local base  = _buildBaseRosters(season)
      local pl; st, pl = pickWeighted(st, hr, W_FOUL)
  local rosters = _applyTransfersUpTo(base, deals, md)
      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 mkEntry(teamObj, pp, line, oppStats)
  local function clamp(x, lo, hi)
      local oppShotsOn = tonumber(oppStats.shots_on) or 0
    if x < lo then return lo end
      local oppGoals   = tonumber(oppStats.goals) or 0
    if x > hi then return hi end
      local rating = scorePlayer(line, pp.pos, oppShotsOn, oppGoals)
    return x
   end


      local saves = 0
  local function scorePlayer(line, pos, oppShotsOn, oppGoals)
      local clean = false
    local goals  = line.goals or 0
      if pp.pos == "Keeper" then
    local assists = line.assists or 0
        saves = math.max(0, oppShotsOn - oppGoals)
    local poss    = line.poss or 0
        clean = (oppGoals == 0)
    local son    = line.son or 0
       end
    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


      return {
    if pos == "Keeper" then
        rating = rating,
      local saves = math.max(0, (oppShotsOn or 0) - (oppGoals or 0))
        name = pp.name or pp.id,
      rating = 6 + saves*0.25 - fouls*0.10
        pos  = pp.pos or "",
      if (oppGoals or 0) == 0 then rating = rating + 1.0 end
        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
     end


     local function evalTeam(teamObj, roster, lines, oppStats)
     return clamp(rating, 0, 10)
      for _,pp in ipairs(roster or {}) do
  end
        local line = lines[pp.id] or {goals=0,assists=0,poss=0,son=0,sof=0,fouls=0}
        local e = mkEntry(teamObj, pp, line, oppStats)


        consider(e, "overall")
  local best = { overall=nil, striker=nil, playmaker=nil, defender=nil, keeper=nil }
        if pp.pos == "Striker"  then consider(e, "striker") end
        if pp.pos == "Playmaker" then consider(e, "playmaker") end
        if pp.pos == "Defender"  then consider(e, "defender") end
        if pp.pos == "Keeper"    then consider(e, "keeper") end
      end
    end


    evalTeam(home, hr, linesH, A)
  local function consider(entry, bucket)
     evalTeam(away, ar, linesA, H)
     if not best[bucket] or entry.rating > best[bucket].rating then best[bucket] = entry end
   end
   end


   local out = {}
   local fixtures = getRoundMatches(md)
   out[#out+1] = "=== Weekly Awards (Matchday " .. tostring(md) .. ") ==="
   for matchIndex, m in ipairs(fixtures) do
  out[#out+1] = '{| class="wikitable sortable" style="width:100%; font-size:90%;"'
    local home, away = m[1], m[2]
  out[#out+1] = "! Award !! Winner !! Team !! Pos !! G !! A !! Poss !! S-on !! S-off !! Fouls !! Saves/CS !! Rating"
    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 {}
 
    local st = _seedFromString(tostring(season).."|AWARDS|MD"..md.."|D"..day.."|M"..matchIndex.."|"..home.code.."|"..away.code)
    local linesH, linesA = {}, {}


  local function row(label, e)
    local function ensure(map, pid)
    if not e then return end
      if not map[pid] then map[pid] = {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0} end
    local sc = "—"
       return map[pid]
    if e.pos == "Keeper" then
       sc = tostring(e.saves) .. (e.clean and " / CS" or "")
     end
     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)
    local function pickWeighted(stt, roster, weights)
   row("Striker of the Week", best.striker)
      local pool = {}
   row("Playmaker of the Week", best.playmaker)
      for _,p in ipairs(roster or {}) do
   row("Defender of the Week", best.defender)
        local w = weights[p.pos] or 1
   row("Keeper of the Week", best.keeper)
        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] = "|}"
   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")
   return table.concat(out, "\n")
end
end

Revision as of 17:44, 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 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 ""
  if logo ~= "" and logo ~= "—" then
    return logo .. " " .. name
  end
  return 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 SCHEDULE
--    • SCHED_LOCKED preserves history (your existing generator)
--    • SCHED_BALANCED ensures equal games going forward (38 full rounds)
--    • mapDays() locks past days and remaps future days to balanced rounds
------------------------------------------------------------------------

-- ====== Existing schedule generator (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_greedy()
  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]]
        intra[#intra+1] = {home=A, away=B}
        intra[#intra+1] = {home=B, away=A}
      end
    end
  end

  local allCodes = {}
  for _,t in ipairs(teams) do allCodes[#allCodes+1] = 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)
        inter[#inter+1] = {home = hFirst and a or b, away = hFirst and b or a}
      end
    end
  end

  return intra, inter
end

local function packRounds_greedy(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] = { [m.home.code]=true, [m.away.code]=true }
    end
  end
  return rounds
end

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

  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_greedy(merged)
end)()

-- ====== Balanced schedule (38 full rounds, no byes) ======
local DIV_ORDER = {
  "Morovian Division",
  "Southern Strait Division",
  "Western Highlands Division",
  "Normarkian Division"
}

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 = {}

  -- Intra: 14 rounds (4 matches per division per round = 16 total)
  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

  -- Inter: 24 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

  if #rounds ~= 38 then error("Balanced schedule should have 38 rounds, got "..tostring(#rounds)) end
  for i=1,#rounds do
    if #rounds[i] ~= 16 then error("Balanced round "..i.." has "..tostring(#rounds[i]).." matches") end
  end
  return rounds
end)()

local TOTAL_ROUNDS = 38

-- Day map builder for a schedule length
local function mapDaysFor(schedule)
  local totalDays  = END - START + 1
  local totalRnds  = #schedule
  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 = mapDaysFor(SCHED_LOCKED)

-- Find last played matchday under locked mapping (history boundary)
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

-- Composite mapDays: locked in the past, balanced in the future
local function mapDays()
  local pivotDay, pivotMd = lastPlayedLocked()

  if not pivotDay or pivotMd == 0 then
    return mapDaysFor(SCHED_BALANCED)
  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 _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

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

------------------------------------------------------------------------
-- 4) MATCH SIMULATION (deterministic by day & pairing)
------------------------------------------------------------------------
local function simScoreFor(h, a, seed)
  math.randomseed(seed)
  local p = math.random()
  local hg, ag
  if p < 0.1 then
    hg = math.random(0, 12); ag = hg
  else
    local wh, wa = (h.championships 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 = {
    "Morovian Division","Southern Strait Division",
    "Western Highlands Division","Normarkian Division"
  }

  local out = {}
  for _, div in ipairs(order) do
    local list = buckets[div] or {}
    out[#out+1] = "== " .. div .. " Standings =="
    out[#out+1] = '{| class="wikitable sortable"'
    out[#out+1] = "! Pos !! Team !! W !! D !! L !! Pts !! PF !! PA !! PD"

    for i, rec in ipairs(list) do
      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

  local p1, p2 = seeds[9], seeds[12]
  local p3, p4 = seeds[10], seeds[11]
  add('Play-in-1', PO_FIRST, p1, p2)
  add('Play-in-2', PO_FIRST, p3, p4)

  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 (embedded)
------------------------------------------------------------------------
local DEFAULT_SEASON = 52
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

-- Wide-variation name generator
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 (Match of the Week + Match Report) + 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 _randTime(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 _genTimes(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] = _randTime(st, firstStart, firstEnd)
    else
      st, times[i] = _randTime(st, secondStart, secondEnd)
    end
  end
  table.sort(times)
  return st, times
end

local function _timesList(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 _pickPlayer(st, roster, posA, posB, posC)
  local candidates = {}
  for _,p in ipairs(roster or {}) do
    if p.pos == posA or p.pos == posB or p.pos == posC then
      candidates[#candidates+1] = p
    end
  end
  if #candidates == 0 then candidates = roster end
  local pl; st, pl = _pick(st, candidates)
  return st, pl
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 = _genTimes(st, tonumber(H.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayPP = _genTimes(st, tonumber(A.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, homeG  = _genTimes(st, tonumber(H.goals) or 0,       FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayG  = _genTimes(st, tonumber(A.goals) or 0,       FIRST_START, FIRST_END, SECOND_START, SECOND_END)

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

  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, _timesList(homeG, 3),
    tonumber(A.goals) or 0, _timesList(awayG, 3)
  )
  out[#out+1] = '|-'
  out[#out+1] = string.format('| Possession Points || %d%s || %d%s',
    tonumber(H.poss_points) or 0, _timesList(homePP, 4),
    tonumber(A.poss_points) or 0, _timesList(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 {}

    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