Module:PillSeasonSchedule: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
NewZimiaGov (talk | contribs)
Undo revision 384312 by NewZimiaGov (talk)
Tag: Undo
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
local function md2day(m,d) return (m-1)*61 + d end     -- 61-day “months”
local function absDay(y,d) return y*DAYS_IN_YEAR + d end
local 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 107: Line 108:


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 3) SCHEDULE LOCK + BALANCED FUTURE SCHEDULE
-- 3) DIVISION-WEIGHTED SCHEDULER
--    • SCHED_LOCKED preserves history (your existing generator)
--    • In-division: 7 rivals twice (H/A) -> 14 matches
--    • SCHED_BALANCED ensures equal games going forward (38 full rounds)
--    • Cross-division: other 24 once      -> 24 matches
--    • mapDays() locks past days and remaps future days to balanced rounds
--    = 38 total per club
------------------------------------------------------------------------
------------------------------------------------------------------------
-- ====== 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 121: Line 120:
end
end


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


Line 128: Line 127:
       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]]
         intra[#intra+1] = {home=A, away=B}
         table.insert(intra, {home=A, away=B})
         intra[#intra+1] = {home=B, away=A}
         table.insert(intra, {home=B, away=A})
       end
       end
     end
     end
Line 135: Line 134:


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


Line 144: Line 143:
         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)
         inter[#inter+1] = {home = hFirst and a or b, away = hFirst and b or a}
         table.insert(inter, {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_greedy(matches)
local function packRounds(matches)
   local rounds, used = {}, {}
   local rounds, used = {}, {}
   local function canPlace(r, m)
   local function canPlace(r, m)
Line 171: Line 184:
     if not placed then
     if not placed then
       rounds[#rounds+1] = { { m.home, m.away } }
       rounds[#rounds+1] = { { m.home, m.away } }
       used[#rounds] = { [m.home.code]=true, [m.away.code]=true }
       used[#rounds] = {}
      used[#rounds][m.home.code] = true
      used[#rounds][m.away.code] = true
     end
     end
   end
   end
Line 177: Line 192:
end
end


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


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


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


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


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


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


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


local SCHED_BALANCED = (function()
------------------------------------------------------------------------
  local rounds = {}
-- 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


  -- Intra: 14 rounds (4 matches per division per round = 16 total)
    local prefix = upcoming and "'''Next matchday:''' " or ""
  local intraByDiv = {}
     local mon, dom = getMonthDay(target)
  for _, divName in ipairs(DIV_ORDER) do
     local label    = dom .. " " .. mon .. " " .. year .. " PSSC"
     local rr7 = roundRobin8(DIVISIONS[divName])
     local rows     = {}
     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
    if not upcoming then
     local full = {}
        for _, r in ipairs(simulateDayResults(target)) do
    for _, divName in ipairs(DIV_ORDER) do
            local h, a, hg, ag = r.home, r.away, r.hg, r.ag
      for _, m in ipairs(intraByDiv[divName][r]) do full[#full+1] = m end
            local hc = (hg > ag and "green") or (hg < ag and "red") or "yellow"
            local ac = (ag > hg and "green") or (ag < hg and "red") or "yellow"
            local hs = string.format("<span style='color:%s;'>'''%d'''</span>", hc, hg)
            local as = string.format("<span style='color:%s;'>'''%d'''</span>", ac, ag)
            rows[#rows+1] =
                string.format("%s %s %s vs %s %s %s",
                    h.logo, hs, h.fullName,
                    a.logo, as, a.fullName
                )
        end
     else
        local idx = dm[target]
        for _, m in ipairs(SCHED[idx]) do
            local h, a = m[1], m[2]
            rows[#rows+1] =
                string.format("%s %s vs %s %s",
                    h.logo, h.fullName,
                    a.logo, a.fullName
                )
        end
     end
     end
    rounds[#rounds+1] = full
  end


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


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


   for blockIndex, pairset in ipairs(blocks) do
    local recs   = {}
    local p1 = interPairRounds(pairset[1][1], pairset[1][2], blockIndex)
    for _, t in ipairs(teams) do
    local p2 = interPairRounds(pairset[2][1], pairset[2][2], blockIndex)
        recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0 }
    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


  if #rounds ~= 38 then error("Balanced schedule should have 38 rounds, got "..tostring(#rounds)) end
    local dm = mapDays()
  for i=1,#rounds do
    for d = START, last do
    if #rounds[i] ~= 16 then error("Balanced round "..i.." has "..tostring(#rounds[i]).." matches") end
        local idx = dm[d]
  end
        if idx then
  return rounds
            for k, m in ipairs(SCHED[idx]) do
end)()
                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 TOTAL_ROUNDS = 38
                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


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


local DM_LOCKED = mapDaysFor(SCHED_LOCKED)
    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


-- Find last played matchday under locked mapping (history boundary)
    local function cmp(a,b)
local function lastPlayedLocked()
        if a.Pts ~= b.Pts then return a.Pts > b.Pts end
  local curr = date.getCurrentDate()
        if a.PD  ~= b.PD  then return a.PD  > b.PD  end
  local today = tonumber(curr:match("^(%d+),")) or 0
        if a.W   ~= b.W   then return a.W  > b.W  end
   local lastDay, lastMd = nil, 0
        return a.team.fullName < b.team.fullName
   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
  end
    for div, list in pairs(buckets) do table.sort(list, cmp) end
  return lastDay, lastMd
end


-- Composite mapDays: locked in the past, balanced in the future
    local order = {
local function mapDays()
        "Morovian Division","Southern Strait Division",
  local pivotDay, pivotMd = lastPlayedLocked()
        "Western Highlands Division","Normarkian Division"
 
    }
  if not pivotDay or pivotMd == 0 then
    local out = {}
    return mapDaysFor(SCHED_BALANCED)
    for _, div in ipairs(order) do
  end
        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"


  local dm = {}
        for i, rec in ipairs(list) do
  for d,md in pairs(DM_LOCKED) do
            local style = ""
     if d <= pivotDay then dm[d] = md end
            if     i <= 2 then style = ' style="background-color:#ccffcc;"'
  end
            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


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


   if remainingDays == 0 or remainingRnds == 0 then
------------------------------------------------------------------------
     return dm
-- 7) PREVIOUS RESULTS (last 5 matchdays)
  end
------------------------------------------------------------------------
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 iv = math.floor(remainingDays / remainingRnds)
    for d = today - 1, START, -1 do
  local ex = remainingDays % remainingRnds
        if dm[d] then played[#played+1] = d end
        if #played == 5 then break end
    end


  local day = futureStartDay
    local out = {
  for i=1,remainingRnds do
        '{| class="wikitable sortable"',
    local mdx = pivotMd + i
        '! Date !! Home !! Score !! Away'
    if mdx > TOTAL_ROUNDS then break end
    }
    dm[day] = mdx
    for _, d in ipairs(played) do
    day = day + iv + (ex > 0 and 1 or 0)
        local mon, dom  = getMonthDay(d)
    if ex > 0 then ex = ex - 1 end
        local dateLabel = dom .. " " .. mon .. " " .. year .. " PSSC"
     if day > END then break end
        local idx      = dm[d]
  end
        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


  return dm
------------------------------------------------------------------------
end
-- 8) PLAYOFFS (12 teams seeded from divisions; unchanged seeding block)
------------------------------------------------------------------------
local PO_YEAR  = 52
local PO_FIRST = md2day(1,4)
local PO_IV    = 3


local function invertDayMap(dm)
local function tb_cmp(a,b)
   local inv = {}
   if a.Pts ~= b.Pts then return a.Pts > b.Pts end
   for day, md in pairs(dm) do inv[md] = day end
  if a.PD  ~= b.PD  then return a.PD  > b.PD  end
   return inv
   if a.W  ~= b.W  then return a.W  > b.W  end
   return a.team.fullName < b.team.fullName
end
end


local function _currentPlayedMatchday()
local function computeRecordsUpTo(dayEnd)
   local curr = date.getCurrentDate()
   local recs = {}
  local today = tonumber(curr:match("^(%d+),")) or 0
  for _, t in ipairs(teams) do
    recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0, Div=DIV_OF[t.code] }
  end
   local dm = mapDays()
   local dm = mapDays()
  local lastMd = 0
   for d = START, math.min(dayEnd, END) do
   for d=START, math.min(today, END) do
     local idx = dm[d]
     if dm[d] and dm[d] > lastMd then lastMd = dm[d] end
    if idx then
  end
      for k, m in ipairs(SCHED[idx]) do
  return lastMd
        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 function schedForRound(roundIdx)
        local H, A = recs[h.code], recs[a.code]
  local _, pivotMd = lastPlayedLocked()
        H.PF, H.PA = H.PF + hg, H.PA + ag
  if roundIdx <= pivotMd then return SCHED_LOCKED end
        A.PF, A.PA = A.PF + ag, A.PA + hg
  return SCHED_BALANCED
end


local function getRoundMatches(roundIdx)
        if hg > ag then
  local sched = schedForRound(roundIdx)
          H.W, H.Pts = H.W + 1, H.Pts + 3
  return sched[roundIdx] or {}
          A.L = A.L + 1
end
        elseif hg < ag then
 
          A.W, A.Pts = A.W + 1, A.Pts + 3
------------------------------------------------------------------------
          H.L = H.L + 1
-- 4) MATCH SIMULATION (deterministic by day & pairing)
        else
------------------------------------------------------------------------
          H.D, H.Pts = H.D + 1, H.Pts + 1
local function simScoreFor(h, a, seed)
          A.D, A.Pts = A.D + 1, A.Pts + 1
  math.randomseed(seed)
        end
  local p = math.random()
      end
  local hg, ag
     end
  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
   end
   return hg, ag
  for _, r in pairs(recs) do r.PD = r.PF - r.PA end
   return recs
end
end


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


------------------------------------------------------------------------
  local buckets = {}
-- 5) TODAY / NEXT MATCHDAY RENDER
   for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
------------------------------------------------------------------------
   for _, r in pairs(recs) do
local function renderRound(curDay, year)
    buckets[r.Div] = buckets[r.Div] or {}
   local dm      = mapDays()
     table.insert(buckets[r.Div], r)
  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
   end
  for div, list in pairs(buckets) do table.sort(list, tb_cmp) end


   local prefix = upcoming and "'''Next matchday:''' " or ""
   local winners, runners = {}, {}
   local mon, dom = getMonthDay(target)
  for _, div in ipairs({
   local label    = dom .. " " .. mon .. " " .. year .. " PSSC"
    "Morovian Division","Southern Strait Division",
   local rows    = {}
    "Western Highlands Division","Normarkian Division"
   }) do
    local list = buckets[div] or {}
    if list[1] then table.insert(winners, list[1]) end
    if list[2] then table.insert(runners,  list[2]) end
   end
  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 idx = dm[target]
   local allList = {}
   local matches = getRoundMatches(idx)
   for _, r in pairs(recs) do table.insert(allList, r) end
  table.sort(allList, tb_cmp)


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


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


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


------------------------------------------------------------------------
local function mkRow(tag, absDate, H, A, salt, absNow)
-- 6) DIVISION STANDINGS
   local hs, win = '—','—'
------------------------------------------------------------------------
   if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then
function p.renderStandings(frame)
    local g1,g2 = playScore(H,A,salt)
   local curr   = date.getCurrentDate()
    hs = string.format("'''%d-%d'''", g1, g2)
  local today = tonumber(curr:match("^(%d+),")) or 0
    win = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
  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


   local dm = mapDays()
function p.renderPlayoffSchedule(frame)
   for d = START, last do
   local seeds = getSeeds12()
     local idx = dm[d]
   local out = {
     if idx then
     '{| class="wikitable sortable"',
      local matches = getRoundMatches(idx)
     '! Round !! Date !! Match-up'
      for k, m in ipairs(matches) do
  }
        local h, a = m[1], m[2]
  local function dateCell(day)
        local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
    local mon,dom = getMonthDay(day)
        local hg, ag = simScoreFor(h, a, salt)
    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 H, A = recs[h.code], recs[a.code]
  local p1, p2 = seeds[9], seeds[12]
        H.PF, H.PA = H.PF + hg, H.PA + ag
  local p3, p4 = seeds[10], seeds[11]
        A.PF, A.PA = A.PF + ag, A.PA + hg
  add('Play-in-1', PO_FIRST, p1, p2)
  add('Play-in-2', PO_FIRST, p3, p4)


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


   local buckets = {}
function p.renderPlayoffResults(frame)
  for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
   local seeds = getSeeds12()
   for code, r in pairs(recs) do
   local now  = date.getCurrentDate()
    r.PD = r.PF - r.PA
  local dNow  = tonumber(now:match('^(%d+),')) or 0
    local div = DIV_OF[code] or "—"
   local yNow  = tonumber(now:match(', (%d+) PSSC')) or 0
    buckets[div] = buckets[div] or {}
   local absNow= absDay(yNow,dNow)
    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 = {
   local rows = {
     "Morovian Division","Southern Strait Division",
     '{| class="wikitable sortable"',
     "Western Highlands Division","Normarkian Division"
     '! Round !! Home !! Score !! Away !! Winner'
   }
   }


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


   return table.concat(out, "\n")
   local absR8 = absPI + PO_IV
end
  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 = {}
-- 7) PREVIOUS RESULTS (last 5 matchdays)
  for i,pair in ipairs(R8) do
------------------------------------------------------------------------
    local H,A = pair[1], pair[2]
function p.renderPreviousResults(frame)
    if absNow >= absR8 and A.fullName~='TBD' then
  local curr  = date.getCurrentDate()
      local g1,g2 = playScore(H,A,absR8*100+i)
  local today  = tonumber(curr:match("^(%d+),")) or 0
      W_R8[i]    = (g1>g2) and H or A
  local year  = tonumber(curr:match(", (%d+) PSSC")) or 0
    end
  local dm     = mapDays()
     rows[#rows+1] = mkRow('R-8-'..i, absR8, H, A, absR8*100+i, absNow)
   local played = {}
   end


   for d = today - 1, START, -1 do
   local absR4 = absR8 + PO_IV
     if dm[d] then played[#played+1] = d end
  local R4 = {}
     if #played == 5 then break 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
   end


   local out = {
   local W_R4 = {}
    '{| class="wikitable sortable"',
   for i=1,2 do
    '! Date !! Home !! Score !! Away'
     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
   for _, d in ipairs(played) do
       local g1,g2 = playScore(H,A,absR4*100+i)
     local mon, dom  = getMonthDay(d)
       W_R4[i]     = (g1>g2) and H or A
     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
    rows[#rows+1] = mkRow('R-4-'..i, absR4, H, A, absR4*100+i, absNow)
   end
   end


   out[#out+1] = "|}"
   local absSF = absR4 + PO_IV
   return table.concat(out, "\n")
  local SF = {}
end
   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 = {}
-- 8) PLAYOFFS (12 teams seeded from divisions)
  for i=1,2 do
------------------------------------------------------------------------
    local H,A = SF[i][1], SF[i][2]
local PO_YEAR  = 52
    if absNow >= absSF and A.fullName~='TBD' then
local PO_FIRST = md2day(1,4)
      local g1,g2 = playScore(H,A,absSF*100+i)
local PO_IV    = 3
      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 tb_cmp(a,b)
   rows[#rows+1] = '|}'
   if a.Pts ~= b.Pts then return a.Pts > b.Pts end
   return table.concat(rows, '\n')
   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 recs = {}
-- 9)  PER-MATCH STATS (compute only on match day)
  for _, t in ipairs(teams) do
--    Guarantees: Final = 3*Goals + PossessionPts
     recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0, Div=DIV_OF[t.code] }
--    Adds column-wise highlight of higher value (light green)
------------------------------------------------------------------------
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 dm = mapDays()
   local h_goals, h_poss = splitScore(hg, seed + 101)
   for d = START, math.min(dayEnd, END) do
   local a_goals, a_poss = splitScore(ag, seed + 102)
    local idx = dm[d]
 
    if idx then
  local function rrange(s, lo, hi) math.randomseed(s); return math.random(lo, hi) end
      local matches = getRoundMatches(idx)
  local h_sh_on = (h_goals > 0) and rrange(seed+201, h_goals, h_goals+2) or rrange(seed+201, 0, 2)
      for k, m in ipairs(matches) do
  local a_sh_on = (a_goals > 0) and rrange(seed+202, a_goals, a_goals+2) or rrange(seed+202, 0, 2)
        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]
  local h_sh_off = rrange(seed+203, 0, 3)
        H.PF, H.PA = H.PF + hg, H.PA + ag
  local a_sh_off = rrange(seed+204, 0, 3)
        A.PF, A.PA = A.PF + ag, A.PA + hg


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


   local winners, runners = {}, {}
   return {
  for _, div in ipairs(DIV_ORDER) do
    [h.code] = {
     local list = buckets[div] or {}
      final = hg, goals = h_goals, poss_points = h_poss,
     if list[1] then table.insert(winners, list[1]) end
      shots_on = h_sh_on, shots_off = h_sh_off,
    if list[2] then table.insert(runners, list[2]) end
      fouls = h_fouls, fks = h_fks, throws = h_throws,
  end
      gk = h_gk, oob = h_oob, poss_pct = h_poss_pct
  table.sort(winners, tb_cmp)
     },
   table.sort(runners, tb_cmp)
     [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


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


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


local function playScore(A,B,salt)
   local target = dm[dayNow] and dayNow or nil
  math.randomseed(salt)
   if not target then
   local p = math.random()
    for d = dayNow + 1, END do
   if p < 0.10 then local g=math.random(0,12); return g,g end
      if dm[d] then target = d; break 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)
   end
  local hi    = math.random(1,12)
   if not target then return "'''No scheduled matches in window.'''" end
   local lo    = math.random(0, math.min(hi-1,12))
   return home and hi or lo, home and lo or hi
end


local function mkRow(tag, absDate, H, A, salt, absNow)
  local idx    = dm[target]
  local hs, win = '—','—'
  local isToday = (target == dayNow)
  if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then
 
     local g1,g2 = playScore(H,A,salt)
  local mon, dom = getMonthDay(target)
     hs  = string.format("'''%d-%d'''", g1, g2)
  local label    = dom .. " " .. mon .. " " .. year .. " PSSC"
     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"',
     '! Round !! Date !! Match-up'
     '|+ 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 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]
   if isToday then
  local p3, p4 = seeds[10], seeds[11]
    local results = simulateDayResults(target)
  add('Play-in-1', PO_FIRST, p1, p2)
    for k, r in ipairs(results) do
  add('Play-in-2', PO_FIRST, p3, p4)
      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 banners = {
      local hF,  aF   = hiPairNum(H.final,       A.final)
    { 'Round-of-8', 1 },
      local hG,   aG  = hiPairNum(H.goals,       A.goals)
    { 'Round-of-4', 2 },
      local hP,  aP   = hiPairNum(H.poss_points, A.poss_points)
    { 'Semi-finals (Seeds 1–2 BYE)', 3 },
      local hSON, aSON = hiPairNum(H.shots_on,    A.shots_on)
    { 'Final', 4 }
      local hSOF, aSOF = hiPairNum(H.shots_off,  A.shots_off)
   }
      local hFO,  aFO  = hiPairNum(H.fouls,      A.fouls)
   for _,b in ipairs(banners) do
      local hFK,  aFK  = hiPairNum(H.fks,        A.fks)
    local d = PO_FIRST + PO_IV * b[2]
      local hTH,  aTH  = hiPairNum(H.throws,      A.throws)
    out[#out+1] = '|-'
      local hGK,  aGK  = hiPairNum(H.gk,          A.gk)
    out[#out+1] = '| colspan="3" | ' .. "'''" .. b[1] .. ":'''" .. ' ' .. dateCell(d)
      local hOOB, aOOB = hiPairNum(H.oob,        A.oob)
  end
      local hPOS, aPOS = hiPairNum(H.poss_pct,    A.poss_pct, '%')
  out[#out+1] = '|}'
  return table.concat(out, '\n')
end


function p.renderPlayoffResults(frame)
      -- HOME ROW (Match cell rowspan=2)
  local seeds = getSeeds12()
      out[#out+1] = '|-'
  local now  = date.getCurrentDate()
      out[#out+1] =
  local dNow  = tonumber(now:match('^(%d+),')) or 0
        '| rowspan="2" | ' .. matchLabel ..
  local yNow  = tonumber(now:match(', (%d+) PSSC')) or 0
        ' || ' .. r.home.logo .. ' ' .. r.home.fullName ..
  local absNow= absDay(yNow,dNow)
        ' || ' .. hF ..
        ' || ' .. hG ..
        ' || ' .. hP ..
        ' || ' .. hSON ..
        ' || ' .. hSOF ..
        ' || ' .. hFO ..
        ' || ' .. hFK ..
        ' || ' .. hTH ..
        ' || ' .. hGK ..
        ' || ' .. hOOB ..
        ' || ' .. hPOS


  local rows = {
      -- AWAY ROW (no Match cell; it’s covered by rowspan)
    '{| class="wikitable sortable"',
      out[#out+1] = '|-'
    '! Round !! Home !! Score !! Away !! Winner'
      out[#out+1] =
  }
        '| ' .. r.away.logo .. ' ' .. r.away.fullName ..
 
        ' || ' .. aF ..
  local absPI = absDay(PO_YEAR, PO_FIRST)
        ' || ' .. aG ..
  local PI = { { seeds[9],  seeds[12] }, { seeds[10], seeds[11] } }
        ' || ' .. aP ..
  local W_PI = {}
        ' || ' .. aSON ..
 
        ' || ' .. aSOF ..
  for i,pair in ipairs(PI) do
        ' || ' .. aFO ..
    local H,A = pair[1], pair[2]
        ' || ' .. aFK ..
    if absNow >= absPI then
        ' || ' .. aTH ..
      local g1,g2 = playScore(H,A,absPI*100+i)
        ' || ' .. aGK ..
      W_PI[i]    = (g1>g2) and H or A
        ' || ' .. aOOB ..
        ' || ' .. aPOS
     end
     end
     rows[#rows+1] = mkRow('PI-'..i, absPI, H, A, absPI*100+i, absNow)
  else
  end
     -- Fixture-only view (no stats yet)
    for _, m in ipairs(SCHED[idx]) do
      local matchLabel = m[1].logo .. ' ' .. m[1].fullName .. ' vs ' .. m[2].logo .. ' ' .. m[2].fullName


  local absR8 = absPI + PO_IV
      out[#out+1] = '|-'
  local pool = { seeds[3],seeds[4],seeds[5],seeds[6],seeds[7],seeds[8],
      out[#out+1] =
                W_PI[1] or {logo='—',fullName='TBD'}, W_PI[2] or {logo='—',fullName='TBD'} }
        '| rowspan="2" | ' .. matchLabel ..
  local R8 = { {pool[3], pool[6]}, {pool[4], pool[5]}, {pool[1], pool[8]}, {pool[2], pool[7]} }
        ' || ' .. m[1].logo .. ' ' .. m[1].fullName ..
        ' || — || — || — || — || — || — || — || — || — || — || —'


  local W_R8 = {}
      out[#out+1] = '|-'
  for i,pair in ipairs(R8) do
      out[#out+1] =
    local H,A = pair[1], pair[2]
        '| ' .. m[2].logo .. ' ' .. m[2].fullName ..
    if absNow >= absR8 and A.fullName~='TBD' then
        ' || — || — || — || — || — || — || — || — || — || — || —'
      local g1,g2 = playScore(H,A,absR8*100+i)
     end
      W_R8[i]     = (g1>g2) and H or A
  end
    end
 
    rows[#rows+1] = mkRow('R-8-'..i, absR8, H, A, absR8*100+i, absNow)
  out[#out+1] = '|}'
  end
  return table.concat(out, '\n')
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
-- 10) ROSTERS + TRANSFERS + PLAYER STATS (embedded, deterministic)
    table.sort(W_R8, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end)
--     Includes wide-variation Bassaridian-style name generation.
     R4 = { { W_R8[1], W_R8[4] }, { W_R8[2], W_R8[3] } }
------------------------------------------------------------------------
  end


  for i=1,2 do
-- Config
    local H = R4[i] and R4[i][1] or {logo='—',fullName='TBD'}
local ROSTER_SIZE = 12
    local A = R4[i] and R4[i][2] or {logo='—',fullName='TBD'}
local TRANSFER_MD_START = 20
    if absNow >= absR4 and R4[i] then
local TRANSFER_MD_END  = 30
      local g1,g2 = playScore(H,A,absR4*100+i)
local DEFAULT_SEASON = 52
      W_R4[i]    = (g1>g2) and H or A
 
    end
-- Positions (edit to match in-world naming later if you want)
    rows[#rows+1] = mkRow('R-4-'..i, absR4, H, A, absR4*100+i, absNow)
local POSITIONS = {
   end
  "Striker","Striker",
  "Playmaker","Playmaker","Playmaker",
  "Defender","Defender","Defender",
  "Keeper",
   "Utility","Utility","Utility"
}


   local absSF = absR4 + PO_IV
-----------------------------------------------------------------------
   local SF = {}
-- Deterministic PRNG (does NOT touch math.random / 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


  if #W_R4 == 2 then
local function _rngNext(st)
    local idxByCode = {}
  st = (1103515245 * st + 12345) % 2147483647
    for i,t in ipairs(seeds) do idxByCode[t.code] = i end
   return st, st / 2147483647
    table.sort(W_R4, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end)
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 = {}
local function _pick(st, arr)
  for i=1,2 do
  if not arr or #arr == 0 then return st, nil end
    local H,A = SF[i][1], SF[i][2]
   local r; st, r = _rngNext(st)
    if absNow >= absSF and A.fullName~='TBD' then
   local idx = math.floor(r * #arr) + 1
      local g1,g2 = playScore(H,A,absSF*100+i)
   return st, arr[idx]
      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
end


------------------------------------------------------------------------
local function _normKey(s)
-- 9) PER-MATCH STATS
   if not s or s == "" then return nil end
------------------------------------------------------------------------
  s = mw.ustring.lower(s)
local function computeMatchStats(h, a, hg, ag, seed)
  s = mw.ustring.gsub(s, "%s+", "_")
   local function splitScore(total, salt)
  s = mw.ustring.gsub(s, "[^%w_]", "")
    local maxG = math.floor(total / 3)
  return s
    if maxG == 0 then return 0, total end
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)
-- Wide-variation name generator (syllables + light diacritics)
 
-----------------------------------------------------------------------
  local function rrange(s, lo, hi) math.randomseed(s); return math.random(lo, hi) end
local NAME_STYLES = {
  local h_sh_on = (h_goals > 0) and rrange(seed+201, h_goals, h_goals+2) or rrange(seed+201, 0, 2)
   bassarid = {
  local a_sh_on = (a_goals > 0) and rrange(seed+202, a_goals, a_goals+2) or rrange(seed+202, 0, 2)
    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"}
  },


   local h_sh_off = rrange(seed+203, 0, 3)
   haifan = {
  local a_sh_off = rrange(seed+204, 0, 3)
    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",""}
  },


   local h_fouls = rrange(seed+205, 0, 3)
   normark = {
  local a_fouls = rrange(seed+206, 0, 3)
    firstA = {"Ei","Ha","Le","Bj","Si","As","In","To","Ka","Sk","Ry","Ul","Va","Sa","Jo","Th"},
  local h_fks  = a_fouls
    firstB = {"rik","kon","if","orn","grid","trid","ga","rsten","ri","ald","var","lva","ren","mund","nar","or"},
   local a_fks  = h_fouls
    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  = {"","","",""}
   },


   local h_throws = rrange(seed+207, 0, 3)
   imperial = {
  local a_throws = rrange(seed+208, 0, 3)
    firstA = {"Mar","Jul","Cas","Oct","Luc","Tib","Sab","Aurel","Flav","Dec","Val","Cor","Dom","Sev","Faust","Cris"},
  local h_oob    = rrange(seed+209, 0, 2)
    firstB = {"cus","ia","sian","avia","ian","er","ina","ius","ia","imus","eri","vin","itian","eran","inus","pus"},
  local a_oob    = rrange(seed+210, 0, 2)
    firstC = {"","us","a","","us","a","us","a","us","a"},
  local h_gk     = rrange(seed+211, 0, 2)
    lastA  = {"Valer","Marcell","Aquil","Sever","Corvin","Drus","Faustin","Liv","Domit","Crisp","Aurel","Cass","Octav","Lucan","Tiber","Flav"},
  local a_gk     = rrange(seed+212, 0, 2)
     lastB  = {"ius","us","a","an","inus","ian","ensis","orum","atus","aris"},
     lastC  = {"","","",""}
  }
}


  local base = 50
local NAME_STYLE_KEYS = {"bassarid","haifan","normark","imperial"}
  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 {
local function _cap(s)
    [h.code] = {
   if not s or s == "" then return s end
      final = hg, goals = h_goals, poss_points = h_poss,
  return mw.ustring.upper(mw.ustring.sub(s,1,1)) .. mw.ustring.sub(s,2)
      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


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


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


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


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


   local matches = getRoundMatches(idx)
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


  if isToday then
-----------------------------------------------------------------------
    local results = simulateDayResults(target)
-- Base roster generation (stable per team+season)
    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 function _genBaseRoster(teamCode, season)
      local S    = computeMatchStats(r.home, r.away, r.hg, r.ag, seed)
  local key = _normKey(teamCode) or teamCode
      local H, A = S[r.home.code], S[r.away.code]
  local st = _seedFromString(tostring(season) .. "|" .. key .. "|ROSTER")
  local style = _chooseStyleForTeam(teamCode, season)


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


      local hF,  aF  = hiPairNum(H.final,      A.final)
    local full
      local hG,  aG  = hiPairNum(H.goals,       A.goals)
    for _=1, 10 do
       local hP,   aP  = hiPairNum(H.poss_points, A.poss_points)
       local first, last
       local hSON, aSON = hiPairNum(H.shots_on,   A.shots_on)
       st, first = _buildNameFrom(style, st, false)
      local hSOF, aSOF = hiPairNum(H.shots_off,  A.shots_off)
       st, last = _buildNameFrom(style, st, true)
       local hFO, aFO = hiPairNum(H.fouls,       A.fouls)
       full = first .. " " .. last
      local hFK, aFK  = hiPairNum(H.fks,        A.fks)
       if not used[full] then used[full] = true; break end
       local hTH,  aTH  = hiPairNum(H.throws,      A.throws)
    end
      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] = '|-'
    roster[i] = {
       out[#out+1] =
       id = id,
        '| rowspan="2" | ' .. matchLabel ..
      name = full or ("Player " .. id),
        ' || ' .. r.home.logo .. ' ' .. r.home.fullName ..
      pos = POSITIONS[i] or "Utility",
        ' || ' .. hF ..
      no  = i
        ' || ' .. hG ..
    }
        ' || ' .. hP ..
  end
        ' || ' .. hSON ..
  roster[1].notes = "Captain"
        ' || ' .. hSOF ..
  return roster
        ' || ' .. hFO ..
end
        ' || ' .. hFK ..
        ' || ' .. hTH ..
        ' || ' .. hGK ..
        ' || ' .. hOOB ..
        ' || ' .. hPOS


      out[#out+1] = '|-'
local function _buildBaseRosters(season)
      out[#out+1] =
  local rosters = {}
        '| ' .. r.away.logo .. ' ' .. r.away.fullName ..
   for _,t in ipairs(teams) do
        ' || ' .. aF ..
    rosters[t.code] = _genBaseRoster(t.code, season)
        ' || ' .. aG ..
   end
        ' || ' .. aP ..
   return rosters
        ' || ' .. 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)
-----------------------------------------------------------------------
   local args = frame.args or {}
-- Transfer market (deterministic 1-for-1 swaps, MD20..MD30)
   local day  = tonumber(args.day or '') or 0
-----------------------------------------------------------------------
  local home = args.home
local function _indexById(roster)
  local away = args.away
   local m = {}
   if day == 0 or not home or not away then
   for i,p in ipairs(roster) do m[p.id] = i end
    return "'''Pass day, home, and away:''' day=###, home=Team, away=Team"
   return m
  end
end


  local curr  = date.getCurrentDate()
local function _findSwapCandidate(st, roster, wantPos)
  local dayNow = tonumber(curr:match('^(%d+),')) or 0
   local candidates = {}
  local year  = tonumber(curr:match(', (%d+) PSSC')) or 0
   for _,p in ipairs(roster) do
  local dm    = mapDays()
     if p.pos == wantPos then candidates[#candidates+1] = p end
   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
   end
   if not matchIdx then return "'''Match not found on that day with that home/away.'''" end
   if #candidates == 0 then candidates = roster end
  return _pick(st, candidates)
end


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


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


   if day == dayNow then
   for md = TRANSFER_MD_START, TRANSFER_MD_END do
     local resList = simulateDayResults(day)
     local r; st, r = _rngNext(st)
     local r = resList[matchIdx]
     local dealCount = 1 + math.floor(r * 3) -- 1–3 per matchday
    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)
     for _=1, dealCount do
       hv = tonumber(hv) or 0
      local A,B
       av = tonumber(av) or 0
       st, A = _pick(st, teamCodes)
       suffix = suffix or ""
       repeat st, B = _pick(st, teamCodes) until B ~= A
       local h = tostring(hv) .. suffix
 
       local a = tostring(av) .. suffix
       local baseA = _genBaseRoster(A, season)
       if hv > av then h = '<span style="background-color:#ccffcc;">'..h..'</span>'
       local baseB = _genBaseRoster(B, season)
      elseif av > hv then a = '<span style="background-color:#ccffcc;">'..a..'</span>' end
 
       return h, a
       local pA; st, pA = _pick(st, baseA)
    end
       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 hF,aF    = hi(H.final,       A.final)
       local pB; st, pB = _findSwapCandidate(st, baseB, (pA and pA.pos) or "Utility")
    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
      if A and B and pA and pB then
    out[#out+1] = '|-'; out[#out+1] = '| Goals (Pillar Strikes) || ' .. hG .. ' || ' .. aG
        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] = '| Possession Points || ' .. hP .. ' || ' .. aP
      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
    out[#out+1] = '|-'; out[#out+1] = '| Free Kicks Awarded || ' .. hFK .. ' || ' .. aFK
    out[#out+1] = '|-'; out[#out+1] = '| Throw-Ins || ' .. hTH .. ' || ' .. aTH
    out[#out+1] = '|-'; out[#out+1] = '| Goal Kicks || ' .. hGK .. ' || ' .. aGK
     out[#out+1] = '|-'; out[#out+1] = '| Out of Bounds || ' .. hOOB .. ' || ' .. aOOB
    out[#out+1] = '|-'; out[#out+1] = '| Ball Possession || ' .. hPOS .. ' || ' .. aPOS
  else
    local function blank(row) out[#out+1] = '|-'; out[#out+1] = row end
    blank('| Final Score || — || —')
    blank('| Goals (Pillar Strikes) || — || —')
    blank('| Possession Points || — || —')
    blank('| Shots on Target || — || —')
    blank('| Shots off Target || — || —')
    blank('| Fouls || — || —')
    blank('| Free Kicks Awarded || — || —')
    blank('| Throw-Ins || — || —')
    blank('| Goal Kicks || — || —')
    blank('| Out of Bounds || — || —')
    blank('| Ball Possession || — || —')
   end
   end


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


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


local POSITIONS = {
  for _,d in ipairs(deals) do
  "Striker","Striker",
    if d.md <= upToMd then
  "Playmaker","Playmaker","Playmaker",
      local RA, RB = rosters[d.A], rosters[d.B]
  "Defender","Defender","Defender",
      if RA and RB then
  "Keeper",
        local ia = _indexById(RA)[d.A_id]
   "Utility","Utility","Utility"
        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


-- deterministic PRNG (does NOT touch math.randomseed)
   return rosters
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)
-----------------------------------------------------------------------
   st = (1103515245 * st + 12345) % 2147483647
-- Matchday helpers
   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 _pick(st, arr)
local function _currentPlayedMatchday()
   if not arr or #arr == 0 then return st, nil end
   local curr = date.getCurrentDate()
   local r; st, r = _rngNext(st)
  local today = tonumber(curr:match("^(%d+),")) or 0
   local idx = math.floor(r * #arr) + 1
   local dm = mapDays()
   return st, arr[idx]
   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
end


local function _normKey(s)
-----------------------------------------------------------------------
   if not s or s == "" then return nil end
-- Allocate per-player events that SUM to computeMatchStats outputs
   s = mw.ustring.lower(s)
-----------------------------------------------------------------------
  s = mw.ustring.gsub(s, "%s+", "_")
local function _alloc(st, roster, posWeights)
  s = mw.ustring.gsub(s, "[^%w_]", "")
   local pool = {}
   return s
   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
end


-- Wide-variation name generator
local function _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster)
local NAME_STYLES = {
  local salt = day*1000 + matchIndex + string.byte(homeTeam.code,1) + string.byte(awayTeam.code,1)
  bassarid = {
  local hg, ag = simScoreFor(homeTeam, awayTeam, salt)
    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"},
  local S = computeMatchStats(homeTeam, awayTeam, hg, ag, salt)
    firstC = {"thros","rios","nax","dros","lion","menos","kar","thes","sos","lios","zian","phoros","kris","dor","thes","ron"},
   local H = S[homeTeam.code]
    lastA  = {"Amin","Delph","Kalli","Vareng","Caspaz","Myren","Aurel","Koin","Therm","Skyr","Erythr","Loth","Chrys","Silen","Nexa","Morov"},
   local A = S[awayTeam.code]
    lastB  = {"a","i","o","e","u","y","ae","io","ou","ea"},
   local stats = { home = H, away = A }
    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)
  local st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)
  if not s or s == "" then return s end
  local events = { home={}, away={} }
  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)
  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 function _maybeDiacritics(st, s)
   local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
   local r; st, r = _rngNext(st)
   local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
  if r < 0.08 then
   local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
    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)
  -- goals + assists
  local t = NAME_STYLES[style] or NAME_STYLES.bassarid
  for _=1, stats.home.goals do
  local a,b,c
    local p; st, p = _alloc(st, homeRoster, W_GOAL)
  if not isLast then
    _ensure(events.home, p.id).goals = _ensure(events.home, p.id).goals + 1
    st, a = _pick(st, t.firstA); st, b = _pick(st, t.firstB); st, c = _pick(st, t.firstC)
    local r; st, r = _rngNext(st)
  else
    if r < 0.70 then
     st, a = _pick(st, t.lastA); st, b = _pick(st, t.lastB); st, c = _pick(st, t.lastC)
      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


   local name = _cap((a or "") .. (b or "") .. (c or ""))
   -- possession points
  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


   local r; st, r = _rngNext(st)
   -- shots on/off
  if r < 0.05 and #name >= 6 then
  local function distributeShots(side, roster, on, off)
    local cut = math.floor(#name/2)
    for _=1, on do
    name = mw.ustring.sub(name,1,cut) .. "-" .. mw.ustring.sub(name,cut+1)
      local p; st, p = _alloc(st, roster, {Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0})
  elseif r < 0.08 and #name >= 6 then
      _ensure(side, p.id).son = _ensure(side, p.id).son + 1
    local cut = math.floor(#name/2)
    end
    name = mw.ustring.sub(name,1,cut) .. "’" .. mw.ustring.sub(name,cut+1)
    for _=1, off do
      local p; st, p = _alloc(st, roster, {Striker=4, Playmaker=3, Utility=2, Defender=1, Keeper=0})
      _ensure(side, p.id).sof = _ensure(side, p.id).sof + 1
    end
   end
   end
  distributeShots(events.home, homeRoster, stats.home.shots_on, stats.home.shots_off)
  distributeShots(events.away, awayRoster, stats.away.shots_on, stats.away.shots_off)


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


local function _chooseStyleForTeam(teamCode, season)
-----------------------------------------------------------------------
   local st = _seedFromString(tostring(season).."|"..tostring(teamCode).."|STYLE")
-- Season totals (recomputed on view; "auto-updates" with matchday)
   local key; st, key = _pick(st, NAME_STYLE_KEYS)
-----------------------------------------------------------------------
   return key or "bassarid"
local function _computePlayerSeasonTotals(season, upToMd)
end
   local dm = mapDays()
  local inv = _invertDayMap(dm)
 
   local deals = _genTransferDeals(season)
   local base = _buildBaseRosters(season)


local function _genBaseRoster(teamCode, season)
   local totals = {} -- pid -> totals row
  local key = _normKey(teamCode) or teamCode
  local st = _seedFromString(tostring(season) .. "|" .. key .. "|ROSTER")
   local style = _chooseStyleForTeam(teamCode, season)


   local roster, used = {}, {}
   local function ensurePlayer(pid, name, pos)
  for i=1, ROSTER_SIZE do
    if not totals[pid] then
    local id = key .. "-" .. string.format("%02d", i)
      totals[pid] = { id=pid, name=name or pid, pos=pos or "—", team="—",
    local full
                      apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0 }
    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
     end
     roster[i] = { id=id, name=full or ("Player "..id), pos=POSITIONS[i] or "Utility", no=i }
     return totals[pid]
   end
   end
  roster[1].notes = "Captain"
  return roster
end


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


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


local function _findSwapCandidate(st, roster, wantPos)
        -- home
  local candidates = {}
        for pid, st in pairs(ev.home) do
  for _,p in ipairs(roster) do if p.pos == wantPos then candidates[#candidates+1] = p end end
          local name, pos = pid, "—"
   if #candidates == 0 then candidates = roster end
          for _,pp in ipairs(hr) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
   return _pick(st, candidates)
          local row = ensurePlayer(pid, name, pos)
end
          row.team    = h.code
          row.apps    = row.apps + (st.apps or 0)
          row.goals   = row.goals + (st.goals or 0)
          row.assists = row.assists + (st.assists or 0)
          row.poss    = row.poss + (st.poss or 0)
          row.son    = row.son + (st.son or 0)
          row.sof    = row.sof + (st.sof or 0)
          row.fouls   = row.fouls + (st.fouls or 0)
        end


local function _genTransferDeals(season)
        -- away
  local st = _seedFromString(tostring(season) .. "|TRANSFERWINDOW|MD" .. TRANSFER_MD_START .. "-" .. TRANSFER_MD_END)
        for pid, st in pairs(ev.away) do
  local deals, teamCodes = {}, {}
          local name, pos = pid, "—"
  for _,t in ipairs(teams) do teamCodes[#teamCodes+1] = t.code end
          for _,pp in ipairs(ar) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
          local row = ensurePlayer(pid, name, pos)
          row.team    = a.code
          row.apps    = row.apps + (st.apps or 0)
          row.goals  = row.goals + (st.goals or 0)
          row.assists = row.assists + (st.assists or 0)
          row.poss    = row.poss + (st.poss or 0)
          row.son    = row.son + (st.son or 0)
          row.sof    = row.sof + (st.sof or 0)
          row.fouls  = row.fouls + (st.fouls or 0)
        end
      end
    end
  end


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


      local baseA = _genBaseRoster(A, season)
-----------------------------------------------------------------------
      local baseB = _genBaseRoster(B, season)
-- PUBLIC: Single roster
-- {{#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 pA; st, pA = _pick(st, baseA)
  local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
      if pA and pA.pos == "Keeper" then
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
        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")
  local deals = _genTransferDeals(season)
      if A and B and pA and pB then
  local base  = _buildBaseRosters(season)
        deals[#deals+1] = { md=md, A=A, B=B, A_id=pA.id, B_id=pB.id, pos=pA.pos }
  local rosters = _applyTransfersUpTo(base, deals, upToMd)
      end
  local r = rosters[team] or {}
    end
  end
  return deals
end


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


   for _,d in ipairs(deals) do
   return (TEAM_BY_CODE[team].logo or "") ..
     if d.md <= upToMd then
     " '''" .. team .. "''' (Roster as of Matchday " .. tostring(upToMd) .. ")\n" ..
      local RA, RB = rosters[d.A], rosters[d.B]
     table.concat(out, "\n")
      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
end


local function _alloc(st, roster, posWeights)
-----------------------------------------------------------------------
   local pool = {}
-- PUBLIC: All rosters (collapsible per team)
   for _,p in ipairs(roster) do
-- {{#invoke:PillSeasonSchedule|renderAllRosters|season=52}}
     local w = posWeights[p.pos] or 1
-----------------------------------------------------------------------
     for _=1,w do pool[#pool+1] = p 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
   end
   return _pick(st, pool)
   return table.concat(out, "\n")
end
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
-- PUBLIC: Transfer log (up to current matchday unless md provided)
   return map[pid]
-- {{#invoke:PillSeasonSchedule|renderTransferLog|season=52}}
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 function _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster)
   local deals = _genTransferDeals(season)
   local salt = day*1000 + matchIndex + string.byte(homeTeam.code,1) + string.byte(awayTeam.code,1)
  local hg, ag = simScoreFor(homeTeam, awayTeam, salt)
  local S = computeMatchStats(homeTeam, awayTeam, hg, ag, salt)
  local H = S[homeTeam.code] or {}
  local A = S[awayTeam.code] or {}
  local stats = { home = H, away = A }


   local st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)
   local out = {
   local events = { home={}, away={} }
    '{| 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] = '|}'


   for _,p in ipairs(homeRoster) do _ensure(events.home, p.id).apps = 1 end
   return "'''Transfer window (Matchdays " .. TRANSFER_MD_START .. "–" .. TRANSFER_MD_END ..
  for _,p in ipairs(awayRoster) do _ensure(events.away, p.id).apps = 1 end
    ") — season " .. season .. " (through Matchday " .. tostring(upToMd) .. ")'''\n" ..
    table.concat(out, "\n")
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 }
-- PUBLIC: Team player totals (team roster as of md, totals through md)
  local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
-- {{#invoke:PillSeasonSchedule|renderPlayerStats|team=Vaeringheim Pillar|season=52}}
 
-----------------------------------------------------------------------
  for _=1, (stats.home.goals or 0) do
function p.renderPlayerStats(frame)
    local pz; st, pz = _alloc(st, homeRoster, W_GOAL)
  local args = frame.args or {}
    _ensure(events.home, pz.id).goals = _ensure(events.home, pz.id).goals + 1
  local team = args.team or args[1]
    local r; st, r = _rngNext(st)
  if not team or not TEAM_BY_CODE[team] then
    if r < 0.70 then
    return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''"
      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


   for _=1, (stats.away.goals or 0) do
   local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
    local pz; st, pz = _alloc(st, awayRoster, W_GOAL)
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    _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 totals, deals, base = _computePlayerSeasonTotals(season, upToMd)
    local pz; st, pz = _alloc(st, homeRoster, W_POSS)
   local rostersNow = _applyTransfersUpTo(base, deals, upToMd)
    _ensure(events.home, pz.id).poss = _ensure(events.home, pz.id).poss + 1
  local rosterNow = rostersNow[team] or {}
  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)
   local out = {
    for _=1, (on or 0) do
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
      local pz; st, pz = _alloc(st, roster, {Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0})
    '! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'
       _ensure(side, pz.id).son = _ensure(side, pz.id).son + 1
  }
    end
 
    for _=1, (off or 0) do
  for _,pl in ipairs(rosterNow) do
      local pz; st, pz = _alloc(st, roster, {Striker=4, Playmaker=3, Utility=2, Defender=1, Keeper=0})
    local r = totals[pl.id]
       _ensure(side, pz.id).sof = _ensure(side, pz.id).sof + 1
    out[#out+1] = '|-'
     end
    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
   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
   out[#out+1] = '|}'
    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
   return (TEAM_BY_CODE[team].logo or "") ..
    " '''" .. team .. "''' — Player totals through Matchday " .. tostring(upToMd) .. "\n" ..
    table.concat(out, "\n")
end
end


local function _computePlayerSeasonTotals(season, upToMd)
-----------------------------------------------------------------------
   local dm = mapDays()
-- PUBLIC: League leaders (top 20)
   local inv = invertDayMap(dm)
-- {{#invoke:PillSeasonSchedule|renderLeaders|stat=goals|season=52}}
-----------------------------------------------------------------------
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 deals = _genTransferDeals(season)
   local stat = (args.stat or args[2] or "goals")
   local base = _buildBaseRosters(season)
   local ok = { goals=true, assists=true, poss=true, son=true, sof=true, fouls=true, apps=true }
 
  if not ok[stat] then
  local totals = {}
    return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''"
 
  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
   end


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


      for matchIndex, m in ipairs(fixtures) do
  table.sort(list, function(a,b)
        local h, a = m[1], m[2]
    if (a[stat] or 0) ~= (b[stat] or 0) then return (a[stat] or 0) > (b[stat] or 0) end
        local hr, ar = rosters[h.code] or {}, rosters[a.code] or {}
    return (a.name or "") < (b.name or "")
        local ev = _matchPlayerEvents(season, md, day, matchIndex, h, a, hr, ar)
  end)


        for pid, stt in pairs(ev.home) do
  local out = {
          local name, pos = pid, "—"
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
          for _,pp in ipairs(hr) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
     '! Rank !! Player !! Team (current) !! Pos !! ' .. stat
          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
  for i=1, math.min(20, #list) do
          local name, pos = pid, "—"
    local r = list[i]
          for _,pp in ipairs(ar) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
    out[#out+1] = '|-'
          local row = ensurePlayer(pid, name, pos)
    out[#out+1] = string.format('| %d || %s || %s || %s || %d',
          row.team    = a.code
      i, r.name or "—", _teamWithLogo(r.team or "—"), r.pos or "—", r[stat] or 0
          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


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


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


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


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


  local out = {
local function _randTime(st, startSec, endSec)
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
   local r; st, r = _rngNext(st)
    '! # !! Player !! Position !! ID !! Notes'
  local span = math.max(1, endSec - startSec - 2)
   }
   local sec = startSec + 1 + math.floor(r * span)
  for _,pl in ipairs(r) do
  return st, sec
    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
end


function p.renderAllRosters(frame)
local function _genTimes(st, n, firstStart, firstEnd, secondStart, secondEnd)
   local args = frame.args or {}
   local times = {}
   local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
   if n <= 0 then return st, times end
   local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
   local split = math.ceil(n / 2)
 
   for i=1, n do
   local out = {}
     if i <= split then
  for _,t in ipairs(teams) do
      st, times[i] = _randTime(st, firstStart, firstEnd)
     local title = (t.logo or "") .. " <b>" .. t.code .. "</b>"
     else
     out[#out+1] =
      st, times[i] = _randTime(st, secondStart, secondEnd)
      '<div class="mw-collapsible mw-collapsed" data-expandtext="Show roster" data-collapsetext="Hide roster" style="margin:0.6em 0;">' ..
    end
        '<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")
   table.sort(times)
  return st, times
end
end


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


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


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


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


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


  return (TEAM_BY_CODE[team].logo or "") ..
    local q
     " '''" .. team .. "''' — Player totals through Matchday " .. tostring(upToMd) .. "\n" ..
    if pickMode == "close" then
     table.concat(out, "\n")
      q = (50 - diff * 10) + total + chaos * 0.25
end
     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


function p.renderLeaders(frame)
    if q > bestScore then bestScore, bestI = q, i 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 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


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


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


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


   for i=1, math.min(20, #list) do
   local home, away = fixtures[matchIndex][1], fixtures[matchIndex][2]
    local r = list[i]
  local derby = _rivalryTag(home.code, away.code)
    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] = '|}'
   local salt = day * 1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
  local hs, as = simScoreFor(home, away, salt)


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


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


local function _abbr(teamName)
  -- Times
   local letters = {}
   local st = _seedFromString(tostring(season).."|REPORT|MD"..md.."|M"..matchIndex.."|"..home.code.."|"..away.code)
  for w in mw.ustring.gmatch(teamName or "", "%S+") do
   local FIRST_START, FIRST_END   = 0, 35*60
    letters[#letters+1] = mw.ustring.upper(mw.ustring.sub(w, 1, 1))
   local SECOND_START, SECOND_END = 35*60, 70*60
  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 homeTag, awayTag = _abbr(home.code), _abbr(away.code)
   local r; st, r = _rngNext(st)
 
   local span = math.max(1, endSec - startSec - 2)
  -- scoring components (GOAL = 3 points, POSS = 1 point)
   local sec = startSec + 1 + math.floor(r * span)
  local homePP, awayPP, homeG, awayG
   return st, sec
  st, homePP = _genTimes(st, _getN(H,"poss_points"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
end
  st, awayPP = _genTimes(st, _getN(A,"poss_points"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   st, homeG  = _genTimes(st, _getN(H,"goals"),      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   st, awayG  = _genTimes(st, _getN(A,"goals"),      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
 
  -- highlights
  local homeSON, awaySON, homeFOUL, awayFOUL, homeOOB, awayOOB
  st, homeSON  = _genTimes(st, _getN(H,"shots_on"), FIRST_START, FIRST_END, SECOND_START, SECOND_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)


local function _genTimes(st, n, firstStart, firstEnd, secondStart, secondEnd)
  -- Scoring timeline: running score must add +3 for goals, +1 for possession
   local times = {}
   local scoreEvents = {}
  if n <= 0 then return st, times end
   local function queueScore(sec, which, kind)
   local split = math.ceil(n / 2)
     scoreEvents[#scoreEvents+1] = { t=sec, which=which, kind=kind } -- kind="PP" or "G"
  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
   end
   table.sort(times)
  for _,sec in ipairs(homePP) do queueScore(sec, "H", "PP") end
  return st, times
  for _,sec in ipairs(awayPP) do queueScore(sec, "A", "PP") end
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 function _timesList(times, maxn)
   local events = {}
  if not times or #times == 0 then return "" end
   local function addEvent(sec, text) events[#events+1] = { t=sec, text=text } 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 hRun, aRun = 0, 0
  local candidates = {}
   local hHalf, aHalf = 0, 0
  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)
  for _,e in ipairs(scoreEvents) do
  local dh, da = DIV_OF[homeCode], DIV_OF[awayCode]
    local add = (e.kind == "G") and 3 or 1
  if dh and da and dh == da then
    if e.which == "H" then hRun = hRun + add else aRun = aRun + add end
     return " (Divisional derby — " .. dh .. ")"
    if e.t < 35*60 then hHalf, aHalf = hRun, aRun end
  end
 
  return ""
     local tag    = (e.which=="H") and homeTag or awayTag
end
    local roster = (e.which=="H") and homeRoster or awayRoster
    local scoreLine = string.format("%s %d–%d %s", _teamWithLogo(home), hRun, aRun, _teamWithLogo(away))


local function _appendToBoldTitle(title, suffix)
    if e.kind == "G" then
  if not suffix or suffix == "" then return title end
      local pz; st, pz = _pickPlayer(st, roster, "Striker", "Utility", "Playmaker")
  if not title then return suffix end
      addEvent(e.t, string.format("%s – Goal (Pillar Strike) (%s): %s converts a direct pillar strike. (%s)",
  if mw.ustring.match(title, "'''%s*$") then
        _fmtTime(e.t), tag, (pz and pz.name or "a forward"), scoreLine))
     return mw.ustring.gsub(title, "'''%s*$", suffix .. "'''", 1)
     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


local function _pickMatchIndexForDay(season, md, pickMode)
  -- A few non-scoring highlights (kept small)
  pickMode = pickMode or "best"
  local function addHighlights(times, roster, tag, label, posA, posB, posC, template)
   local fixtures = getRoundMatches(md)
    local cap = math.min(2, #times)
   if not fixtures or #fixtures == 0 then return nil, "No fixtures found for this matchday." end
    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.")


   local inv = invertDayMap(mapDays())
   table.sort(events, function(a,b) return a.t < b.t end)
  local day = inv[md] or (START + md)


   local bestI, bestScore = 1, -1e18
   local firstHalf, secondHalf = {}, {}
   for i, m in ipairs(fixtures) do
   for _,e in ipairs(events) do
     local home, away = m[1], m[2]
     if e.t < 35*60 then firstHalf[#firstHalf+1] = e.text else secondHalf[#secondHalf+1] = e.text end
    local salt = day * 1000 + i + string.byte(home.code,1) + string.byte(away.code,1)
  end
    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 diff = math.abs(hs - as)
    local total = hs + as
  local mood = (diff <= 1) and "a tight, tactical contest" or "a high-variance clash"
    local chaos = (tonumber(H.fouls) or 0) + (tonumber(A.fouls) or 0)
  local summary = string.format(
                + (tonumber(H.shots_on) or 0) + (tonumber(A.shots_on) 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_off) or 0) + (tonumber(A.shots_off) or 0)
    md, mood, _teamWithLogo(home), _teamWithLogo(away), hs, as
  )


    local q
  -- Title with derby tag (works for both override and default)
    if pickMode == "close" then
  local baseTitle = titleOverride or string.format("'''Match of the Week — %d PSSC (Matchday %d)'''", season, md)
      q = (50 - diff * 10) + total + chaos * 0.25
  local title = _appendToBoldTitle(baseTitle, derby)
    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
  -- Output
  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
   return bestI
   out[#out+1] = ""
end
  out[#out+1] = string.format("35:00 – Halftime: %s %d–%d %s.", _teamWithLogo(home), hHalf, aHalf, _teamWithLogo(away))
 
  out[#out+1] = ""
local function _renderMatchReport(season, md, matchIndex, titleOverride)
   out[#out+1] = "==== Second Half (35:00–70:00) ===="
   local fixtures = getRoundMatches(md)
   if #secondHalf == 0 then
   if not fixtures or not fixtures[matchIndex] then
     out[#out+1] = "—"
     return "'''No such match (md=" .. tostring(md) .. ", match=" .. tostring(matchIndex) .. ").'''"
  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',
    _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


   local inv = invertDayMap(mapDays())
-- PUBLIC: Match of the Week (auto-pick match on a matchday)
   local day = inv[md] or (START + md)
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"


   local home, away = fixtures[matchIndex][1], fixtures[matchIndex][2]
   if md < 1 then return "'''No matchday has been played yet.'''"
   local derby = _rivalryTag(home.code, away.code)
  end
  if not SCHED[md] 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


   local salt = day * 1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
-- PUBLIC: Force a specific match report (md + match index)
   local hs, as = simScoreFor(home, away, salt)
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 S = computeMatchStats(home, away, hs, as, salt)
   local titleOverride = string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''", season, md, matchIndex)
   local H = S[home.code] or {}
   return _renderMatchReport(season, md, matchIndex, titleOverride)
  local A = S[away.code] or {}
end


   local deals = _genTransferDeals(season)
-- MATCHDAY CAPSULE (safe: will not error if renderWeeklyAwards isn't present)
   local base  = _buildBaseRosters(season)
function p.renderMatchdayCapsule(frame)
   local rosters = _applyTransfersUpTo(base, deals, md)
   local args = frame.args or {}
  local homeRoster = rosters[home.code] or {}
   local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
   local awayRoster = rosters[away.code] or {}
   local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
   local pick = args.pick or "chaos"  -- default to chaos


   local st = _seedFromString(tostring(season).."|REPORT|MD"..md.."|M"..matchIndex.."|"..home.code.."|"..away.code)
   if md < 1 or not SCHED[md] then
  local FIRST_START, FIRST_END  = 0, 35*60
    return "'''No matchday has been played yet (or no fixtures found).'''"
   local SECOND_START, SECOND_END = 35*60, 70*60
   end


   local homeTag, awayTag = _abbr(home.code), _abbr(away.code)
  -- Convert matchday -> PSSC day (needed for simulateDayResults)
   local inv = _invertDayMap(mapDays())
  local day = inv[md]
  if not day then
    return "'''Unable to resolve PSSC day for matchday " .. tostring(md) .. ".'''"
  end


   local homePP, awayPP, homeG, awayG
   local results = simulateDayResults(day)
  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
   local out = {}
   st, homeSON  = _genTimes(st, tonumber(H.shots_on) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   out[#out+1] = "== Matchday " .. tostring(md) .. " Capsule =="
  st, awaySON  = _genTimes(st, tonumber(A.shots_on) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
   out[#out+1] = ""
  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 = {}
   -- Featured match (auto recap + player names)
   local function queueScore(sec, which, kind) scoreEvents[#scoreEvents+1] = { t=sec, which=which, kind=kind } end
   out[#out+1] = p.renderMatchOfWeek({ args = { season = season, md = md, pick = pick } })
  for _,sec in ipairs(homePP) do queueScore(sec, "H", "PP") end
   out[#out+1] = ""
   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 = {}
   -- Full results list
   local function addEvent(sec, text) events[#events+1] = { t=sec, text=text } end
  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


   local hRun, aRun = 0, 0
   -- Weekly awards (optional)
   local hHalf, aHalf = 0, 0
  if type(p.renderWeeklyAwards) == "function" then
    out[#out+1] = ""
    out[#out+1] = p.renderWeeklyAwards({ args = { season = season, md = md } })
   else
    out[#out+1] = ""
    out[#out+1] = "''(Weekly awards not enabled yet.)''"
  end


   for _,e in ipairs(scoreEvents) do
   return table.concat(out, "\n")
    local add = (e.kind == "G") and 3 or 1
end
    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
-- WEEKLY AWARDS (drop-in)
    local scoreLine = string.format("%s %d–%d %s", _teamWithLogo(home), hRun, aRun, _teamWithLogo(away))
-----------------------------------------------------------------------
function p.renderWeeklyAwards(frame)
  local args = frame.args or {}


    if e.kind == "G" then
  -- LOCAL clamp so it can’t go missing / global-nil
      local pz; st, pz = _pickPlayer(st, roster, "Striker", "Utility", "Playmaker")
  local function _clamp(x, lo, hi)
      addEvent(e.t, string.format("%s – Goal (Pillar Strike) (%s): %s converts a direct pillar strike. (%s)",
    if x < lo then return lo end
        _fmtTime(e.t), tag, (pz and pz.name or "a forward"), scoreLine))
     if x > hi then return hi end
     else
     return x
      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


   local function addHighlights(times, roster, tag, label, posA, posB, posC, template)
   local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
    local cap = math.min(2, #times)
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    for i=1, cap do
 
      local sec = times[i]
  if md < 1 or not SCHED[md] then
      local px; st, px = _pickPlayer(st, roster, posA, posB, posC)
    return "'''No fixtures found for matchday " .. tostring(md) .. ".'''"
      addEvent(sec, string.format("%s – %s (%s): " .. template,
        _fmtTime(sec), label, tag, (px and px.name or "a player")))
    end
   end
   end


   addHighlights(homeFOUL, homeRoster, homeTag, "Foul", "Defender", "Utility", "Playmaker",
   -- Resolve PSSC day for this matchday
    "%s clips a runner during a shielded carry; restart taken quickly.")
  local inv = {}
   addHighlights(awayFOUL, awayRoster, awayTag, "Foul", "Defender", "Utility", "Playmaker",
   for d, mday in pairs(mapDays()) do inv[mday] = d end
    "%s commits a tactical pull in transition; shape preserved on the restart.")
   local day = inv[md] or (START + md)
  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)
   -- Rosters (transfer-aware if available)
  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 firstHalf, secondHalf = {}, {}
   local function ensure(map, pid)
  for _,e in ipairs(events) do
     if not map[pid] then map[pid] = {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0} end
     if e.t < 35*60 then firstHalf[#firstHalf+1] = e.text else secondHalf[#secondHalf+1] = e.text end
    return map[pid]
   end
   end


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


   local baseTitle = titleOverride or string.format("'''Match of the Week — %d PSSC (Matchday %d)'''", season, md)
   local function scorePlayer(line, pos, oppShotsOn, oppGoals)
   local title = _appendToBoldTitle(baseTitle, derby)
    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


  local out = {}
    if pos == "Keeper" then
  out[#out+1] = title
      local saves = math.max(0, (oppShotsOn or 0) - (oppGoals or 0))
  out[#out+1] = ""
      rating = 6 + saves*0.25 - fouls*0.10
  out[#out+1] = "=== Match Summary ==="
      if (oppGoals or 0) == 0 then rating = rating + 1.0 end
  out[#out+1] = summary
     end
  out[#out+1] = ""
 
  out[#out+1] = "=== Key Events by Time ==="
     return _clamp(rating, 0, 10)
  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] = ""
 
  out[#out+1] = string.format("35:00 – Halftime: %s %d–%d %s.", _teamWithLogo(home), hHalf, aHalf, _teamWithLogo(away))
   local best = { overall=nil, striker=nil, playmaker=nil, defender=nil, keeper=nil }
  out[#out+1] = ""
 
   out[#out+1] = "==== Second Half (35:00–70:00) ===="
   local function consider(entry, bucket)
  if #secondHalf == 0 then
    if not best[bucket] or entry.rating > best[bucket].rating then best[bucket] = entry end
    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


function p.renderMatchOfWeek(frame)
  for matchIndex, m in ipairs(SCHED[md]) do
  local args = frame.args or {}
    local home, away = m[1], m[2]
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
    local hr = (rosters and rosters[home.code]) or {}
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    local ar = (rosters and rosters[away.code]) or {}
  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)
    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 {}
 
    -- deterministic per-match RNG
    local st = _seedFromString(tostring(season).."|AWARDS|MD"..md.."|D"..day.."|M"..matchIndex.."|"..home.code.."|"..away.code)


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


  local idx, err = _pickMatchIndexForDay(season, md, pick)
    local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
  if not idx then return "'''" .. (err or "Unable to select match.") .. "'''" end
    local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
  return _renderMatchReport(season, md, idx)
    local W_SHOT = { Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
end
    local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }


function p.renderChaosOfWeek(frame)
    -- allocate goals (+ assists)
  local args = frame.args or {}
    for _=1, (tonumber(H.goals) or 0) do
  args.pick = args.pick or "chaos"
      local pl; st, pl = pickWeighted(st, hr, W_GOAL)
  args.md = nil
      ensure(linesH, pl.id).goals = ensure(linesH, pl.id).goals + 1
  return p.renderMatchOfWeek({ args = args })
      local r; st, r = _rngNext(st)
end
      if r < 0.70 then
 
        local a; st, a = pickWeighted(st, hr, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
function p.renderMatchReport(frame)
        if a and a.id ~= pl.id then ensure(linesH, a.id).assists = ensure(linesH, a.id).assists + 1 end
  local args = frame.args or {}
      end
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
    end
  local md = tonumber(args.md or args.matchday or args[2] or "") or _currentPlayedMatchday()
    for _=1, (tonumber(A.goals) or 0) do
  local matchIndex = tonumber(args.match or args[3] or "1") or 1
      local pl; st, pl = pickWeighted(st, ar, W_GOAL)
  local titleOverride = string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''", season, md, matchIndex)
      ensure(linesA, pl.id).goals = ensure(linesA, pl.id).goals + 1
  return _renderMatchReport(season, md, matchIndex, titleOverride)
      local r; st, r = _rngNext(st)
end
      if r < 0.70 then
        local a; st, a = pickWeighted(st, ar, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
        if a and a.id ~= pl.id then ensure(linesA, a.id).assists = ensure(linesA, a.id).assists + 1 end
      end
    end


------------------------------------------------------------------------
    -- possession points
-- 12) WEEKLY AWARDS + MATCHDAY CAPSULE
    for _=1, (tonumber(H.poss_points) or 0) do
------------------------------------------------------------------------
      local pl; st, pl = pickWeighted(st, hr, W_POSS)
function p.renderWeeklyAwards(frame)
      ensure(linesH, pl.id).poss = ensure(linesH, pl.id).poss + 1
  local args = frame.args or {}
    end
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
    for _=1, (tonumber(A.poss_points) or 0) do
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
      local pl; st, pl = pickWeighted(st, ar, W_POSS)
  if md < 1 then return "'''No matchday has been played yet.'''" end
      ensure(linesA, pl.id).poss = ensure(linesA, pl.id).poss + 1
    end


  local inv = invertDayMap(mapDays())
    -- shots on/off
  local day = inv[md] or (START + md)
    for _=1, (tonumber(H.shots_on) or 0) do
 
      local pl; st, pl = pickWeighted(st, hr, W_SHOT)
  local deals = _genTransferDeals(season)
      ensure(linesH, pl.id).son = ensure(linesH, pl.id).son + 1
  local base  = _buildBaseRosters(season)
    end
  local rosters = _applyTransfersUpTo(base, deals, md)
    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
    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 clamp(x, lo, hi)
    local function mkEntry(teamObj, pp, line, oppStats)
    if x < lo then return lo end
      local oppShotsOn = tonumber(oppStats.shots_on) or 0
    if x > hi then return hi end
      local oppGoals  = tonumber(oppStats.goals) or 0
    return x
      local rating = scorePlayer(line, pp.pos, oppShotsOn, oppGoals)
  end


  local function scorePlayer(line, pos, oppShotsOn, oppGoals)
      local saves = 0
    local goals  = line.goals or 0
      local clean = false
    local assists = line.assists or 0
      if pp.pos == "Keeper" then
    local poss    = line.poss or 0
        saves = math.max(0, oppShotsOn - oppGoals)
    local son    = line.son or 0
        clean = (oppGoals == 0)
    local sof    = line.sof or 0
       end
    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
      return {
      local saves = math.max(0, (oppShotsOn or 0) - (oppGoals or 0))
        rating = rating,
      rating = 6 + saves*0.25 - fouls*0.10
        name = pp.name or pp.id,
      if (oppGoals or 0) == 0 then rating = rating + 1.0 end
        pos  = pp.pos or "—",
        team = teamObj.code,
        logo = teamObj.logo or "",
        goals  = line.goals or 0,
        assists = line.assists or 0,
        poss    = line.poss or 0,
        son    = line.son or 0,
        sof    = line.sof or 0,
        fouls   = line.fouls or 0,
        saves  = saves,
        clean  = clean
      }
     end
     end


     return clamp(rating, 0, 10)
     local function evalTeam(teamObj, roster, lines, oppStats)
  end
      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 e = mkEntry(teamObj, pp, line, oppStats)


  local best = { overall=nil, striker=nil, playmaker=nil, defender=nil, keeper=nil }
        consider(e, "overall")
        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


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


   local fixtures = getRoundMatches(md)
   local out = {}
   for matchIndex, m in ipairs(fixtures) do
  out[#out+1] = "=== Weekly Awards (Matchday " .. tostring(md) .. ") ==="
    local home, away = m[1], m[2]
   out[#out+1] = '{| class="wikitable sortable" style="width:100%; font-size:90%;"'
    local hr = rosters[home.code] or {}
  out[#out+1] = "! Award !! Winner !! Team !! Pos !! G !! A !! Poss !! S-on !! S-off !! Fouls !! Saves/CS !! Rating"
    local ar = rosters[away.code] or {}


    local salt = day*1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
  local function row(label, e)
     local hs, as = simScoreFor(home, away, salt)
     if not e then return end
     local S = computeMatchStats(home, away, hs, as, salt)
     local sc = "—"
     local H = S[home.code] or {}
     if e.pos == "Keeper" then
    local A = S[away.code] or {}
      sc = tostring(e.saves) .. (e.clean and " / CS" 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
     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


    local function pickWeighted(stt, roster, weights)
   row("Player of the Week", best.overall)
      local pool = {}
   row("Striker of the Week", best.striker)
      for _,p in ipairs(roster or {}) do
   row("Playmaker of the Week", best.playmaker)
        local w = weights[p.pos] or 1
   row("Defender of the Week", best.defender)
        for _=1,w do pool[#pool+1] = p end
   row("Keeper of the Week", best.keeper)
      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 } })


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

Revision as of 17:46, 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     -- 61-day “months”
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

-- Team display helpers (always show logo + name when available)
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) DIVISION-WEIGHTED SCHEDULER
--    • In-division: 7 rivals twice (H/A)  -> 14 matches
--    • Cross-division: other 24 once      -> 24 matches
--    = 38 total per club
------------------------------------------------------------------------
local function homeFirst(codeA, codeB)
  local sA, sB = 0, 0
  for i=1,#codeA do sA = (sA + string.byte(codeA,i)) % 9973 end
  for i=1,#codeB do sB = (sB + string.byte(codeB,i)) % 9973 end
  return sA > sB
end

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

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

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

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

  table.sort(intra, function(a,b)
    if DIV_OF[a.home.code] ~= DIV_OF[b.home.code] then
      return DIV_OF[a.home.code] < DIV_OF[b.home.code]
    end
    return a.home.code .. a.away.code < b.home.code .. b.away.code
  end)

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

  return intra, inter
end

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

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

  local function chunk(arr, size)
    local out = {}
    for i=1,#arr,size do
      local b = {}
      for j=i, math.min(i+size-1, #arr) do b[#b+1] = arr[j] end
      out[#out+1] = b
    end
    return out
  end

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

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

  return packRounds(merged)
end)()

local function generateSchedule() return SCHED end

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

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

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

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

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

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

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

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

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

------------------------------------------------------------------------
-- 6) DIVISION STANDINGS (color-coded by divisional position)
------------------------------------------------------------------------
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
            for k, m in ipairs(SCHED[idx]) do
                local h, a = m[1], m[2]
                local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
                local hg, ag = simScoreFor(h, a, salt)

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

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

    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]
        for k, m in ipairs(SCHED[idx]) do
            local h, a = m[1], m[2]
            local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
            local hg, ag = simScoreFor(h, a, salt)
            local hs = "'''" .. hg .. "-" .. ag .. "'''"
            out[#out+1] = "|-"
            out[#out+1] =
                "| " .. dateLabel
                .. " || " .. h.logo .. " " .. h.fullName
                .. " || " .. hs
                .. " || " .. a.logo .. " " .. a.fullName
        end
    end
    out[#out+1] = "|}"
    return table.concat(out, "\n")
end

------------------------------------------------------------------------
-- 8) PLAYOFFS (12 teams seeded from divisions; unchanged seeding block)
------------------------------------------------------------------------
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
      for k, m in ipairs(SCHED[idx]) do
        local h, a = m[1], m[2]
        local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
        local hg, ag = simScoreFor(h, a, salt)

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

        if hg > ag then
          H.W, H.Pts = H.W + 1, H.Pts + 3
          A.L = A.L + 1
        elseif hg < ag then
          A.W, A.Pts = A.W + 1, A.Pts + 3
          H.L = H.L + 1
        else
          H.D, H.Pts = H.D + 1, H.Pts + 1
          A.D, A.Pts = A.D + 1, A.Pts + 1
        end
      end
    end
  end
  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({
    "Morovian Division","Southern Strait Division",
    "Western Highlands Division","Normarkian Division"
  }) do
    local list = buckets[div] or {}
    if list[1] then table.insert(winners, list[1]) end
    if list[2] then table.insert(runners,  list[2]) end
  end
  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+1,B.championships+1
  local home  = (p-0.10)/0.90 < wa/(wa+wb)
  local hi    = math.random(1,12)
  local lo    = math.random(0, math.min(hi-1,12))
  return home and hi or lo, home and lo or hi
end

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

  local W_R4 = {}
  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 (compute only on match day)
--     Guarantees: Final = 3*Goals + PossessionPts
--     Adds column-wise highlight of higher value (light green)
------------------------------------------------------------------------
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

-- highlight helper: returns HTML-wrapped cells for the higher value
local function hiPair(hVal, aVal)
  local function cell(v, isHigh)
    if isHigh then
      return string.format('<span style="background-color:#ccffcc;">%s</span>', v)
    else
      return tostring(v)
    end
  end
  local hHigh = (hVal > aVal)
  local aHigh = (aVal > hVal)
  return cell(hVal, hHigh), cell(aVal, aHigh)
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"

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

  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 %'
  }

  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, '%')

      -- HOME ROW (Match cell rowspan=2)
      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

      -- AWAY ROW (no Match cell; it’s covered by rowspan)
      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
    -- Fixture-only view (no stats yet)
    for _, m in ipairs(SCHED[idx]) do
      local matchLabel = m[1].logo .. ' ' .. m[1].fullName .. ' vs ' .. m[2].logo .. ' ' .. m[2].fullName

      out[#out+1] = '|-'
      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


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 10) ROSTERS + TRANSFERS + PLAYER STATS (embedded, deterministic)
--     Includes wide-variation Bassaridian-style name generation.
------------------------------------------------------------------------

-- Config
local ROSTER_SIZE = 12
local TRANSFER_MD_START = 20
local TRANSFER_MD_END   = 30
local DEFAULT_SEASON = 52

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

-----------------------------------------------------------------------
-- Deterministic PRNG (does NOT touch math.random / 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 (syllables + light diacritics)
-----------------------------------------------------------------------
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)
  -- very light touch
  local r; st, r = _rngNext(st)
  if r < 0.08 then
    s = mw.ustring.gsub(s, "a", "ä", 1)
  elseif r < 0.16 then
    s = mw.ustring.gsub(s, "i", "ï", 1)
  elseif r < 0.24 then
    s = mw.ustring.gsub(s, "o", "ö", 1)
  elseif r < 0.32 then
    s = mw.ustring.gsub(s, "u", "ü", 1)
  end
  return st, s
end

local function _buildNameFrom(style, st, isLast)
  local 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 ""))

  -- rare hyphen/apostrophe
  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

-----------------------------------------------------------------------
-- Base roster generation (stable per team+season)
-----------------------------------------------------------------------
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

-----------------------------------------------------------------------
-- Transfer market (deterministic 1-for-1 swaps, MD20..MD30)
-----------------------------------------------------------------------
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) -- 1–3 per matchday

    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

-----------------------------------------------------------------------
-- Matchday helpers
-----------------------------------------------------------------------
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

-----------------------------------------------------------------------
-- Allocate per-player events that SUM to computeMatchStats outputs
-----------------------------------------------------------------------
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]
  local A = S[awayTeam.code]
  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={} }

  -- appearances: count everyone on roster as 1 appearance (simple)
  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 }

  -- goals + assists
  for _=1, stats.home.goals do
    local p; st, p = _alloc(st, homeRoster, W_GOAL)
    _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

  -- possession points
  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 function distributeShots(side, roster, on, off)
    for _=1, on do
      local p; st, p = _alloc(st, roster, {Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0})
      _ensure(side, p.id).son = _ensure(side, p.id).son + 1
    end
    for _=1, off do
      local p; st, p = _alloc(st, roster, {Striker=4, Playmaker=3, Utility=2, Defender=1, Keeper=0})
      _ensure(side, p.id).sof = _ensure(side, p.id).sof + 1
    end
  end
  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
  for _=1, stats.home.fouls do
    local p; st, p = _alloc(st, homeRoster, W_FOUL)
    _ensure(events.home, p.id).fouls = _ensure(events.home, p.id).fouls + 1
  end
  for _=1, stats.away.fouls do
    local p; st, p = _alloc(st, awayRoster, W_FOUL)
    _ensure(events.away, p.id).fouls = _ensure(events.away, p.id).fouls + 1
  end

  return events
end

-----------------------------------------------------------------------
-- Season totals (recomputed on view; "auto-updates" with matchday)
-----------------------------------------------------------------------
local function _computePlayerSeasonTotals(season, upToMd)
  local dm = mapDays()
  local inv = _invertDayMap(dm)

  local deals = _genTransferDeals(season)
  local base = _buildBaseRosters(season)

  local totals = {} -- pid -> totals row

  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, #SCHED) do
    local day = inv[md]
    if day then
      local rosters = _applyTransfersUpTo(base, deals, md)

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

        -- home
        for pid, st 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 + (st.apps or 0)
          row.goals   = row.goals + (st.goals or 0)
          row.assists = row.assists + (st.assists or 0)
          row.poss    = row.poss + (st.poss or 0)
          row.son     = row.son + (st.son or 0)
          row.sof     = row.sof + (st.sof or 0)
          row.fouls   = row.fouls + (st.fouls or 0)
        end

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

  return totals, deals, base
end

-----------------------------------------------------------------------
-- PUBLIC: Single roster
-- {{#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 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

-----------------------------------------------------------------------
-- PUBLIC: All rosters (collapsible per team)
-- {{#invoke:PillSeasonSchedule|renderAllRosters|season=52}}
-----------------------------------------------------------------------
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

-----------------------------------------------------------------------
-- PUBLIC: Transfer log (up to current matchday unless md provided)
-- {{#invoke:PillSeasonSchedule|renderTransferLog|season=52}}
-----------------------------------------------------------------------
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

-----------------------------------------------------------------------
-- PUBLIC: Team player totals (team roster as of md, totals through md)
-- {{#invoke:PillSeasonSchedule|renderPlayerStats|team=Vaeringheim Pillar|season=52}}
-----------------------------------------------------------------------
function p.renderPlayerStats(frame)
  local args = frame.args or {}
  local 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

-----------------------------------------------------------------------
-- PUBLIC: League leaders (top 20)
-- {{#invoke:PillSeasonSchedule|renderLeaders|stat=goals|season=52}}
-----------------------------------------------------------------------
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

-----------------------------------------------------------------------
-- MATCH REPORTS: Match of the Week + single match report (WITH 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 _getN(t, k) return (t and tonumber(t[k])) or 0 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 _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 _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

-- Rivalry tag: same division => derby
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

-- Append derby tag INSIDE trailing bold quotes if title ends with '''
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 = SCHED[md]
  if not fixtures 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 = (_getN(H,"fouls") + _getN(A,"fouls"))
                + (_getN(H,"shots_on") + _getN(A,"shots_on"))
                + (_getN(H,"shots_off") + _getN(A,"shots_off"))

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

  -- Rosters (transfer-aware for this matchday)
  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 {}

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

  -- scoring components (GOAL = 3 points, POSS = 1 point)
  local homePP, awayPP, homeG, awayG
  st, homePP = _genTimes(st, _getN(H,"poss_points"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayPP = _genTimes(st, _getN(A,"poss_points"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, homeG  = _genTimes(st, _getN(H,"goals"),       FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayG  = _genTimes(st, _getN(A,"goals"),       FIRST_START, FIRST_END, SECOND_START, SECOND_END)

  -- highlights
  local homeSON, awaySON, homeFOUL, awayFOUL, homeOOB, awayOOB
  st, homeSON  = _genTimes(st, _getN(H,"shots_on"), FIRST_START, FIRST_END, SECOND_START, SECOND_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
  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

  -- A few non-scoring highlights (kept small)
  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
  )

  -- Title with derby tag (works for both override and default)
  local baseTitle = titleOverride or string.format("'''Match of the Week — %d PSSC (Matchday %d)'''", season, md)
  local title = _appendToBoldTitle(baseTitle, derby)

  -- Output
  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',
    _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)
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
  if not SCHED[md] 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

-- PUBLIC: Force a specific match report (md + match index)
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

-- MATCHDAY CAPSULE (safe: will not error if renderWeeklyAwards isn't present)
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
    return "'''No matchday has been played yet (or no fixtures found).'''"
  end

  -- Convert matchday -> PSSC day (needed for simulateDayResults)
  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] = ""

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

  -- Full results list
  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

  -- Weekly awards (optional)
  if type(p.renderWeeklyAwards) == "function" then
    out[#out+1] = ""
    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")
end

-----------------------------------------------------------------------
-- WEEKLY AWARDS (drop-in)
-----------------------------------------------------------------------
function p.renderWeeklyAwards(frame)
  local args = frame.args or {}

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

  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 or not SCHED[md] then
    return "'''No fixtures found for matchday " .. tostring(md) .. ".'''"
  end

  -- Resolve PSSC day for this matchday
  local inv = {}
  for d, mday in pairs(mapDays()) do inv[mday] = d end
  local day = inv[md] or (START + md)

  -- Rosters (transfer-aware if available)
  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)
    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(st, 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(st, pool)
  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

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

    -- match stats (truth source)
    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 {}

    -- deterministic per-match RNG
    local st = _seedFromString(tostring(season).."|AWARDS|MD"..md.."|D"..day.."|M"..matchIndex.."|"..home.code.."|"..away.code)

    local linesH, linesA = {}, {}

    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 }

    -- allocate goals (+ assists)
    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

    -- possession points
    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

    -- shots on/off
    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

    -- fouls
    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 mkEntry(teamObj, pp, line, oppStats)
      local oppShotsOn = tonumber(oppStats.shots_on) or 0
      local oppGoals   = tonumber(oppStats.goals) or 0
      local rating = scorePlayer(line, pp.pos, oppShotsOn, oppGoals)

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

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

    local function evalTeam(teamObj, roster, lines, oppStats)
      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 e = mkEntry(teamObj, pp, line, oppStats)

        consider(e, "overall")
        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)
    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) .. (e.clean and " / CS" or "")
    end
    out[#out+1] = "|-"
    out[#out+1] = string.format("| %s || %s || %s || %s || %d || %d || %d || %d || %d || %d || %s || %.2f",
      label,
      e.name,
      _teamWithLogo(e.team or "—"),
      e.pos or "—",
      e.goals or 0,
      e.assists or 0,
      e.poss or 0,
      e.son or 0,
      e.sof or 0,
      e.fouls or 0,
      sc,
      e.rating or 0
    )
  end

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

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

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