Module:PillSeasonSchedule

From MicrasWiki
Revision as of 03:26, 12 December 2025 by NewZimiaGov (talk | contribs)
Jump to navigationJump to search

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

------------------------------------------------------------------------
-- 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"
  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     = hiPair(H.final,        A.final)
      local hG,aG     = hiPair(H.goals,        A.goals)
      local hP,aP     = hiPair(H.poss_points,  A.poss_points)
      local hSON,aSON = hiPair(H.shots_on,     A.shots_on)
      local hSOF,aSOF = hiPair(H.shots_off,    A.shots_off)
      local hFO,aFO   = hiPair(H.fouls,        A.fouls)
      local hFK,aFK   = hiPair(H.fks,          A.fks)
      local hTH,aTH   = hiPair(H.throws,       A.throws)
      local hGK,aGK   = hiPair(H.gk,           A.gk)
      local hOOB,aOOB = hiPair(H.oob,          A.oob)
      local hPOS,aPOS = hiPair(H.poss_pct .. '%', A.poss_pct .. '%')

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

      out[#out+1] = '|-'
      out[#out+1] = string.format(
        '| %s %s || %s || %s || %s || %s || %s || %s || %s || %s || %s || %s || %s',
        r.away.logo, r.away.fullName,
        aF, aG, aP, aSON, aSOF, aFO, aFK, aTH, aGK, aOOB, aPOS)
    end
  else
    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] = string.format('| rowspan="2" | %s || %s %s || — || — || — || — || — || — || — || — || — || — || —',
        matchLabel, m[1].logo, m[1].fullName)
      out[#out+1] = '|-'
      out[#out+1] = string.format('| %s %s || — || — || — || — || — || — || — || — || — || —',
        m[2].logo, m[2].fullName)
    end
  end

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

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

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

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

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

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

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

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

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

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

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

------------------------------------------------------------------------
------------------------------------------------------------------------
-- 10) ROSTERS + TRANSFERS + PLAYER STATS (embedded, 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]
-- ADD THIS:
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, d.A, 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 "—", 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
-- Fixes:
--  • Uses MediaWiki headings (=) (no "1.1.1" numbering)
--  • Running score uses Pillarion scoring: +3 per Goal, +1 per Possession Play
--  • Uses computeMatchStats keys: final, goals, poss_points, shots_on/off, fouls, fks, throws, gk, oob, poss_pct
-----------------------------------------------------------------------

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

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

  -- Build 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", home.code, hRun, aRun, away.code)

    if e.kind == "G" then
      local p; st, p = _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, (p and p.name or "a forward"), scoreLine))
    else
      local p; st, p = _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, (p and p.name or "a midfielder"), scoreLine))
    end
  end

  -- Add 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 p; st, p = _pickPlayer(st, roster, posA, posB, posC)
      addEvent(sec, string.format("%s – %s (%s): " .. template,
        _fmtTime(sec), label, tag, (p and p.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)

  -- Split into halves
  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

  -- Summary
  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, home.code, away.code, hs, as
  )

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

  -- Output using MediaWiki headings (no numbered list artifacts)
  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.", home.code, hHalf, aHalf, away.code)
  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.", home.code, hs, as, away.code)
  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', home.code, away.code)
  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 "chaos"

  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 title = string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''", season, md, matchIndex)
  return _renderMatchReport(season, md, matchIndex, title)
end

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

-- Per-match player allocation + weekly awards (deterministic; sums to match stats)
local function _clamp(x, lo, hi)
  if x < lo then return lo end
  if x > hi then return hi end
  return x
end

local function _scorePlayer(line, pos, oppShotsOn, oppGoals)
  -- Base rating around 6; tune weights whenever you want.
  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.2
    + assists*0.8
    + poss*0.05
    + son*0.15
    - sof*0.08
    - fouls*0.15

  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 -- clean sheet bonus
  end

  return _clamp(rating, 0, 10)
end

local function _allocMatchPlayerLines(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster)
  local salt = day*1000 + matchIndex + string.byte(homeTeam.code,1) + string.byte(awayTeam.code,1)
  local hg, ag = simScoreFor(homeTeam, awayTeam, salt)

  local S = computeMatchStats(homeTeam, awayTeam, hg, ag, salt)
  local H = S[homeTeam.code] or {}
  local A = S[awayTeam.code] or {}

  local st = _seedFromString(tostring(season).."|AWARDS|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)

  local linesH, linesA = {}, {}

  local function ensure(map, pid)
    map[pid] = map[pid] or { goals=0, assists=0, poss=0, son=0, sof=0, fouls=0 }
    return map[pid]
  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 }

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

  -- Home goals (+ assists sometimes)
  for _=1, _getN(H,"goals") do
    local p; st, p = alloc(st, homeRoster, W_GOAL)
    ensure(linesH, p.id).goals = ensure(linesH, 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(linesH, a.id).assists = ensure(linesH, a.id).assists + 1 end
    end
  end

  -- Away goals
  for _=1, _getN(A,"goals") do
    local p; st, p = alloc(st, awayRoster, W_GOAL)
    ensure(linesA, p.id).goals = ensure(linesA, 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(linesA, a.id).assists = ensure(linesA, a.id).assists + 1 end
    end
  end

  -- Possession points
  for _=1, _getN(H,"poss_points") do
    local p; st, p = alloc(st, homeRoster, W_POSS)
    ensure(linesH, p.id).poss = ensure(linesH, p.id).poss + 1
  end
  for _=1, _getN(A,"poss_points") do
    local p; st, p = alloc(st, awayRoster, W_POSS)
    ensure(linesA, p.id).poss = ensure(linesA, p.id).poss + 1
  end

  -- Shots on/off
  for _=1, _getN(H,"shots_on") do
    local p; st, p = alloc(st, homeRoster, W_SHOT)
    ensure(linesH, p.id).son = ensure(linesH, p.id).son + 1
  end
  for _=1, _getN(H,"shots_off") do
    local p; st, p = alloc(st, homeRoster, W_SHOT)
    ensure(linesH, p.id).sof = ensure(linesH, p.id).sof + 1
  end

  for _=1, _getN(A,"shots_on") do
    local p; st, p = alloc(st, awayRoster, W_SHOT)
    ensure(linesA, p.id).son = ensure(linesA, p.id).son + 1
  end
  for _=1, _getN(A,"shots_off") do
    local p; st, p = alloc(st, awayRoster, W_SHOT)
    ensure(linesA, p.id).sof = ensure(linesA, p.id).sof + 1
  end

  -- Fouls
  for _=1, _getN(H,"fouls") do
    local p; st, p = alloc(st, homeRoster, W_FOUL)
    ensure(linesH, p.id).fouls = ensure(linesH, p.id).fouls + 1
  end
  for _=1, _getN(A,"fouls") do
    local p; st, p = alloc(st, awayRoster, W_FOUL)
    ensure(linesA, p.id).fouls = ensure(linesA, p.id).fouls + 1
  end

  return linesH, linesA, H, A
end

function p.renderWeeklyAwards(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
  if not SCHED[md] then return "'''No fixtures for matchday " .. tostring(md) .. ".'''" 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 deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local rosters = _applyTransfersUpTo(base, deals, md)

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

  local function consider(teamObj, roster, pid, pos, line, oppShotsOn, oppGoals)
    local r = _scorePlayer(line, pos, oppShotsOn, oppGoals)
    local name = pid
    for _,pp in ipairs(roster) do if pp.id == pid then name = pp.name; break end end
    local entry = { rating=r, name=name, team=teamObj.code, logo=teamObj.logo or "", pos=pos }

    local function upd(slot)
      if (not best[slot]) or entry.rating > best[slot].rating then best[slot] = entry end
    end

    upd("overall")
    if pos == "Striker"   then upd("striker") end
    if pos == "Playmaker" then upd("playmaker") end
    if pos == "Defender"  then upd("defender") end
    if pos == "Keeper"    then upd("keeper") end
  end

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

    local linesH, linesA, H, A = _allocMatchPlayerLines(season, md, day, matchIndex, home, away, hr, ar)

    -- evaluate home players (their keeper uses away shots/goals for saves/clean sheet)
    for _,pp in ipairs(hr) do
      local line = linesH[pp.id] or { goals=0,assists=0,poss=0,son=0,sof=0,fouls=0 }
      consider(home, hr, pp.id, pp.pos, line, _getN(A,"shots_on"), _getN(A,"goals"))
    end

    -- evaluate away players
    for _,pp in ipairs(ar) do
      local line = linesA[pp.id] or { goals=0,assists=0,poss=0,son=0,sof=0,fouls=0 }
      consider(away, ar, pp.id, pp.pos, line, _getN(H,"shots_on"), _getN(H,"goals"))
    end
  end

  local out = {}
  out[#out+1] = "=== Weekly Awards (Matchday " .. tostring(md) .. ") ==="
  out[#out+1] = '{| class="wikitable" style="width:100%; font-size:90%;"'
  out[#out+1] = "! Award !! Winner !! Team !! Rating"
  local function row(label, e)
    if not e then return end
    out[#out+1] = "|-"
    out[#out+1] = string.format("| %s || %s || %s %s || %.2f",
      label, e.name, e.logo or "", e.team, e.rating)
  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"  -- default to chaos, per your request

  if md < 1 or not SCHED[md] then
    return "'''No matchday has been played yet (or no fixtures found).'''"
  end

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

  -- Results list (uses same sim method as standings/schedule outputs)
  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) do
    out[#out+1] = string.format("* %s '''%d''' %s vs %s '''%d''' %s",
      r.home.logo or "", r.hg or 0, r.home.fullName or r.home.code,
      r.away.logo or "", r.ag or 0, r.away.fullName or r.away.code
    )
  end
  out[#out+1] = ""
  out[#out+1] = p.renderWeeklyAwards({ args = { season = season, md = md } })

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

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