Module:PillSeasonSchedule: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
NewZimiaGov (talk | contribs)
No edit summary
 
(87 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:PillSeasonSchedule
-- Module:PillSeasonSchedule
-- COMPLETE 40-CLUB / 5-DIVISION EXPANSION REWRITE
-- Built from the previously supplied season-lifecycle module.
-- Historical 53 PSSC pairing data is retained in SCHED_LEGACY for reference;
-- the active schedule uses the expanded 40-team competition.
--
-- Purpose:
--  * Preserve the established Pillarion regular-season structure.
--  * Keep completed 53 PSSC results stable.
--  * Automatically move from regular season -> playoffs -> Regional Cup
--    -> offseason -> next regular season.
--  * Freeze completed-season standings/statistics instead of resetting them
--    when the PSSC calendar rolls into Atosiel of the following year.
--  * Keep historical season= arguments working.
--  * Give future seasons season-specific deterministic results so they do not
--    simply replay 53 PSSC.
--
-- The public render functions used by the existing Pillarion page are retained.
local p = {}
local p = {}
local date = require('Module:BassaridianCalendar')
local date = require('Module:BassaridianCalendar')


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 0) CONFIG / CALENDAR
-- 0) CONFIG / CALENDAR / LIFECYCLE
------------------------------------------------------------------------
------------------------------------------------------------------------
local START, END = 62, 183     -- season window within the PSSC year
local START, END = 62, 183
local DAYS_IN_YEAR = 183
local DAYS_IN_YEAR = 183
local LEGACY_SEASON = 53
local PO_FIRST = 4          -- 4 Atosiel of season+1
local PO_IV = 3            -- playoff rounds every 3 days
local RC_GAP_AFTER_FINAL = 21
local RC_ROUND_GAP = 2
local RC_FINAL_GAP = 3


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


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


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


------------------------------------------------------------------------
local function _dateLabel(y, d)
-- 2) DIVISIONS (4 x 8)
    local mon, dom = getMonthDay(d)
------------------------------------------------------------------------
     return string.format('%d %s %d PSSC', dom, mon, y)
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
end


------------------------------------------------------------------------
local function _dateLabelFromAbs(a)
-- 3) DIVISION-WEIGHTED SCHEDULER
    local y, d = _dateFromAbs(a)
--    • In-division: 7 rivals twice (H/A)  -> 14 matches
    return _dateLabel(y, d)
--    • 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
end


local function buildPairings()
local function _currentPSSC()
  local intra, inter = {}, {}
    local curr = date.getCurrentDate() or ''
    local day = tonumber(curr:match('^(%d+),')) or 0
    local year = tonumber(curr:match(', (%d+) PSSC')) or 0
    return day, year
end


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


  local allCodes = {}
-- The active Pillarion competition season is the current year during
  for _,t in ipairs(teams) do table.insert(allCodes, t.code) end
-- Thalassiel/Opsitheiel, but the previous year during Atosiel because Atosiel
  table.sort(allCodes)
-- carries that season's playoffs and Regional Cup.
-- During Atosiel the previous season remains active only while its
-- post-season is actually underway. Once the Regional Cup is finished,
-- all default league/standings calls must advance to the upcoming year.
-- Otherwise the next season displays the previous year's full table.
local function _activeSeason()
    local day, year = _currentPSSC()
    if year <= 0 then return LEGACY_SEASON end
    if day >= START then return year end
    local previousRegionalFinalDay = PO_FIRST + (PO_IV * 3)
        + RC_GAP_AFTER_FINAL + (RC_ROUND_GAP * 4) + RC_FINAL_GAP
    if day <= previousRegionalFinalDay then return year - 1 end
    return year
end


  for ai=1,#allCodes-1 do
local function _resolveSeason(args, positional)
     for bi=ai+1,#allCodes do
     args = args or {}
      local ca, cb = allCodes[ai], allCodes[bi]
    local explicit = tonumber(args.season)
      if DIV_OF[ca] ~= DIV_OF[cb] then
    if not explicit and positional then explicit = tonumber(args[positional]) end
        local a, b = TEAM_BY_CODE[ca], TEAM_BY_CODE[cb]
    if explicit then return explicit end
        local hFirst = homeFirst(ca, cb)
     return _activeSeason()
        table.insert(inter, {home = hFirst and a or b, away = hFirst and b or a})
end
      end
     end
  end


  table.sort(intra, function(a,b)
local function _seasonTimes(season)
     if DIV_OF[a.home.code] ~= DIV_OF[b.home.code] then
     local regularStart = absDay(season, START)
      return DIV_OF[a.home.code] < DIV_OF[b.home.code]
     local regularEnd = absDay(season, END)
     end
     local playoffStart = absDay(season + 1, PO_FIRST)
     return a.home.code .. a.away.code < b.home.code .. b.away.code
     local playoffFinal = playoffStart + PO_IV * 3
  end)
    local regionalStart = playoffFinal + RC_GAP_AFTER_FINAL
  table.sort(inter, function(a,b)
     local regionalFinal = regionalStart + RC_ROUND_GAP * 4 + RC_FINAL_GAP
     local ka = DIV_OF[a.home.code] .. "|" .. DIV_OF[a.away.code]
     local nextRegular = absDay(season + 1, START)
     local kb = DIV_OF[b.home.code] .. "|" .. DIV_OF[b.away.code]
    return {
     if ka ~= kb then return ka < kb end
        regularStart = regularStart,
     return a.home.code .. a.away.code < b.home.code .. b.away.code
        regularEnd = regularEnd,
  end)
        playoffStart = playoffStart,
        playoffFinal = playoffFinal,
        regionalStart = regionalStart,
        regionalFinal = regionalFinal,
        nextRegular = nextRegular,
     }
end


  return intra, inter
local function _seasonStage(season)
    local now = _nowAbs()
    local t = _seasonTimes(season)
    if now < t.regularStart then return 'before_regular', t end
    if now <= t.regularEnd then return 'regular', t end
    if now < t.playoffStart then return 'pre_playoff', t end
    if now <= t.playoffFinal then return 'playoffs', t end
    if now < t.regionalStart then return 'pre_regional', t end
    if now <= t.regionalFinal then return 'regional_cup', t end
    if now < t.nextRegular then return 'offseason', t end
    return 'complete', t
end
end


local function packRounds(matches)
------------------------------------------------------------------------
  local rounds, used = {}, {}
-- 1) TEAMS / DIVISIONS / KITS
   local function canPlace(r, m)
------------------------------------------------------------------------
     return not (used[r] and (used[r][m.home.code] or used[r][m.away.code]))
local teams = {
   end
    { code='Allegro Symphonara',      fullName='Allegro Symphonara',        logo='[[File:AllegrosymphonaraB.png|25px]]', championships=4 },
  for _, m in ipairs(matches) do
    { code='Ashguard Pyralis',        fullName='Ashguard Pyralis',          logo='[[File:AshguardpyralisB.png|25px]]', championships=1 },
     local placed = false
    { code='Aurelia Auric',            fullName='Aurelia Auric',            logo='[[File:AureliaAuricB.png|25px]]', championships=2 },
     for r=1,#rounds do
    { code='Brightpath Aureum',        fullName='Brightpath Aureum',        logo='[[File:BrightpathaureumB.png|25px]]', championships=2 },
      if canPlace(r, m) then
    { code='Celestial Sancta Lunaris', fullName='Celestial Sancta Lunaris',  logo='[[File:CelestialsanctaB.png|25px]]', championships=1 },
        rounds[r][#rounds[r]+1] = { m.home, m.away }
    { code='Delphica Windborne',      fullName='Delphica Windborne',        logo='[[File:DelphicawindborneB.png|25px]]', championships=1 },
        used[r] = used[r] or {}
    { code='Emberstone Vaeringheim',   fullName='Emberstone Vaeringheim',    logo='[[File:EmberstonevaeringheimB.png|25px]]', championships=3 },
        used[r][m.home.code] = true
    { code='Flameborne Erythros',      fullName='Flameborne Erythros',      logo='[[File:FlameborneerythrosB.png|25px]]', championships=1 },
        used[r][m.away.code] = true
    { code='Forgeward Nexa',          fullName='Forgeward Nexa',            logo='[[File:ForgewardnexaB.png|25px]]', championships=0 },
        placed = true
    { code='Hearthkeeper Koinon',      fullName='Hearthkeeper Koinon',      logo='[[File:HearthkeeperkoinonB.png|25px]]', championships=0 },
        break
    { code='Ironbough Sylvapolis',    fullName='Ironbough Sylvapolis',      logo='[[File:IronboughSylvapolisB.png|25px]]', championships=1 },
      end
    { code='Redcliff Citadel',        fullName='Redcliff Citadel',          logo='[[File:RedcliffCitadel.png|25px]]', championships=1 },
     end
     { code='Riftwarden Acheron',      fullName='Riftwarden Acheron',        logo='[[File:RiftwardenAcheronB.png|25px]]', championships=3 },
     if not placed then
    { code='Saluria Skylight',        fullName='Saluria Skylight',          logo='[[File:SaluriaSkylightB.png|25px]]', championships=0 },
      rounds[#rounds+1] = { { m.home, m.away } }
    { code='Sandveil Somniumpolis',    fullName='Sandveil Somniumpolis',    logo='[[File:SandVeilSomniumB.png|25px]]', championships=1 },
      used[#rounds] = {}
    { code='Serena Tidesong',          fullName='Serena Tidesong',          logo='[[File:Serena TidesongB.png|25px]]', championships=2 },
      used[#rounds][m.home.code] = true
    { code='Steppe Luminaria',        fullName='Steppe Luminaria',          logo='[[File:Steppe LuminariaB.png|25px]]', championships=4 },
      used[#rounds][m.away.code] = true
    { code='Strider Myrene',          fullName='Strider Myrene',            logo='[[File:StriderMyreneB.png|25px]]', championships=1 },
     end
    { code='Vaeringheim Pillar',      fullName='Vaeringheim Pillar',        logo='[[File:VaeringheimPillarB.png|25px]]', championships=5 },
  end
    { code='Imperial Delphica',        fullName='Imperial Delphica',        logo='[[File:ImperialDelphicaB.png|25px]]', championships=1 },
  return rounds
    { code='Ascendant Aetherium',      fullName='Ascendant Aetherium',      logo='[[File:AscendantAetheriumB.png|25px]]', championships=2 },
end
    { code='Sufriya Stormwake',        fullName='Sufriya Stormwake',        logo='[[File:SufriyaStormwake.png|25px]]', championships=1 },
    { code='Pillarion Club Suncliff',  fullName='Pillarion Club Suncliff',   logo='[[File:PCSuncliff.png|25px]]', championships=0 },
    { code='Jogi Regiment',            fullName='Jogi Regiment',            logo='[[File:JogiRegiment.png|25px]]', championships=0 },
    { code='Vine Fleet Mylecia',      fullName='Vine Fleet Mylecia',        logo='[[File:VineFleetMylecia.png|25px]]', championships=0 },
    { code='Port of Blore Heath',      fullName='Port of Blore Heath',      logo='[[File:PortBloreHeath.png|25px]]', championships=0 },
    { code='Free State Abeis',        fullName='Free State Abeis',          logo='[[File:FreeStateAbeis.png|25px]]', championships=0 },
    { code='Jezeraah City',            fullName='Jezeraah City',            logo='[[File:JezeraahCity.png|25px]]', championships=0 },
    { code='Ourid Pegasi',            fullName='Ourid Pegasi',              logo='[[File:OuridPegasiLogo.png|25px]]', championships=0 },
    { code='Pillarion Club Caspazani', fullName='Pillarion Club Caspazani',  logo='[[File:PCCaspazani.png|25px]]', championships=0 },
    { code='Amberwatch Slevik',        fullName='Amberwatch Slevik',        logo='[[File:AmberwatchSlevik.png|25px]]', championships=0 },
    { code='Fanghorn Rein',            fullName='Fanghorn Rein',            logo='[[File:FanghornRein.png|25px]]', championships=0 },
    { code='Thyrsa Maena SC', fullName='Thyrsa Maena SC', logo='[[File:ThyrsaMaenaSC.png|25px]]', championships=0 },
    { code='Pharos-Fener Hayr', fullName='Pharos-Fener Hayr', logo='[[File:PharosFenerHayr.png|25px]]', championships=0 },
    { code='Khalkodromoi Chaskal', fullName='Khalkodromoi Chaskal', logo='[[File:KhalkodromoiChaskal.png|25px]]', championships=0 },
    { code='Lenaia Krene AC', fullName='Lenaia Krene AC', logo='[[File:LenaiaKreneAC.png|25px]]', championships=0 },
    { code='Pontikos Mirovia', fullName='Pontikos Mirovia', logo='[[File:PontikosMirovia.png|25px]]', championships=0 },
    { code='Hodos Selenarion', fullName='Hodos Selenarion', logo='[[File:HodosSelenarion.png|25px]]', championships=0 },
    { code='Lithos Kefaya SC', fullName='Lithos Kefaya SC', logo='[[File:LithosKefayaSC.png|25px]]', championships=0 },
    { code='Lacaran Velai PC', fullName='Lacaran Velai PC', logo='[[File:LacaranVelaiPC.png|25px]]', championships=0 },
}
 
local TEAM_BY_CODE = {}
for _, t in ipairs(teams) do TEAM_BY_CODE[t.code] = t end
 
local JERSEYS_BY_TEAMNAME = {
    ['Allegro Symphonara']={home='AllegroHome.png',away='AllegroAway25.png',third='Symphonara3rd.png'},
    ['Ashguard Pyralis']={home='AshguardHome.png',away='AshguardAway.png',third='Pyralis3rdB.png'},
    ['Aurelia Auric']={home='AureliaHome.png',away='AureliaAway.png',third='Aurelia3rd.png'},
     ['Brightpath Aureum']={home='BrightpathHome.png',away='BrightpathAway.png',third='Aureum3rd.png'},
    ['Celestial Sancta Lunaris']={home='CelestialSanctaHome.png',away='CelestialSanctaAway.png',third='Lunalis3rdB.png'},
    ['Delphica Windborne']={home='DelphicaWBAway.png',away='DelphicaWBHome.png',third='Delphica3rd.png'},
    ['Emberstone Vaeringheim']={home='EmberstoneHome.png',away='EmberstoneAway.png',third='Vaeringheim3rdA.png'},
    ['Flameborne Erythros']={home='FlameborneHome.png',away='FlameborneAway.png',third='Erythros3rd.png'},
     ['Forgeward Nexa']={home='ForgewardHome.png',away='ForegewardAway.png',third='Nexa3rd.png'},
    ['Hearthkeeper Koinon']={home='HearthkeepHome.png',away='HearthkeepAway.png',third='Koinon3rd.png'},
    ['Ironbough Sylvapolis']={home='IronboughHome.png',away='IronboughAway.png',third='Sylvapolis3rd.png'},
    ['Redcliff Citadel']={home='RedcliffHome.png',away='RedcliffAway.png',third='Ferrum3rd.png'},
    ['Riftwarden Acheron']={home='RiftwardenHome.png',away='RiftwardenAway.png',third='Acheron3rd.png'},
    ['Saluria Skylight']={home='SkylightHome.png',away='SkylightAway.png',third='Saluria3rdB.png'},
    ['Sandveil Somniumpolis']={home='SomniumpolisHome.png',away='SomniumPolisAway.png',third='Somniumpolis3rd.png'},
    ['Serena Tidesong']={home='SerenaHome.png',away='SerenaAway.png',third='Serena3rd.png'},
    ['Steppe Luminaria']={home='SteppeHome.png',away='SteppeAway25.png',third='Luminaria3rd.png'},
    ['Strider Myrene']={home='StriderHome.png',away='StriderAway.png',third='Myrene3rd.png'},
    ['Vaeringheim Pillar']={home='PillarHome.png',away='PillarAway.png',third='Vaeringheim3rdB.png'},
    ['Imperial Delphica']={home='ImperialHome.png',away='ImperialAway.png',third='Delphica3rdB.png'},
    ['Ascendant Aetherium']={home='AscendentHome.png',away='AscendentAway.png',third='Aetherium3rd.png'},
    ['Sufriya Stormwake']={home='SufriyaHome.png',away='SufriyaAway.png',third='Sufriya3rd.png'},
    ['Pillarion Club Suncliff']={home='SuncliffHome.png',away='SuncliffAway.png',third='Suncliff3rd.png'},
    ['Jogi Regiment']={home='JogiHome.png',away='JogiAway.png',third='Jogi3rd.png'},
    ['Vine Fleet Mylecia']={home='VineFleetHomeB.png',away='VineFleetAwayB.png',third='Mylecia3rd.png'},
    ['Port of Blore Heath']={home='BloreHeathHome.png',away='BloreHeathAway.png',third='BloreHeath3rd.png'},
    ['Free State Abeis']={home='AbeisHome.png',away='AbeisAway.png',third='Abeis3rd.png'},
    ['Jezeraah City']={home='JezeraahHome.png',away='JezeraahAway.png',third='Jezeraah3rd.png'},
     ['Ourid Pegasi']={home='OuridPegasiHome.png',away='OuridPegasiAwayB.png',third='OuridPegasi3rd.png'},
     ['Pillarion Club Caspazani']={home='PCCaspazaniHome.png',away='PCCCaspazaniAway.png',third='PCCaspazani3rd.png'},
    ['Amberwatch Slevik']={home='AmberwatchHome.png',away='AmberwatchAway.png',third='Amberwatch3rd.png'},
    ['Fanghorn Rein']={home='ReinHome.png',away='ReinAway.png',third='Rein3rd.png'},
    ['Thyrsa Maena SC']={home='ThyrsaMaenaHomeB.png',away='ThyrsaMaenaAwayB.png',third='ThyrsaMaena3rdB.png'},
    ['Pharos-Fener Hayr']={home='PharosFenerHome.png',away='PharosFenerAway.png',third='PharosFener3rd.png.png'},
    ['Khalkodromoi Chaskal']={home='KhalkodromoiHome.png',away='KhalkodromoiAway.png',third='Khalkodromoi3rd.png'},
    ['Lenaia Krene AC']={home='LenaiaKreneHome.png',away='LenaiaKreneAway.png',third='LenaiaKrene3rd.png'},
    ['Pontikos Mirovia']={home='PontikosMiroviaHome.png',away='PontikosMiroviaAway.png',third='PontikosMirovia3rd.png'},
    ['Hodos Selenarion']={home='HodosSelenarionHome.png',away='HodosSelenarionAway.png',third='HodosSelenarion3rd.png'},
    ['Lithos Kefaya SC']={home='LithosKefayaHome.png',away='LithosKefayaAway.png',third='LithosKefaya3rd.png'},
    ['Lacaran Velai PC']={home='LacaranVelaiHome.png',away='LacaranVelaiAwayC.png',third='LacaranVelai3rd.png'},
}
 
local RC_JERSEYS_BY_TEAMNAME = {
    ['Morovian Select']={home='MorovianSelectHome.png',away='MorovianSelectAway.png'},
    ['Southern Strait Select']={home='SouthernStraitSelectHome.png',away='SouthernStraitSelectAway.png'},
    ['Western Highlands Select']={home='WesternHighlandsSelectHome.png',away='WesternHighlandsSelectAway.png'},
     ['Normarkian Select']={home='NormarkianSelectHome.png',away='NormarkianSelectAway.png'},
    -- Supply these optional kit files when available on the wiki:
    ['Lacaran Hodos Select']={home='',away=''},
}


local SCHED = (function()
local divisions = {
  local intra, inter = buildPairings()
    ['Morovian']={
  local function chunk(arr, size)
        'Emberstone Vaeringheim','Vaeringheim Pillar','Imperial Delphica','Delphica Windborne',
     local out = {}
        'Allegro Symphonara','Steppe Luminaria','Jezeraah City','Saluria Skylight'
     for i=1,#arr,size do
     },
      local b = {}
     ['Southern Strait']={
      for j=i, math.min(i+size-1, #arr) do b[#b+1] = arr[j] end
        'Sufriya Stormwake','Vine Fleet Mylecia','Port of Blore Heath','Jogi Regiment',
      out[#out+1] = b
        'Free State Abeis','Sandveil Somniumpolis','Brightpath Aureum','Flameborne Erythros'
    end
    },
     return out
    ['Western Highlands']={
  end
        'Ashguard Pyralis','Aurelia Auric','Celestial Sancta Lunaris','Forgeward Nexa',
  local intraBlocks = chunk(intra, 64)
        'Hearthkeeper Koinon','Ironbough Sylvapolis','Riftwarden Acheron','Serena Tidesong'
  local interBlocks = chunk(inter, 128)
    },
    ['Normarkian']={
        'Redcliff Citadel','Strider Myrene','Ascendant Aetherium','Pillarion Club Suncliff',
        'Ourid Pegasi','Pillarion Club Caspazani','Amberwatch Slevik','Fanghorn Rein'
    },
    ['Lacaran Hodos']={
        'Thyrsa Maena SC','Pharos-Fener Hayr','Khalkodromoi Chaskal','Lenaia Krene AC',
        'Pontikos Mirovia','Hodos Selenarion','Lithos Kefaya SC','Lacaran Velai PC'
     },
}
local DIV_ORDER={'Morovian','Southern Strait','Western Highlands','Normarkian','Lacaran Hodos'}
local DIV_OF={}
for div, list in pairs(divisions) do for _, c in ipairs(list) do DIV_OF[c]=div end end


  local merged, ia, ib = {}, 1, 1
local function _safeHash(key)
  while ia <= #intraBlocks or ib <= #interBlocks do
     if mw and mw.hash and mw.hash.hashValue then
     if ia <= #intraBlocks then
        return mw.hash.hashValue('md5', key or '')
      for _,m in ipairs(intraBlocks[ia]) do merged[#merged+1] = m end
      ia = ia + 1
     end
     end
     if ib <= #interBlocks then
     local h=2166136261
      for _,m in ipairs(interBlocks[ib]) do merged[#merged+1] = m end
    key=tostring(key or '')
      ib = ib + 1
    for i=1,#key do h=(h*16777619 + string.byte(key,i)) % 4294967291 end
     end
     return string.format('%08x',h)
  end
end
  return packRounds(merged)
end)()


local function generateSchedule() return SCHED end
local function _pctFromKey(key)
    local h=_safeHash(key)
    return (tonumber(h:sub(1,8),16) or 0)%100
end


local function mapDays()
local function _kitImgForMatch(team, baseWhich, matchKey, px)
  local totalDays  = END - START + 1
    px=px or 25
  local totalRnds  = #SCHED
    if not team or not team.fullName then return '—' end
  local iv        = math.floor(totalDays / totalRnds)
    local kits=JERSEYS_BY_TEAMNAME[team.fullName] or RC_JERSEYS_BY_TEAMNAME[team.fullName]
  local ex        = totalDays % totalRnds
    if not kits then return '—' end
  local m, d      = {}, START
    local which=baseWhich
  for i=1,totalRnds do
    if kits.third and kits.third~='' and _pctFromKey((matchKey or '')..'|'..team.fullName..'|'..baseWhich)<30 then which='third' end
    m[d] = i
    local f=kits[which]
     d = d + iv + (ex > 0 and 1 or 0)
     if not f or f=='' then return '—' end
    if ex > 0 then ex = ex - 1 end
    return string.format('[[File:%s|%dpx]]',f,px)
  end
  return m
end
end


------------------------------------------------------------------------
local function _asTeam(x)
-- 4) MATCH SIMULATION (deterministic by day & pairing)
    if type(x)=='table' then return x end
------------------------------------------------------------------------
     return TEAM_BY_CODE[x] or {code=tostring(x),fullName=tostring(x),logo=''}
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
end


local function simulateDayResults(day)
local function _teamWithLogo(x, bold)
  local dm = mapDays()
    local t=_asTeam(x)
  local idx = dm[day]
    local n=bold and ("'''"..t.fullName.."'''") or t.fullName
  if not idx then return {} end
     return (t.logo and t.logo~='' and (t.logo..' ') or '')..n
  local out = {}
  for k, m in ipairs(SCHED[idx]) do
     local h, a = m[1], m[2]
    local salt = day*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
    local hg, ag = simScoreFor(h, a, salt)
    out[#out+1] = { home=h, away=a, hg=hg, ag=ag }
  end
  return out
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 5) TODAY / NEXT MATCHDAY RENDER
-- 2) LOCKED FIXTURE LIST
--    This reproduces the original 53 PSSC pairing/packing algorithm.
------------------------------------------------------------------------
------------------------------------------------------------------------
local function renderRound(curDay, year)
local function _homeFirst(codeA, codeB)
     local dm      = mapDays()
     local function sum(s)
     local target  = curDay
        local n=0
     local upcoming= false
        for i=1,#s do n=(n+string.byte(s,i))%9973 end
        return n
     end
     return sum(codeA)>sum(codeB)
end


     if not dm[target] then
local function _buildPairings()
         for d = curDay + 1, END do
     local intra, inter = {}, {}
            if dm[d] then target = d; upcoming = true; break end
    for _, div in ipairs(DIV_ORDER) do
        local L=divisions[div]
         for i=1,#L-1 do
            for j=i+1,#L do
                intra[#intra+1]={home=TEAM_BY_CODE[L[i]],away=TEAM_BY_CODE[L[j]]}
                intra[#intra+1]={home=TEAM_BY_CODE[L[j]],away=TEAM_BY_CODE[L[i]]}
            end
         end
         end
     end
     end
     if not dm[target] then
     for di=1,#DIV_ORDER-1 do
         return "'''No more matches this season.'''"
        for dj=di+1,#DIV_ORDER do
            local A,B=divisions[DIV_ORDER[di]],divisions[DIV_ORDER[dj]]
            for _,ca in ipairs(A) do
                for _,cb in ipairs(B) do
                    local h,a
                    if _homeFirst(ca,cb) then h,a=ca,cb else h,a=cb,ca end
                    inter[#inter+1]={home=TEAM_BY_CODE[h],away=TEAM_BY_CODE[a]}
                end
            end
         end
     end
     end
    table.sort(intra,function(a,b)
        local da,db=DIV_OF[a.home.code] or '',DIV_OF[b.home.code] or ''
        if da~=db then return da<db 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] or '')..'|'..(DIV_OF[a.away.code] or '')
        local kb=(DIV_OF[b.home.code] or '')..'|'..(DIV_OF[b.away.code] or '')
        if ka~=kb then return ka<kb end
        return (a.home.code..a.away.code)<(b.home.code..b.away.code)
    end)
    local merged={}
    local function appendBlock(src,start,count)
        for i=start,math.min(#src,start+count-1) do merged[#merged+1]=src[i] end
    end
    local ai,bi=1,1
    while ai<=#intra or bi<=#inter do
        if ai<=#intra then appendBlock(intra,ai,64); ai=ai+64 end
        if bi<=#inter then appendBlock(inter,bi,128); bi=bi+128 end
    end
    return merged
end


     local prefix = upcoming and "'''Next matchday:''' " or ""
local function _packRounds(pairings)
     local mon, dom = getMonthDay(target)
     local rounds={}
    local label    = dom .. " " .. mon .. " " .. year .. " PSSC"
     for _,g in ipairs(pairings) do
    local rows    = {}
        local placed=false
 
         for _,r in ipairs(rounds) do
    if not upcoming then
             if not r.used[g.home.code] and not r.used[g.away.code] then
         for _, r in ipairs(simulateDayResults(target)) do
                r.matches[#r.matches+1]={g.home,g.away}
             local h, a, hg, ag = r.home, r.away, r.hg, r.ag
                 r.used[g.home.code]=true; r.used[g.away.code]=true
            local hc = (hg > ag and "green") or (hg < ag and "red") or "yellow"
                 placed=true; break
            local ac = (ag > hg and "green") or (ag < hg and "red") or "yellow"
            end
            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
         end
    else
         if not placed then
         local idx = dm[target]
             rounds[#rounds+1]={matches={{g.home,g.away}},used={[g.home.code]=true,[g.away.code]=true}}
        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
     end
 
    local out={}
     return string.format(
     for _,r in ipairs(rounds) do out[#out+1]=r.matches end
        "|-\n| %s%d || %s || %s",
     return out
        prefix, target, label, table.concat(rows, "<br>")
     )
end
end


function p.renderSchedule(frame)
-- Historic schedule remains available for explicitly requested 53 PSSC archives.
     local curr = date.getCurrentDate()
-- Existing four-division pairing logic is evaluated with the original four divisions.
     local day  = tonumber(curr:match('^(%d+),')) or 0
local function _legacyFixtures()
     local year = tonumber(curr:match(', (%d+) PSSC')) or 0
     local savedOrder = DIV_ORDER
     return table.concat({
     DIV_ORDER={'Morovian','Southern Strait','Western Highlands','Normarkian'}
        '{| class="wikitable sortable"',
     local fixtures=_packRounds(_buildPairings())
        '! Day !! Date !! Matches',
     DIV_ORDER=savedOrder
        renderRound(day, year),
     return fixtures
        '|}'
     }, "\n")
end
end
local SCHED_LEGACY=_legacyFixtures()


------------------------------------------------------------------------
-- Expansion-era schedule: 14 divisional (home and away), 32 interdivisional.
-- 6) DIVISION STANDINGS (color-coded by divisional position)
-- Round-robin pairings use the existing deterministic match orientation function.
------------------------------------------------------------------------
local function _expandedPairings()
function p.renderStandings(frame)
     local pairs={}
    local curr  = date.getCurrentDate()
     for _,div in ipairs(DIV_ORDER) do
    local today  = tonumber(curr:match("^(%d+),")) or 0
         local L=divisions[div]
    local last  = math.min(today, END)
        for i=1,#L-1 do for j=i+1,#L do
 
            pairs[#pairs+1]={home=TEAM_BY_CODE[L[i]],away=TEAM_BY_CODE[L[j]]}
     local recs  = {}
            pairs[#pairs+1]={home=TEAM_BY_CODE[L[j]],away=TEAM_BY_CODE[L[i]]}
     for _, t in ipairs(teams) do
        end end
         recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0 }
     end
     end
 
     for i=1,#DIV_ORDER-1 do for j=i+1,#DIV_ORDER do
     local dm = mapDays()
         for _,a in ipairs(divisions[DIV_ORDER[i]]) do
    for d = START, last do
             for _,b in ipairs(divisions[DIV_ORDER[j]]) do
         local idx = dm[d]
                 local home,away=a,b
        if idx then
                 if not _homeFirst(a,b) then home,away=b,a end
             for k, m in ipairs(SCHED[idx]) do
                 pairs[#pairs+1]={home=TEAM_BY_CODE[home],away=TEAM_BY_CODE[away]}
                 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
         end
    end end
    return pairs
end
-- Structured 54-matchday schedule, rather than greedily packing all fixtures.
-- 14 full divisional rounds + 40 interdivisional rounds (one division bye
-- per interdivisional matchday). Every club plays 46 matches, 23 home/away
-- split may vary by opponent but every club has 14 divisional and 32 external.
local function _rotateEight(codes)
    local arr={}; for i=1,8 do arr[i]=codes[i] end
    local rounds={}
    for r=1,7 do
        local games={}
        for i=1,4 do
            local h,a=TEAM_BY_CODE[arr[i]],TEAM_BY_CODE[arr[9-i]]
            if (r+i)%2~=0 then h,a=a,h end
            games[#games+1]={h,a}
        end
        rounds[r]=games
        local last=arr[8];for i=8,3,-1 do arr[i]=arr[i-1] end;arr[2]=last
     end
     end
 
    return rounds
     local buckets = {}
end
     for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
local function _expandedFixtures()
    for code, r in pairs(recs) do
     local rounds={}
        r.PD = r.PF - r.PA
    local divisionRounds={}
        local div = DIV_OF[code] or "—"
     for _,div in ipairs(DIV_ORDER) do divisionRounds[div]=_rotateEight(divisions[div]) end
        buckets[div] = buckets[div] or {}
    for r=1,14 do
         table.insert(buckets[div], r)
        local matchday={}
        for _,div in ipairs(DIV_ORDER) do
            local rr=divisionRounds[div][(r-1)%7+1]
            for _,g in ipairs(rr) do
                if r<=7 then matchday[#matchday+1]=g
                else matchday[#matchday+1]={g[2],g[1]} end
            end
         end
        rounds[#rounds+1]=matchday
     end
     end
 
    -- Complete round robin over five divisions plus one rotating bye.
     local function cmp(a,b)
     local pairBlocks={
        if a.Pts ~= b.Pts then return a.Pts > b.Pts end
      {{2,5},{3,4}},
        if a.PD  ~= b.PD  then return a.PD  > b.PD  end
      {{1,3},{4,5}},
        if a.W  ~= b.W  then return a.W  > b.W  end
      {{1,5},{2,4}},
        return a.team.fullName < b.team.fullName
      {{1,2},{3,5}},
    end
      {{1,4},{2,3}},
    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 block, pairs in ipairs(pairBlocks) do
    for _, div in ipairs(order) do
        for t=0,7 do
        local list = buckets[div] or {}
            local matchday={}
        out[#out+1] = "== " .. div .. " Standings =="
            for _,pair in ipairs(pairs) do
        out[#out+1] = '{| class="wikitable sortable"'
                local A=divisions[DIV_ORDER[pair[1]]]
        out[#out+1] = "! Pos !! Team !! W !! D !! L !! Pts !! PF !! PA !! PD"
                local B=divisions[DIV_ORDER[pair[2]]]
 
                for i=1,8 do
        for i, rec in ipairs(list) do
                    local h=TEAM_BY_CODE[A[i]]
            local style = ""
                    local a=TEAM_BY_CODE[B[((i+t-1)%8)+1]]
            if    i <= 2 then style = ' style="background-color:#ccffcc;"'
                    if (block+t)%2~=0 then h,a=a,h end
            elseif i <= 4 then style = ' style="background-color:#cce5ff;"'
                    matchday[#matchday+1]={h,a}
            elseif i <= 6 then style = ' style="background-color:#F1EB9C;"'
                end
            else              style = ' style="background-color:#ffcccc;"'
             end
             end
 
             rounds[#rounds+1]=matchday
             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] = "|}"
        out[#out+1] = ""
     end
     end
     return table.concat(out, "\n")
     return rounds
end
end
local SCHED_EXPANDED=_expandedFixtures()
local SCHED_LOCKED=SCHED_EXPANDED
local TOTAL_ROUNDS=#SCHED_LOCKED
local function getRoundMatches(i) return SCHED_LOCKED[tonumber(i) or 0] or {} end


------------------------------------------------------------------------
-- Date helpers used by standings, awards, match reports, rosters and transfers.
-- 7) PREVIOUS RESULTS (last 5 matchdays)
-- Keep one stable calendar mapping throughout the season.
------------------------------------------------------------------------
local _DAY_MAP, _INV_DAY_MAP = {}, {}
function p.renderPreviousResults(frame)
do
    local curr  = date.getCurrentDate()
     local totalDays=END-START+1
     local today  = tonumber(curr:match("^(%d+),")) or 0
     for i=1,TOTAL_ROUNDS do
     local year  = tonumber(curr:match(", (%d+) PSSC")) or 0
        local d=START+math.floor((i-1)*(totalDays-1)/math.max(1,TOTAL_ROUNDS-1))
    local dm    = mapDays()
         _DAY_MAP[d]=i
    local played = {}
        _INV_DAY_MAP[i]=d
 
    for d = today - 1, START, -1 do
         if dm[d] then played[#played+1] = d end
        if #played == 5 then break end
     end
     end
end
local function mapDays() return _DAY_MAP end
local function invertDayMap(dm)
    if dm==_DAY_MAP then return _INV_DAY_MAP end
    local inv={};for day,md in pairs(dm or {}) do inv[md]=day end
    return inv
end
local function _finalRegularSeasonDay() return _INV_DAY_MAP[TOTAL_ROUNDS] or END end
local function _regularSeasonCutoff(season)
    season=tonumber(season) or _activeSeason()
    local now,day,year=_nowAbs()
    if year<season then return START-1 end
    if year>season then return _finalRegularSeasonDay() end
    return math.min(math.max(day,START-1),_finalRegularSeasonDay())
end
-- A result is public only once its matchday has actually arrived in the
-- specified PSSC season. This also protects explicit ?season=54&md=... calls.
local function _isPlayedMatchday(season, md)
    md = tonumber(md) or 0
    if md < 1 or md > TOTAL_ROUNDS then return false end
    local day = _INV_DAY_MAP[md]
    return day ~= nil and _nowAbs() >= absDay(season,day)
end


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


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 8) PLAYOFFS (12 teams seeded from divisions; unchanged seeding block)
-- 3) DETERMINISTIC MATCH ENGINE
------------------------------------------------------------------------
------------------------------------------------------------------------
local PO_YEAR  = 52
local function simScoreFor(h,a,seed)
local PO_FIRST = md2day(1,4)
    math.randomseed(seed)
local PO_IV    = 3
    local p0=math.random()
    local hg,ag
    if p0<0.10 then
        hg=math.random(0,12); ag=hg
    else
        local wh=(h.championships or 0)+1
        local wa=(a.championships or 0)+1
        local homeWin=((p0-0.10)/0.90)<(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 tb_cmp(a,b)
-- 53 PSSC uses the legacy seed exactly. Later seasons add the year so future
  if a.Pts ~= b.Pts then return a.Pts > b.Pts end
-- seasons are deterministic but not identical replays of 53.
  if a.PD  ~= b.PD  then return a.PD  > b.PD  end
local function _matchSeed(season,day,k,h,a)
  if a.W  ~= b.W  then return a.W  > b.W  end
    local legacy=day*1000+k+string.byte(h.code,1)+string.byte(a.code,1)
  return a.team.fullName < b.team.fullName
    if tonumber(season)==53 then return legacy end
    return legacy+(tonumber(season) or 0)*1000003
end
end


local function computeRecordsUpTo(dayEnd)
local function _rrange(seed,lo,hi)
  local recs = {}
     math.randomseed(seed)
  for _, t in ipairs(teams) do
     return math.random(lo,hi)
     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
  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]
local function _splitPillarScore(total,salt)
        H.PF, H.PA = H.PF + hg, H.PA + ag
    total=tonumber(total) or 0
        A.PF, A.PA = A.PF + ag, A.PA + hg
    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
    return g,total-3*g
end


        if hg > ag then
local function computeMatchStats(home,away,hg,ag,salt)
          H.W, H.Pts = H.W + 1, H.Pts + 3
    local hGoals,hPoss=_splitPillarScore(hg,salt+101)
          A.L = A.L + 1
    local aGoals,aPoss=_splitPillarScore(ag,salt+102)
        elseif hg < ag then
    local hOn=_rrange(salt+101,hGoals,hGoals+2)
          A.W, A.Pts = A.W + 1, A.Pts + 3
    local aOn=_rrange(salt+102,aGoals,aGoals+2)
          H.L = H.L + 1
    local hOff=_rrange(salt+103,0,3); local aOff=_rrange(salt+104,0,3)
        else
    local hF=_rrange(salt+105,0,3); local aF=_rrange(salt+106,0,3)
          H.D, H.Pts = H.D + 1, H.Pts + 1
    local hThr=_rrange(salt+107,0,3); local aThr=_rrange(salt+108,0,3)
          A.D, A.Pts = A.D + 1, A.Pts + 1
    local hGK=_rrange(salt+109,0,2); local aGK=_rrange(salt+110,0,2)
         end
    local hOOB=_rrange(salt+111,0,2); local aOOB=_rrange(salt+112,0,2)
      end
    local hp,ap=50,50
    if hg~=ag then
        local sw=_rrange(salt+113,3,6)
        if hg>ag then hp,ap=50+sw,50-sw else hp,ap=50-sw,50+sw end
    else
         local tilt=_rrange(salt+113,-3,3); hp,ap=50+tilt,50-tilt
     end
     end
  end
    return {
  for _, r in pairs(recs) do r.PD = r.PF - r.PA end
        [home.code]={final=hg,goals=hGoals,poss_points=hPoss,shots_on=hOn,shots_off=hOff,fouls=hF,fks=aF,throws=hThr,gk=hGK,oob=hOOB,poss_pct=hp},
  return recs
        [away.code]={final=ag,goals=aGoals,poss_points=aPoss,shots_on=aOn,shots_off=aOff,fouls=aF,fks=hF,throws=aThr,gk=aGK,oob=aOOB,poss_pct=ap},
    }
end
end


local function getSeeds12()
local function simulateDayResults(day,season)
  local curr  = date.getCurrentDate()
    season=tonumber(season) or _activeSeason()
  local today  = tonumber(curr:match("^(%d+),")) or END
    local md=mapDays()[day]
  local recs  = computeRecordsUpTo(today)
    local out={}
    if not md then return out end
    for k,m in ipairs(getRoundMatches(md)) do
        local h,a=m[1],m[2]
        local seed=_matchSeed(season,day,k,h,a)
        local hg,ag=simScoreFor(h,a,seed)
        out[#out+1]={home=h,away=a,hg=hg,ag=ag,seed=seed,stats=computeMatchStats(h,a,hg,ag,seed),index=k,md=md,day=day}
    end
    return out
end


  local buckets = {}
------------------------------------------------------------------------
  for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
-- 4) STANDINGS / SEEDING
  for _, r in pairs(recs) do
------------------------------------------------------------------------
    buckets[r.Div] = buckets[r.Div] or {}
local function _emptyStandings()
     table.insert(buckets[r.Div], r)
    local recs={}
  end
    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,GP=0} end
  for div, list in pairs(buckets) do table.sort(list, tb_cmp) end
     return recs
end


  local winners, runners = {}, {}
local function _applyResult(recH,recA,hg,ag)
  for _, div in ipairs({
     recH.GP=recH.GP+1; recA.GP=recA.GP+1
     "Morovian Division","Southern Strait Division",
     recH.PF=recH.PF+hg; recH.PA=recH.PA+ag
     "Western Highlands Division","Normarkian Division"
     recA.PF=recA.PF+ag; recA.PA=recA.PA+hg
  }) do
     if hg>ag then recH.W=recH.W+1; recA.L=recA.L+1; recH.Pts=recH.Pts+3
     local list = buckets[div] or {}
     elseif ag>hg then recA.W=recA.W+1; recH.L=recH.L+1; recA.Pts=recA.Pts+3
     if list[1] then table.insert(winners, list[1]) end
    else recH.D=recH.D+1; recA.D=recA.D+1; recH.Pts=recH.Pts+1; recA.Pts=recA.Pts+1 end
     if list[2] then table.insert(runners,  list[2]) end
    recH.PD=recH.PF-recH.PA; recA.PD=recA.PF-recA.PA
  end
end
  table.sort(winners, tb_cmp)
  table.sort(runners, tb_cmp)


  local chosen = {}
local function _standingsThrough(season,lastDay)
  for _,r in ipairs(winners) do chosen[r.team.code] = true end
    local recs=_emptyStandings(); local dm=mapDays()
  for _,r in ipairs(runners) do chosen[r.team.code] = true end
    -- Clamp requested dates to the most recently played matchday.
 
    -- This is essential: future fixtures must never affect live tables.
  local allList = {}
    local playedCutoff=_regularSeasonCutoff(season)
  for _, r in pairs(recs) do table.insert(allList, r) end
    local effectiveEnd=math.min(lastDay or playedCutoff, playedCutoff, _finalRegularSeasonDay())
  table.sort(allList, tb_cmp)
    for d=START,effectiveEnd do
 
        if dm[d] then
  local wild = {}
            for _,r in ipairs(simulateDayResults(d,season)) do _applyResult(recs[r.home.code],recs[r.away.code],r.hg,r.ag) end
  for _,r in ipairs(allList) do
        end
    if not chosen[r.team.code] then
      table.insert(wild, r)
      if #wild == 4 then break end
     end
     end
  end
    return recs
end


  local seeds = {}
local function _standCmp(a,b)
  for i=1,#winners do seeds[#seeds+1] = winners[i].team end
    if a.Pts~=b.Pts then return a.Pts>b.Pts end
  for i=1,#runners do seeds[#seeds+1] = runners[i].team end
    if a.PD~=b.PD then return a.PD>b.PD end
  for i=1,#wild    do seeds[#seeds+1] = wild[i].team    end
    if a.W~=b.W then return a.W>b.W end
  return seeds
    return a.team.fullName<b.team.fullName
end
end


local function playScore(A,B,salt)
local function _divisionRows(season,div,lastDay)
  math.randomseed(salt)
    local recs=_standingsThrough(season,lastDay); local list={}
  local p = math.random()
    for _,c in ipairs(divisions[div]) do list[#list+1]=recs[c] end
  if p < 0.10 then local g=math.random(0,12); return g,g end
    table.sort(list,_standCmp); return list
  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
end


local function mkRow(tag, absDate, H, A, salt, absNow)
local function getSeeds12(season)
  local hs, win = '—','—'
    local winners,runners={},{}
  if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then
    for _,div in ipairs(DIV_ORDER) do
    local g1,g2 = playScore(H,A,salt)
        local L=_divisionRows(season,div,_finalRegularSeasonDay())
     hs  = string.format("'''%d-%d'''", g1, g2)
        winners[#winners+1]=L[1]; runners[#runners+1]=L[2]
     win = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
    end
  end
     table.sort(winners,_standCmp); table.sort(runners,_standCmp)
  return string.format(
     local seeds={}
    "|-\n| %s || %s %s || %s || %s %s || %s",
    for _,r in ipairs(winners) do seeds[#seeds+1]=r.team end
    tag, H.logo,H.fullName, hs, A.logo,A.fullName, win)
    for _,r in ipairs(runners) do seeds[#seeds+1]=r.team end
    return seeds
end
end


function p.renderPlayoffSchedule(frame)
local function getConferencePlayIns(season)
  local seeds = getSeeds12()
     local games={}
  local out = {
     for _,div in ipairs(DIV_ORDER) do
    '{| class="wikitable sortable"',
        local L=_divisionRows(season,div,_finalRegularSeasonDay())
    '! Round !! Date !! Match-up'
        games[#games+1]={L[3].team,L[4].team,div}
  }
    end
  local function dateCell(day)
    return games
     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
end


function p.renderPlayoffResults(frame)
------------------------------------------------------------------------
  local seeds = getSeeds12()
-- 5) REGULAR-SEASON RENDERERS / AUTOMATIC LIFECYCLE
  local now  = date.getCurrentDate()
------------------------------------------------------------------------
  local dNow  = tonumber(now:match('^(%d+),')) or 0
local function _renderRegularRound(day,season)
  local yNow  = tonumber(now:match(', (%d+) PSSC')) or 0
    local dm=mapDays()
  local absNow= absDay(yNow,dNow)
    local target=nil
 
    for d=math.max(day,START),_finalRegularSeasonDay() do if dm[d] then target=d; break end end
  local rows = {
     if not target then return "|-\n| colspan=3 | '''Regular season complete.'''" end
     '{| class="wikitable sortable"',
    local md=dm[target]; local mon,dom=getMonthDay(target); local rows={}
    '! Round !! Home !! Score !! Away !! Winner'
     for k,m in ipairs(getRoundMatches(md)) do
  }
        local h,a=m[1],m[2]
 
        local key=string.format('REG|%d|%d|%d|%s|%s',season,target,k,h.code,a.code)
  local absPI = absDay(PO_YEAR, PO_FIRST)
        local score='—'
  local PI = {
        local now=_nowAbs()
    { seeds[9],  seeds[12] },
        if now>=absDay(season,target) then
     { seeds[10], seeds[11] }
            local hg,ag=simScoreFor(h,a,_matchSeed(season,target,k,h,a)); score=string.format("'''%d-%d'''",hg,ag)
  }
        end
  local W_PI = {}
        rows[#rows+1]=string.format('%s %s %s %s %s %s',_kitImgForMatch(h,'home',key),h.logo,h.fullName,score,_kitImgForMatch(a,'away',key),a.logo..' '..a.fullName)
  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
     end
     rows[#rows+1] = mkRow('PI-'..i, absPI, H, A, absPI*100+i, absNow)
     return string.format('|-\n| %d (MD %d) || %d %s %d PSSC || %s',target,md,dom,mon,season,table.concat(rows,'<br>'))
  end
end


  local absR8 = absPI + PO_IV
function p.renderSchedule(frame)
  local pool = { seeds[3],seeds[4],seeds[5],seeds[6],seeds[7],seeds[8],
    local args=(frame and frame.args) or {}
                W_PI[1] or {logo='—',fullName='TBD'}, W_PI[2] or {logo='—',fullName='TBD'} }
    local season=_resolveSeason(args,1)
  local R8 = { {pool[3], pool[6]}, {pool[4], pool[5]}, {pool[1], pool[8]}, {pool[2], pool[7]} }
    local stage,t=_seasonStage(season)
    local now,day,year=_nowAbs()


  local W_R8 = {}
    if stage=='regular' or stage=='before_regular' then
  for i,pair in ipairs(R8) do
        local startDay=(year==season and day or START)
     local H,A = pair[1], pair[2]
        return table.concat({
     if absNow >= absR8 and A.fullName~='TBD' then
            '{| class="wikitable sortable"',
      local g1,g2 = playScore(H,A,absR8*100+i)
            '! Day !! Date !! Matches',
      W_R8[i]     = (g1>g2) and H or A
            _renderRegularRound(startDay,season),
            '|}'
        },'\n')
    elseif stage=='pre_playoff' then
        return string.format("'''%d PSSC regular season complete. Play-offs begin %s.'''",season,_dateLabelFromAbs(t.playoffStart))
     elseif stage=='playoffs' then
        return "=== Current Play-off Results ===\n"..p.renderPlayoffResults({args={season=season,progressive='1'}})
     elseif stage=='pre_regional' then
        return "'''Play-offs complete. The Off-Season Regional Cup begins ".._dateLabelFromAbs(t.regionalStart)..".'''"
    elseif stage=='regional_cup' then
        return "=== Off-Season Regional Cup — Live Results ===\n"..p.renderRegionalCupResults({args={season=season}})
    elseif stage=='offseason' then
        return string.format("'''%d PSSC competition cycle complete. %d PSSC regular season begins %s.'''",season,season+1,_dateLabelFromAbs(t.nextRegular))
     else
        return string.format("'''%d PSSC competition cycle is complete.'''",season)
     end
     end
    rows[#rows+1] = mkRow('R-8-'..i, absR8, H, A, absR8*100+i, absNow)
end
  end


  local absR4 = absR8 + PO_IV
-- Explicit alias for pages that want a semantically named lifecycle call.
  local R4 = {}
p.renderLifecycle=p.renderSchedule
  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 = {}
function p.renderStandings(frame)
  for i=1,2 do
    local args=(frame and frame.args) or {}
     local H = R4[i] and R4[i][1] or {logo='—',fullName='TBD'}
    -- Tables represent the upcoming/current regular season, even when
    local A = R4[i] and R4[i][2] or {logo='—',fullName='TBD'}
    -- Atosiel is hosting the previous year's playoffs or Regional Cup.
    if absNow >= absR4 and R4[i] then
    -- Explicit |season=53 still permits historical standings.
      local g1,g2 = playScore(H,A,absR4*100+i)
    local explicit=tonumber(args.season or args[1])
      W_R4[i]     = (g1>g2) and H or A
    local _,calendarYear=_currentPSSC()
     local season=explicit or (calendarYear > 0 and calendarYear or _activeSeason())
    local last=_regularSeasonCutoff(season)
    local out={}
    for _,div in ipairs(DIV_ORDER) do
        out[#out+1]='=== '..div..' Division ==='
        out[#out+1]='{| class="wikitable sortable" style="width:100%;"'
        out[#out+1]='! # !! Team !! GP !! W !! D !! L !! Pts !! PF !! PA !! PD'
        local list=_divisionRows(season,div,last)
        for i,r in ipairs(list) do
            -- Original positional color bands: 1-2 green, 3-4 blue, 5-6 yellow, 7-8 red.
            local shade = (i <= 2 and '#ccffcc') or (i <= 4 and '#cce5ff') or (i <= 6 and '#F1EB9C') or '#ffcccc'
            out[#out+1]='|- style="background-color:'..shade..';"'
            out[#out+1]=string.format('| %d || %s || %d || %d || %d || %d || %d || %d || %d || %+d',i,_teamWithLogo(r.team),r.GP,r.W,r.D,r.L,r.Pts,r.PF,r.PA,r.PD)
        end
        out[#out+1]='|}'
     end
     end
     rows[#rows+1] = mkRow('R-4-'..i, absR4, H, A, absR4*100+i, absNow)
     return table.concat(out,'\n')
  end
end


  local absSF = absR4 + PO_IV
function p.renderPreviousResults(frame)
  local SF = {}
    local args=(frame and frame.args) or {}
  if #W_R4 == 2 then
    local season=_resolveSeason(args,1)
     local idxByCode = {}
     local now,day,year=_nowAbs(); local dm=mapDays(); local played={}
     for i,t in ipairs(seeds) do idxByCode[t.code] = i end
     local fromDay
     table.sort(W_R4, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end)
     if now>absDay(season,_finalRegularSeasonDay()) then fromDay=_finalRegularSeasonDay()
     SF = { { seeds[1], W_R4[2] }, { seeds[2], W_R4[1] } }
     elseif year==season then fromDay=day-1 else fromDay=START-1 end
  else
    for d=fromDay,START,-1 do if dm[d] then played[#played+1]=d end; if #played>=5 then break end end
     SF = { { seeds[1], {logo='—',fullName='TBD'} }, { seeds[2], {logo='—',fullName='TBD'} } }
     if #played==0 then return "'''No regular-season matches have been played in "..season.." PSSC. Opening matchday: 1 Thalassiel "..season.." PSSC.'''" end
  end
    local out={'{| class="wikitable sortable"','! Date !! Home !! Score !! Away'}
 
    for _,d in ipairs(played) do
  local W_SF = {}
        for _,r in ipairs(simulateDayResults(d,season)) do
  for i=1,2 do
            out[#out+1]='|-'
    local H,A = SF[i][1], SF[i][2]
            out[#out+1]=string.format('| %s || %s || \'\'\'%d-%d\'\'\' || %s',_dateLabel(season,d),_teamWithLogo(r.home),r.hg,r.ag,_teamWithLogo(r.away))
    if absNow >= absSF and A.fullName~='TBD' then
        end
      local g1,g2 = playScore(H,A,absSF*100+i)
      W_SF[i]    = (g1>g2) and H or A
     end
     end
     rows[#rows+1] = mkRow('SF-'..i, absSF, H, A, absSF*100+i, absNow)
     out[#out+1]='|}'
  end
    return table.concat(out,'\n')
 
  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


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 9)  PER-MATCH STATS (compute only on match day)
-- 6) PLAYOFFS
--    Guarantees: Final = 3*Goals + PossessionPts
--    Adds column-wise highlight of higher value (light green)
------------------------------------------------------------------------
------------------------------------------------------------------------
local function computeMatchStats(h, a, hg, ag, seed)
local function _playScore(H,A,salt)
  local function splitScore(total, salt)
     return simScoreFor(H,A,salt)
     local maxG = math.floor(total / 3)
end
    if maxG == 0 then return 0, total end
    math.randomseed(salt)
    local g = maxG
    if maxG >= 1 and math.random() < 0.40 then g = maxG - 1 end
    local p = total - 3*g
    return g, p
  end


  local h_goals, h_poss = splitScore(hg, seed + 101)
local function _winner(H,A,hg,ag)
  local a_goals, a_poss = splitScore(ag, seed + 102)
    -- Preserve the legacy deterministic tiebreak: away side advances on a draw.
    if hg>ag then return H end
    return A
end


  local function rrange(s, lo, hi) math.randomseed(s); return math.random(lo, hi) end
local function _seedIndex(seeds,team)
  local h_sh_on = (h_goals > 0) and rrange(seed+201, h_goals, h_goals+2) or rrange(seed+201, 0, 2)
    for i,t in ipairs(seeds) do if t.code==team.code then return i end end
  local a_sh_on = (a_goals > 0) and rrange(seed+202, a_goals, a_goals+2) or rrange(seed+202, 0, 2)
    return 999
end


  local h_sh_off = rrange(seed+203, 0, 3)
local function _playoffState(season)
  local a_sh_off = rrange(seed+204, 0, 3)
    local qualifiers, fourths={},{}
    for _,div in ipairs(DIV_ORDER) do
        local L=_divisionRows(season,div,_finalRegularSeasonDay())
        for rank=1,3 do qualifiers[#qualifiers+1]=L[rank] end
        fourths[#fourths+1]=L[4]
    end
    table.sort(fourths,_standCmp)
    qualifiers[#qualifiers+1]=fourths[1]
    table.sort(qualifiers,_standCmp)
    local seeds={}
    for _,r in ipairs(qualifiers) do seeds[#seeds+1]=r.team end
    local poAbs=absDay(season+1,PO_FIRST)
    local state={seeds=seeds,rounds={}}
    local remaining=seeds
    for ri=1,4 do
        local name=({'Round of 16','Quarterfinal','Semifinal','Final'})[ri]
        local abs=poAbs+(ri-1)*PO_IV
        local games,advancing={},{}
        for i=1,math.floor(#remaining/2) do
            local H,A=remaining[i],remaining[#remaining-i+1]
            local hg,ag=_playScore(H,A,abs*100+i)
            local winner=_winner(H,A,hg,ag)
            games[#games+1]={H,A,hg,ag,winner}
            advancing[#advancing+1]=winner
        end
        state.rounds[ri]={name=name,abs=abs,games=games}
        -- Preserve initial seeding, strongest remaining vs weakest.
        table.sort(advancing,function(a,b) return _seedIndex(seeds,a)<_seedIndex(seeds,b) end)
        remaining=advancing
    end
    state.champion=remaining[1]
    return state
end


  local h_fouls = rrange(seed+205, 0, 3)
local function _renderPlayoffTable(season,progressive,scheduleOnly)
  local a_fouls = rrange(seed+206, 0, 3)
    local S=_playoffState(season); local now=_nowAbs()
  local h_fks  = a_fouls
    local out={'{| class="wikitable sortable"','! Round !! Date !! Home !! Score !! Away !! Winner'}
  local a_fks  = h_fouls
    for _,round in ipairs(S.rounds) do
        local visible=(not progressive) or now>=round.abs
        if visible or scheduleOnly then
            for gi,g in ipairs(round.games) do
                local H,A=g[1],g[2]; local score='—'; local win='—'
                if not scheduleOnly and now>=round.abs then score=string.format("'''%d-%d'''",g[3],g[4]); win=_teamWithLogo(g[5]) end
                local key=string.format('PO|%d|%s|%d',season,round.name,gi)
                out[#out+1]='|-'
                out[#out+1]=string.format('| %s || %s || %s %s || %s || %s %s || %s',round.name,_dateLabelFromAbs(round.abs),_kitImgForMatch(H,'home',key,20),_teamWithLogo(H),score,_kitImgForMatch(A,'away',key,20),_teamWithLogo(A),win)
            end
        end
    end
    out[#out+1]='|}'
    return table.concat(out,'\n')
end


  local h_throws = rrange(seed+207, 0, 3)
function p.renderPlayoffSchedule(frame)
  local a_throws = rrange(seed+208, 0, 3)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1)
  local h_oob    = rrange(seed+209, 0, 2)
     return _renderPlayoffTable(season,false,true)
  local a_oob    = rrange(seed+210, 0, 2)
end
  local h_gk     = rrange(seed+211, 0, 2)
  local a_gk    = rrange(seed+212, 0, 2)


  local base = 50
function p.renderPlayoffResults(frame)
  local swing = rrange(seed+213, 3, 6)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1)
  local h_poss_pct, a_poss_pct
     local progressive=tostring(args.progressive or '1')~='0'
  if hg > ag then
     return _renderPlayoffTable(season,progressive,false)
    h_poss_pct, a_poss_pct = base + swing, base - swing
end
  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] = {
-- 7) MATCH STATS
      final = hg, goals = h_goals, poss_points = h_poss,
------------------------------------------------------------------------
      shots_on = h_sh_on, shots_off = h_sh_off,
local function _statsTable(r)
      fouls = h_fouls, fks = h_fks, throws = h_throws,
     local H=r.stats[r.home.code]; local A=r.stats[r.away.code]
      gk = h_gk, oob = h_oob, poss_pct = h_poss_pct
    local out={
    },
        string.format("'''%s %d-%d %s'''",r.home.fullName,r.hg,r.ag,r.away.fullName),
    [a.code] = {
        '{| class="wikitable" style="width:100%;"',
      final = ag, goals = a_goals, poss_points = a_poss,
        '! Stat !! '..r.home.fullName..' !! '..r.away.fullName,
      shots_on = a_sh_on, shots_off = a_sh_off,
        '|-','| Goals || '..H.goals..' || '..A.goals,
      fouls = a_fouls, fks = a_fks, throws = a_throws,
        '|-','| Possession Points || '..H.poss_points..' || '..A.poss_points,
      gk = a_gk, oob = a_oob, poss_pct = a_poss_pct
        '|-','| Shots on Target || '..H.shots_on..' || '..A.shots_on,
        '|-','| Shots off Target || '..H.shots_off..' || '..A.shots_off,
        '|-','| Fouls || '..H.fouls..' || '..A.fouls,
        '|-','| Free Kicks || '..H.fks..' || '..A.fks,
        '|-','| Goal Kicks || '..H.gk..' || '..A.gk,
        '|-','| Out of Bounds || '..H.oob..' || '..A.oob,
        '|-','| Throw-Ins || '..H.throws..' || '..A.throws,
        '|-','| Ball Possession || '..H.poss_pct..'% || '..A.poss_pct..'%',
        '|}'
     }
     }
  }
     return table.concat(out,'\n')
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
end


function p.renderMatchStatsForDay(frame)
function p.renderMatchStatsForDay(frame)
  local curr  = date.getCurrentDate()
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,nil)
  local dayNow = tonumber(curr:match('^(%d+),')) or 0
    local now,day,year=_nowAbs(); local target=tonumber(args.day)
  local year   = tonumber(curr:match(', (%d+) PSSC')) or 0
    if not target then
  local dm    = mapDays()
        if year==season and mapDays()[day] then target=day else
 
            local cutoff=_regularSeasonCutoff(season)
  local target = dm[dayNow] and dayNow or nil
            for d=cutoff,START,-1 do if mapDays()[d] then target=d; break end end
  if not target then
         end
    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
  end
    if not target or not mapDays()[target] then return "'''No Pillarion matchday is available for this date.'''" end
 
    if now<absDay(season,target) then return "'''That matchday has not yet been played.'''" end
  out[#out+1] = '|}'
    local out={}
  return table.concat(out, '\n')
    for _,r in ipairs(simulateDayResults(target,season)) do out[#out+1]=_statsTable(r) end
    return table.concat(out,'\n\n')
end
end


function p.renderMatchStatsSingle(frame)
function p.renderMatchStatsSingle(frame)
  local args = frame.args or {}
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,nil)
  local day = tonumber(args.day or '') or 0
    local day=tonumber(args.day or '') or 0; local home=args.home; local away=args.away
  local home = args.home
    if day==0 or not home or not away then return "'''Pass day, home, and away:''' day=###, home=Team, away=Team" end
  local away = args.away
    if _nowAbs()<absDay(season,day) then return "'''That matchday has not yet been played.'''" end
  if day == 0 or not home or not away then
    for _,r in ipairs(simulateDayResults(day,season)) do
    return "'''Pass day, home, and away:''' day=###, home=Team, away=Team"
        if r.home.code==home and r.away.code==away then return _statsTable(r) end
  end
 
  local curr  = date.getCurrentDate()
  local dayNow = tonumber(curr:match('^(%d+),')) or 0
  local year  = tonumber(curr:match(', (%d+) PSSC')) or 0
  local dm    = mapDays()
  local idx    = dm[day]
  if not idx then return "'''No round mapped for that day.''' end" 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
     end
 
     return "'''Match not found on that Pillarion matchday.'''"
     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
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 8) ROSTERS / TRANSFERS / PLAYER EVENT ENGINE
------------------------------------------------------------------------
------------------------------------------------------------------------
-- 10) ROSTERS + TRANSFERS + PLAYER STATS (embedded, deterministic)
local ROSTER_SIZE=12
--    Includes wide-variation Bassaridian-style name generation.
local TRANSFER_MD_START,TRANSFER_MD_END=20,30
------------------------------------------------------------------------
local POSITIONS={'Striker','Striker','Playmaker','Playmaker','Playmaker','Defender','Defender','Defender','Keeper','Utility','Utility','Utility'}
 
-- 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 function _seedFromString(s)
  local h = 0
    local h=0; s=tostring(s or '')
  for i=1,#s do h = (h*31 + string.byte(s,i)) % 2147483647 end
    for i=1,#s do h=(h*31+string.byte(s,i))%2147483647 end
  return (h == 0) and 1 or h
    if h==0 then h=1 end
end
    return h
 
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
end
 
local function _rngNext(st) st=(1103515245*st+12345)%2147483647; return st,st/2147483647 end
local function _pick(st,list) local r; st,r=_rngNext(st); return st,list[math.floor(r*#list)+1] end
local function _normKey(s)
local function _normKey(s)
  if not s or s == "" then return nil end
    if not s or s=='' then return nil end
  s = mw.ustring.lower(s)
    if mw and mw.ustring then
  s = mw.ustring.gsub(s, "%s+", "_")
        s=mw.ustring.lower(s)
  s = mw.ustring.gsub(s, "[^%w_]", "")
        s=mw.ustring.gsub(s,'%s+','_')
  return s
        s=mw.ustring.gsub(s,'[^%w_]','')
        return s
    end
    return (string.lower(tostring(s)):gsub('%s+','_'):gsub('[^%w_]',''))
end
end


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


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


local function _maybeDiacritics(st, s)
local function _maybeDiacritics(st,s)
  -- very light touch
    local r; st,r=_rngNext(st)
  local r; st, r = _rngNext(st)
    if r<.08 then s=s:gsub('a','ä',1)
  if r < 0.08 then
    elseif r<.16 then s=s:gsub('i','ï',1)
    s = mw.ustring.gsub(s, "a", "ä", 1)
    elseif r<.24 then s=s:gsub('o','ö',1)
  elseif r < 0.16 then
    elseif r<.32 then s=s:gsub('u','ü',1) end
    s = mw.ustring.gsub(s, "i", "ï", 1)
    return st,s
  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
end


local function _buildNameFrom(style, st, isLast)
local function _buildNameFrom(style, st, isLast)
  local t = NAME_STYLES[style] or NAME_STYLES.bassarid
    local t=NAME_STYLES[style] or NAME_STYLES.bassarid
  local a,b,c
    local a,b,c
  if not isLast then
    if not isLast then
    st, a = _pick(st, t.firstA); st, b = _pick(st, t.firstB); st, c = _pick(st, t.firstC)
        st,a=_pick(st,t.firstA); st,b=_pick(st,t.firstB); st,c=_pick(st,t.firstC)
  else
    else
    st, a = _pick(st, t.lastA); st, b = _pick(st, t.lastB); st, c = _pick(st, t.lastC)
        st,a=_pick(st,t.lastA); st,b=_pick(st,t.lastB); st,c=_pick(st,t.lastC)
  end
    end
 
    local name=_cap((a or '')..(b or '')..(c or ''))
  local name = _cap((a or "") .. (b or "") .. (c or ""))
    local r; st,r=_rngNext(st)
 
    if r<0.05 and #name>=6 then
  -- rare hyphen/apostrophe
        local cut=math.floor(#name/2)
  local r; st, r = _rngNext(st)
        if mw and mw.ustring then name=mw.ustring.sub(name,1,cut)..'-'..mw.ustring.sub(name,cut+1)
  if r < 0.05 and #name >= 6 then
        else name=name:sub(1,cut)..'-'..name:sub(cut+1) end
    local cut = math.floor(#name/2)
    elseif r<0.08 and #name>=6 then
    name = mw.ustring.sub(name,1,cut) .. "-" .. mw.ustring.sub(name,cut+1)
        local cut=math.floor(#name/2)
  elseif r < 0.08 and #name >= 6 then
        if mw and mw.ustring then name=mw.ustring.sub(name,1,cut)..'’'..mw.ustring.sub(name,cut+1)
    local cut = math.floor(#name/2)
        else name=name:sub(1,cut)..'’'..name:sub(cut+1) end
    name = mw.ustring.sub(name,1,cut) .. "’" .. mw.ustring.sub(name,cut+1)
    end
  end
    st,name=_maybeDiacritics(st,name)
 
    return st,name
  st, name = _maybeDiacritics(st, name)
  return st, name
end
end


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


-----------------------------------------------------------------------
-- Base roster generation (stable per team+season)
-----------------------------------------------------------------------
local function _genBaseRoster(teamCode, season)
local function _genBaseRoster(teamCode, season)
  local key = _normKey(teamCode) or teamCode
    local key=_normKey(teamCode) or teamCode
  local st = _seedFromString(tostring(season) .. "|" .. key .. "|ROSTER")
    local st=_seedFromString(tostring(season)..'|'..key..'|ROSTER')
  local style = _chooseStyleForTeam(teamCode, season)
    local style=_chooseStyleForTeam(teamCode,season)
 
    local roster,used={},{}
  local roster, used = {}, {}
    for i=1,ROSTER_SIZE do
  for i=1, ROSTER_SIZE do
        local id=key..'-'..string.format('%02d',i)
    local id = key .. "-" .. string.format("%02d", i)
        local full
 
        for _=1,10 do
    local full
            local first,last
    for _=1, 10 do
            st,first=_buildNameFrom(style,st,false)
      local first, last
            st,last=_buildNameFrom(style,st,true)
      st, first = _buildNameFrom(style, st, false)
            full=first..' '..last
      st, last = _buildNameFrom(style, st, true)
            if not used[full] then used[full]=true; break end
      full = first .. " " .. last
        end
      if not used[full] then used[full] = true; break end
        roster[i]={id=id,name=full or ('Player '..id),pos=POSITIONS[i] or 'Utility',no=i}
     end
     end
 
     roster[1].notes='Captain'
    roster[i] = {
    return roster
      id = id,
      name = full or ("Player " .. id),
      pos = POSITIONS[i] or "Utility",
      no  = i
     }
  end
  roster[1].notes = "Captain"
  return roster
end
end


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


-----------------------------------------------------------------------
local function _copyRosters(base)
-- Transfer market (deterministic 1-for-1 swaps, MD20..MD30)
    local R={}
-----------------------------------------------------------------------
    for tc,list in pairs(base) do R[tc]={}; for _,p0 in ipairs(list) do local q={}; for k,v in pairs(p0) do q[k]=v end; R[tc][#R[tc]+1]=q end end
local function _indexById(roster)
    return R
  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
end


local function _genTransferDeals(season)
local function _genTransferDeals(season)
  local st = _seedFromString(tostring(season) .. "|TRANSFERWINDOW|MD" .. TRANSFER_MD_START .. "-" .. TRANSFER_MD_END)
    local st=_seedFromString(season..'|TRANSFERWINDOW|MD20-30'); local deals={}
 
    for md=TRANSFER_MD_START,TRANSFER_MD_END do
  local deals, teamCodes = {}, {}
        local r; st,r=_rngNext(st); local count=1+math.floor(r*3)
  for _,t in ipairs(teams) do teamCodes[#teamCodes+1] = t.code end
        for n=1,count do
 
            local ia,ib
  for md = TRANSFER_MD_START, TRANSFER_MD_END do
            st,r=_rngNext(st); ia=math.floor(r*#teams)+1
    local r; st, r = _rngNext(st)
            repeat st,r=_rngNext(st); ib=math.floor(r*#teams)+1 until ib~=ia
    local dealCount = 1 + math.floor(r * 3) -- 1–3 per matchday
            local A,B=teams[ia].code,teams[ib].code
 
            local ra=_genBaseRoster(A,season); local rb=_genBaseRoster(B,season)
    for _=1, dealCount do
            st,r=_rngNext(st); local pa=ra[math.floor(r*#ra)+1]
      local A,B
            if pa.pos=='Keeper' then st,r=_rngNext(st); if r<.75 then pa=ra[1+math.floor(r*(#ra-1))] end end
      st, A = _pick(st, teamCodes)
            local candidates={}; for _,pl in ipairs(rb) do if pl.pos==pa.pos then candidates[#candidates+1]=pl end end
      repeat st, B = _pick(st, teamCodes) until B ~= A
            local pb; st,pb=_pick(st,candidates)
 
            deals[#deals+1]={md=md,A=A,B=B,A_id=pa.id,B_id=pb.id,pos=pa.pos}
      local baseA = _genBaseRoster(A, season)
        end
      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
  end
    return deals
 
  return deals
end
end


local function _applyTransfersUpTo(baseRosters, deals, upToMd)
local function _findPlayer(list,id) for i,p0 in ipairs(list or {}) do if p0.id==id then return i,p0 end end end
  local rosters = {}
local function _applyTransfersUpTo(base,deals,upToMd)
  for code, r in pairs(baseRosters) do
    local R=_copyRosters(base)
    rosters[code] = {}
     for _,d in ipairs(deals or {}) do
    for i,p in ipairs(r) do
        if d.md<=(upToMd or 0) then
      rosters[code][i] = { id=p.id, name=p.name, pos=p.pos, no=p.no, notes=p.notes }
            local ia,pa=_findPlayer(R[d.A],d.A_id); local ib,pb=_findPlayer(R[d.B],d.B_id)
     end
            if ia and ib then pa.team=d.B; pb.team=d.A; R[d.A][ia],R[d.B][ib]=pb,pa 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
     end
  end
    return R
 
  return rosters
end
end


-----------------------------------------------------------------------
local function _buildPlayerNameLookup(base)
-- Matchday helpers
    local out={}; for _,list in pairs(base) do for _,pl in ipairs(list) do out[pl.id]=pl.name end end; return out
-----------------------------------------------------------------------
local function _invertDayMap(dm)
  local inv = {}
  for day, md in pairs(dm) do inv[md] = day end
  return inv
end
end


local function _currentPlayedMatchday()
  local 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 function _alloc(st, roster, posWeights)
  local pool = {}
    local pool={}
  for _,p in ipairs(roster) do
    for _,pl in ipairs(roster or {}) do
    local w = posWeights[p.pos] or 1
        local w=(posWeights and posWeights[pl.pos]) or 1
    for _=1,w do pool[#pool+1] = p end
        for _=1,w do pool[#pool+1]=pl end
  end
    end
  return _pick(st, pool)
    return _pick(st,pool)
end
end


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


local function _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster)
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 salt=_matchSeed(season,day,matchIndex,homeTeam,awayTeam)
  local hg, ag = simScoreFor(homeTeam, awayTeam, salt)
    local hg,ag=simScoreFor(homeTeam,awayTeam,salt)
  local stats = computeMatchStats(homeTeam, awayTeam, hg, ag, salt)
    local S=computeMatchStats(homeTeam,awayTeam,hg,ag,salt)
    local stats={home=S[homeTeam.code] or {},away=S[awayTeam.code] or {}}
    local st=_seedFromString(tostring(season)..'|MD'..md..'|D'..day..'|M'..matchIndex..'|'..homeTeam.code..'|'..awayTeam.code)
    local events={home={},away={}}
    for _,pl in ipairs(homeRoster or {}) do _ensure(events.home,pl.id).apps=1 end
    for _,pl in ipairs(awayRoster or {}) do _ensure(events.away,pl.id).apps=1 end


  local st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)
    local W_GOAL={Striker=6,Playmaker=3,Utility=2,Defender=1,Keeper=0}
  local events = { home={}, away={} }
    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}
    local W_ASSIST={Playmaker=6,Striker=3,Utility=2,Defender=1,Keeper=0}


  -- appearances: count everyone on roster as 1 appearance (simple)
    for _=1,(stats.home.goals or 0) do
  for _,p in ipairs(homeRoster) do _ensure(events.home, p.id).apps = 1 end
        local scorer; st,scorer=_alloc(st,homeRoster,W_GOAL)
  for _,p in ipairs(awayRoster) do _ensure(events.away, p.id).apps = 1 end
        if scorer then
 
            _ensure(events.home,scorer.id).goals=_ensure(events.home,scorer.id).goals+1
  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.70 then
  local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
                local ast; st,ast=_alloc(st,homeRoster,W_ASSIST)
 
                if ast and ast.id~=scorer.id then _ensure(events.home,ast.id).assists=_ensure(events.home,ast.id).assists+1 end
  -- goals + assists
            end
  for _=1, stats.home.goals do
        end
    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
  end
    for _=1,(stats.away.goals or 0) do
  for _=1, stats.away.goals do
        local scorer; st,scorer=_alloc(st,awayRoster,W_GOAL)
    local p; st, p = _alloc(st, awayRoster, W_GOAL)
        if scorer then
    _ensure(events.away, p.id).goals = _ensure(events.away, p.id).goals + 1
            _ensure(events.away,scorer.id).goals=_ensure(events.away,scorer.id).goals+1
    local r; st, r = _rngNext(st)
            local r; st,r=_rngNext(st)
    if r < 0.70 then
            if r<0.70 then
      local a; st, a = _alloc(st, awayRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
                local ast; st,ast=_alloc(st,awayRoster,W_ASSIST)
      if a and a.id ~= p.id then _ensure(events.away, a.id).assists = _ensure(events.away, a.id).assists + 1 end
                if ast and ast.id~=scorer.id then _ensure(events.away,ast.id).assists=_ensure(events.away,ast.id).assists+1 end
            end
        end
     end
     end
  end
    for _=1,(stats.home.poss_points or 0) do local pl;st,pl=_alloc(st,homeRoster,W_POSS);if pl then _ensure(events.home,pl.id).poss=_ensure(events.home,pl.id).poss+1 end end
    for _=1,(stats.away.poss_points or 0) do local pl;st,pl=_alloc(st,awayRoster,W_POSS);if pl then _ensure(events.away,pl.id).poss=_ensure(events.away,pl.id).poss+1 end end


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


-----------------------------------------------------------------------
local function _computePlayerSeasonTotals(season,upToMd)
-- Season totals (recomputed on view; "auto-updates" with matchday)
    upToMd=math.min(tonumber(upToMd) or 0,TOTAL_ROUNDS)
-----------------------------------------------------------------------
    local base=_buildBaseRosters(season); local deals=_genTransferDeals(season); local totals={}
local function _computePlayerSeasonTotals(season, upToMd)
    for _,list in pairs(base) do for _,pl in ipairs(list) do totals[pl.id]={id=pl.id,name=pl.name,pos=pl.pos,team=pl.team,apps=0,goals=0,assists=0,poss=0,son=0,sof=0,fouls=0} end end
  local dm = mapDays()
     local inv=invertDayMap(mapDays())
  local inv = _invertDayMap(dm)
     for md=1,upToMd do
 
        local day=inv[md]
  local deals = _genTransferDeals(season)
        if day then
  local base = _buildBaseRosters(season)
            local R=_applyTransfersUpTo(base,deals,md)
 
            for mi,m in ipairs(getRoundMatches(md)) do
  local totals = {} -- pid -> totals row
                local h,a=m[1],m[2]; local hr,ar=R[h.code] or {},R[a.code] or {}
 
                local ev=_matchPlayerEvents(season,md,day,mi,h,a,hr,ar)
  local function ensurePlayer(pid, name, pos)
                for pid,line in pairs(ev.home) do local z=totals[pid]; if z then for _,k in ipairs({'apps','goals','assists','poss','son','sof','fouls'}) do z[k]=z[k]+(line[k] or 0) end; z.team=h.code end end
    if not totals[pid] then
                for pid,line in pairs(ev.away) do local z=totals[pid]; if z then for _,k in ipairs({'apps','goals','assists','poss','son','sof','fouls'}) do z[k]=z[k]+(line[k] or 0) end; z.team=a.code end end
      totals[pid] = { id=pid, name=name or pid, pos=pos or "—", team="—",
            end
                      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
         end
        -- away
        for pid, st in pairs(ev.away) do
          local name, pos = pid, "—"
          for _,pp in ipairs(ar) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
          local row = ensurePlayer(pid, name, pos)
          row.team    = a.code
          row.apps    = row.apps + (st.apps or 0)
          row.goals  = row.goals + (st.goals or 0)
          row.assists = row.assists + (st.assists or 0)
          row.poss    = row.poss + (st.poss or 0)
          row.son    = row.son + (st.son or 0)
          row.sof    = row.sof + (st.sof or 0)
          row.fouls  = row.fouls + (st.fouls or 0)
        end
      end
     end
     end
  end
    local Rnow=_applyTransfersUpTo(base,deals,upToMd)
 
    for tc,list in pairs(Rnow) do for _,pl in ipairs(list) do if totals[pl.id] then totals[pl.id].team=tc end end end
  return totals, deals, base
    return totals,deals,base
end
end


-----------------------------------------------------------------------
-- PUBLIC: Single roster
-- {{#invoke:PillSeasonSchedule|renderRoster|team=Vaeringheim Pillar|season=52}}
-----------------------------------------------------------------------
function p.renderRoster(frame)
function p.renderRoster(frame)
  local args = frame.args or {}
    local args=(frame and frame.args) or {}; local team=args.team or args[1]
  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
  if not team or not TEAM_BY_CODE[team] then
    local season=_resolveSeason(args,2); local upToMd=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season)
    return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''"
    local R=_applyTransfersUpTo(_buildBaseRosters(season),_genTransferDeals(season),upToMd)[team] or {}
  end
    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 "..upToMd..')\n'..table.concat(out,'\n')
end


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


  local deals = _genTransferDeals(season)
function p.renderTransferLog(frame)
  local base = _buildBaseRosters(season)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season)
  local rosters = _applyTransfersUpTo(base, deals, upToMd)
    local deals=_genTransferDeals(season); local base=_buildBaseRosters(season); local names=_buildPlayerNameLookup(base)
  local r = rosters[team] or {}
    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<=md then out[#out+1]='|-'; out[#out+1]=string.format('| %d || %s || %s || %s ⇄ %s (%s)',d.md,_teamWithLogo(d.A),_teamWithLogo(d.B),names[d.A_id] or d.A_id,names[d.B_id] or d.B_id,d.pos or '—') end end
    out[#out+1]='|}'
    return string.format("'''Transfer window (Matchdays %d–%d) — season %d (through Matchday %d)'''\n",TRANSFER_MD_START,TRANSFER_MD_END,season,md)..table.concat(out,'\n')
end


  local out = {
function p.renderPlayerStats(frame)
     '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    local args=(frame and frame.args) or {}; local team=args.team or args[1]
    '! # !! Player !! Position !! ID !! Notes'
    if not team or not TEAM_BY_CODE[team] then return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''" end
  }
    local season=_resolveSeason(args,2); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season)
  for _,pl in ipairs(r) do
    local totals,deals,base=_computePlayerSeasonTotals(season,md); local roster=_applyTransfersUpTo(base,deals,md)[team] or {}
    out[#out+1] = '|-'
     local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'}
    out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> || %s',
    for _,pl in ipairs(roster) do local r=totals[pl.id] or {}; out[#out+1]='|-'; out[#out+1]=string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d',pl.name,pl.pos,r.apps or 0,r.goals or 0,r.assists or 0,r.poss or 0,r.son or 0,r.sof or 0,r.fouls or 0) end
      pl.no or 0, pl.name or '—', pl.pos or '—', pl.id or '—', pl.notes or '—'
    out[#out+1]='|}'
    )
    return (TEAM_BY_CODE[team].logo or '').." '''"..team.."''' — Player totals through Matchday "..md..'\n'..table.concat(out,'\n')
  end
end
  out[#out+1] = '|}'


  return (TEAM_BY_CODE[team].logo or "") ..
function p.renderLeaders(frame)
     " '''" .. team .. "''' (Roster as of Matchday " .. tostring(upToMd) .. ")\n" ..
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season)
    table.concat(out, "\n")
     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,md); 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 lim=tonumber(args.limit or '10') or 10; local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! Rank !! Player !! Team (current) !! Pos !! '..stat}
    for i=1,math.min(lim,#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 string.format("'''%d PSSC leaders through Matchday %d'''\n",season,md)..table.concat(out,'\n')
end
end


-----------------------------------------------------------------------
------------------------------------------------------------------------
-- PUBLIC: All rosters (collapsible per team)
-- 9) REGIONAL CUP
-- {{#invoke:PillSeasonSchedule|renderAllRosters|season=52}}
------------------------------------------------------------------------
-----------------------------------------------------------------------
local RC_META={
function p.renderAllRosters(frame)
    ['Morovian']={fullName='Morovian Select',logo='[[File:MorovianSelect.png|25px]]'},
  local args = frame.args or {}
    ['Southern Strait']={fullName='Southern Strait Select',logo='[[File:SouthernStraitSelect.png|25px]]'},
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
    ['Western Highlands']={fullName='Western Highlands Select',logo='[[File:WesternHighlandsSelect.png|25px]]'},
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    ['Normarkian']={fullName='Normarkian Select',logo='[[File:NormarkianSelect.png|25px]]'},
    ['Lacaran Hodos']={fullName='Lacaran Hodos Select',logo=''},
}


  local out = {}
local function _rcRating(r)
  for _,t in ipairs(teams) do
    local apps=tonumber(r.apps) or 0; local denom=math.max(1,apps)
     local title = (t.logo or "") .. " <b>" .. t.code .. "</b>"
     local g=(r.goals or 0)/denom; local a=(r.assists or 0)/denom; local pr=(r.poss or 0)/denom; local sr=(r.son or 0)/denom; local orr=(r.sof or 0)/denom; local fr=(r.fouls or 0)/denom
     out[#out+1] =
     local rel=math.min(1,denom/6); local raw
      '<div class="mw-collapsible mw-collapsed" data-expandtext="Show roster" data-collapsetext="Hide roster" style="margin:0.6em 0;">' ..
    if r.pos=='Keeper' then raw=5.9+pr*.08+a*.8-fr*.50-orr*.12 else raw=5.8+g*3+a*1.8+pr*.10+sr*.22-orr*.18-fr*.40 end
        '<div>' .. title .. '</div>' ..
    local rating=6+(raw-6)*rel; if rating<0 then rating=0 elseif rating>9.7 then rating=9.7 end; return rating
        '<div class="mw-collapsible-content">' ..
          p.renderRoster({ args = { team = t.code, season = season, md = upToMd } }) ..
        '</div>' ..
      '</div>'
  end
  return table.concat(out, "\n")
end
end


-----------------------------------------------------------------------
local function _regionalTeams(season)
-- PUBLIC: Transfer log (up to current matchday unless md provided)
    local totals=_computePlayerSeasonTotals(season,TOTAL_ROUNDS)
-- {{#invoke:PillSeasonSchedule|renderTransferLog|season=52}}
    local byDiv={}; for _,d in ipairs(DIV_ORDER) do byDiv[d]={Keeper={},Defender={},Playmaker={},Striker={},Utility={}} end
-----------------------------------------------------------------------
    for _,r in pairs(totals) do local d=DIV_OF[r.team]; if d and byDiv[d] and byDiv[d][r.pos] then r.rating=_rcRating(r); byDiv[d][r.pos][#byDiv[d][r.pos]+1]=r end end
function p.renderTransferLog(frame)
    for _,d in ipairs(DIV_ORDER) do for _,list in pairs(byDiv[d]) do table.sort(list,function(a,b) if a.rating~=b.rating then return a.rating>b.rating end return a.name<b.name end) end end
  local args = frame.args or {}
    local need={Keeper=1,Defender=4,Playmaker=3,Striker=3,Utility=1}; local out={}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
    for _,d in ipairs(DIV_ORDER) do
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
        local meta=RC_META[d]; local sq={}
        for _,pos in ipairs({'Keeper','Defender','Playmaker','Striker','Utility'}) do for i=1,math.min(need[pos],#byDiv[d][pos]) do sq[#sq+1]=byDiv[d][pos][i] end end
        out[#out+1]={code=meta.fullName,fullName=meta.fullName,logo=meta.logo,division=d,squad=sq,championships=0}
    end
    return out
end


  local deals = _genTransferDeals(season)
local function _regionalCupStartAbs(season) return absDay(season+1,PO_FIRST)+PO_IV*3+RC_GAP_AFTER_FINAL end
 
local function _rcStrength(T) local s=0; for _,pl in ipairs(T.squad or {}) do s=s+(pl.rating or 6) end; return s/math.max(1,#(T.squad or {})) end
  local out = {
local function _rcPlayScore(H,A,salt)
     '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    math.randomseed(salt); local sh,sa=_rcStrength(H),_rcStrength(A); local p0=math.random(); local hg,ag
     '! Matchday !! From !! To !! Swap (pos)'
     if p0<.10 then hg=math.random(0,12);ag=hg else local ph=sh/(sh+sa); local homeWin=((p0-.10)/.90)<ph; local w=math.random(1,12); local l=math.random(0,w-1); if homeWin then hg,ag=w,l else hg,ag=l,w end end
  }
     return hg,ag
  for _,d in ipairs(deals) do
end
    if d.md <= upToMd then
local function _rcFixtures(T)
      out[#out+1] = '|-'
    local fixtures={}
      out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> ⇄ <code>%s</code> (%s)',
    -- Berger table for five entrants plus a rotating bye.
         d.md, d.A, d.B, d.A_id, d.B_id, d.pos or '—'
    local rot={T[1],T[2],T[3],T[4],T[5],false}
      )
    for r=1,5 do
        local games={}
        for i=1,3 do
            local h,a=rot[i],rot[7-i]
            if h and a then
                if r%2==0 then h,a=a,h end
                games[#games+1]={h,a}
            end
        end
        fixtures[#fixtures+1]={round='Round '..r,games=games}
         local last=rot[6]
        for i=6,3,-1 do rot[i]=rot[i-1] end
        rot[2]=last
     end
     end
  end
    return fixtures
  out[#out+1] = '|}'
end
local function _rcStandingsInit(T) local st={}; for _,x in ipairs(T) do st[x.fullName]={team=x,GP=0,W=0,D=0,L=0,GF=0,GA=0,GD=0,Pts=0} end; return st end
local function _rcApply(st,H,A,hg,ag) local h,a=st[H.fullName],st[A.fullName]; h.GP=h.GP+1;a.GP=a.GP+1;h.GF=h.GF+hg;h.GA=h.GA+ag;a.GF=a.GF+ag;a.GA=a.GA+hg;if hg>ag then h.W=h.W+1;a.L=a.L+1;h.Pts=h.Pts+3 elseif ag>hg then a.W=a.W+1;h.L=h.L+1;a.Pts=a.Pts+3 else h.D=h.D+1;a.D=a.D+1;h.Pts=h.Pts+1;a.Pts=a.Pts+1 end;h.GD=h.GF-h.GA;a.GD=a.GF-a.GA end
local function _rcSorted(st) local L={};for _,r in pairs(st) do L[#L+1]=r end;table.sort(L,function(a,b) if a.Pts~=b.Pts then return a.Pts>b.Pts end;if a.GD~=b.GD then return a.GD>b.GD end;if a.GF~=b.GF then return a.GF>b.GF end;return a.team.fullName<b.team.fullName end);return L end


  return "'''Transfer window (Matchdays " .. TRANSFER_MD_START .. "–" .. TRANSFER_MD_END ..
function p.renderRegionalCupRosters(frame)
    ") — season " .. season .. " (through Matchday " .. tostring(upToMd) .. ")'''\n" ..
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local T=_regionalTeams(season); local out={'== Off-Season Regional Cup — Call-Ups ('..season..' PSSC) =='}
     table.concat(out, "\n")
    for _,rt in ipairs(T) do out[#out+1]='=== '..rt.logo..' '..rt.fullName..' ===';out[#out+1]='{| class="wikitable sortable" style="width:100%; font-size:90%;"';out[#out+1]='! Player !! Pos !! Club !! Rating !! G !! A';for _,pl in ipairs(rt.squad) do out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %.2f || %d || %d',pl.name,pl.pos,_teamWithLogo(pl.team),pl.rating or 0,pl.goals or 0,pl.assists or 0) end;out[#out+1]='|}' end
     return table.concat(out,'\n')
end
end


-----------------------------------------------------------------------
function p.renderRegionalCupSchedule(frame)
-- PUBLIC: Team player totals (team roster as of md, totals through md)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local T=_regionalTeams(season); local fixtures=_rcFixtures(T); local start=_regionalCupStartAbs(season)
-- {{#invoke:PillSeasonSchedule|renderPlayerStats|team=Vaeringheim Pillar|season=52}}
    local out={'{| class="wikitable sortable"','! Stage !! Date !! Home (kit) !! Score !! Away (kit)'}
-----------------------------------------------------------------------
    for i,blk in ipairs(fixtures) do local a=start+(i-1)*RC_ROUND_GAP; for gi,g in ipairs(blk.games) do local key='RC|'..season..'|'..blk.round..'|'..gi;out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s %s || — || %s %s',blk.round,_dateLabelFromAbs(a),_kitImgForMatch(g[1],'home',key,20),_teamWithLogo(g[1]),_kitImgForMatch(g[2],'away',key,20),_teamWithLogo(g[2])) end end
function p.renderPlayerStats(frame)
     local finalAbs=start+RC_ROUND_GAP*4+RC_FINAL_GAP;out[#out+1]='|-';out[#out+1]=string.format('| Final || %s || TBD || — || TBD',_dateLabelFromAbs(finalAbs));out[#out+1]='|-';out[#out+1]=string.format('| 3rd Place || %s || TBD || — || TBD',_dateLabelFromAbs(finalAbs));out[#out+1]='|}'
  local args = frame.args or {}
    return table.concat(out,'\n')
  local team = args.team or args[1]
end
  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
function p.renderRegionalCupResults(frame)
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local T=_regionalTeams(season); local fixtures=_rcFixtures(T); local st=_rcStandingsInit(T); local start=_regionalCupStartAbs(season); local now=_nowAbs(); local out={}
    out[#out+1]='{| class="wikitable sortable"';out[#out+1]='! Stage !! Date !! Home !! Score !! Away'
    for i,blk in ipairs(fixtures) do
        local a=start+(i-1)*RC_ROUND_GAP
        for gi,g in ipairs(blk.games) do
            local score='—'; if now>=a then local hg,ag=_rcPlayScore(g[1],g[2],a*100+gi);score=string.format("'''%d-%d'''",hg,ag);_rcApply(st,g[1],g[2],hg,ag) end
            out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %s || %s',blk.round,_dateLabelFromAbs(a),_teamWithLogo(g[1]),score,_teamWithLogo(g[2]))
        end
    end
    out[#out+1]='|}';out[#out+1]='';out[#out+1]='=== Regional Cup Table ===';out[#out+1]='{| class="wikitable sortable"';out[#out+1]='! # !! Team !! GP !! W !! D !! L !! GF !! GA !! GD !! Pts'
    local L=_rcSorted(st);for i,r in ipairs(L) do out[#out+1]='|-';out[#out+1]=string.format('| %d || %s || %d || %d || %d || %d || %d || %d || %+d || %d',i,_teamWithLogo(r.team),r.GP,r.W,r.D,r.L,r.GF,r.GA,r.GD,r.Pts) end;out[#out+1]='|}'
    local finalAbs=start+RC_ROUND_GAP*4+RC_FINAL_GAP;out[#out+1]='';out[#out+1]='=== Finals Day — '.._dateLabelFromAbs(finalAbs)..' ==='
    local A1,A2,A3,A4=L[1].team,L[2].team,L[3].team,L[4].team
    local function line(label,X,Y,k) local score='—';local winner='TBD';if now>=finalAbs then local gx,gy=_rcPlayScore(X,Y,finalAbs*100+k);score=string.format("'''%d-%d'''",gx,gy);winner=(gx>gy and X.fullName) or (gy>gx and Y.fullName) or 'Draw' end;return string.format('* %s: %s %s %s (Winner: %s)',label,_teamWithLogo(X),score,_teamWithLogo(Y),winner) end
    out[#out+1]=line('Final',A1,A2,1);out[#out+1]=line('3rd Place',A3,A4,2)
    return table.concat(out,'\n')
end


  local totals, deals, base = _computePlayerSeasonTotals(season, upToMd)
------------------------------------------------------------------------
  local rostersNow = _applyTransfersUpTo(base, deals, upToMd)
-- 10) MATCH REPORTS / WEEKLY FEATURES
  local rosterNow = rostersNow[team] or {}
------------------------------------------------------------------------
local function _pickMatchIndexForDay(season,md,pick)
    local day=invertDayMap(mapDays())[md]; if not day then return nil,'Unable to resolve matchday.' end
    local R=simulateDayResults(day,season); if #R==0 then return nil,'No fixtures found.' end
    local bestI,bestScore=1,nil
    for i,r in ipairs(R) do
        local s
        if pick=='chaos' then s=r.hg+r.ag+math.abs(r.hg-r.ag)
        else s=(r.hg+r.ag)-math.abs(r.hg-r.ag)*0.5 end
        if not bestScore or s>bestScore then bestScore=s;bestI=i end
    end
    return bestI
end


  local out = {
local function _renderMatchReport(season,md,matchIndex,titleOverride)
     '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    local day=invertDayMap(mapDays())[md]; if not day then return "'''Unable to resolve that matchday.'''" end
    '! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'
     if not _isPlayedMatchday(season,md) then return "'''That matchday has not yet been played.'''" end
  }
    local R=simulateDayResults(day,season);local r=R[matchIndex];if not r then return "'''Match not found.'''" end
    return (titleOverride or string.format("'''Match Report — %d PSSC (Matchday %d)'''",season,md))..'\n'.._statsTable(r)
end


  for _,pl in ipairs(rosterNow) do
function p.renderMatchOfWeek(frame)
     local r = totals[pl.id]
     local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season);local pick=args.pick or 'best'
    out[#out+1] = '|-'
     if not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet (or the requested matchday is in the future).'''" 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)
     out[#out+1] = string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d',
end
      pl.name, pl.pos,
function p.renderChaosOfWeek(frame) local args=(frame and frame.args) or {};args.pick=args.pick or 'chaos';return p.renderMatchOfWeek({args=args}) end
      r and r.apps or 0,
function p.renderMatchReport(frame) local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local md=tonumber(args.md or args.matchday or args[2] or '') or _currentPlayedMatchday(season);local mi=tonumber(args.match or args[3] or '1') or 1;if not _isPlayedMatchday(season,md) then return "'''That matchday has not yet been played.'''" end;return _renderMatchReport(season,md,mi,string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''",season,md,mi)) end
      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] = '|}'
local function _playerRating(r)
    if not r then return 0 end
    local apps=math.max(1,r.apps or 0)
    local x=6+(r.goals or 0)*1.25/apps+(r.assists or 0)*.85/apps+(r.poss or 0)*.05/apps+(r.son or 0)*.15/apps-(r.sof or 0)*.08/apps-(r.fouls or 0)*.16/apps
    if x<0 then x=0 elseif x>10 then x=10 end;return x
end


  return (TEAM_BY_CODE[team].logo or "") ..
function p.renderWeeklyAwards(frame)
     " '''" .. team .. "''' — Player totals through Matchday " .. tostring(upToMd) .. "\n" ..
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1);local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season);if not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet.'''" end
    table.concat(out, "\n")
    local totals=_computePlayerSeasonTotals(season,md);local prior=_computePlayerSeasonTotals(season,md-1);local list={}
    for id,r in pairs(totals) do local q=prior[id] or {};local d={id=id,name=r.name,pos=r.pos,team=r.team,apps=(r.apps or 0)-(q.apps or 0),goals=(r.goals or 0)-(q.goals or 0),assists=(r.assists or 0)-(q.assists or 0),poss=(r.poss or 0)-(q.poss or 0),son=(r.son or 0)-(q.son or 0),sof=(r.sof or 0)-(q.sof or 0),fouls=(r.fouls or 0)-(q.fouls or 0)};if d.apps>0 then d.rating=_playerRating(d);list[#list+1]=d end end
     table.sort(list,function(a,b) if a.rating~=b.rating then return a.rating>b.rating end;return a.name<b.name end)
    local out={"'''Weekly Awards — Matchday "..md.."'''",'{| class="wikitable sortable"','! Award !! Player !! Team !! Pos !! Rating'}
    local labels={'Player of the Week','Second Star','Third Star'};for i=1,math.min(3,#list) do local r=list[i];out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %s || %.2f',labels[i],r.name,_teamWithLogo(r.team),r.pos,r.rating) end;out[#out+1]='|}';return table.concat(out,'\n')
end
end


-----------------------------------------------------------------------
function p.renderTeamOfWeek(frame)
-- PUBLIC: League leaders (top 20)
    local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local md=tonumber(args.md or args.matchday or args[2] or '') or _currentPlayedMatchday(season);if not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet.'''" end
-- {{#invoke:PillSeasonSchedule|renderLeaders|stat=goals|season=52}}
    local totals=_computePlayerSeasonTotals(season,md);local prior=_computePlayerSeasonTotals(season,md-1);local by={Keeper={},Defender={},Playmaker={},Striker={},Utility={}}
-----------------------------------------------------------------------
    for id,r in pairs(totals) do local q=prior[id] or {};local d={name=r.name,pos=r.pos,team=r.team,apps=(r.apps or 0)-(q.apps or 0),goals=(r.goals or 0)-(q.goals or 0),assists=(r.assists or 0)-(q.assists or 0),poss=(r.poss or 0)-(q.poss or 0),son=(r.son or 0)-(q.son or 0),sof=(r.sof or 0)-(q.sof or 0),fouls=(r.fouls or 0)-(q.fouls or 0)};if d.apps>0 and by[d.pos] then d.rating=_playerRating(d);by[d.pos][#by[d.pos]+1]=d end end
function p.renderLeaders(frame)
    for _,L in pairs(by) do table.sort(L,function(a,b) if a.rating~=b.rating then return a.rating>b.rating end;return a.name<b.name end) end
  local args = frame.args or {}
    local need={Keeper=1,Defender=3,Playmaker=3,Striker=2,Utility=2};local out={"'''Team of the Week — Matchday "..md.."'''",'{| class="wikitable"','! Role !! Player !! Team !! Rating'}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
    for _,pos in ipairs({'Keeper','Defender','Playmaker','Striker','Utility'}) do for i=1,math.min(need[pos],#by[pos]) do local r=by[pos][i];out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %.2f',pos,r.name,_teamWithLogo(r.team),r.rating) end end;out[#out+1]='|}';return table.concat(out,'\n')
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
end


  local stat = (args.stat or args[2] or "goals")
function p.renderMatchdayCapsule(frame)
  local ok = { goals=true, assists=true, poss=true, son=true, sof=true, fouls=true, apps=true }
    local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season);if not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet.'''" end
  if not ok[stat] then
    local day=invertDayMap(mapDays())[md];if not day then return "'''Unable to resolve PSSC day for matchday "..md..".'''" end;local out={'== Matchday '..md..' Capsule ==','',p.renderMatchOfWeek({args={season=season,md=md,pick=args.pick or 'chaos'}}),'','=== Full Results ==='}
    return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''"
    for _,r in ipairs(simulateDayResults(day,season)) do out[#out+1]=string.format('* %s \'\'\'%d\'\'\' %s vs %s \'\'\'%d\'\'\' %s',r.home.logo,r.hg,r.home.fullName,r.away.logo,r.ag,r.away.fullName) end;out[#out+1]='';out[#out+1]=p.renderWeeklyAwards({args={season=season,md=md}});return table.concat(out,'\n')
  end
end


  local totals = (_computePlayerSeasonTotals(season, upToMd))
function p.renderMatchdayDensity(frame)
  local list = {}
    local totalDays=END-START+1;local iv=math.floor(totalDays/TOTAL_ROUNDS);local ex=totalDays%TOTAL_ROUNDS;local cnt=0;for d=START,END do if mapDays()[d] then cnt=cnt+1 end end;return string.format('Locked rounds: %d; season days: %d; iv=%d; ex=%d; matchdays placed=%d',TOTAL_ROUNDS,totalDays,iv,ex,cnt)
  for _,r in pairs(totals) do list[#list+1] = r end
end


  table.sort(list, function(a,b)
function p.renderNextMatchdays(frame)
     if (a[stat] or 0) ~= (b[stat] or 0) then return (a[stat] or 0) > (b[stat] or 0) end
    local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local now,day,year=_nowAbs();local from=(year==season and math.max(day,START) or START);local out={'{| class="wikitable"','! # !! Matchday !! Calendar Day !! Gap (days) !! Date'};local shown=0;local prev=nil
    return (a.name or "") < (b.name or "")
     for d=from,END do local md=mapDays()[d];if md and absDay(season,d)>=now then local gap=prev and d-prev or '—';prev=d;shown=shown+1;out[#out+1]='|-';out[#out+1]=string.format('| %d || %d || %d || %s || %s',shown,md,d,tostring(gap),_dateLabel(season,d));if shown>=20 then break end end end;out[#out+1]='|}';return table.concat(out,'\n')
  end)
end


  local out = {
------------------------------------------------------------------------
     '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
-- 11) SEASON AWARDS
    '! Rank !! Player !! Team (current) !! Pos !! ' .. stat
------------------------------------------------------------------------
  }
local function _seasonAwardData(season)
    local totals=_computePlayerSeasonTotals(season,TOTAL_ROUNDS);local list={};for _,r in pairs(totals) do r.rating=_playerRating(r);list[#list+1]=r end
     local function best(stat,filter,ascending)
        local L={};for _,r in ipairs(list) do if not filter or filter(r) then L[#L+1]=r end end;table.sort(L,function(a,b) local av,bv=a[stat] or 0,b[stat] or 0;if av~=bv then if ascending then return av<bv else return av>bv end end;return a.name<b.name end);return L[1]
    end
    local awards={mvp=best('rating'),goldenPillar=best('goals'),playmaker=best('assists'),possession=best('poss'),sharpshooter=best('son'),ironman=best('apps'),fairPlay=best('fouls',function(r)return (r.apps or 0)>=10 end,true),goldenGloves=best('rating',function(r)return r.pos=='Keeper' end)}
    local by={Keeper={},Defender={},Playmaker={},Striker={},Utility={}};for _,r in ipairs(list) do if by[r.pos] then by[r.pos][#by[r.pos]+1]=r end end;for _,L in pairs(by) do table.sort(L,function(a,b)if a.rating~=b.rating then return a.rating>b.rating end;return a.name<b.name end)end
    return awards,by
end


  for i=1, math.min(20, #list) do
function p.renderSeasonAwards(frame)
     local r = list[i]
    local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local now=_nowAbs();local finalAbs=absDay(season,_finalRegularSeasonDay())
     out[#out+1] = '|-'
    if now<finalAbs then return string.format("'''End-of-season awards will be available after the regular season concludes (%s).'''",_dateLabelFromAbs(finalAbs)) end
    out[#out+1] = string.format('| %d || %s || %s || %s || %d',
     local A,by=_seasonAwardData(season);local out={'== End-of-Season Awards ==','{| class="wikitable sortable"','! Award !! Winner !! Team !! Pos !! Notes'}
      i, r.name or "—", r.team or "—", r.pos or "—", r[stat] or 0
     local function row(label,r,note) out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %s || %s',label,r and r.name or '—',r and _teamWithLogo(r.team) or '—',r and r.pos or '—',note and note(r) or '—') end
     )
    row('MVP (Player of the Season)',A.mvp,function(r)return string.format('Rating: %.2f',r.rating)end);row('Golden Pillar (Top Scorer)',A.goldenPillar,function(r)return 'Goals: '..(r.goals or 0)end);row('Playmaker of the Season',A.playmaker,function(r)return 'Assists: '..(r.assists or 0)end);row('Possession Master',A.possession,function(r)return 'Possession points: '..(r.poss or 0)end);row('Golden Gloves',A.goldenGloves,function(r)return string.format('Rating: %.2f',r.rating)end);row('Sharpshooter',A.sharpshooter,function(r)return 'Shots on target: '..(r.son or 0)end);row('Discipline / Fair Play',A.fairPlay,function(r)return 'Fouls: '..(r.fouls or 0)end);row('Ironman',A.ironman,function(r)return 'Appearances: '..(r.apps or 0)end);out[#out+1]='|}'
  end
    out[#out+1]='';out[#out+1]='=== Team of the Season (Best XI) ===';out[#out+1]='{| class="wikitable"';out[#out+1]='! Role !! Player !! Team !! Rating'
     local need={Keeper=1,Defender=3,Playmaker=3,Striker=2,Utility=2};for _,pos in ipairs({'Keeper','Defender','Playmaker','Striker','Utility'}) do for i=1,math.min(need[pos],#by[pos]) do local r=by[pos][i];out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %.2f',pos,r.name,_teamWithLogo(r.team),r.rating)end end;out[#out+1]='|}';return table.concat(out,'\n')
end


  out[#out+1] = '|}'
------------------------------------------------------------------------
 
-- 12) DIAGNOSTIC STATUS (optional but useful for the wiki page)
  return "'''League leaders (" .. stat .. ") — season " .. season ..
------------------------------------------------------------------------
    " through Matchday " .. tostring(upToMd) .. "'''\n" ..
function p.renderSeasonStatus(frame)
    table.concat(out, "\n")
    local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local stage,t=_seasonStage(season);local day,year=_currentPSSC();local labels={before_regular='Before regular season',regular='Regular season',pre_playoff='Awaiting playoffs',playoffs='Playoffs',pre_regional='Awaiting Regional Cup',regional_cup='Regional Cup',offseason='Off-season',complete='Completed/archive'}
    return string.format("'''Pillarion season engine:''' active season %d PSSC; calendar %s; phase: '''%s'''.<br>Regular season: %s–%s; playoffs: %s–%s; Regional Cup: %s–%s; next regular season: %s.",season,_dateLabel(year,day),labels[stage] or stage,_dateLabelFromAbs(t.regularStart),_dateLabelFromAbs(t.regularEnd),_dateLabelFromAbs(t.playoffStart),_dateLabelFromAbs(t.playoffFinal),_dateLabelFromAbs(t.regionalStart),_dateLabelFromAbs(t.regionalFinal),_dateLabelFromAbs(t.nextRegular))
end
end


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

Latest revision as of 16:52, 12 October 2026

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

-- Module:PillSeasonSchedule
-- COMPLETE 40-CLUB / 5-DIVISION EXPANSION REWRITE
-- Built from the previously supplied season-lifecycle module.
-- Historical 53 PSSC pairing data is retained in SCHED_LEGACY for reference;
-- the active schedule uses the expanded 40-team competition.
--
-- Purpose:
--   * Preserve the established Pillarion regular-season structure.
--   * Keep completed 53 PSSC results stable.
--   * Automatically move from regular season -> playoffs -> Regional Cup
--     -> offseason -> next regular season.
--   * Freeze completed-season standings/statistics instead of resetting them
--     when the PSSC calendar rolls into Atosiel of the following year.
--   * Keep historical season= arguments working.
--   * Give future seasons season-specific deterministic results so they do not
--     simply replay 53 PSSC.
--
-- The public render functions used by the existing Pillarion page are retained.

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

------------------------------------------------------------------------
-- 0) CONFIG / CALENDAR / LIFECYCLE
------------------------------------------------------------------------
local START, END = 62, 183
local DAYS_IN_YEAR = 183
local LEGACY_SEASON = 53

local PO_FIRST = 4          -- 4 Atosiel of season+1
local PO_IV = 3             -- playoff rounds every 3 days
local RC_GAP_AFTER_FINAL = 21
local RC_ROUND_GAP = 2
local RC_FINAL_GAP = 3

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

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

local function _dateFromAbs(a)
    local y = math.floor((a - 1) / DAYS_IN_YEAR)
    local d = a - y * DAYS_IN_YEAR
    if d < 1 then
        y = y - 1
        d = d + DAYS_IN_YEAR
    end
    return y, d
end

local function _dateLabel(y, d)
    local mon, dom = getMonthDay(d)
    return string.format('%d %s %d PSSC', dom, mon, y)
end

local function _dateLabelFromAbs(a)
    local y, d = _dateFromAbs(a)
    return _dateLabel(y, d)
end

local function _currentPSSC()
    local curr = date.getCurrentDate() or ''
    local day = tonumber(curr:match('^(%d+),')) or 0
    local year = tonumber(curr:match(', (%d+) PSSC')) or 0
    return day, year
end

local function _nowAbs()
    local d, y = _currentPSSC()
    return absDay(y, d), d, y
end

-- The active Pillarion competition season is the current year during
-- Thalassiel/Opsitheiel, but the previous year during Atosiel because Atosiel
-- carries that season's playoffs and Regional Cup.
-- During Atosiel the previous season remains active only while its
-- post-season is actually underway. Once the Regional Cup is finished,
-- all default league/standings calls must advance to the upcoming year.
-- Otherwise the next season displays the previous year's full table.
local function _activeSeason()
    local day, year = _currentPSSC()
    if year <= 0 then return LEGACY_SEASON end
    if day >= START then return year end
    local previousRegionalFinalDay = PO_FIRST + (PO_IV * 3)
        + RC_GAP_AFTER_FINAL + (RC_ROUND_GAP * 4) + RC_FINAL_GAP
    if day <= previousRegionalFinalDay then return year - 1 end
    return year
end

local function _resolveSeason(args, positional)
    args = args or {}
    local explicit = tonumber(args.season)
    if not explicit and positional then explicit = tonumber(args[positional]) end
    if explicit then return explicit end
    return _activeSeason()
end

local function _seasonTimes(season)
    local regularStart = absDay(season, START)
    local regularEnd = absDay(season, END)
    local playoffStart = absDay(season + 1, PO_FIRST)
    local playoffFinal = playoffStart + PO_IV * 3
    local regionalStart = playoffFinal + RC_GAP_AFTER_FINAL
    local regionalFinal = regionalStart + RC_ROUND_GAP * 4 + RC_FINAL_GAP
    local nextRegular = absDay(season + 1, START)
    return {
        regularStart = regularStart,
        regularEnd = regularEnd,
        playoffStart = playoffStart,
        playoffFinal = playoffFinal,
        regionalStart = regionalStart,
        regionalFinal = regionalFinal,
        nextRegular = nextRegular,
    }
end

local function _seasonStage(season)
    local now = _nowAbs()
    local t = _seasonTimes(season)
    if now < t.regularStart then return 'before_regular', t end
    if now <= t.regularEnd then return 'regular', t end
    if now < t.playoffStart then return 'pre_playoff', t end
    if now <= t.playoffFinal then return 'playoffs', t end
    if now < t.regionalStart then return 'pre_regional', t end
    if now <= t.regionalFinal then return 'regional_cup', t end
    if now < t.nextRegular then return 'offseason', t end
    return 'complete', t
end

------------------------------------------------------------------------
-- 1) TEAMS / DIVISIONS / KITS
------------------------------------------------------------------------
local teams = {
    { code='Allegro Symphonara',       fullName='Allegro Symphonara',        logo='[[File:AllegrosymphonaraB.png|25px]]', championships=4 },
    { code='Ashguard Pyralis',         fullName='Ashguard Pyralis',          logo='[[File:AshguardpyralisB.png|25px]]', championships=1 },
    { code='Aurelia Auric',            fullName='Aurelia Auric',             logo='[[File:AureliaAuricB.png|25px]]', championships=2 },
    { code='Brightpath Aureum',        fullName='Brightpath Aureum',         logo='[[File:BrightpathaureumB.png|25px]]', championships=2 },
    { code='Celestial Sancta Lunaris', fullName='Celestial Sancta Lunaris',  logo='[[File:CelestialsanctaB.png|25px]]', championships=1 },
    { code='Delphica Windborne',       fullName='Delphica Windborne',        logo='[[File:DelphicawindborneB.png|25px]]', championships=1 },
    { code='Emberstone Vaeringheim',   fullName='Emberstone Vaeringheim',    logo='[[File:EmberstonevaeringheimB.png|25px]]', championships=3 },
    { code='Flameborne Erythros',      fullName='Flameborne Erythros',       logo='[[File:FlameborneerythrosB.png|25px]]', championships=1 },
    { code='Forgeward Nexa',           fullName='Forgeward Nexa',            logo='[[File:ForgewardnexaB.png|25px]]', championships=0 },
    { code='Hearthkeeper Koinon',      fullName='Hearthkeeper Koinon',       logo='[[File:HearthkeeperkoinonB.png|25px]]', championships=0 },
    { code='Ironbough Sylvapolis',     fullName='Ironbough Sylvapolis',      logo='[[File:IronboughSylvapolisB.png|25px]]', championships=1 },
    { code='Redcliff Citadel',         fullName='Redcliff Citadel',          logo='[[File:RedcliffCitadel.png|25px]]', championships=1 },
    { code='Riftwarden Acheron',       fullName='Riftwarden Acheron',        logo='[[File:RiftwardenAcheronB.png|25px]]', championships=3 },
    { code='Saluria Skylight',         fullName='Saluria Skylight',          logo='[[File:SaluriaSkylightB.png|25px]]', championships=0 },
    { code='Sandveil Somniumpolis',    fullName='Sandveil Somniumpolis',     logo='[[File:SandVeilSomniumB.png|25px]]', championships=1 },
    { code='Serena Tidesong',          fullName='Serena Tidesong',           logo='[[File:Serena TidesongB.png|25px]]', championships=2 },
    { code='Steppe Luminaria',         fullName='Steppe Luminaria',          logo='[[File:Steppe LuminariaB.png|25px]]', championships=4 },
    { code='Strider Myrene',           fullName='Strider Myrene',            logo='[[File:StriderMyreneB.png|25px]]', championships=1 },
    { code='Vaeringheim Pillar',       fullName='Vaeringheim Pillar',        logo='[[File:VaeringheimPillarB.png|25px]]', championships=5 },
    { code='Imperial Delphica',        fullName='Imperial Delphica',         logo='[[File:ImperialDelphicaB.png|25px]]', championships=1 },
    { code='Ascendant Aetherium',      fullName='Ascendant Aetherium',       logo='[[File:AscendantAetheriumB.png|25px]]', championships=2 },
    { code='Sufriya Stormwake',        fullName='Sufriya Stormwake',         logo='[[File:SufriyaStormwake.png|25px]]', championships=1 },
    { code='Pillarion Club Suncliff',  fullName='Pillarion Club Suncliff',   logo='[[File:PCSuncliff.png|25px]]', championships=0 },
    { code='Jogi Regiment',            fullName='Jogi Regiment',             logo='[[File:JogiRegiment.png|25px]]', championships=0 },
    { code='Vine Fleet Mylecia',       fullName='Vine Fleet Mylecia',        logo='[[File:VineFleetMylecia.png|25px]]', championships=0 },
    { code='Port of Blore Heath',      fullName='Port of Blore Heath',       logo='[[File:PortBloreHeath.png|25px]]', championships=0 },
    { code='Free State Abeis',         fullName='Free State Abeis',          logo='[[File:FreeStateAbeis.png|25px]]', championships=0 },
    { code='Jezeraah City',            fullName='Jezeraah City',             logo='[[File:JezeraahCity.png|25px]]', championships=0 },
    { code='Ourid Pegasi',             fullName='Ourid Pegasi',              logo='[[File:OuridPegasiLogo.png|25px]]', championships=0 },
    { code='Pillarion Club Caspazani', fullName='Pillarion Club Caspazani',  logo='[[File:PCCaspazani.png|25px]]', championships=0 },
    { code='Amberwatch Slevik',        fullName='Amberwatch Slevik',         logo='[[File:AmberwatchSlevik.png|25px]]', championships=0 },
    { code='Fanghorn Rein',            fullName='Fanghorn Rein',             logo='[[File:FanghornRein.png|25px]]', championships=0 },
    { code='Thyrsa Maena SC', fullName='Thyrsa Maena SC', logo='[[File:ThyrsaMaenaSC.png|25px]]', championships=0 },
    { code='Pharos-Fener Hayr', fullName='Pharos-Fener Hayr', logo='[[File:PharosFenerHayr.png|25px]]', championships=0 },
    { code='Khalkodromoi Chaskal', fullName='Khalkodromoi Chaskal', logo='[[File:KhalkodromoiChaskal.png|25px]]', championships=0 },
    { code='Lenaia Krene AC', fullName='Lenaia Krene AC', logo='[[File:LenaiaKreneAC.png|25px]]', championships=0 },
    { code='Pontikos Mirovia', fullName='Pontikos Mirovia', logo='[[File:PontikosMirovia.png|25px]]', championships=0 },
    { code='Hodos Selenarion', fullName='Hodos Selenarion', logo='[[File:HodosSelenarion.png|25px]]', championships=0 },
    { code='Lithos Kefaya SC', fullName='Lithos Kefaya SC', logo='[[File:LithosKefayaSC.png|25px]]', championships=0 },
    { code='Lacaran Velai PC', fullName='Lacaran Velai PC', logo='[[File:LacaranVelaiPC.png|25px]]', championships=0 },
}

local TEAM_BY_CODE = {}
for _, t in ipairs(teams) do TEAM_BY_CODE[t.code] = t end

local JERSEYS_BY_TEAMNAME = {
    ['Allegro Symphonara']={home='AllegroHome.png',away='AllegroAway25.png',third='Symphonara3rd.png'},
    ['Ashguard Pyralis']={home='AshguardHome.png',away='AshguardAway.png',third='Pyralis3rdB.png'},
    ['Aurelia Auric']={home='AureliaHome.png',away='AureliaAway.png',third='Aurelia3rd.png'},
    ['Brightpath Aureum']={home='BrightpathHome.png',away='BrightpathAway.png',third='Aureum3rd.png'},
    ['Celestial Sancta Lunaris']={home='CelestialSanctaHome.png',away='CelestialSanctaAway.png',third='Lunalis3rdB.png'},
    ['Delphica Windborne']={home='DelphicaWBAway.png',away='DelphicaWBHome.png',third='Delphica3rd.png'},
    ['Emberstone Vaeringheim']={home='EmberstoneHome.png',away='EmberstoneAway.png',third='Vaeringheim3rdA.png'},
    ['Flameborne Erythros']={home='FlameborneHome.png',away='FlameborneAway.png',third='Erythros3rd.png'},
    ['Forgeward Nexa']={home='ForgewardHome.png',away='ForegewardAway.png',third='Nexa3rd.png'},
    ['Hearthkeeper Koinon']={home='HearthkeepHome.png',away='HearthkeepAway.png',third='Koinon3rd.png'},
    ['Ironbough Sylvapolis']={home='IronboughHome.png',away='IronboughAway.png',third='Sylvapolis3rd.png'},
    ['Redcliff Citadel']={home='RedcliffHome.png',away='RedcliffAway.png',third='Ferrum3rd.png'},
    ['Riftwarden Acheron']={home='RiftwardenHome.png',away='RiftwardenAway.png',third='Acheron3rd.png'},
    ['Saluria Skylight']={home='SkylightHome.png',away='SkylightAway.png',third='Saluria3rdB.png'},
    ['Sandveil Somniumpolis']={home='SomniumpolisHome.png',away='SomniumPolisAway.png',third='Somniumpolis3rd.png'},
    ['Serena Tidesong']={home='SerenaHome.png',away='SerenaAway.png',third='Serena3rd.png'},
    ['Steppe Luminaria']={home='SteppeHome.png',away='SteppeAway25.png',third='Luminaria3rd.png'},
    ['Strider Myrene']={home='StriderHome.png',away='StriderAway.png',third='Myrene3rd.png'},
    ['Vaeringheim Pillar']={home='PillarHome.png',away='PillarAway.png',third='Vaeringheim3rdB.png'},
    ['Imperial Delphica']={home='ImperialHome.png',away='ImperialAway.png',third='Delphica3rdB.png'},
    ['Ascendant Aetherium']={home='AscendentHome.png',away='AscendentAway.png',third='Aetherium3rd.png'},
    ['Sufriya Stormwake']={home='SufriyaHome.png',away='SufriyaAway.png',third='Sufriya3rd.png'},
    ['Pillarion Club Suncliff']={home='SuncliffHome.png',away='SuncliffAway.png',third='Suncliff3rd.png'},
    ['Jogi Regiment']={home='JogiHome.png',away='JogiAway.png',third='Jogi3rd.png'},
    ['Vine Fleet Mylecia']={home='VineFleetHomeB.png',away='VineFleetAwayB.png',third='Mylecia3rd.png'},
    ['Port of Blore Heath']={home='BloreHeathHome.png',away='BloreHeathAway.png',third='BloreHeath3rd.png'},
    ['Free State Abeis']={home='AbeisHome.png',away='AbeisAway.png',third='Abeis3rd.png'},
    ['Jezeraah City']={home='JezeraahHome.png',away='JezeraahAway.png',third='Jezeraah3rd.png'},
    ['Ourid Pegasi']={home='OuridPegasiHome.png',away='OuridPegasiAwayB.png',third='OuridPegasi3rd.png'},
    ['Pillarion Club Caspazani']={home='PCCaspazaniHome.png',away='PCCCaspazaniAway.png',third='PCCaspazani3rd.png'},
    ['Amberwatch Slevik']={home='AmberwatchHome.png',away='AmberwatchAway.png',third='Amberwatch3rd.png'},
    ['Fanghorn Rein']={home='ReinHome.png',away='ReinAway.png',third='Rein3rd.png'},
    ['Thyrsa Maena SC']={home='ThyrsaMaenaHomeB.png',away='ThyrsaMaenaAwayB.png',third='ThyrsaMaena3rdB.png'},
    ['Pharos-Fener Hayr']={home='PharosFenerHome.png',away='PharosFenerAway.png',third='PharosFener3rd.png.png'},
    ['Khalkodromoi Chaskal']={home='KhalkodromoiHome.png',away='KhalkodromoiAway.png',third='Khalkodromoi3rd.png'},
    ['Lenaia Krene AC']={home='LenaiaKreneHome.png',away='LenaiaKreneAway.png',third='LenaiaKrene3rd.png'},
    ['Pontikos Mirovia']={home='PontikosMiroviaHome.png',away='PontikosMiroviaAway.png',third='PontikosMirovia3rd.png'},
    ['Hodos Selenarion']={home='HodosSelenarionHome.png',away='HodosSelenarionAway.png',third='HodosSelenarion3rd.png'},
    ['Lithos Kefaya SC']={home='LithosKefayaHome.png',away='LithosKefayaAway.png',third='LithosKefaya3rd.png'},
    ['Lacaran Velai PC']={home='LacaranVelaiHome.png',away='LacaranVelaiAwayC.png',third='LacaranVelai3rd.png'},
}

local RC_JERSEYS_BY_TEAMNAME = {
    ['Morovian Select']={home='MorovianSelectHome.png',away='MorovianSelectAway.png'},
    ['Southern Strait Select']={home='SouthernStraitSelectHome.png',away='SouthernStraitSelectAway.png'},
    ['Western Highlands Select']={home='WesternHighlandsSelectHome.png',away='WesternHighlandsSelectAway.png'},
    ['Normarkian Select']={home='NormarkianSelectHome.png',away='NormarkianSelectAway.png'},
    -- Supply these optional kit files when available on the wiki:
    ['Lacaran Hodos Select']={home='',away=''},
}

local divisions = {
    ['Morovian']={
        'Emberstone Vaeringheim','Vaeringheim Pillar','Imperial Delphica','Delphica Windborne',
        'Allegro Symphonara','Steppe Luminaria','Jezeraah City','Saluria Skylight'
    },
    ['Southern Strait']={
        'Sufriya Stormwake','Vine Fleet Mylecia','Port of Blore Heath','Jogi Regiment',
        'Free State Abeis','Sandveil Somniumpolis','Brightpath Aureum','Flameborne Erythros'
    },
    ['Western Highlands']={
        'Ashguard Pyralis','Aurelia Auric','Celestial Sancta Lunaris','Forgeward Nexa',
        'Hearthkeeper Koinon','Ironbough Sylvapolis','Riftwarden Acheron','Serena Tidesong'
    },
    ['Normarkian']={
        'Redcliff Citadel','Strider Myrene','Ascendant Aetherium','Pillarion Club Suncliff',
        'Ourid Pegasi','Pillarion Club Caspazani','Amberwatch Slevik','Fanghorn Rein'
    },
    ['Lacaran Hodos']={
        'Thyrsa Maena SC','Pharos-Fener Hayr','Khalkodromoi Chaskal','Lenaia Krene AC',
        'Pontikos Mirovia','Hodos Selenarion','Lithos Kefaya SC','Lacaran Velai PC'
    },
}
local DIV_ORDER={'Morovian','Southern Strait','Western Highlands','Normarkian','Lacaran Hodos'}
local DIV_OF={}
for div, list in pairs(divisions) do for _, c in ipairs(list) do DIV_OF[c]=div end end

local function _safeHash(key)
    if mw and mw.hash and mw.hash.hashValue then
        return mw.hash.hashValue('md5', key or '')
    end
    local h=2166136261
    key=tostring(key or '')
    for i=1,#key do h=(h*16777619 + string.byte(key,i)) % 4294967291 end
    return string.format('%08x',h)
end

local function _pctFromKey(key)
    local h=_safeHash(key)
    return (tonumber(h:sub(1,8),16) or 0)%100
end

local function _kitImgForMatch(team, baseWhich, matchKey, px)
    px=px or 25
    if not team or not team.fullName then return '—' end
    local kits=JERSEYS_BY_TEAMNAME[team.fullName] or RC_JERSEYS_BY_TEAMNAME[team.fullName]
    if not kits then return '—' end
    local which=baseWhich
    if kits.third and kits.third~='' and _pctFromKey((matchKey or '')..'|'..team.fullName..'|'..baseWhich)<30 then which='third' end
    local f=kits[which]
    if not f or f=='' then return '—' end
    return string.format('[[File:%s|%dpx]]',f,px)
end

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

local function _teamWithLogo(x, bold)
    local t=_asTeam(x)
    local n=bold and ("'''"..t.fullName.."'''") or t.fullName
    return (t.logo and t.logo~='' and (t.logo..' ') or '')..n
end

------------------------------------------------------------------------
-- 2) LOCKED FIXTURE LIST
--    This reproduces the original 53 PSSC pairing/packing algorithm.
------------------------------------------------------------------------
local function _homeFirst(codeA, codeB)
    local function sum(s)
        local n=0
        for i=1,#s do n=(n+string.byte(s,i))%9973 end
        return n
    end
    return sum(codeA)>sum(codeB)
end

local function _buildPairings()
    local intra, inter = {}, {}
    for _, div in ipairs(DIV_ORDER) do
        local L=divisions[div]
        for i=1,#L-1 do
            for j=i+1,#L do
                intra[#intra+1]={home=TEAM_BY_CODE[L[i]],away=TEAM_BY_CODE[L[j]]}
                intra[#intra+1]={home=TEAM_BY_CODE[L[j]],away=TEAM_BY_CODE[L[i]]}
            end
        end
    end
    for di=1,#DIV_ORDER-1 do
        for dj=di+1,#DIV_ORDER do
            local A,B=divisions[DIV_ORDER[di]],divisions[DIV_ORDER[dj]]
            for _,ca in ipairs(A) do
                for _,cb in ipairs(B) do
                    local h,a
                    if _homeFirst(ca,cb) then h,a=ca,cb else h,a=cb,ca end
                    inter[#inter+1]={home=TEAM_BY_CODE[h],away=TEAM_BY_CODE[a]}
                end
            end
        end
    end
    table.sort(intra,function(a,b)
        local da,db=DIV_OF[a.home.code] or '',DIV_OF[b.home.code] or ''
        if da~=db then return da<db 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] or '')..'|'..(DIV_OF[a.away.code] or '')
        local kb=(DIV_OF[b.home.code] or '')..'|'..(DIV_OF[b.away.code] or '')
        if ka~=kb then return ka<kb end
        return (a.home.code..a.away.code)<(b.home.code..b.away.code)
    end)
    local merged={}
    local function appendBlock(src,start,count)
        for i=start,math.min(#src,start+count-1) do merged[#merged+1]=src[i] end
    end
    local ai,bi=1,1
    while ai<=#intra or bi<=#inter do
        if ai<=#intra then appendBlock(intra,ai,64); ai=ai+64 end
        if bi<=#inter then appendBlock(inter,bi,128); bi=bi+128 end
    end
    return merged
end

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

-- Historic schedule remains available for explicitly requested 53 PSSC archives.
-- Existing four-division pairing logic is evaluated with the original four divisions.
local function _legacyFixtures()
    local savedOrder = DIV_ORDER
    DIV_ORDER={'Morovian','Southern Strait','Western Highlands','Normarkian'}
    local fixtures=_packRounds(_buildPairings())
    DIV_ORDER=savedOrder
    return fixtures
end
local SCHED_LEGACY=_legacyFixtures()

-- Expansion-era schedule: 14 divisional (home and away), 32 interdivisional.
-- Round-robin pairings use the existing deterministic match orientation function.
local function _expandedPairings()
    local pairs={}
    for _,div in ipairs(DIV_ORDER) do
        local L=divisions[div]
        for i=1,#L-1 do for j=i+1,#L do
            pairs[#pairs+1]={home=TEAM_BY_CODE[L[i]],away=TEAM_BY_CODE[L[j]]}
            pairs[#pairs+1]={home=TEAM_BY_CODE[L[j]],away=TEAM_BY_CODE[L[i]]}
        end end
    end
    for i=1,#DIV_ORDER-1 do for j=i+1,#DIV_ORDER do
        for _,a in ipairs(divisions[DIV_ORDER[i]]) do
            for _,b in ipairs(divisions[DIV_ORDER[j]]) do
                local home,away=a,b
                if not _homeFirst(a,b) then home,away=b,a end
                pairs[#pairs+1]={home=TEAM_BY_CODE[home],away=TEAM_BY_CODE[away]}
            end
        end
    end end
    return pairs
end
-- Structured 54-matchday schedule, rather than greedily packing all fixtures.
-- 14 full divisional rounds + 40 interdivisional rounds (one division bye
-- per interdivisional matchday). Every club plays 46 matches, 23 home/away
-- split may vary by opponent but every club has 14 divisional and 32 external.
local function _rotateEight(codes)
    local arr={}; for i=1,8 do arr[i]=codes[i] end
    local rounds={}
    for r=1,7 do
        local games={}
        for i=1,4 do
            local h,a=TEAM_BY_CODE[arr[i]],TEAM_BY_CODE[arr[9-i]]
            if (r+i)%2~=0 then h,a=a,h end
            games[#games+1]={h,a}
        end
        rounds[r]=games
        local last=arr[8];for i=8,3,-1 do arr[i]=arr[i-1] end;arr[2]=last
    end
    return rounds
end
local function _expandedFixtures()
    local rounds={}
    local divisionRounds={}
    for _,div in ipairs(DIV_ORDER) do divisionRounds[div]=_rotateEight(divisions[div]) end
    for r=1,14 do
        local matchday={}
        for _,div in ipairs(DIV_ORDER) do
            local rr=divisionRounds[div][(r-1)%7+1]
            for _,g in ipairs(rr) do
                if r<=7 then matchday[#matchday+1]=g
                else matchday[#matchday+1]={g[2],g[1]} end
            end
        end
        rounds[#rounds+1]=matchday
    end
    -- Complete round robin over five divisions plus one rotating bye.
    local pairBlocks={
       {{2,5},{3,4}},
       {{1,3},{4,5}},
       {{1,5},{2,4}},
       {{1,2},{3,5}},
       {{1,4},{2,3}},
    }
    for block, pairs in ipairs(pairBlocks) do
        for t=0,7 do
            local matchday={}
            for _,pair in ipairs(pairs) do
                local A=divisions[DIV_ORDER[pair[1]]]
                local B=divisions[DIV_ORDER[pair[2]]]
                for i=1,8 do
                    local h=TEAM_BY_CODE[A[i]]
                    local a=TEAM_BY_CODE[B[((i+t-1)%8)+1]]
                    if (block+t)%2~=0 then h,a=a,h end
                    matchday[#matchday+1]={h,a}
                end
            end
            rounds[#rounds+1]=matchday
        end
    end
    return rounds
end
local SCHED_EXPANDED=_expandedFixtures()
local SCHED_LOCKED=SCHED_EXPANDED
local TOTAL_ROUNDS=#SCHED_LOCKED
local function getRoundMatches(i) return SCHED_LOCKED[tonumber(i) or 0] or {} end

-- Date helpers used by standings, awards, match reports, rosters and transfers.
-- Keep one stable calendar mapping throughout the season.
local _DAY_MAP, _INV_DAY_MAP = {}, {}
do
    local totalDays=END-START+1
    for i=1,TOTAL_ROUNDS do
        local d=START+math.floor((i-1)*(totalDays-1)/math.max(1,TOTAL_ROUNDS-1))
        _DAY_MAP[d]=i
        _INV_DAY_MAP[i]=d
    end
end
local function mapDays() return _DAY_MAP end
local function invertDayMap(dm)
    if dm==_DAY_MAP then return _INV_DAY_MAP end
    local inv={};for day,md in pairs(dm or {}) do inv[md]=day end
    return inv
end
local function _finalRegularSeasonDay() return _INV_DAY_MAP[TOTAL_ROUNDS] or END end
local function _regularSeasonCutoff(season)
    season=tonumber(season) or _activeSeason()
    local now,day,year=_nowAbs()
    if year<season then return START-1 end
    if year>season then return _finalRegularSeasonDay() end
    return math.min(math.max(day,START-1),_finalRegularSeasonDay())
end
-- A result is public only once its matchday has actually arrived in the
-- specified PSSC season. This also protects explicit ?season=54&md=... calls.
local function _isPlayedMatchday(season, md)
    md = tonumber(md) or 0
    if md < 1 or md > TOTAL_ROUNDS then return false end
    local day = _INV_DAY_MAP[md]
    return day ~= nil and _nowAbs() >= absDay(season,day)
end

local function _currentPlayedMatchday(season)
    local cutoff=_regularSeasonCutoff(season)
    local md=0
    for d=START,cutoff do if _DAY_MAP[d] then md=_DAY_MAP[d] end end
    return md
end

------------------------------------------------------------------------
-- 3) DETERMINISTIC MATCH ENGINE
------------------------------------------------------------------------
local function simScoreFor(h,a,seed)
    math.randomseed(seed)
    local p0=math.random()
    local hg,ag
    if p0<0.10 then
        hg=math.random(0,12); ag=hg
    else
        local wh=(h.championships or 0)+1
        local wa=(a.championships or 0)+1
        local homeWin=((p0-0.10)/0.90)<(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

-- 53 PSSC uses the legacy seed exactly. Later seasons add the year so future
-- seasons are deterministic but not identical replays of 53.
local function _matchSeed(season,day,k,h,a)
    local legacy=day*1000+k+string.byte(h.code,1)+string.byte(a.code,1)
    if tonumber(season)==53 then return legacy end
    return legacy+(tonumber(season) or 0)*1000003
end

local function _rrange(seed,lo,hi)
    math.randomseed(seed)
    return math.random(lo,hi)
end

local function _splitPillarScore(total,salt)
    total=tonumber(total) or 0
    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
    return g,total-3*g
end

local function computeMatchStats(home,away,hg,ag,salt)
    local hGoals,hPoss=_splitPillarScore(hg,salt+101)
    local aGoals,aPoss=_splitPillarScore(ag,salt+102)
    local hOn=_rrange(salt+101,hGoals,hGoals+2)
    local aOn=_rrange(salt+102,aGoals,aGoals+2)
    local hOff=_rrange(salt+103,0,3); local aOff=_rrange(salt+104,0,3)
    local hF=_rrange(salt+105,0,3); local aF=_rrange(salt+106,0,3)
    local hThr=_rrange(salt+107,0,3); local aThr=_rrange(salt+108,0,3)
    local hGK=_rrange(salt+109,0,2); local aGK=_rrange(salt+110,0,2)
    local hOOB=_rrange(salt+111,0,2); local aOOB=_rrange(salt+112,0,2)
    local hp,ap=50,50
    if hg~=ag then
        local sw=_rrange(salt+113,3,6)
        if hg>ag then hp,ap=50+sw,50-sw else hp,ap=50-sw,50+sw end
    else
        local tilt=_rrange(salt+113,-3,3); hp,ap=50+tilt,50-tilt
    end
    return {
        [home.code]={final=hg,goals=hGoals,poss_points=hPoss,shots_on=hOn,shots_off=hOff,fouls=hF,fks=aF,throws=hThr,gk=hGK,oob=hOOB,poss_pct=hp},
        [away.code]={final=ag,goals=aGoals,poss_points=aPoss,shots_on=aOn,shots_off=aOff,fouls=aF,fks=hF,throws=aThr,gk=aGK,oob=aOOB,poss_pct=ap},
    }
end

local function simulateDayResults(day,season)
    season=tonumber(season) or _activeSeason()
    local md=mapDays()[day]
    local out={}
    if not md then return out end
    for k,m in ipairs(getRoundMatches(md)) do
        local h,a=m[1],m[2]
        local seed=_matchSeed(season,day,k,h,a)
        local hg,ag=simScoreFor(h,a,seed)
        out[#out+1]={home=h,away=a,hg=hg,ag=ag,seed=seed,stats=computeMatchStats(h,a,hg,ag,seed),index=k,md=md,day=day}
    end
    return out
end

------------------------------------------------------------------------
-- 4) STANDINGS / SEEDING
------------------------------------------------------------------------
local function _emptyStandings()
    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,GP=0} end
    return recs
end

local function _applyResult(recH,recA,hg,ag)
    recH.GP=recH.GP+1; recA.GP=recA.GP+1
    recH.PF=recH.PF+hg; recH.PA=recH.PA+ag
    recA.PF=recA.PF+ag; recA.PA=recA.PA+hg
    if hg>ag then recH.W=recH.W+1; recA.L=recA.L+1; recH.Pts=recH.Pts+3
    elseif ag>hg then recA.W=recA.W+1; recH.L=recH.L+1; recA.Pts=recA.Pts+3
    else recH.D=recH.D+1; recA.D=recA.D+1; recH.Pts=recH.Pts+1; recA.Pts=recA.Pts+1 end
    recH.PD=recH.PF-recH.PA; recA.PD=recA.PF-recA.PA
end

local function _standingsThrough(season,lastDay)
    local recs=_emptyStandings(); local dm=mapDays()
    -- Clamp requested dates to the most recently played matchday.
    -- This is essential: future fixtures must never affect live tables.
    local playedCutoff=_regularSeasonCutoff(season)
    local effectiveEnd=math.min(lastDay or playedCutoff, playedCutoff, _finalRegularSeasonDay())
    for d=START,effectiveEnd do
        if dm[d] then
            for _,r in ipairs(simulateDayResults(d,season)) do _applyResult(recs[r.home.code],recs[r.away.code],r.hg,r.ag) end
        end
    end
    return recs
end

local function _standCmp(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 _divisionRows(season,div,lastDay)
    local recs=_standingsThrough(season,lastDay); local list={}
    for _,c in ipairs(divisions[div]) do list[#list+1]=recs[c] end
    table.sort(list,_standCmp); return list
end

local function getSeeds12(season)
    local winners,runners={},{}
    for _,div in ipairs(DIV_ORDER) do
        local L=_divisionRows(season,div,_finalRegularSeasonDay())
        winners[#winners+1]=L[1]; runners[#runners+1]=L[2]
    end
    table.sort(winners,_standCmp); table.sort(runners,_standCmp)
    local seeds={}
    for _,r in ipairs(winners) do seeds[#seeds+1]=r.team end
    for _,r in ipairs(runners) do seeds[#seeds+1]=r.team end
    return seeds
end

local function getConferencePlayIns(season)
    local games={}
    for _,div in ipairs(DIV_ORDER) do
        local L=_divisionRows(season,div,_finalRegularSeasonDay())
        games[#games+1]={L[3].team,L[4].team,div}
    end
    return games
end

------------------------------------------------------------------------
-- 5) REGULAR-SEASON RENDERERS / AUTOMATIC LIFECYCLE
------------------------------------------------------------------------
local function _renderRegularRound(day,season)
    local dm=mapDays()
    local target=nil
    for d=math.max(day,START),_finalRegularSeasonDay() do if dm[d] then target=d; break end end
    if not target then return "|-\n| colspan=3 | '''Regular season complete.'''" end
    local md=dm[target]; local mon,dom=getMonthDay(target); local rows={}
    for k,m in ipairs(getRoundMatches(md)) do
        local h,a=m[1],m[2]
        local key=string.format('REG|%d|%d|%d|%s|%s',season,target,k,h.code,a.code)
        local score='—'
        local now=_nowAbs()
        if now>=absDay(season,target) then
            local hg,ag=simScoreFor(h,a,_matchSeed(season,target,k,h,a)); score=string.format("'''%d-%d'''",hg,ag)
        end
        rows[#rows+1]=string.format('%s %s %s %s %s %s',_kitImgForMatch(h,'home',key),h.logo,h.fullName,score,_kitImgForMatch(a,'away',key),a.logo..' '..a.fullName)
    end
    return string.format('|-\n| %d (MD %d) || %d %s %d PSSC || %s',target,md,dom,mon,season,table.concat(rows,'<br>'))
end

function p.renderSchedule(frame)
    local args=(frame and frame.args) or {}
    local season=_resolveSeason(args,1)
    local stage,t=_seasonStage(season)
    local now,day,year=_nowAbs()

    if stage=='regular' or stage=='before_regular' then
        local startDay=(year==season and day or START)
        return table.concat({
            '{| class="wikitable sortable"',
            '! Day !! Date !! Matches',
            _renderRegularRound(startDay,season),
            '|}'
        },'\n')
    elseif stage=='pre_playoff' then
        return string.format("'''%d PSSC regular season complete. Play-offs begin %s.'''",season,_dateLabelFromAbs(t.playoffStart))
    elseif stage=='playoffs' then
        return "=== Current Play-off Results ===\n"..p.renderPlayoffResults({args={season=season,progressive='1'}})
    elseif stage=='pre_regional' then
        return "'''Play-offs complete. The Off-Season Regional Cup begins ".._dateLabelFromAbs(t.regionalStart)..".'''"
    elseif stage=='regional_cup' then
        return "=== Off-Season Regional Cup — Live Results ===\n"..p.renderRegionalCupResults({args={season=season}})
    elseif stage=='offseason' then
        return string.format("'''%d PSSC competition cycle complete. %d PSSC regular season begins %s.'''",season,season+1,_dateLabelFromAbs(t.nextRegular))
    else
        return string.format("'''%d PSSC competition cycle is complete.'''",season)
    end
end

-- Explicit alias for pages that want a semantically named lifecycle call.
p.renderLifecycle=p.renderSchedule

function p.renderStandings(frame)
    local args=(frame and frame.args) or {}
    -- Tables represent the upcoming/current regular season, even when
    -- Atosiel is hosting the previous year's playoffs or Regional Cup.
    -- Explicit |season=53 still permits historical standings.
    local explicit=tonumber(args.season or args[1])
    local _,calendarYear=_currentPSSC()
    local season=explicit or (calendarYear > 0 and calendarYear or _activeSeason())
    local last=_regularSeasonCutoff(season)
    local out={}
    for _,div in ipairs(DIV_ORDER) do
        out[#out+1]='=== '..div..' Division ==='
        out[#out+1]='{| class="wikitable sortable" style="width:100%;"'
        out[#out+1]='! # !! Team !! GP !! W !! D !! L !! Pts !! PF !! PA !! PD'
        local list=_divisionRows(season,div,last)
        for i,r in ipairs(list) do
            -- Original positional color bands: 1-2 green, 3-4 blue, 5-6 yellow, 7-8 red.
            local shade = (i <= 2 and '#ccffcc') or (i <= 4 and '#cce5ff') or (i <= 6 and '#F1EB9C') or '#ffcccc'
            out[#out+1]='|- style="background-color:'..shade..';"'
            out[#out+1]=string.format('| %d || %s || %d || %d || %d || %d || %d || %d || %d || %+d',i,_teamWithLogo(r.team),r.GP,r.W,r.D,r.L,r.Pts,r.PF,r.PA,r.PD)
        end
        out[#out+1]='|}'
    end
    return table.concat(out,'\n')
end

function p.renderPreviousResults(frame)
    local args=(frame and frame.args) or {}
    local season=_resolveSeason(args,1)
    local now,day,year=_nowAbs(); local dm=mapDays(); local played={}
    local fromDay
    if now>absDay(season,_finalRegularSeasonDay()) then fromDay=_finalRegularSeasonDay()
    elseif year==season then fromDay=day-1 else fromDay=START-1 end
    for d=fromDay,START,-1 do if dm[d] then played[#played+1]=d end; if #played>=5 then break end end
    if #played==0 then return "'''No regular-season matches have been played in "..season.." PSSC. Opening matchday: 1 Thalassiel "..season.." PSSC.'''" end
    local out={'{| class="wikitable sortable"','! Date !! Home !! Score !! Away'}
    for _,d in ipairs(played) do
        for _,r in ipairs(simulateDayResults(d,season)) do
            out[#out+1]='|-'
            out[#out+1]=string.format('| %s || %s || \'\'\'%d-%d\'\'\' || %s',_dateLabel(season,d),_teamWithLogo(r.home),r.hg,r.ag,_teamWithLogo(r.away))
        end
    end
    out[#out+1]='|}'
    return table.concat(out,'\n')
end

------------------------------------------------------------------------
-- 6) PLAYOFFS
------------------------------------------------------------------------
local function _playScore(H,A,salt)
    return simScoreFor(H,A,salt)
end

local function _winner(H,A,hg,ag)
    -- Preserve the legacy deterministic tiebreak: away side advances on a draw.
    if hg>ag then return H end
    return A
end

local function _seedIndex(seeds,team)
    for i,t in ipairs(seeds) do if t.code==team.code then return i end end
    return 999
end

local function _playoffState(season)
    local qualifiers, fourths={},{}
    for _,div in ipairs(DIV_ORDER) do
        local L=_divisionRows(season,div,_finalRegularSeasonDay())
        for rank=1,3 do qualifiers[#qualifiers+1]=L[rank] end
        fourths[#fourths+1]=L[4]
    end
    table.sort(fourths,_standCmp)
    qualifiers[#qualifiers+1]=fourths[1]
    table.sort(qualifiers,_standCmp)
    local seeds={}
    for _,r in ipairs(qualifiers) do seeds[#seeds+1]=r.team end
    local poAbs=absDay(season+1,PO_FIRST)
    local state={seeds=seeds,rounds={}}
    local remaining=seeds
    for ri=1,4 do
        local name=({'Round of 16','Quarterfinal','Semifinal','Final'})[ri]
        local abs=poAbs+(ri-1)*PO_IV
        local games,advancing={},{}
        for i=1,math.floor(#remaining/2) do
            local H,A=remaining[i],remaining[#remaining-i+1]
            local hg,ag=_playScore(H,A,abs*100+i)
            local winner=_winner(H,A,hg,ag)
            games[#games+1]={H,A,hg,ag,winner}
            advancing[#advancing+1]=winner
        end
        state.rounds[ri]={name=name,abs=abs,games=games}
        -- Preserve initial seeding, strongest remaining vs weakest.
        table.sort(advancing,function(a,b) return _seedIndex(seeds,a)<_seedIndex(seeds,b) end)
        remaining=advancing
    end
    state.champion=remaining[1]
    return state
end

local function _renderPlayoffTable(season,progressive,scheduleOnly)
    local S=_playoffState(season); local now=_nowAbs()
    local out={'{| class="wikitable sortable"','! Round !! Date !! Home !! Score !! Away !! Winner'}
    for _,round in ipairs(S.rounds) do
        local visible=(not progressive) or now>=round.abs
        if visible or scheduleOnly then
            for gi,g in ipairs(round.games) do
                local H,A=g[1],g[2]; local score='—'; local win='—'
                if not scheduleOnly and now>=round.abs then score=string.format("'''%d-%d'''",g[3],g[4]); win=_teamWithLogo(g[5]) end
                local key=string.format('PO|%d|%s|%d',season,round.name,gi)
                out[#out+1]='|-'
                out[#out+1]=string.format('| %s || %s || %s %s || %s || %s %s || %s',round.name,_dateLabelFromAbs(round.abs),_kitImgForMatch(H,'home',key,20),_teamWithLogo(H),score,_kitImgForMatch(A,'away',key,20),_teamWithLogo(A),win)
            end
        end
    end
    out[#out+1]='|}'
    return table.concat(out,'\n')
end

function p.renderPlayoffSchedule(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1)
    return _renderPlayoffTable(season,false,true)
end

function p.renderPlayoffResults(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1)
    local progressive=tostring(args.progressive or '1')~='0'
    return _renderPlayoffTable(season,progressive,false)
end

------------------------------------------------------------------------
-- 7) MATCH STATS
------------------------------------------------------------------------
local function _statsTable(r)
    local H=r.stats[r.home.code]; local A=r.stats[r.away.code]
    local out={
        string.format("'''%s %d-%d %s'''",r.home.fullName,r.hg,r.ag,r.away.fullName),
        '{| class="wikitable" style="width:100%;"',
        '! Stat !! '..r.home.fullName..' !! '..r.away.fullName,
        '|-','| Goals || '..H.goals..' || '..A.goals,
        '|-','| Possession Points || '..H.poss_points..' || '..A.poss_points,
        '|-','| Shots on Target || '..H.shots_on..' || '..A.shots_on,
        '|-','| Shots off Target || '..H.shots_off..' || '..A.shots_off,
        '|-','| Fouls || '..H.fouls..' || '..A.fouls,
        '|-','| Free Kicks || '..H.fks..' || '..A.fks,
        '|-','| Goal Kicks || '..H.gk..' || '..A.gk,
        '|-','| Out of Bounds || '..H.oob..' || '..A.oob,
        '|-','| Throw-Ins || '..H.throws..' || '..A.throws,
        '|-','| Ball Possession || '..H.poss_pct..'% || '..A.poss_pct..'%',
        '|}'
    }
    return table.concat(out,'\n')
end

function p.renderMatchStatsForDay(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,nil)
    local now,day,year=_nowAbs(); local target=tonumber(args.day)
    if not target then
        if year==season and mapDays()[day] then target=day else
            local cutoff=_regularSeasonCutoff(season)
            for d=cutoff,START,-1 do if mapDays()[d] then target=d; break end end
        end
    end
    if not target or not mapDays()[target] then return "'''No Pillarion matchday is available for this date.'''" end
    if now<absDay(season,target) then return "'''That matchday has not yet been played.'''" end
    local out={}
    for _,r in ipairs(simulateDayResults(target,season)) do out[#out+1]=_statsTable(r) end
    return table.concat(out,'\n\n')
end

function p.renderMatchStatsSingle(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,nil)
    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
    if _nowAbs()<absDay(season,day) then return "'''That matchday has not yet been played.'''" end
    for _,r in ipairs(simulateDayResults(day,season)) do
        if r.home.code==home and r.away.code==away then return _statsTable(r) end
    end
    return "'''Match not found on that Pillarion matchday.'''"
end

------------------------------------------------------------------------
-- 8) ROSTERS / TRANSFERS / PLAYER EVENT ENGINE
------------------------------------------------------------------------
local ROSTER_SIZE=12
local TRANSFER_MD_START,TRANSFER_MD_END=20,30
local POSITIONS={'Striker','Striker','Playmaker','Playmaker','Playmaker','Defender','Defender','Defender','Keeper','Utility','Utility','Utility'}

local function _seedFromString(s)
    local h=0; s=tostring(s or '')
    for i=1,#s do h=(h*31+string.byte(s,i))%2147483647 end
    if h==0 then h=1 end
    return h
end
local function _rngNext(st) st=(1103515245*st+12345)%2147483647; return st,st/2147483647 end
local function _pick(st,list) local r; st,r=_rngNext(st); return st,list[math.floor(r*#list)+1] end
local function _normKey(s)
    if not s or s=='' then return nil end
    if mw and mw.ustring then
        s=mw.ustring.lower(s)
        s=mw.ustring.gsub(s,'%s+','_')
        s=mw.ustring.gsub(s,'[^%w_]','')
        return s
    end
    return (string.lower(tostring(s)):gsub('%s+','_'):gsub('[^%w_]',''))
end

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

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

local function _maybeDiacritics(st,s)
    local r; st,r=_rngNext(st)
    if r<.08 then s=s:gsub('a','ä',1)
    elseif r<.16 then s=s:gsub('i','ï',1)
    elseif r<.24 then s=s:gsub('o','ö',1)
    elseif r<.32 then s=s:gsub('u','ü',1) end
    return st,s
end

local function _buildNameFrom(style, st, isLast)
    local t=NAME_STYLES[style] or NAME_STYLES.bassarid
    local a,b,c
    if not isLast then
        st,a=_pick(st,t.firstA); st,b=_pick(st,t.firstB); st,c=_pick(st,t.firstC)
    else
        st,a=_pick(st,t.lastA); st,b=_pick(st,t.lastB); st,c=_pick(st,t.lastC)
    end
    local name=_cap((a or '')..(b or '')..(c or ''))
    local r; st,r=_rngNext(st)
    if r<0.05 and #name>=6 then
        local cut=math.floor(#name/2)
        if mw and mw.ustring then name=mw.ustring.sub(name,1,cut)..'-'..mw.ustring.sub(name,cut+1)
        else name=name:sub(1,cut)..'-'..name:sub(cut+1) end
    elseif r<0.08 and #name>=6 then
        local cut=math.floor(#name/2)
        if mw and mw.ustring then name=mw.ustring.sub(name,1,cut)..'’'..mw.ustring.sub(name,cut+1)
        else name=name:sub(1,cut)..'’'..name:sub(cut+1) end
    end
    st,name=_maybeDiacritics(st,name)
    return st,name
end

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

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

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

local function _copyRosters(base)
    local R={}
    for tc,list in pairs(base) do R[tc]={}; for _,p0 in ipairs(list) do local q={}; for k,v in pairs(p0) do q[k]=v end; R[tc][#R[tc]+1]=q end end
    return R
end

local function _genTransferDeals(season)
    local st=_seedFromString(season..'|TRANSFERWINDOW|MD20-30'); local deals={}
    for md=TRANSFER_MD_START,TRANSFER_MD_END do
        local r; st,r=_rngNext(st); local count=1+math.floor(r*3)
        for n=1,count do
            local ia,ib
            st,r=_rngNext(st); ia=math.floor(r*#teams)+1
            repeat st,r=_rngNext(st); ib=math.floor(r*#teams)+1 until ib~=ia
            local A,B=teams[ia].code,teams[ib].code
            local ra=_genBaseRoster(A,season); local rb=_genBaseRoster(B,season)
            st,r=_rngNext(st); local pa=ra[math.floor(r*#ra)+1]
            if pa.pos=='Keeper' then st,r=_rngNext(st); if r<.75 then pa=ra[1+math.floor(r*(#ra-1))] end end
            local candidates={}; for _,pl in ipairs(rb) do if pl.pos==pa.pos then candidates[#candidates+1]=pl end end
            local pb; st,pb=_pick(st,candidates)
            deals[#deals+1]={md=md,A=A,B=B,A_id=pa.id,B_id=pb.id,pos=pa.pos}
        end
    end
    return deals
end

local function _findPlayer(list,id) for i,p0 in ipairs(list or {}) do if p0.id==id then return i,p0 end end end
local function _applyTransfersUpTo(base,deals,upToMd)
    local R=_copyRosters(base)
    for _,d in ipairs(deals or {}) do
        if d.md<=(upToMd or 0) then
            local ia,pa=_findPlayer(R[d.A],d.A_id); local ib,pb=_findPlayer(R[d.B],d.B_id)
            if ia and ib then pa.team=d.B; pb.team=d.A; R[d.A][ia],R[d.B][ib]=pb,pa end
        end
    end
    return R
end

local function _buildPlayerNameLookup(base)
    local out={}; for _,list in pairs(base) do for _,pl in ipairs(list) do out[pl.id]=pl.name end end; return out
end

local function _alloc(st, roster, posWeights)
    local pool={}
    for _,pl in ipairs(roster or {}) do
        local w=(posWeights and posWeights[pl.pos]) or 1
        for _=1,w do pool[#pool+1]=pl 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=_matchSeed(season,day,matchIndex,homeTeam,awayTeam)
    local hg,ag=simScoreFor(homeTeam,awayTeam,salt)
    local S=computeMatchStats(homeTeam,awayTeam,hg,ag,salt)
    local stats={home=S[homeTeam.code] or {},away=S[awayTeam.code] or {}}
    local st=_seedFromString(tostring(season)..'|MD'..md..'|D'..day..'|M'..matchIndex..'|'..homeTeam.code..'|'..awayTeam.code)
    local events={home={},away={}}
    for _,pl in ipairs(homeRoster or {}) do _ensure(events.home,pl.id).apps=1 end
    for _,pl in ipairs(awayRoster or {}) do _ensure(events.away,pl.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}
    local W_ASSIST={Playmaker=6,Striker=3,Utility=2,Defender=1,Keeper=0}

    for _=1,(stats.home.goals or 0) do
        local scorer; st,scorer=_alloc(st,homeRoster,W_GOAL)
        if scorer then
            _ensure(events.home,scorer.id).goals=_ensure(events.home,scorer.id).goals+1
            local r; st,r=_rngNext(st)
            if r<0.70 then
                local ast; st,ast=_alloc(st,homeRoster,W_ASSIST)
                if ast and ast.id~=scorer.id then _ensure(events.home,ast.id).assists=_ensure(events.home,ast.id).assists+1 end
            end
        end
    end
    for _=1,(stats.away.goals or 0) do
        local scorer; st,scorer=_alloc(st,awayRoster,W_GOAL)
        if scorer then
            _ensure(events.away,scorer.id).goals=_ensure(events.away,scorer.id).goals+1
            local r; st,r=_rngNext(st)
            if r<0.70 then
                local ast; st,ast=_alloc(st,awayRoster,W_ASSIST)
                if ast and ast.id~=scorer.id then _ensure(events.away,ast.id).assists=_ensure(events.away,ast.id).assists+1 end
            end
        end
    end
    for _=1,(stats.home.poss_points or 0) do local pl;st,pl=_alloc(st,homeRoster,W_POSS);if pl then _ensure(events.home,pl.id).poss=_ensure(events.home,pl.id).poss+1 end end
    for _=1,(stats.away.poss_points or 0) do local pl;st,pl=_alloc(st,awayRoster,W_POSS);if pl then _ensure(events.away,pl.id).poss=_ensure(events.away,pl.id).poss+1 end end

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

local function _computePlayerSeasonTotals(season,upToMd)
    upToMd=math.min(tonumber(upToMd) or 0,TOTAL_ROUNDS)
    local base=_buildBaseRosters(season); local deals=_genTransferDeals(season); local totals={}
    for _,list in pairs(base) do for _,pl in ipairs(list) do totals[pl.id]={id=pl.id,name=pl.name,pos=pl.pos,team=pl.team,apps=0,goals=0,assists=0,poss=0,son=0,sof=0,fouls=0} end end
    local inv=invertDayMap(mapDays())
    for md=1,upToMd do
        local day=inv[md]
        if day then
            local R=_applyTransfersUpTo(base,deals,md)
            for mi,m in ipairs(getRoundMatches(md)) do
                local h,a=m[1],m[2]; local hr,ar=R[h.code] or {},R[a.code] or {}
                local ev=_matchPlayerEvents(season,md,day,mi,h,a,hr,ar)
                for pid,line in pairs(ev.home) do local z=totals[pid]; if z then for _,k in ipairs({'apps','goals','assists','poss','son','sof','fouls'}) do z[k]=z[k]+(line[k] or 0) end; z.team=h.code end end
                for pid,line in pairs(ev.away) do local z=totals[pid]; if z then for _,k in ipairs({'apps','goals','assists','poss','son','sof','fouls'}) do z[k]=z[k]+(line[k] or 0) end; z.team=a.code end end
            end
        end
    end
    local Rnow=_applyTransfersUpTo(base,deals,upToMd)
    for tc,list in pairs(Rnow) do for _,pl in ipairs(list) do if totals[pl.id] then totals[pl.id].team=tc end end end
    return totals,deals,base
end

function p.renderRoster(frame)
    local args=(frame and 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=_resolveSeason(args,2); local upToMd=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season)
    local R=_applyTransfersUpTo(_buildBaseRosters(season),_genTransferDeals(season),upToMd)[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 "..upToMd..')\n'..table.concat(out,'\n')
end

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

function p.renderTransferLog(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season)
    local deals=_genTransferDeals(season); local base=_buildBaseRosters(season); local names=_buildPlayerNameLookup(base)
    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<=md then out[#out+1]='|-'; out[#out+1]=string.format('| %d || %s || %s || %s ⇄ %s (%s)',d.md,_teamWithLogo(d.A),_teamWithLogo(d.B),names[d.A_id] or d.A_id,names[d.B_id] or d.B_id,d.pos or '—') end end
    out[#out+1]='|}'
    return string.format("'''Transfer window (Matchdays %d–%d) — season %d (through Matchday %d)'''\n",TRANSFER_MD_START,TRANSFER_MD_END,season,md)..table.concat(out,'\n')
end

function p.renderPlayerStats(frame)
    local args=(frame and 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=_resolveSeason(args,2); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season)
    local totals,deals,base=_computePlayerSeasonTotals(season,md); local roster=_applyTransfersUpTo(base,deals,md)[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(roster) do local r=totals[pl.id] or {}; out[#out+1]='|-'; out[#out+1]=string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d',pl.name,pl.pos,r.apps or 0,r.goals or 0,r.assists or 0,r.poss or 0,r.son or 0,r.sof or 0,r.fouls or 0) end
    out[#out+1]='|}'
    return (TEAM_BY_CODE[team].logo or '').." '''"..team.."''' — Player totals through Matchday "..md..'\n'..table.concat(out,'\n')
end

function p.renderLeaders(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season)
    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,md); 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 lim=tonumber(args.limit or '10') or 10; local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! Rank !! Player !! Team (current) !! Pos !! '..stat}
    for i=1,math.min(lim,#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 string.format("'''%d PSSC leaders through Matchday %d'''\n",season,md)..table.concat(out,'\n')
end

------------------------------------------------------------------------
-- 9) REGIONAL CUP
------------------------------------------------------------------------
local RC_META={
    ['Morovian']={fullName='Morovian Select',logo='[[File:MorovianSelect.png|25px]]'},
    ['Southern Strait']={fullName='Southern Strait Select',logo='[[File:SouthernStraitSelect.png|25px]]'},
    ['Western Highlands']={fullName='Western Highlands Select',logo='[[File:WesternHighlandsSelect.png|25px]]'},
    ['Normarkian']={fullName='Normarkian Select',logo='[[File:NormarkianSelect.png|25px]]'},
    ['Lacaran Hodos']={fullName='Lacaran Hodos Select',logo=''},
}

local function _rcRating(r)
    local apps=tonumber(r.apps) or 0; local denom=math.max(1,apps)
    local g=(r.goals or 0)/denom; local a=(r.assists or 0)/denom; local pr=(r.poss or 0)/denom; local sr=(r.son or 0)/denom; local orr=(r.sof or 0)/denom; local fr=(r.fouls or 0)/denom
    local rel=math.min(1,denom/6); local raw
    if r.pos=='Keeper' then raw=5.9+pr*.08+a*.8-fr*.50-orr*.12 else raw=5.8+g*3+a*1.8+pr*.10+sr*.22-orr*.18-fr*.40 end
    local rating=6+(raw-6)*rel; if rating<0 then rating=0 elseif rating>9.7 then rating=9.7 end; return rating
end

local function _regionalTeams(season)
    local totals=_computePlayerSeasonTotals(season,TOTAL_ROUNDS)
    local byDiv={}; for _,d in ipairs(DIV_ORDER) do byDiv[d]={Keeper={},Defender={},Playmaker={},Striker={},Utility={}} end
    for _,r in pairs(totals) do local d=DIV_OF[r.team]; if d and byDiv[d] and byDiv[d][r.pos] then r.rating=_rcRating(r); byDiv[d][r.pos][#byDiv[d][r.pos]+1]=r end end
    for _,d in ipairs(DIV_ORDER) do for _,list in pairs(byDiv[d]) do table.sort(list,function(a,b) if a.rating~=b.rating then return a.rating>b.rating end return a.name<b.name end) end end
    local need={Keeper=1,Defender=4,Playmaker=3,Striker=3,Utility=1}; local out={}
    for _,d in ipairs(DIV_ORDER) do
        local meta=RC_META[d]; local sq={}
        for _,pos in ipairs({'Keeper','Defender','Playmaker','Striker','Utility'}) do for i=1,math.min(need[pos],#byDiv[d][pos]) do sq[#sq+1]=byDiv[d][pos][i] end end
        out[#out+1]={code=meta.fullName,fullName=meta.fullName,logo=meta.logo,division=d,squad=sq,championships=0}
    end
    return out
end

local function _regionalCupStartAbs(season) return absDay(season+1,PO_FIRST)+PO_IV*3+RC_GAP_AFTER_FINAL end
local function _rcStrength(T) local s=0; for _,pl in ipairs(T.squad or {}) do s=s+(pl.rating or 6) end; return s/math.max(1,#(T.squad or {})) end
local function _rcPlayScore(H,A,salt)
    math.randomseed(salt); local sh,sa=_rcStrength(H),_rcStrength(A); local p0=math.random(); local hg,ag
    if p0<.10 then hg=math.random(0,12);ag=hg else local ph=sh/(sh+sa); local homeWin=((p0-.10)/.90)<ph; local w=math.random(1,12); local l=math.random(0,w-1); if homeWin then hg,ag=w,l else hg,ag=l,w end end
    return hg,ag
end
local function _rcFixtures(T)
    local fixtures={}
    -- Berger table for five entrants plus a rotating bye.
    local rot={T[1],T[2],T[3],T[4],T[5],false}
    for r=1,5 do
        local games={}
        for i=1,3 do
            local h,a=rot[i],rot[7-i]
            if h and a then
                if r%2==0 then h,a=a,h end
                games[#games+1]={h,a}
            end
        end
        fixtures[#fixtures+1]={round='Round '..r,games=games}
        local last=rot[6]
        for i=6,3,-1 do rot[i]=rot[i-1] end
        rot[2]=last
    end
    return fixtures
end
local function _rcStandingsInit(T) local st={}; for _,x in ipairs(T) do st[x.fullName]={team=x,GP=0,W=0,D=0,L=0,GF=0,GA=0,GD=0,Pts=0} end; return st end
local function _rcApply(st,H,A,hg,ag) local h,a=st[H.fullName],st[A.fullName]; h.GP=h.GP+1;a.GP=a.GP+1;h.GF=h.GF+hg;h.GA=h.GA+ag;a.GF=a.GF+ag;a.GA=a.GA+hg;if hg>ag then h.W=h.W+1;a.L=a.L+1;h.Pts=h.Pts+3 elseif ag>hg then a.W=a.W+1;h.L=h.L+1;a.Pts=a.Pts+3 else h.D=h.D+1;a.D=a.D+1;h.Pts=h.Pts+1;a.Pts=a.Pts+1 end;h.GD=h.GF-h.GA;a.GD=a.GF-a.GA end
local function _rcSorted(st) local L={};for _,r in pairs(st) do L[#L+1]=r end;table.sort(L,function(a,b) if a.Pts~=b.Pts then return a.Pts>b.Pts end;if a.GD~=b.GD then return a.GD>b.GD end;if a.GF~=b.GF then return a.GF>b.GF end;return a.team.fullName<b.team.fullName end);return L end

function p.renderRegionalCupRosters(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local T=_regionalTeams(season); local out={'== Off-Season Regional Cup — Call-Ups ('..season..' PSSC) =='}
    for _,rt in ipairs(T) do out[#out+1]='=== '..rt.logo..' '..rt.fullName..' ===';out[#out+1]='{| class="wikitable sortable" style="width:100%; font-size:90%;"';out[#out+1]='! Player !! Pos !! Club !! Rating !! G !! A';for _,pl in ipairs(rt.squad) do out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %.2f || %d || %d',pl.name,pl.pos,_teamWithLogo(pl.team),pl.rating or 0,pl.goals or 0,pl.assists or 0) end;out[#out+1]='|}' end
    return table.concat(out,'\n')
end

function p.renderRegionalCupSchedule(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local T=_regionalTeams(season); local fixtures=_rcFixtures(T); local start=_regionalCupStartAbs(season)
    local out={'{| class="wikitable sortable"','! Stage !! Date !! Home (kit) !! Score !! Away (kit)'}
    for i,blk in ipairs(fixtures) do local a=start+(i-1)*RC_ROUND_GAP; for gi,g in ipairs(blk.games) do local key='RC|'..season..'|'..blk.round..'|'..gi;out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s %s || — || %s %s',blk.round,_dateLabelFromAbs(a),_kitImgForMatch(g[1],'home',key,20),_teamWithLogo(g[1]),_kitImgForMatch(g[2],'away',key,20),_teamWithLogo(g[2])) end end
    local finalAbs=start+RC_ROUND_GAP*4+RC_FINAL_GAP;out[#out+1]='|-';out[#out+1]=string.format('| Final || %s || TBD || — || TBD',_dateLabelFromAbs(finalAbs));out[#out+1]='|-';out[#out+1]=string.format('| 3rd Place || %s || TBD || — || TBD',_dateLabelFromAbs(finalAbs));out[#out+1]='|}'
    return table.concat(out,'\n')
end

function p.renderRegionalCupResults(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local T=_regionalTeams(season); local fixtures=_rcFixtures(T); local st=_rcStandingsInit(T); local start=_regionalCupStartAbs(season); local now=_nowAbs(); local out={}
    out[#out+1]='{| class="wikitable sortable"';out[#out+1]='! Stage !! Date !! Home !! Score !! Away'
    for i,blk in ipairs(fixtures) do
        local a=start+(i-1)*RC_ROUND_GAP
        for gi,g in ipairs(blk.games) do
            local score='—'; if now>=a then local hg,ag=_rcPlayScore(g[1],g[2],a*100+gi);score=string.format("'''%d-%d'''",hg,ag);_rcApply(st,g[1],g[2],hg,ag) end
            out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %s || %s',blk.round,_dateLabelFromAbs(a),_teamWithLogo(g[1]),score,_teamWithLogo(g[2]))
        end
    end
    out[#out+1]='|}';out[#out+1]='';out[#out+1]='=== Regional Cup Table ===';out[#out+1]='{| class="wikitable sortable"';out[#out+1]='! # !! Team !! GP !! W !! D !! L !! GF !! GA !! GD !! Pts'
    local L=_rcSorted(st);for i,r in ipairs(L) do out[#out+1]='|-';out[#out+1]=string.format('| %d || %s || %d || %d || %d || %d || %d || %d || %+d || %d',i,_teamWithLogo(r.team),r.GP,r.W,r.D,r.L,r.GF,r.GA,r.GD,r.Pts) end;out[#out+1]='|}'
    local finalAbs=start+RC_ROUND_GAP*4+RC_FINAL_GAP;out[#out+1]='';out[#out+1]='=== Finals Day — '.._dateLabelFromAbs(finalAbs)..' ==='
    local A1,A2,A3,A4=L[1].team,L[2].team,L[3].team,L[4].team
    local function line(label,X,Y,k) local score='—';local winner='TBD';if now>=finalAbs then local gx,gy=_rcPlayScore(X,Y,finalAbs*100+k);score=string.format("'''%d-%d'''",gx,gy);winner=(gx>gy and X.fullName) or (gy>gx and Y.fullName) or 'Draw' end;return string.format('* %s: %s %s %s (Winner: %s)',label,_teamWithLogo(X),score,_teamWithLogo(Y),winner) end
    out[#out+1]=line('Final',A1,A2,1);out[#out+1]=line('3rd Place',A3,A4,2)
    return table.concat(out,'\n')
end

------------------------------------------------------------------------
-- 10) MATCH REPORTS / WEEKLY FEATURES
------------------------------------------------------------------------
local function _pickMatchIndexForDay(season,md,pick)
    local day=invertDayMap(mapDays())[md]; if not day then return nil,'Unable to resolve matchday.' end
    local R=simulateDayResults(day,season); if #R==0 then return nil,'No fixtures found.' end
    local bestI,bestScore=1,nil
    for i,r in ipairs(R) do
        local s
        if pick=='chaos' then s=r.hg+r.ag+math.abs(r.hg-r.ag)
        else s=(r.hg+r.ag)-math.abs(r.hg-r.ag)*0.5 end
        if not bestScore or s>bestScore then bestScore=s;bestI=i end
    end
    return bestI
end

local function _renderMatchReport(season,md,matchIndex,titleOverride)
    local day=invertDayMap(mapDays())[md]; if not day then return "'''Unable to resolve that matchday.'''" end
    if not _isPlayedMatchday(season,md) then return "'''That matchday has not yet been played.'''" end
    local R=simulateDayResults(day,season);local r=R[matchIndex];if not r then return "'''Match not found.'''" end
    return (titleOverride or string.format("'''Match Report — %d PSSC (Matchday %d)'''",season,md))..'\n'.._statsTable(r)
end

function p.renderMatchOfWeek(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season);local pick=args.pick or 'best'
    if not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet (or the requested matchday is in the future).'''" end;local idx,err=_pickMatchIndexForDay(season,md,pick);if not idx then return "'''"..(err or 'Unable to select match.').."'''" end;return _renderMatchReport(season,md,idx)
end
function p.renderChaosOfWeek(frame) local args=(frame and frame.args) or {};args.pick=args.pick or 'chaos';return p.renderMatchOfWeek({args=args}) end
function p.renderMatchReport(frame) local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local md=tonumber(args.md or args.matchday or args[2] or '') or _currentPlayedMatchday(season);local mi=tonumber(args.match or args[3] or '1') or 1;if not _isPlayedMatchday(season,md) then return "'''That matchday has not yet been played.'''" end;return _renderMatchReport(season,md,mi,string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''",season,md,mi)) end

local function _playerRating(r)
    if not r then return 0 end
    local apps=math.max(1,r.apps or 0)
    local x=6+(r.goals or 0)*1.25/apps+(r.assists or 0)*.85/apps+(r.poss or 0)*.05/apps+(r.son or 0)*.15/apps-(r.sof or 0)*.08/apps-(r.fouls or 0)*.16/apps
    if x<0 then x=0 elseif x>10 then x=10 end;return x
end

function p.renderWeeklyAwards(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1);local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season);if not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet.'''" end
    local totals=_computePlayerSeasonTotals(season,md);local prior=_computePlayerSeasonTotals(season,md-1);local list={}
    for id,r in pairs(totals) do local q=prior[id] or {};local d={id=id,name=r.name,pos=r.pos,team=r.team,apps=(r.apps or 0)-(q.apps or 0),goals=(r.goals or 0)-(q.goals or 0),assists=(r.assists or 0)-(q.assists or 0),poss=(r.poss or 0)-(q.poss or 0),son=(r.son or 0)-(q.son or 0),sof=(r.sof or 0)-(q.sof or 0),fouls=(r.fouls or 0)-(q.fouls or 0)};if d.apps>0 then d.rating=_playerRating(d);list[#list+1]=d end end
    table.sort(list,function(a,b) if a.rating~=b.rating then return a.rating>b.rating end;return a.name<b.name end)
    local out={"'''Weekly Awards — Matchday "..md.."'''",'{| class="wikitable sortable"','! Award !! Player !! Team !! Pos !! Rating'}
    local labels={'Player of the Week','Second Star','Third Star'};for i=1,math.min(3,#list) do local r=list[i];out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %s || %.2f',labels[i],r.name,_teamWithLogo(r.team),r.pos,r.rating) end;out[#out+1]='|}';return table.concat(out,'\n')
end

function p.renderTeamOfWeek(frame)
    local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local md=tonumber(args.md or args.matchday or args[2] or '') or _currentPlayedMatchday(season);if not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet.'''" end
    local totals=_computePlayerSeasonTotals(season,md);local prior=_computePlayerSeasonTotals(season,md-1);local by={Keeper={},Defender={},Playmaker={},Striker={},Utility={}}
    for id,r in pairs(totals) do local q=prior[id] or {};local d={name=r.name,pos=r.pos,team=r.team,apps=(r.apps or 0)-(q.apps or 0),goals=(r.goals or 0)-(q.goals or 0),assists=(r.assists or 0)-(q.assists or 0),poss=(r.poss or 0)-(q.poss or 0),son=(r.son or 0)-(q.son or 0),sof=(r.sof or 0)-(q.sof or 0),fouls=(r.fouls or 0)-(q.fouls or 0)};if d.apps>0 and by[d.pos] then d.rating=_playerRating(d);by[d.pos][#by[d.pos]+1]=d end end
    for _,L in pairs(by) do table.sort(L,function(a,b) if a.rating~=b.rating then return a.rating>b.rating end;return a.name<b.name end) end
    local need={Keeper=1,Defender=3,Playmaker=3,Striker=2,Utility=2};local out={"'''Team of the Week — Matchday "..md.."'''",'{| class="wikitable"','! Role !! Player !! Team !! Rating'}
    for _,pos in ipairs({'Keeper','Defender','Playmaker','Striker','Utility'}) do for i=1,math.min(need[pos],#by[pos]) do local r=by[pos][i];out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %.2f',pos,r.name,_teamWithLogo(r.team),r.rating) end end;out[#out+1]='|}';return table.concat(out,'\n')
end

function p.renderMatchdayCapsule(frame)
    local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season);if not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet.'''" end
    local day=invertDayMap(mapDays())[md];if not day then return "'''Unable to resolve PSSC day for matchday "..md..".'''" end;local out={'== Matchday '..md..' Capsule ==','',p.renderMatchOfWeek({args={season=season,md=md,pick=args.pick or 'chaos'}}),'','=== Full Results ==='}
    for _,r in ipairs(simulateDayResults(day,season)) do out[#out+1]=string.format('* %s \'\'\'%d\'\'\' %s vs %s \'\'\'%d\'\'\' %s',r.home.logo,r.hg,r.home.fullName,r.away.logo,r.ag,r.away.fullName) end;out[#out+1]='';out[#out+1]=p.renderWeeklyAwards({args={season=season,md=md}});return table.concat(out,'\n')
end

function p.renderMatchdayDensity(frame)
    local totalDays=END-START+1;local iv=math.floor(totalDays/TOTAL_ROUNDS);local ex=totalDays%TOTAL_ROUNDS;local cnt=0;for d=START,END do if mapDays()[d] then cnt=cnt+1 end end;return string.format('Locked rounds: %d; season days: %d; iv=%d; ex=%d; matchdays placed=%d',TOTAL_ROUNDS,totalDays,iv,ex,cnt)
end

function p.renderNextMatchdays(frame)
    local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local now,day,year=_nowAbs();local from=(year==season and math.max(day,START) or START);local out={'{| class="wikitable"','! # !! Matchday !! Calendar Day !! Gap (days) !! Date'};local shown=0;local prev=nil
    for d=from,END do local md=mapDays()[d];if md and absDay(season,d)>=now then local gap=prev and d-prev or '—';prev=d;shown=shown+1;out[#out+1]='|-';out[#out+1]=string.format('| %d || %d || %d || %s || %s',shown,md,d,tostring(gap),_dateLabel(season,d));if shown>=20 then break end end end;out[#out+1]='|}';return table.concat(out,'\n')
end

------------------------------------------------------------------------
-- 11) SEASON AWARDS
------------------------------------------------------------------------
local function _seasonAwardData(season)
    local totals=_computePlayerSeasonTotals(season,TOTAL_ROUNDS);local list={};for _,r in pairs(totals) do r.rating=_playerRating(r);list[#list+1]=r end
    local function best(stat,filter,ascending)
        local L={};for _,r in ipairs(list) do if not filter or filter(r) then L[#L+1]=r end end;table.sort(L,function(a,b) local av,bv=a[stat] or 0,b[stat] or 0;if av~=bv then if ascending then return av<bv else return av>bv end end;return a.name<b.name end);return L[1]
    end
    local awards={mvp=best('rating'),goldenPillar=best('goals'),playmaker=best('assists'),possession=best('poss'),sharpshooter=best('son'),ironman=best('apps'),fairPlay=best('fouls',function(r)return (r.apps or 0)>=10 end,true),goldenGloves=best('rating',function(r)return r.pos=='Keeper' end)}
    local by={Keeper={},Defender={},Playmaker={},Striker={},Utility={}};for _,r in ipairs(list) do if by[r.pos] then by[r.pos][#by[r.pos]+1]=r end end;for _,L in pairs(by) do table.sort(L,function(a,b)if a.rating~=b.rating then return a.rating>b.rating end;return a.name<b.name end)end
    return awards,by
end

function p.renderSeasonAwards(frame)
    local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local now=_nowAbs();local finalAbs=absDay(season,_finalRegularSeasonDay())
    if now<finalAbs then return string.format("'''End-of-season awards will be available after the regular season concludes (%s).'''",_dateLabelFromAbs(finalAbs)) end
    local A,by=_seasonAwardData(season);local out={'== End-of-Season Awards ==','{| class="wikitable sortable"','! Award !! Winner !! Team !! Pos !! Notes'}
    local function row(label,r,note) out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %s || %s',label,r and r.name or '—',r and _teamWithLogo(r.team) or '—',r and r.pos or '—',note and note(r) or '—') end
    row('MVP (Player of the Season)',A.mvp,function(r)return string.format('Rating: %.2f',r.rating)end);row('Golden Pillar (Top Scorer)',A.goldenPillar,function(r)return 'Goals: '..(r.goals or 0)end);row('Playmaker of the Season',A.playmaker,function(r)return 'Assists: '..(r.assists or 0)end);row('Possession Master',A.possession,function(r)return 'Possession points: '..(r.poss or 0)end);row('Golden Gloves',A.goldenGloves,function(r)return string.format('Rating: %.2f',r.rating)end);row('Sharpshooter',A.sharpshooter,function(r)return 'Shots on target: '..(r.son or 0)end);row('Discipline / Fair Play',A.fairPlay,function(r)return 'Fouls: '..(r.fouls or 0)end);row('Ironman',A.ironman,function(r)return 'Appearances: '..(r.apps or 0)end);out[#out+1]='|}'
    out[#out+1]='';out[#out+1]='=== Team of the Season (Best XI) ===';out[#out+1]='{| class="wikitable"';out[#out+1]='! Role !! Player !! Team !! Rating'
    local need={Keeper=1,Defender=3,Playmaker=3,Striker=2,Utility=2};for _,pos in ipairs({'Keeper','Defender','Playmaker','Striker','Utility'}) do for i=1,math.min(need[pos],#by[pos]) do local r=by[pos][i];out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %.2f',pos,r.name,_teamWithLogo(r.team),r.rating)end end;out[#out+1]='|}';return table.concat(out,'\n')
end

------------------------------------------------------------------------
-- 12) DIAGNOSTIC STATUS (optional but useful for the wiki page)
------------------------------------------------------------------------
function p.renderSeasonStatus(frame)
    local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local stage,t=_seasonStage(season);local day,year=_currentPSSC();local labels={before_regular='Before regular season',regular='Regular season',pre_playoff='Awaiting playoffs',playoffs='Playoffs',pre_regional='Awaiting Regional Cup',regional_cup='Regional Cup',offseason='Off-season',complete='Completed/archive'}
    return string.format("'''Pillarion season engine:''' active season %d PSSC; calendar %s; phase: '''%s'''.<br>Regular season: %s–%s; playoffs: %s–%s; Regional Cup: %s–%s; next regular season: %s.",season,_dateLabel(year,day),labels[stage] or stage,_dateLabelFromAbs(t.regularStart),_dateLabelFromAbs(t.regularEnd),_dateLabelFromAbs(t.playoffStart),_dateLabelFromAbs(t.playoffFinal),_dateLabelFromAbs(t.regionalStart),_dateLabelFromAbs(t.regionalFinal),_dateLabelFromAbs(t.nextRegular))
end

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