Module:PillSeasonSchedule: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
Undo revision 383849 by NewZimiaGov (talk)
Tag: Undo
NewZimiaGov (talk | contribs)
No edit summary
 
(88 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


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 10) ROSTERS + TRANSFERS + PLAYER STATS (embedded, deterministic)
-- 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'}


-- Config
local ROSTER_SIZE = 12
local TRANSFER_MD_START = 20
local TRANSFER_MD_END  = 30
local DEFAULT_SEASON = 52
-- IMPORTANT: use our own PRNG so we don't disturb math.randomseed usage elsewhere
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
    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
end


local function _rngNext(st)
local NAME_STYLES={
  st = (1103515245 * st + 12345) % 2147483647
bassarid={
  return st, st / 2147483647
  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
end


local function _pick(st, arr)
local function _maybeDiacritics(st,s)
  if not arr or #arr == 0 then return st, nil end
    local r; st,r=_rngNext(st)
  local r; st, r = _rngNext(st)
    if r<.08 then s=s:gsub('a','ä',1)
  local idx = math.floor(r * #arr) + 1
    elseif r<.16 then s=s:gsub('i','ï',1)
  return st, arr[idx]
    elseif r<.24 then s=s:gsub('o','ö',1)
    elseif r<.32 then s=s:gsub('u','ü',1) end
    return st,s
end
end


local function _normKey(s)
local function _buildNameFrom(style, st, isLast)
  if not s or s == "" then return nil end
    local t=NAME_STYLES[style] or NAME_STYLES.bassarid
  s = mw.ustring.lower(s)
    local a,b,c
  s = mw.ustring.gsub(s, "%s+", "_")
    if not isLast then
  s = mw.ustring.gsub(s, "[^%w_]", "")
        st,a=_pick(st,t.firstA); st,b=_pick(st,t.firstB); st,c=_pick(st,t.firstC)
  return s
    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
end


-- Bassaridian-ish pools (edit freely)
local function _chooseStyleForTeam(teamCode, season)
local NAME = {
    local st=_seedFromString(tostring(season)..'|'..tostring(teamCode)..'|STYLE')
  first = {
     local key; st,key=_pick(st,NAME_STYLE_KEYS)
     "Ariston","Demetrios","Eryx","Kalithros","Lothar","Nefra","Nikolaos","Pyrrhos",
     return key or 'bassarid'
    "Silenos","Thalios","Eosphoros","Aither","Cassian","Sabina","Selim","Leyla",
end
    "Leif","Astrid","Bjorn","Sigrid"
  },
  last = {
    "Sekeri","Kallistron","Aminis","Delphikos","Varengar","Caspazani","Myrenos","Aurellian",
     "Koinoros","Sleviksen","Yildirim","Demir","Kaya","Arslan","Bjornsen","Ravnsson","Thorsen"
  }
}
 
local POSITIONS = { -- keep simple; you can lore-rename later
  "Striker","Striker",
  "Playmaker","Playmaker","Playmaker",
  "Defender","Defender","Defender",
  "Keeper",
  "Utility","Utility","Utility"
}


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 roster = {}
    local style=_chooseStyleForTeam(teamCode,season)
  for i=1, ROSTER_SIZE do
    local roster,used={},{}
    local fn, ln
    for i=1,ROSTER_SIZE do
    st, fn = _pick(st, NAME.first)
        local id=key..'-'..string.format('%02d',i)
    st, ln = _pick(st, NAME.last)
        local full
    local id = key .. "-" .. string.format("%02d", i)
        for _=1,10 do
    roster[i] = {
            local first,last
      id = id,
            st,first=_buildNameFrom(style,st,false)
      name = (fn or "—") .. " " .. (ln or "—"),
            st,last=_buildNameFrom(style,st,true)
      pos = POSITIONS[i] or "Utility",
            full=first..' '..last
      no = i
            if not used[full] then used[full]=true; break end
     }
        end
  end
        roster[i]={id=id,name=full or ('Player '..id),pos=POSITIONS[i] or 'Utility',no=i}
  roster[1].notes = "Captain"
     end
  return roster
    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


-- Transfer engine (deterministic swaps to keep roster size constant)
local function _copyRosters(base)
local function _indexById(roster)
    local R={}
  local m = {}
    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
  for i,p in ipairs(roster) do m[p.id] = i end
    return R
  return m
end
end


local function _findSwapCandidate(st, roster, wantPos)
local function _genTransferDeals(season)
  local candidates = {}
    local st=_seedFromString(season..'|TRANSFERWINDOW|MD20-30'); local deals={}
  for _,p in ipairs(roster) do
    for md=TRANSFER_MD_START,TRANSFER_MD_END do
    if p.pos == wantPos then candidates[#candidates+1] = p end
        local r; st,r=_rngNext(st); local count=1+math.floor(r*3)
  end
        for n=1,count do
  if #candidates == 0 then candidates = roster end
            local ia,ib
  return _pick(st, candidates)
            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
end


local function _genTransferDeals(season)
local function _findPlayer(list,id) for i,p0 in ipairs(list or {}) do if p0.id==id then return i,p0 end end end
  -- Produce a full list of deals for MD20..MD30.
local function _applyTransfersUpTo(base,deals,upToMd)
  -- Each deal is a 1-for-1 swap between two clubs (same position where possible).
    local R=_copyRosters(base)
  local st = _seedFromString(tostring(season) .. "|TRANSFERWINDOW|MD" .. TRANSFER_MD_START .. "-" .. TRANSFER_MD_END)
    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 deals = {}
local function _buildPlayerNameLookup(base)
  local teamCodes = {}
    local out={}; for _,list in pairs(base) do for _,pl in ipairs(list) do out[pl.id]=pl.name end end; return out
  for _,t in ipairs(teams) do teamCodes[#teamCodes+1] = t.code end
end


  for md = TRANSFER_MD_START, TRANSFER_MD_END do
local function _alloc(st, roster, posWeights)
     -- 1–3 deals per matchday
     local pool={}
     local r; st, r = _rngNext(st)
     for _,pl in ipairs(roster or {}) do
    local dealCount = 1 + math.floor(r * 3)
        local w=(posWeights and posWeights[pl.pos]) or 1
        for _=1,w do pool[#pool+1]=pl end
    end
    return _pick(st,pool)
end


     for _=1, dealCount do
local function _ensure(map,pid)
      local a,b
     if not map[pid] then map[pid]={goals=0,assists=0,poss=0,son=0,sof=0,fouls=0,apps=0} end
      st, a = _pick(st, teamCodes)
    return map[pid]
      repeat st, b = _pick(st, teamCodes) until b ~= a
end


      -- choose a “from” player from A (not keeper too often)
local function _matchPlayerEvents(season,md,day,matchIndex,homeTeam,awayTeam,homeRoster,awayRoster)
      local baseRosterA = _genBaseRoster(a, season)
    local salt=_matchSeed(season,day,matchIndex,homeTeam,awayTeam)
      local baseRosterB = _genBaseRoster(b, season)
    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 pA
    local W_GOAL={Striker=6,Playmaker=3,Utility=2,Defender=1,Keeper=0}
      st, pA = _pick(st, baseRosterA)
    local W_POSS={Playmaker=6,Defender=3,Utility=2,Striker=1,Keeper=1}
      if pA and pA.pos == "Keeper" then
    local W_FOUL={Defender=4,Utility=3,Playmaker=2,Striker=1,Keeper=1}
        local rr; st, rr = _rngNext(st)
    local W_ASSIST={Playmaker=6,Striker=3,Utility=2,Defender=1,Keeper=0}
        if rr < 0.75 then st, pA = _pick(st, baseRosterA) end
      end


      local pB
    for _=1,(stats.home.goals or 0) do
      st, pB = _findSwapCandidate(st, baseRosterB, pA and pA.pos or "Utility")
        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


      if a and b and pA and pB then
    local function distributeShots(side,roster,on,off)
         deals[#deals+1] = { md=md, A=a, B=b, A_id=pA.id, B_id=pB.id, pos=pA.pos }
        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
      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
     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)
  return deals
    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
end


local function _applyTransfersUpTo(baseRosters, deals, upToMd)
local function _computePlayerSeasonTotals(season,upToMd)
  -- deep-ish copy rosters as arrays of tables
    upToMd=math.min(tonumber(upToMd) or 0,TOTAL_ROUNDS)
  local rosters = {}
    local base=_buildBaseRosters(season); local deals=_genTransferDeals(season); local totals={}
  for code, r in pairs(baseRosters) do
    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
    rosters[code] = {}
    local inv=invertDayMap(mapDays())
    for i,p in ipairs(r) do
     for md=1,upToMd do
      rosters[code][i] = { id=p.id, name=p.name, pos=p.pos, no=p.no, notes=p.notes }
        local day=inv[md]
     end
        if day then
  end
            local R=_applyTransfersUpTo(base,deals,md)
 
            for mi,m in ipairs(getRoundMatches(md)) do
  for _,d in ipairs(deals) do
                local h,a=m[1],m[2]; local hr,ar=R[h.code] or {},R[a.code] or {}
    if d.md <= upToMd then
                local ev=_matchPlayerEvents(season,md,day,mi,h,a,hr,ar)
      local RA, RB = rosters[d.A], rosters[d.B]
                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 RA and RB 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
        local ia = _indexById(RA)[d.A_id]
            end
        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
    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


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


-- Matchday helpers
function p.renderAllRosters(frame)
local function _invertDayMap(dm)
    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={}
  local inv = {}
    for _,t in ipairs(teams) do
  for day, md in pairs(dm) do inv[md] = day end
        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>'
  return inv
    end
    return table.concat(out,'\n')
end
end


local function _currentPlayedMatchday()
function p.renderTransferLog(frame)
  local curr = date.getCurrentDate()
    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 today = tonumber(curr:match("^(%d+),")) or 0
    local deals=_genTransferDeals(season); local base=_buildBaseRosters(season); local names=_buildPlayerNameLookup(base)
  local dm = mapDays()
    local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! Matchday !! From !! To !! Swap (pos)'}
  local lastMd = 0
    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
  for d=START, math.min(today, END) do
    out[#out+1]='|}'
    if dm[d] and dm[d] > lastMd then lastMd = dm[d] end
    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
  return lastMd
end
end


-- Allocate per-player events that SUM to the match stats
function p.renderPlayerStats(frame)
local function _alloc(st, roster, posWeights)
    local args=(frame and frame.args) or {}; local team=args.team or args[1]
  local pool = {}
    if not team or not TEAM_BY_CODE[team] then return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''" end
  for _,p in ipairs(roster) do
    local season=_resolveSeason(args,2); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season)
    local w = posWeights[p.pos] or 1
    local totals,deals,base=_computePlayerSeasonTotals(season,md); local roster=_applyTransfersUpTo(base,deals,md)[team] or {}
    for _=1,w do pool[#pool+1] = p end
    local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'}
  end
    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
  return _pick(st, pool)
    out[#out+1]='|}'
    return (TEAM_BY_CODE[team].logo or '').." '''"..team.."''' — Player totals through Matchday "..md..'\n'..table.concat(out,'\n')
end
end


local function _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster)
function p.renderLeaders(frame)
  -- Use same salt pattern used throughout module for determinism
    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 salt = day*1000 + matchIndex + string.byte(homeTeam.code,1) + string.byte(awayTeam.code,1)
    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}
  local hg, ag = simScoreFor(homeTeam, awayTeam, salt)
    if not ok[stat] then return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''" end
  local stats = computeMatchStats(homeTeam, awayTeam, hg, ag, salt)
    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


  local events = { home={}, away={} } -- list of {pid, goals, assists, poss, son, sof, fouls}
------------------------------------------------------------------------
-- 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 initMap()
local function _rcRating(r)
     return { goals=0, assists=0, poss=0, son=0, sof=0, fouls=0, apps=0 }
     local apps=tonumber(r.apps) or 0; local denom=math.max(1,apps)
  end
    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
  local function ensure(m, pid)
     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
     m[pid] = m[pid] or initMap()
    local rating=6+(raw-6)*rel; if rating<0 then rating=0 elseif rating>9.7 then rating=9.7 end; return rating
    return m[pid]
end
  end


  -- weights by position
local function _regionalTeams(season)
  local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
    local totals=_computePlayerSeasonTotals(season,TOTAL_ROUNDS)
  local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
    local byDiv={}; for _,d in ipairs(DIV_ORDER) do byDiv[d]={Keeper={},Defender={},Playmaker={},Striker={},Utility={}} end
  local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
    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 st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)
     local need={Keeper=1,Defender=4,Playmaker=3,Striker=3,Utility=1}; local out={}
 
     for _,d in ipairs(DIV_ORDER) do
  -- appearances (everyone on roster "available"; you can later add minutes)
        local meta=RC_META[d]; local sq={}
  for _,p in ipairs(homeRoster) do ensure(events.home, p.id).apps = 1 end
        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
  for _,p in ipairs(awayRoster) do ensure(events.away, p.id).apps = 1 end
        out[#out+1]={code=meta.fullName,fullName=meta.fullName,logo=meta.logo,division=d,squad=sq,championships=0}
 
  -- Goals (pillar strikes)
  for _=1, stats.home.goals do
     local pid; st, pid = _alloc(st, homeRoster, W_GOAL)
    ensure(events.home, pid.id).goals = ensure(events.home, pid.id).goals + 1
     -- 70% assist
    local r; st, r = _rngNext(st)
    if r < 0.70 then
      local ap; st, ap = _alloc(st, homeRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
      if ap.id ~= pid.id then ensure(events.home, ap.id).assists = ensure(events.home, ap.id).assists + 1 end
     end
     end
  end
    return out
end


  for _=1, stats.away.goals do
local function _regionalCupStartAbs(season) return absDay(season+1,PO_FIRST)+PO_IV*3+RC_GAP_AFTER_FINAL end
     local pid; st, pid = _alloc(st, awayRoster, W_GOAL)
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
     ensure(events.away, pid.id).goals = ensure(events.away, pid.id).goals + 1
local function _rcPlayScore(H,A,salt)
     local r; st, r = _rngNext(st)
     math.randomseed(salt); local sh,sa=_rcStrength(H),_rcStrength(A); local p0=math.random(); local hg,ag
     if r < 0.70 then
     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
      local ap; st, ap = _alloc(st, awayRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
     return hg,ag
      if ap.id ~= pid.id then ensure(events.away, ap.id).assists = ensure(events.away, ap.id).assists + 1 end
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
     end
  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


  -- Possession points
function p.renderRegionalCupRosters(frame)
  for _=1, stats.home.poss_points do
     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) =='}
     local p; st, p = _alloc(st, homeRoster, W_POSS)
     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
     ensure(events.home, p.id).poss = ensure(events.home, p.id).poss + 1
    return table.concat(out,'\n')
  end
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 (goals are already “on”, so distribute remaining)
function p.renderRegionalCupSchedule(frame)
  local function distributeShots(sideEvents, roster, on, off)
     local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local T=_regionalTeams(season); local fixtures=_rcFixtures(T); local start=_regionalCupStartAbs(season)
     -- allocate ON (including goals already counted)
    local out={'{| class="wikitable sortable"','! Stage !! Date !! Home (kit) !! Score !! Away (kit)'}
    for _=1, on do
     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 p; st, p = _alloc(st, roster, {Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0})
    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]='|}'
      ensure(sideEvents, p.id).son = ensure(sideEvents, p.id).son + 1
    return table.concat(out,'\n')
    end
     for _=1, off do
      local p; st, p = _alloc(st, roster, {Striker=4, Playmaker=3, Utility=2, Defender=1, Keeper=0})
      ensure(sideEvents, p.id).sof = ensure(sideEvents, p.id).sof + 1
    end
  end
  distributeShots(events.home, homeRoster, stats.home.shots_on,  stats.home.shots_off)
  distributeShots(events.away, awayRoster, stats.away.shots_on, stats.away.shots_off)
 
  -- Fouls
  for _=1, stats.home.fouls do
    local p; st, p = _alloc(st, homeRoster, W_FOUL)
    ensure(events.home, p.id).fouls = ensure(events.home, p.id).fouls + 1
  end
  for _=1, stats.away.fouls do
    local p; st, p = _alloc(st, awayRoster, W_FOUL)
    ensure(events.away, p.id).fouls = ensure(events.away, p.id).fouls + 1
  end
 
  return events
end
end


local function _computePlayerSeasonTotals(season, upToMd)
function p.renderRegionalCupResults(frame)
  local dm = mapDays()
    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={}
  local inv = _invertDayMap(dm)
     out[#out+1]='{| class="wikitable sortable"';out[#out+1]='! Stage !! Date !! Home !! Score !! Away'
  local deals = _genTransferDeals(season)
     for i,blk in ipairs(fixtures) do
  local base = _buildBaseRosters(season)
         local a=start+(i-1)*RC_ROUND_GAP
 
         for gi,g in ipairs(blk.games) do
  local totals = {} -- totals[playerId] = {name,pos,currentTeam, apps, goals, assists, poss, son, sof, fouls}
            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
  local function ensurePlayer(pid, pmeta)
            out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %s || %s',blk.round,_dateLabelFromAbs(a),_teamWithLogo(g[1]),score,_teamWithLogo(g[2]))
    totals[pid] = totals[pid] or {
      id=pid, name=pmeta.name, pos=pmeta.pos, team=pmeta.team,
      apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0
    }
     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)
 
        -- accumulate home
         for _,p in ipairs(hr) do
          local row = ensurePlayer(p.id, {name=p.name, pos=p.pos, team=h.code})
          row.team = h.code
        end
        for pid, st in pairs(ev.home) do
          local pmeta = { name = pid, pos="—", team=h.code }
          -- find display info from roster
          for _,pp in ipairs(hr) do if pp.id == pid then pmeta.name=pp.name; pmeta.pos=pp.pos; break end end
          local row = ensurePlayer(pid, pmeta)
          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
    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


        -- accumulate away
------------------------------------------------------------------------
        for _,p in ipairs(ar) do
-- 10) MATCH REPORTS / WEEKLY FEATURES
          local row = ensurePlayer(p.id, {name=p.name, pos=p.pos, team=a.code})
------------------------------------------------------------------------
          row.team = a.code
local function _pickMatchIndexForDay(season,md,pick)
        end
    local day=invertDayMap(mapDays())[md]; if not day then return nil,'Unable to resolve matchday.' end
        for pid, st in pairs(ev.away) do
    local R=simulateDayResults(day,season); if #R==0 then return nil,'No fixtures found.' end
          local pmeta = { name = pid, pos="—", team=a.code }
    local bestI,bestScore=1,nil
          for _,pp in ipairs(ar) do if pp.id == pid then pmeta.name=pp.name; pmeta.pos=pp.pos; break end end
    for i,r in ipairs(R) do
          local row = ensurePlayer(pid, pmeta)
        local s
          row.apps    = row.apps + (st.apps or 0)
        if pick=='chaos' then s=r.hg+r.ag+math.abs(r.hg-r.ag)
          row.goals  = row.goals + (st.goals or 0)
        else s=(r.hg+r.ag)-math.abs(r.hg-r.ag)*0.5 end
          row.assists = row.assists + (st.assists or 0)
         if not bestScore or s>bestScore then bestScore=s;bestI=i end
          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
    return bestI
end


  return totals, deals, base
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
end


-- PUBLIC: roster table
function p.renderMatchOfWeek(frame)
function p.renderRoster(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'
  local args = frame.args or {}
    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)
  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.'''" end
function p.renderChaosOfWeek(frame) local args=(frame and frame.args) or {};args.pick=args.pick or 'chaos';return p.renderMatchOfWeek({args=args}) end
  local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
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 upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
local function _playerRating(r)
  local deals = _genTransferDeals(season)
    if not r then return 0 end
  local base  = _buildBaseRosters(season)
    local apps=math.max(1,r.apps or 0)
  local rosters = _applyTransfersUpTo(base, deals, upToMd)
    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
  local r = rosters[team] or {}
    if x<0 then x=0 elseif x>10 then x=10 end;return x
end


  local out = {
function p.renderWeeklyAwards(frame)
     '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    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
    '! # !! Player !! Position !! ID !! Notes'
    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
  for _,pl in ipairs(r) 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)
    out[#out+1] = '|-'
     local out={"'''Weekly Awards — Matchday "..md.."'''",'{| class="wikitable sortable"','! Award !! Player !! Team !! Pos !! Rating'}
    out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> || %s',
    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')
      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 .. " '''" .. team .. "''' (roster as of Matchday " .. tostring(upToMd) .. ")\n" .. table.concat(out, "\n")
end
end


-- PUBLIC: transfer log
function p.renderTeamOfWeek(frame)
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 args[2] or '') or _currentPlayedMatchday(season);if not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet.'''" end
  local args = frame.args or {}
    local totals=_computePlayerSeasonTotals(season,md);local prior=_computePlayerSeasonTotals(season,md-1);local by={Keeper={},Defender={},Playmaker={},Striker={},Utility={}}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
    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
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    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 deals = _genTransferDeals(season)
    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


  local out = {
function p.renderMatchdayCapsule(frame)
     '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    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
    '! Matchday !! From !! To !! Swap (pos)'
     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')
  for _,d in ipairs(deals) do
    if d.md <= upToMd then
      out[#out+1] = '|-'
      out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> ⇄ <code>%s</code> (%s)',
        d.md, d.A, d.B, d.A_id, d.B_id, d.pos or '—'
      )
    end
  end
  out[#out+1] = '|}'
  return "'''Transfer window (Matchdays " .. TRANSFER_MD_START .. "–" .. TRANSFER_MD_END .. ") — season " .. season .. "'''\n" .. table.concat(out, "\n")
end
end


-- PUBLIC: team player stats table
function p.renderMatchdayDensity(frame)
function p.renderPlayerStats(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)
  local args = frame.args or {}
end
  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.'''" end
  local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()


  local totals = _computePlayerSeasonTotals(season, upToMd)
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


  local rows = {}
------------------------------------------------------------------------
  for _,t in ipairs(_applyTransfersUpTo(_buildBaseRosters(season), _genTransferDeals(season), upToMd)[team] or {}) do
-- 11) SEASON AWARDS
    rows[#rows+1] = t.id
------------------------------------------------------------------------
  end
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 out = {
    local function best(stat,filter,ascending)
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
        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]
    '! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'
  }
 
  for _,pid in ipairs(rows) do
    local r = totals[pid]
    if r then
      out[#out+1] = '|-'
      out[#out+1] = string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d',
        r.name, r.pos, r.apps, r.goals, r.assists, r.poss, r.son, r.sof, r.fouls
      )
     end
     end
  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)}
  out[#out+1] = '|}'
    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 TEAM_BY_CODE[team].logo .. " '''" .. team .. "''' — Player totals through Matchday " .. tostring(upToMd) .. "\n" .. table.concat(out, "\n")
    return awards,by
end
end


-- PUBLIC: league leaders (top 20)
function p.renderSeasonAwards(frame)
function p.renderLeaders(frame)
    local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local now=_nowAbs();local finalAbs=absDay(season,_finalRegularSeasonDay())
  local args = frame.args or {}
    if now<finalAbs then return string.format("'''End-of-season awards will be available after the regular season concludes (%s).'''",_dateLabelFromAbs(finalAbs)) end
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
    local A,by=_seasonAwardData(season);local out={'== End-of-Season Awards ==','{| class="wikitable sortable"','! Award !! Winner !! Team !! Pos !! Notes'}
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    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
  local stat = (args.stat or args[2] or "goals")
    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]='|}'
  local ok = { goals=true, assists=true, poss=true, son=true, sof=true, fouls=true, apps=true }
    out[#out+1]='';out[#out+1]='=== Team of the Season (Best XI) ===';out[#out+1]='{| class="wikitable"';out[#out+1]='! Role !! Player !! Team !! Rating'
  if not ok[stat] then return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''" end
    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


  local totals = _computePlayerSeasonTotals(season, upToMd)
------------------------------------------------------------------------
  local list = {}
-- 12) DIAGNOSTIC STATUS (optional but useful for the wiki page)
  for _,r in pairs(totals) do
------------------------------------------------------------------------
     list[#list+1] = r
function p.renderSeasonStatus(frame)
  end
     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'}
  table.sort(list, function(a,b)
     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))
    if (a[stat] or 0) ~= (b[stat] or 0) then return (a[stat] or 0) > (b[stat] or 0) end
    return a.name < b.name
  end)
 
  local out = {
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    '! Rank !! Player !! Team (current) !! Pos !! ' .. stat
  }
  for i=1, math.min(20, #list) do
    local r = list[i]
    out[#out+1] = '|-'
     out[#out+1] = string.format('| %d || %s || %s || %s || %d',
      i, r.name, r.team or "—", r.pos or "—", r[stat] or 0
    )
  end
  out[#out+1] = '|}'
  return "'''League leaders (" .. stat .. ") — season " .. season .. " through Matchday " .. tostring(upToMd) .. "'''\n" .. table.concat(out, "\n")
end
end



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