Module:PillSeasonSchedule: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
Tag: Manual revert
NewZimiaGov (talk | contribs)
Undo revision 414988 by NewZimiaGov (talk)
Tag: Undo
Line 1: Line 1:
-- Module:PillSeasonSchedule
-- Module:PillSeasonSchedule
-- COMPLETE SEASON-LIFECYCLE REWRITE
--
-- 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 / LIFECYCLE
-- 0) CONFIG / CALENDAR
------------------------------------------------------------------------
------------------------------------------------------------------------
local START, END = 62, 183
local START, END = 62, 183     -- season window within the PSSC year
local DAYS_IN_YEAR = 183
local DAYS_IN_YEAR = 183
local LEGACY_SEASON = 53
local DEFAULT_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
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)
------------------------------------------------------------------------
    local y = math.floor((a - 1) / DAYS_IN_YEAR)
-- 1) TEAMS
     local d = a - y * DAYS_IN_YEAR
------------------------------------------------------------------------
     if d < 1 then
local teams = {
         y = y - 1
  { code="Allegro Symphonara",      fullName="Allegro Symphonara",        logo="[[File:AllegrosymphonaraB.png|25px]]", championships=4 },
         d = d + DAYS_IN_YEAR
  { code="Ashguard Pyralis",        fullName="Ashguard Pyralis",          logo="[[File:AshguardpyralisB.png|25px]]", championships=1 },
    end
  { code="Aurelia Auric",            fullName="Aurelia Auric",            logo="[[File:AureliaAuricB.png|25px]]", championships=2 },
    return y, d
  { code="Brightpath Aureum",        fullName="Brightpath Aureum",        logo="[[File:BrightpathaureumB.png|25px]]", championships=2 },
end
  { 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 },
}


local function _dateLabel(y, d)
local TEAM_BY_CODE = {}
    local mon, dom = getMonthDay(d)
for _,t in ipairs(teams) do TEAM_BY_CODE[t.code] = t end
    return string.format('%d %s %d PSSC', dom, mon, y)
end


local function _dateLabelFromAbs(a)
------------------------------------------------------------------------
    local y, d = _dateFromAbs(a)
-- 1b) JERSEYS (manual list keyed by team FULL NAME)
    return _dateLabel(y, d)
------------------------------------------------------------------------
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" },


local function _currentPSSC()
  -- As shown in your table (even if swapped)
    local curr = date.getCurrentDate() or ''
  ["Delphica Windborne"]            = { home="DelphicaWBAway.png",        away="DelphicaWBHome.png",       third="Delphica3rd.png" },
    local day = tonumber(curr:match('^(%d+),')) or 0
    local year = tonumber(curr:match(', (%d+) PSSC')) or 0
    return day, year
end


local function _nowAbs()
  ["Emberstone Vaeringheim"]        = { home="EmberstoneHome.png",        away="EmberstoneAway.png",       third="Vaeringheim3rdA.png" },
    local d, y = _currentPSSC()
  ["Flameborne Erythros"]          = { home="FlameborneHome.png",         away="FlameborneAway.png",       third="Erythros3rd.png" },
    return absDay(y, d), d, y
end


-- The active Pillarion competition season is the current year during
  -- Note: keep exact spelling from your table
-- Thalassiel/Opsitheiel, but the previous year during Atosiel because Atosiel
  ["Forgeward Nexa"]                = { home="ForgewardHome.png",         away="ForegewardAway.png",       third="Nexa3rd.png" },
-- carries that season's playoffs and Regional Cup.
local function _activeSeason()
    local day, year = _currentPSSC()
    if year <= 0 then return LEGACY_SEASON end
    if day < START 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)
  ["Hearthkeeper Koinon"]          = { home="HearthkeepHome.png",         away="HearthkeepAway.png",       third="Koinon3rd.png" },
    local regularStart = absDay(season, START)
  ["Ironbough Sylvapolis"]          = { home="IronboughHome.png",          away="IronboughAway.png",        third="Sylvapolis3rd.png" },
    local regularEnd = absDay(season, END)
  ["Redcliff Citadel"]              = { home="RedcliffHome.png",          away="RedcliffAway.png",         third="Ferrum3rd.png" },
    local playoffStart = absDay(season + 1, PO_FIRST)
  ["Riftwarden Acheron"]            = { home="RiftwardenHome.png",        away="RiftwardenAway.png",       third="Acheron3rd.png" },
    local playoffFinal = playoffStart + PO_IV * 4
  ["Saluria Skylight"]              = { home="SkylightHome.png",           away="SkylightAway.png",         third="Saluria3rdB.png" },
    local regionalStart = playoffFinal + RC_GAP_AFTER_FINAL
    local regionalFinal = regionalStart + RC_ROUND_GAP * 2 + 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)
  ["Sandveil Somniumpolis"]        = { home="SomniumpolisHome.png",       away="SomniumPolisAway.png",     third="Somniumpolis3rd.png" },
    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


------------------------------------------------------------------------
   ["Serena Tidesong"]               = { home="SerenaHome.png",            away="SerenaAway.png",            third="Serena3rd.png" },
-- 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 },
}


local TEAM_BY_CODE = {}
  ["Steppe Luminaria"]              = { home="SteppeHome.png",            away="SteppeAway25.png",          third="Luminaria3rd.png" },
for _, t in ipairs(teams) do TEAM_BY_CODE[t.code] = t end
  ["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" },


local JERSEYS_BY_TEAMNAME = {
  ["Pillarion Club Suncliff"]       = { home="SuncliffHome.png",           away="SuncliffAway.png",         third="Suncliff3rd.png" },
    ['Allegro Symphonara']={home='AllegroHome.png',away='AllegroAway25.png',third='Symphonara3rd.png'},
  ["Jogi Regiment"]                 = { home="JogiHome.png",               away="JogiAway.png",             third="Jogi3rd.png" },
    ['Ashguard Pyralis']={home='AshguardHome.png',away='AshguardAway.png',third='Pyralis3rdB.png'},
  ["Vine Fleet Mylecia"]           = { home="VineFleetHomeB.png",         away="VineFleetAwayB.png",       third="Mylecia3rd.png" },
    ['Aurelia Auric']={home='AureliaHome.png',away='AureliaAway.png',third='Aurelia3rd.png'},
  ["Port of Blore Heath"]           = { home="BloreHeathHome.png",         away="BloreHeathAway.png",       third="BloreHeath3rd.png" },
    ['Brightpath Aureum']={home='BrightpathHome.png',away='BrightpathAway.png',third='Aureum3rd.png'},
  ["Free State Abeis"]             = { home="AbeisHome.png",             away="AbeisAway.png",             third="Abeis3rd.png" },
    ['Celestial Sancta Lunaris']={home='CelestialSanctaHome.png',away='CelestialSanctaAway.png',third='Lunalis3rdB.png'},
  ["Jezeraah City"]                 = { home="JezeraahHome.png",           away="JezeraahAway.png",         third="Jezeraah3rd.png" },
    ['Delphica Windborne']={home='DelphicaWBAway.png',away='DelphicaWBHome.png',third='Delphica3rd.png'},
  ["Ourid Pegasi"]                 = { home="OuridPegasiHome.png",       away="OuridPegasiAwayB.png",     third="OuridPegasi3rd.png" },
    ['Emberstone Vaeringheim']={home='EmberstoneHome.png',away='EmberstoneAway.png',third='Vaeringheim3rdA.png'},
  ["Pillarion Club Caspazani"]     = { home="PCCaspazaniHome.png",       away="PCCCaspazaniAway.png",     third="PCCaspazani3rd.png" },
    ['Flameborne Erythros']={home='FlameborneHome.png',away='FlameborneAway.png',third='Erythros3rd.png'},
  ["Amberwatch Slevik"]             = { home="AmberwatchHome.png",         away="AmberwatchAway.png",       third="Amberwatch3rd.png" },
    ['Forgeward Nexa']={home='ForgewardHome.png',away='ForegewardAway.png',third='Nexa3rd.png'},
  ["Fanghorn Rein"]                 = { home="ReinHome.png",               away="ReinAway.png",             third="Rein3rd.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'},
}
}


-- 1c) JERSEYS (regional select teams)
-- Put ONLY filenames here (no "File:" and no brackets) — same format as club kits above.
local RC_JERSEYS_BY_TEAMNAME = {
local RC_JERSEYS_BY_TEAMNAME = {
    ['Morovian Select']={home='MorovianSelectHome.png',away='MorovianSelectAway.png'},
  ["Morovian Select"]         = { home="MorovianSelectHome.png",         away="MorovianSelectAway.png" },
    ['Southern Strait Select']={home='SouthernStraitSelectHome.png',away='SouthernStraitSelectAway.png'},
  ["Southern Strait Select"]   = { home="SouthernStraitSelectHome.png",   away="SouthernStraitSelectAway.png" },
    ['Western Highlands Select']={home='WesternHighlandsSelectHome.png',away='WesternHighlandsSelectAway.png'},
  ["Western Highlands Select"] = { home="WesternHighlandsSelectHome.png", away="WesternHighlandsSelectAway.png" },
    ['Normarkian Select']={home='NormarkianSelectHome.png',away='NormarkianSelectAway.png'},
  ["Normarkian Select"]       = { home="NormarkianSelectHome.png",       away="NormarkianSelectAway.png" },
}
}


local divisions = {
-- Stable 0..99 "random" from a key (so kits don't change every page view)
    ['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'
    },
}
local DIV_ORDER={'Morovian','Southern Strait','Western Highlands','Normarkian'}
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 function _pctFromKey(key)
    local h=_safeHash(key)
  local h = mw.hash.hashValue("md5", key or "")
    return (tonumber(h:sub(1,8),16) or 0)%100
  local first8 = h:sub(1, 8)
  local n = tonumber(first8, 16) or 0
  return n % 100
end
end


-- 30% chance of THIRD kit (if it exists), otherwise HOME/AWAY
local function _kitImgForMatch(team, baseWhich, matchKey, px)
local function _kitImgForMatch(team, baseWhich, matchKey, px)
    px=px or 25
  px = px or 25
    if not team or not team.fullName then return '—' end
  if not team or not team.fullName then return "—" end
    local kits=JERSEYS_BY_TEAMNAME[team.fullName] or RC_JERSEYS_BY_TEAMNAME[team.fullName]
local kits = JERSEYS_BY_TEAMNAME[team.fullName] or RC_JERSEYS_BY_TEAMNAME[team.fullName]
    if not kits then return '—' end
  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 which = baseWhich
    local f=kits[which]
  if kits.third and kits.third ~= "" then
    if not f or f=='' then return '—' end
    local pct = _pctFromKey((matchKey or "") .. "|" .. (team.fullName or "") .. "|" .. baseWhich)
    return string.format('[[File:%s|%dpx]]',f,px)
    if pct < 30 then which = "third" end
  end
 
  local f = kits[which]
  if not f or f == "" then return "—" end
  return string.format("[[File:%s|%dpx]]", f, px)
end
end


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


local function _teamWithLogo(x, bold)
local function _teamWithLogo(x, bold)
    local t=_asTeam(x)
  local t = _asTeam(x)
    local n=bold and ("'''"..t.fullName.."'''") or t.fullName
  local name = t.fullName or t.code or tostring(x)
    return (t.logo and t.logo~='' and (t.logo..' ') or '')..n
  if bold then name = "'''" .. name .. "'''" end
  local logo = t.logo or ""
  return (logo ~= "" and logo ~= "—") and (logo .. " " .. name) or name
end
 
------------------------------------------------------------------------
-- 2) DIVISIONS (4 x 8)
------------------------------------------------------------------------
local DIVISIONS = {
  ["Morovian Division"] = {
    "Emberstone Vaeringheim","Vaeringheim Pillar","Imperial Delphica","Delphica Windborne",
    "Allegro Symphonara","Steppe Luminaria","Jezeraah City","Saluria Skylight"
  },
  ["Southern Strait Division"] = {
    "Sufriya Stormwake","Vine Fleet Mylecia","Port of Blore Heath","Jogi Regiment",
    "Free State Abeis","Sandveil Somniumpolis","Brightpath Aureum","Flameborne Erythros"
  },
  ["Western Highlands Division"] = {
    "Ashguard Pyralis","Aurelia Auric","Celestial Sancta Lunaris","Forgeward Nexa",
    "Hearthkeeper Koinon","Ironbough Sylvapolis","Riftwarden Acheron","Serena Tidesong"
  },
  ["Normarkian Division"] = {
    "Redcliff Citadel","Strider Myrene","Ascendant Aetherium","Pillarion Club Suncliff",
    "Ourid Pegasi","Pillarion Club Caspazani","Amberwatch Slevik","Fanghorn Rein"
  }
}
 
local DIV_OF = {}
for div, list in pairs(DIVISIONS) do
  for _, code in ipairs(list) do DIV_OF[code] = div end
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 2) LOCKED FIXTURE LIST
-- 3) SCHEDULE LOCK + BALANCED FUTURE
--    This reproduces the original 53 PSSC pairing/packing algorithm.
------------------------------------------------------------------------
------------------------------------------------------------------------
local function _homeFirst(codeA, codeB)
 
    local function sum(s)
-- === LOCKED schedule: EXACT original logic (preserves history) ===
        local n=0
local function homeFirst(codeA, codeB)
        for i=1,#s do n=(n+string.byte(s,i))%9973 end
  local sA, sB = 0, 0
         return n
  for i=1,#codeA do sA = (sA + string.byte(codeA,i)) % 9973 end
  for i=1,#codeB do sB = (sB + string.byte(codeB,i)) % 9973 end
  return sA > sB
end
 
local function buildPairings()
  local intra, inter = {}, {}
 
  for _, list in pairs(DIVISIONS) do
    for i=1,#list-1 do
      for j=i+1,#list do
        local A, B = TEAM_BY_CODE[list[i]], TEAM_BY_CODE[list[j]]
        table.insert(intra, {home=A, away=B})
         table.insert(intra, {home=B, away=A})
      end
     end
     end
    return sum(codeA)>sum(codeB)
  end
end
 
  local allCodes = {}
  for _,t in ipairs(teams) do table.insert(allCodes, t.code) end
  table.sort(allCodes)


local function _buildPairings()
  for ai=1,#allCodes-1 do
    local intra, inter = {}, {}
    for bi=ai+1,#allCodes do
    for _, div in ipairs(DIV_ORDER) do
      local ca, cb = allCodes[ai], allCodes[bi]
        local L=divisions[div]
      if DIV_OF[ca] ~= DIV_OF[cb] then
        for i=1,#L-1 do
        local a, b = TEAM_BY_CODE[ca], TEAM_BY_CODE[cb]
            for j=i+1,#L do
        local hFirst = homeFirst(ca, cb)
                intra[#intra+1]={home=TEAM_BY_CODE[L[i]],away=TEAM_BY_CODE[L[j]]}
        table.insert(inter, {home = hFirst and a or b, away = hFirst and b or a})
                intra[#intra+1]={home=TEAM_BY_CODE[L[j]],away=TEAM_BY_CODE[L[i]]}
      end
            end
        end
     end
     end
    for di=1,#DIV_ORDER-1 do
  end
        for dj=di+1,#DIV_ORDER do
 
            local A,B=divisions[DIV_ORDER[di]],divisions[DIV_ORDER[dj]]
  table.sort(intra, function(a,b)
            for _,ca in ipairs(A) do
    if DIV_OF[a.home.code] ~= DIV_OF[b.home.code] then
                for _,cb in ipairs(B) do
      return DIV_OF[a.home.code] < DIV_OF[b.home.code]
                    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)
     return a.home.code .. a.away.code < b.home.code .. b.away.code
        local da,db=DIV_OF[a.home.code] or '',DIV_OF[b.home.code] or ''
  end)
        if da~=db then return da<db end
 
        return (a.home.code..a.away.code)<(b.home.code..b.away.code)
  table.sort(inter, function(a,b)
    end)
    local ka = DIV_OF[a.home.code] .. "|" .. DIV_OF[a.away.code]
    table.sort(inter,function(a,b)
    local kb = DIV_OF[b.home.code] .. "|" .. DIV_OF[b.away.code]
        local ka=(DIV_OF[a.home.code] or '')..'|'..(DIV_OF[a.away.code] or '')
    if ka ~= kb then return ka < kb end
        local kb=(DIV_OF[b.home.code] or '')..'|'..(DIV_OF[b.away.code] or '')
    return a.home.code .. a.away.code < b.home.code .. b.away.code
        if ka~=kb then return ka<kb end
  end)
        return (a.home.code..a.away.code)<(b.home.code..b.away.code)
 
    end)
  return intra, inter
    local merged={}
end
    local function appendBlock(src,start,count)
 
        for i=start,math.min(#src,start+count-1) do merged[#merged+1]=src[i] end
local function packRounds(matches)
  local rounds, used = {}, {}
  local function canPlace(r, m)
    return not (used[r] and (used[r][m.home.code] or used[r][m.away.code]))
  end
  for _, m in ipairs(matches) do
    local placed = false
    for r=1,#rounds do
      if canPlace(r, m) then
        rounds[r][#rounds[r]+1] = { m.home, m.away }
        used[r] = used[r] or {}
        used[r][m.home.code] = true
        used[r][m.away.code] = true
        placed = true
        break
      end
     end
     end
     local ai,bi=1,1
     if not placed then
    while ai<=#intra or bi<=#inter do
      rounds[#rounds+1] = { { m.home, m.away } }
        if ai<=#intra then appendBlock(intra,ai,64); ai=ai+64 end
      used[#rounds] = {}
        if bi<=#inter then appendBlock(inter,bi,128); bi=bi+128 end
      used[#rounds][m.home.code] = true
      used[#rounds][m.away.code] = true
     end
     end
    return merged
  end
  return rounds
end
end


local function _packRounds(pairings)
local SCHED_LOCKED = (function()
     local rounds={}
  local intra, inter = buildPairings()
     for _,g in ipairs(pairings) do
 
        local placed=false
  local function chunk(arr, size)
        for _,r in ipairs(rounds) do
     local out = {}
            if not r.used[g.home.code] and not r.used[g.away.code] then
     for i=1,#arr,size do
                r.matches[#r.matches+1]={g.home,g.away}
      local b = {}
                r.used[g.home.code]=true; r.used[g.away.code]=true
      for j=i, math.min(i+size-1, #arr) do b[#b+1] = arr[j] end
                placed=true; break
      out[#out+1] = b
            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
     end
    local out={}
    for _,r in ipairs(rounds) do out[#out+1]=r.matches end
     return out
     return out
end
  end


local SCHED_LOCKED=_packRounds(_buildPairings())
  local intraBlocks = chunk(intra, 64)
local TOTAL_ROUNDS=#SCHED_LOCKED
  local interBlocks = chunk(inter, 128)


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


local DAY_MAP_CACHE=nil
  return packRounds(merged)
local function mapDays()
end)()
    if DAY_MAP_CACHE then return DAY_MAP_CACHE end
 
    local dm={}
local function mapDaysLocked()
    local totalDays=END-START+1
  local totalDays = END - START + 1
    local iv=math.floor(totalDays/TOTAL_ROUNDS)
  local totalRnds  = #SCHED_LOCKED
    local ex=totalDays%TOTAL_ROUNDS
  local iv         = math.floor(totalDays / totalRnds)
    local d=START
  local ex         = totalDays % totalRnds
    for md=1,TOTAL_ROUNDS do
  local m, d       = {}, START
        dm[d]=md
  for i=1,totalRnds do
        if md<TOTAL_ROUNDS then
    m[d] = i
            d=d+iv
    d = d + iv + (ex > 0 and 1 or 0)
            if md<=ex then d=d+1 end
    if ex > 0 then ex = ex - 1 end
        end
  end
    end
  return m
    DAY_MAP_CACHE=dm
    return dm
end
end


local function invertDayMap(dm)
local DM_LOCKED = mapDaysLocked()
    local inv={}
 
    for d,md in pairs(dm or {}) do inv[md]=d end
local function lastPlayedLocked()
    return inv
  local curr = date.getCurrentDate()
  local today = tonumber(curr:match("^(%d+),")) or 0
  local lastDay, lastMd = nil, 0
  for d=START, math.min(today, END) do
    local md = DM_LOCKED[d]
    if md and md > lastMd then lastMd = md; lastDay = d end
  end
  return lastDay, lastMd
end
end


local function _finalRegularSeasonDay()
-- === SEASON / DISPLAY ORDER ===
    return invertDayMap(mapDays())[TOTAL_ROUNDS] or END
-- SCHED_LOCKED already contains every match needed for a 38-game season per team,
-- but it may require MORE than 38 matchdays due to byes created by packRounds().
-- To preserve already-played results, we keep SCHED_LOCKED and let the season run
-- for all locked matchdays.
local DIV_ORDER = { "Morovian Division","Southern Strait Division","Western Highlands Division","Normarkian Division" }
local TOTAL_ROUNDS = #SCHED_LOCKED
 
local function _copy(arr)
  local t = {}
  for i=1,#arr do t[i] = arr[i] end
  return t
end
end


local function _regularSeasonCutoff(season)
local function roundRobin8(teamCodes)
    local now=_nowAbs()
  local n = #teamCodes
    local startAbs=absDay(season,START)
  if n ~= 8 then error("roundRobin8 expects exactly 8 teams") end
     local endAbs=absDay(season,_finalRegularSeasonDay())
  local arr = _copy(teamCodes)
     if now<startAbs then return START-1 end
  local rounds = {}
     if now>=endAbs then return _finalRegularSeasonDay() end
  for r=1,7 do
     local _,d=_dateFromAbs(now)
     local matches = {}
     return math.min(d,_finalRegularSeasonDay())
    for i=1,4 do
      local a = TEAM_BY_CODE[arr[i]]
      local b = TEAM_BY_CODE[arr[n+1-i]]
      local home, away
      if (r+i) % 2 == 0 then home, away = a, b else home, away = b, a end
      matches[#matches+1] = { home, away }
     end
     rounds[#rounds+1] = matches
     local last = arr[n]
    for i=n,3,-1 do arr[i] = arr[i-1] end
     arr[2] = last
  end
  return rounds
end
end


local function _currentPlayedMatchday(season)
local function swapHA(rounds)
    season=tonumber(season) or _activeSeason()
  local out = {}
    local cutoff=_regularSeasonCutoff(season)
  for r=1,#rounds do
    local dm=mapDays()
     out[r] = {}
     local last=0
     for i=1,#rounds[r] do
     for d=START,cutoff do if dm[d] and dm[d]>last then last=dm[d] end end
      local m = rounds[r][i]
    return last
      out[r][i] = { m[2], m[1] }
    end
  end
  return out
end
end


------------------------------------------------------------------------
local function interPairRounds(divA_codes, divB_codes, blockIndex)
-- 3) DETERMINISTIC MATCH ENGINE
  local rounds = {}
------------------------------------------------------------------------
  for t=0,7 do
local function simScoreFor(h,a,seed)
     local matches = {}
    math.randomseed(seed)
     for i=1,8 do
    local p0=math.random()
      local a = TEAM_BY_CODE[divA_codes[i]]
    local hg,ag
      local b = TEAM_BY_CODE[divB_codes[((i+t-1) % 8) + 1]]
     if p0<0.10 then
      local home, away
        hg=math.random(0,12); ag=hg
      if ((blockIndex + t) % 2 == 0) then home, away = a, b else home, away = b, a end
     else
      matches[#matches+1] = { home, away }
        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
     end
     return hg,ag
     rounds[#rounds+1] = matches
  end
  return rounds
end
end


-- 53 PSSC uses the legacy seed exactly. Later seasons add the year so future
local SCHED_BALANCED = (function()
-- seasons are deterministic but not identical replays of 53.
  local rounds = {}
local function _matchSeed(season,day,k,h,a)
 
     local legacy=day*1000+k+string.byte(h.code,1)+string.byte(a.code,1)
  -- 14 intra rounds
    if tonumber(season)==53 then return legacy end
  local intraByDiv = {}
    return legacy+(tonumber(season) or 0)*1000003
  for _, divName in ipairs(DIV_ORDER) do
    local rr7 = roundRobin8(DIVISIONS[divName])
    local rr14 = {}
    for i=1,7 do rr14[i] = rr7[i] end
    local rr7s = swapHA(rr7)
    for i=1,7 do rr14[7+i] = rr7s[i] end
    intraByDiv[divName] = rr14
  end
 
  for r=1,14 do
    local full = {}
    for _, divName in ipairs(DIV_ORDER) do
      for _, m in ipairs(intraByDiv[divName][r]) do full[#full+1] = m end
    end
    rounds[#rounds+1] = full
  end
 
  -- 24 inter rounds = 3 blocks × 8 rounds
  local A = DIVISIONS[DIV_ORDER[1]]
  local B = DIVISIONS[DIV_ORDER[2]]
  local C = DIVISIONS[DIV_ORDER[3]]
  local D = DIVISIONS[DIV_ORDER[4]]
 
  local blocks = {
    { {A,B}, {C,D} },
    { {A,C}, {B,D} },
    { {A,D}, {B,C} },
  }
 
  for blockIndex, pairset in ipairs(blocks) do
    local p1 = interPairRounds(pairset[1][1], pairset[1][2], blockIndex)
    local p2 = interPairRounds(pairset[2][1], pairset[2][2], blockIndex)
     for t=1,8 do
      local full = {}
      for _,m in ipairs(p1[t]) do full[#full+1] = m end
      for _,m in ipairs(p2[t]) do full[#full+1] = m end
      rounds[#rounds+1] = full
    end
  end
 
  return rounds
end)()
 
-- Composite mapDays: past days keep locked mapping; future days map to balanced rounds
local function mapDays()
  local pivotDay, pivotMd = lastPlayedLocked()
local curr = date.getCurrentDate()
local today = tonumber(curr:match("^(%d+),")) or 0
 
 
  -- If nothing has been played yet, keep the old even-spacing behavior.
  -- (You can also enforce 3–4 here if you want, but I’m matching the “don’t break history” spirit.)
  if not pivotDay or pivotMd == 0 then
    local totalDays  = END - START + 1
    local iv        = math.floor(totalDays / TOTAL_ROUNDS)
    local ex        = totalDays % TOTAL_ROUNDS
    local m, d      = {}, START
    for i=1,TOTAL_ROUNDS do
      m[d] = i
      d = d + iv + (ex > 0 and 1 or 0)
      if ex > 0 then ex = ex - 1 end
    end
    return m
  end
 
  -- Past: preserve exactly (locked schedule mapping)
  local dm = {}
  for d,md in pairs(DM_LOCKED) do
    if d <= pivotDay then dm[d] = md end
  end
 
      -- Future: schedule ALL remaining rounds; relax gap only if needed.
      local futureStartDay = math.max(pivotDay + 1, today + 1)
      local remainingDays = math.max(0, END - futureStartDay + 1)
      local remainingRnds = math.max(0, TOTAL_ROUNDS - pivotMd)
      if remainingDays == 0 or remainingRnds == 0 then return dm end
 
      -- Choose the smallest minGap that can still fit all remaining rounds.
      local function canFit(gap)
        return (1 + gap * (remainingRnds - 1)) <= remainingDays
      end
 
      local minGap = 3
      if not canFit(minGap) then minGap = 2 end
      if not canFit(minGap) then minGap = 1 end  -- last resort, but prevents truncation
 
      local gaps = math.max(0, remainingRnds - 1)
      local extra = (remainingDays - 1) - (minGap * gaps)  -- spare days after minimum spacing
      extra = math.max(0, extra)
 
      -- We’ll distribute +1 day into some gaps until we run out of 'extra'
      local day = futureStartDay
      for i=1, remainingRnds do
        local mdx = pivotMd + i
        if mdx > TOTAL_ROUNDS then break end
        if day > END then break end
        dm[day] = mdx
 
        if i < remainingRnds then
          local bump = (extra > 0) and 1 or 0
          if extra > 0 then extra = extra - 1 end
          day = day + minGap + bump
        end
      end
 
      return dm
 
end
end


local function _rrange(seed,lo,hi)
 
    math.randomseed(seed)
local function invertDayMap(dm)
    return math.random(lo,hi)
  local inv = {}
  for day, md in pairs(dm) do inv[md] = day end
  return inv
end
end


local function _splitPillarScore(total,salt)
local function getRoundMatches(roundIdx)
    total=tonumber(total) or 0
  -- Always use the locked schedule so we eventually play every remaining match.
    local maxG=math.floor(total/3)
  return SCHED_LOCKED[roundIdx] or {}
    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
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)
local function _currentPlayedMatchday()
    season=tonumber(season) or _activeSeason()
  local curr = date.getCurrentDate()
    local md=mapDays()[day]
  local today = tonumber(curr:match("^(%d+),")) or 0
    local out={}
  local dm = mapDays()
    if not md then return out end
  local lastMd = 0
    for k,m in ipairs(getRoundMatches(md)) do
  for d=START, math.min(today, END) do
        local h,a=m[1],m[2]
    if dm[d] and dm[d] > lastMd then lastMd = dm[d] end
        local seed=_matchSeed(season,day,k,h,a)
  end
        local hg,ag=simScoreFor(h,a,seed)
  return lastMd
        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
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 4) STANDINGS / SEEDING
-- 4) MATCH SIMULATION
------------------------------------------------------------------------
------------------------------------------------------------------------
local function _emptyStandings()
local function simScoreFor(h, a, seed)
    local recs={}
  math.randomseed(seed)
     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
  local p = math.random()
    return recs
  local hg, ag
  if p < 0.1 then
     hg = math.random(0, 12); ag = hg
  else
    local wh, wa = (h.championships or 0) + 1, (a.championships or 0) + 1
    local rem    = (p - 0.1) / 0.9
    local homeWin= rem < (wh / (wh + wa))
    local winG  = math.random(1, 12)
    local loseG  = math.random(0, math.min(winG - 1, 12))
    if homeWin then hg, ag = winG, loseG else hg, ag = loseG, winG end
  end
  return hg, ag
end
end


local function _applyResult(recH,recA,hg,ag)
local function simulateDayResults(day)
     recH.GP=recH.GP+1; recA.GP=recA.GP+1
  local dm = mapDays()
     recH.PF=recH.PF+hg; recH.PA=recH.PA+ag
  local idx = dm[day]
    recA.PF=recA.PF+ag; recA.PA=recA.PA+hg
  if not idx then return {} end
     if hg>ag then recH.W=recH.W+1; recA.L=recA.L+1; recH.Pts=recH.Pts+3
  local matches = getRoundMatches(idx)
     elseif ag>hg then recA.W=recA.W+1; recH.L=recH.L+1; recA.Pts=recA.Pts+3
  local out = {}
    else recH.D=recH.D+1; recA.D=recA.D+1; recH.Pts=recH.Pts+1; recA.Pts=recA.Pts+1 end
  for k, m in ipairs(matches) do
    recH.PD=recH.PF-recH.PA; recA.PD=recA.PF-recA.PA
     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


local function _standingsThrough(season,lastDay)
------------------------------------------------------------------------
    local recs=_emptyStandings(); local dm=mapDays()
-- 5) TODAY / NEXT MATCHDAY RENDER
     for d=START,math.min(lastDay or END,_finalRegularSeasonDay()) do
------------------------------------------------------------------------
        if dm[d] then
local function renderRound(curDay, year)
            for _,r in ipairs(simulateDayResults(d,season)) do _applyResult(recs[r.home.code],recs[r.away.code],r.hg,r.ag) end
  local dm      = mapDays()
        end
  local target  = curDay
  local upcoming= false
 
  if not dm[target] then
     for d = curDay + 1, END do
      if dm[d] then target = d; upcoming = true; break end
     end
     end
     return recs
  end
end
  if not dm[target] then
     return "'''No more matches this season.'''"
  end
 
  local prefix = upcoming and "'''Next matchday:''' " or ""
  local mon, dom = getMonthDay(target)
  local label    = dom .. " " .. mon .. " " .. year .. " PSSC"
  local rows    = {}
 
  local idx = dm[target]
  local matches = getRoundMatches(idx)


local function _standCmp(a,b)
  if not upcoming then
    if a.Pts~=b.Pts then return a.Pts>b.Pts end
    for k, m in ipairs(matches) do
    if a.PD~=b.PD then return a.PD>b.PD end
      local h, a = m[1], m[2]
    if a.W~=b.W then return a.W>b.W end
      local salt = target*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
     return a.team.fullName<b.team.fullName
      local hg, ag = simScoreFor(h, a, salt)
end
      local hc = (hg > ag and "green") or (hg < ag and "red") or "yellow"
      local ac = (ag > hg and "green") or (ag < hg and "red") or "yellow"
      local hs = string.format("<span style='color:%s;'>'''%d'''</span>", hc, hg)
      local as = string.format("<span style='color:%s;'>'''%d'''</span>", ac, ag)
local matchKey = tostring(target or "") .. "|" .. (h.code or "") .. "v" .. (a.code or "")
rows[#rows+1] =
  string.format("%s %s %s %s vs %s %s %s %s",
     _kitImgForMatch(h, "home", matchKey), h.logo, hs, h.fullName,
    _kitImgForMatch(a, "away", matchKey), a.logo, as, a.fullName
  )


local function _divisionRows(season,div,lastDay)
    end
    local recs=_standingsThrough(season,lastDay); local list={}
  else
     for _,c in ipairs(divisions[div]) do list[#list+1]=recs[c] end
     for _, m in ipairs(matches) do
     table.sort(list,_standCmp); return list
      local h, a = m[1], m[2]
end
local matchKey = tostring(target or "") .. "|" .. (h.code or "") .. "v" .. (a.code or "")
rows[#rows+1] =
  string.format("%s %s %s vs %s %s %s",
     _kitImgForMatch(h, "home", matchKey), h.logo, h.fullName,
    _kitImgForMatch(a, "away", matchKey), a.logo, a.fullName
  )


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
     end
    table.sort(winners,_standCmp); table.sort(runners,_standCmp)
  end
    local seeds={}
 
    for _,r in ipairs(winners) do seeds[#seeds+1]=r.team end
  return string.format("|-\n| %s%d || %s || %s", prefix, target, label, table.concat(rows, "<br>"))
    for _,r in ipairs(runners) do seeds[#seeds+1]=r.team end
    return seeds
end
end


local function getConferencePlayIns(season)
function p.renderSchedule(frame)
    local games={}
  local curr = date.getCurrentDate()
    for _,div in ipairs(DIV_ORDER) do
  local day  = tonumber(curr:match('^(%d+),')) or 0
        local L=_divisionRows(season,div,_finalRegularSeasonDay())
  local year = tonumber(curr:match(', (%d+) PSSC')) or 0
        games[#games+1]={L[3].team,L[4].team,div}
  return table.concat({
     end
    '{| class="wikitable sortable"',
     return games
    '! Day !! Date !! Matches',
     renderRound(day, year),
     '|}'
  }, "\n")
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 5) REGULAR-SEASON RENDERERS / AUTOMATIC LIFECYCLE
-- 6) DIVISION STANDINGS
------------------------------------------------------------------------
------------------------------------------------------------------------
local function _renderRegularRound(day,season)
function p.renderStandings(frame)
    local dm=mapDays()
  local curr  = date.getCurrentDate()
    local target=nil
  local today  = tonumber(curr:match("^(%d+),")) or 0
    for d=math.max(day,START),_finalRegularSeasonDay() do if dm[d] then target=d; break end end
  local last  = math.min(today, END)
    if not target then return "|-\n| colspan=3 | '''Regular season complete.'''" end
 
     local md=dm[target]; local mon,dom=getMonthDay(target); local rows={}
  local recs  = {}
    for k,m in ipairs(getRoundMatches(md)) do
  for _, t in ipairs(teams) do
         local h,a=m[1],m[2]
    recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0 }
         local key=string.format('REG|%d|%d|%d|%s|%s',season,target,k,h.code,a.code)
  end
         local score='—'
 
         local now=_nowAbs()
  local dm = mapDays()
         if now>=absDay(season,target) then
  for d = START, last do
            local hg,ag=simScoreFor(h,a,_matchSeed(season,target,k,h,a)); score=string.format("'''%d-%d'''",hg,ag)
     local idx = dm[d]
    if idx then
      local matches = getRoundMatches(idx)
      for k, m in ipairs(matches) do
         local h, a = m[1], m[2]
         local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
        local hg, ag = simScoreFor(h, a, salt)
 
        local H, A = recs[h.code], recs[a.code]
         H.PF, H.PA = H.PF + hg, H.PA + ag
         A.PF, A.PA = A.PF + ag, A.PA + hg
 
         if hg > ag then
          H.W, H.Pts = H.W + 1, H.Pts + 3
          A.L = A.L + 1
        elseif hg < ag then
          A.W, A.Pts = A.W + 1, A.Pts + 3
          H.L = H.L + 1
        else
          H.D, H.Pts = H.D + 1, H.Pts + 1
          A.D, A.Pts = A.D + 1, A.Pts + 1
         end
         end
        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
     end
     end
    return string.format('|-\n| %d (MD %d) || %d %s %d PSSC || %s',target,md,dom,mon,season,table.concat(rows,'<br>'))
  end
end


function p.renderSchedule(frame)
  local buckets = {}
    local args=(frame and frame.args) or {}
  for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
     local season=_resolveSeason(args,1)
  for code, r in pairs(recs) do
     local stage,t=_seasonStage(season)
     r.PD = r.PF - r.PA
     local now,day,year=_nowAbs()
     local div = DIV_OF[code] or "—"
     buckets[div] = buckets[div] or {}
    table.insert(buckets[div], r)
  end


    if stage=='regular' or stage=='before_regular' then
  local function cmp(a,b)
        local startDay=(year==season and day or START)
     if a.Pts ~= b.Pts then return a.Pts > b.Pts end
        return table.concat({
     if a.PD  ~= b.PD  then return a.PD  > b.PD  end
            '{| class="wikitable sortable"',
     if a.W  ~= b.W  then return a.W  > b.W  end
            '! Day !! Date !! Matches',
     return a.team.fullName < b.team.fullName
            _renderRegularRound(startDay,season),
  end
            '|}'
  for div, list in pairs(buckets) do table.sort(list, cmp) end
        },'\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.
  local order = DIV_ORDER
p.renderLifecycle=p.renderSchedule
  local out = {}
  for _, div in ipairs(order) do
    local list = buckets[div] or {}
    out[#out+1] = "== " .. div .. " Standings =="
    out[#out+1] = '{| class="wikitable sortable"'
    out[#out+1] = "! Pos !! Team !! GP !! W !! D !! L !! Pts !! PF !! PA !! PD"
    for i, rec in ipairs(list) do
      local style = ""
      if    i <= 2 then style = ' style="background-color:#ccffcc;"'
      elseif i <= 4 then style = ' style="background-color:#cce5ff;"'
      elseif i <= 6 then style = ' style="background-color:#F1EB9C;"'
      else              style = ' style="background-color:#ffcccc;"'
      end
      out[#out+1] = "|-" .. style
out[#out+1] =
  "| " .. i
  .. " || " .. rec.team.logo .. " " .. rec.team.fullName
  .. " || " .. (rec.W + rec.D + rec.L)
  .. " || " .. rec.W
  .. " || " .. rec.D
  .. " || " .. rec.L
  .. " || " .. rec.Pts
  .. " || " .. rec.PF
  .. " || " .. rec.PA
  .. " || " .. rec.PD


function p.renderStandings(frame)
    local args=(frame and frame.args) or {}
    local season=_resolveSeason(args,1)
    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
            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.Pts,r.PF,r.PA,r.PD)
        end
        out[#out+1]='|}'
     end
     end
     return table.concat(out,'\n')
     out[#out+1] = "|}"
    out[#out+1] = ""
  end
  return table.concat(out, "\n")
end
end


------------------------------------------------------------------------
-- 7) PREVIOUS RESULTS (last 5 matchdays)
------------------------------------------------------------------------
function p.renderPreviousResults(frame)
function p.renderPreviousResults(frame)
    local args=(frame and frame.args) or {}
  local curr  = date.getCurrentDate()
    local season=_resolveSeason(args,1)
  local today  = tonumber(curr:match("^(%d+),")) or 0
    local now,day,year=_nowAbs(); local dm=mapDays(); local played={}
  local year   = tonumber(curr:match(", (%d+) PSSC")) or 0
    local fromDay
  local dm     = mapDays()
    if now>absDay(season,_finalRegularSeasonDay()) then fromDay=_finalRegularSeasonDay()
  local played = {}
    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
  for d = today - 1, START, -1 do
    local out={'{| class="wikitable sortable"','! Date !! Home !! Score !! Away'}
    if dm[d] then played[#played+1] = d end
    for _,d in ipairs(played) do
    if #played == 5 then break end
        for _,r in ipairs(simulateDayResults(d,season)) do
  end
            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))
  local out = {
        end
    '{| class="wikitable sortable"',
    '! Date !! Home !! Score !! Away'
  }
 
  for _, d in ipairs(played) do
    local mon, dom  = getMonthDay(d)
    local dateLabel = dom .. " " .. mon .. " " .. year .. " PSSC"
    local idx = dm[d]
    local matches = getRoundMatches(idx)
    for k, m in ipairs(matches) do
      local h, a = m[1], m[2]
      local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
      local hg, ag = simScoreFor(h, a, salt)
      local hs = "'''" .. hg .. "-" .. ag .. "'''"
      out[#out+1] = "|-"
      out[#out+1] =
        "| " .. dateLabel
        .. " || " .. h.logo .. " " .. h.fullName
        .. " || " .. hs
        .. " || " .. a.logo .. " " .. a.fullName
     end
     end
    out[#out+1]='|}'
  end
    return table.concat(out,'\n')
 
  out[#out+1] = "|}"
  return table.concat(out, "\n")
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 6) PLAYOFFS
-- 8) PLAYOFFS (12 teams seeded from divisions)
------------------------------------------------------------------------
------------------------------------------------------------------------
local function _playScore(H,A,salt)
local PO_YEAR  = 53
    return simScoreFor(H,A,salt)
local PO_FIRST = md2day(1,4)
local PO_IV    = 3
 
local function tb_cmp(a,b)
  if a.Pts ~= b.Pts then return a.Pts > b.Pts end
  if a.PD  ~= b.PD  then return a.PD  > b.PD  end
  if a.W  ~= b.W  then return a.W  > b.W  end
  return a.team.fullName < b.team.fullName
end
end


local function _winner(H,A,hg,ag)
local function computeRecordsUpTo(dayEnd)
    -- Preserve the legacy deterministic tiebreak: away side advances on a draw.
  local recs = {}
    if hg>ag then return H end
  for _, t in ipairs(teams) do
     return A
    recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0, Div=DIV_OF[t.code] }
  end
 
  local dm = mapDays()
  for d = START, math.min(dayEnd, END) do
    local idx = dm[d]
    if idx then
      local matches = getRoundMatches(idx)
      for k, m in ipairs(matches) do
        local h, a = m[1], m[2]
        local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
        local hg, ag = simScoreFor(h, a, salt)
 
        local H, A = recs[h.code], recs[a.code]
        H.PF, H.PA = H.PF + hg, H.PA + ag
        A.PF, A.PA = A.PF + ag, A.PA + hg
 
        if hg > ag then
          H.W, H.Pts = H.W + 1, H.Pts + 3
          A.L = A.L + 1
        elseif hg < ag then
          A.W, A.Pts = A.W + 1, A.Pts + 3
          H.L = H.L + 1
        else
          H.D, H.Pts = H.D + 1, H.Pts + 1
          A.D, A.Pts = A.D + 1, A.Pts + 1
        end
      end
     end
  end
 
  for _, r in pairs(recs) do r.PD = r.PF - r.PA end
  return recs
end
end


local function _seedIndex(seeds,team)
local function getSeeds12(seasonYear)
     for i,t in ipairs(seeds) do if t.code==team.code then return i end end
  seasonYear = tonumber(seasonYear) or DEFAULT_SEASON
    return 999
 
  local curr  = date.getCurrentDate()
  local dNow  = tonumber(curr:match("^(%d+),")) or END
  local yNow  = tonumber(curr:match(", (%d+) PSSC")) or seasonYear
 
  -- If we're looking at a past season, lock standings to END (final table).
  local cutoffDay = (yNow > seasonYear) and END or dNow
  local recs  = computeRecordsUpTo(cutoffDay)
 
  local buckets = {}
  for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
  for _, r in pairs(recs) do
    buckets[r.Div] = buckets[r.Div] or {}
     table.insert(buckets[r.Div], r)
  end
  for div, list in pairs(buckets) do table.sort(list, tb_cmp) end
 
  local winners, runners = {}, {}
  for _, div in ipairs(DIV_ORDER) do
    local list = buckets[div] or {}
    if list[1] then table.insert(winners, list[1]) end
    if list[2] then table.insert(runners,  list[2]) end
  end
  table.sort(winners, tb_cmp)
  table.sort(runners, tb_cmp)
 
  local seeds = {}
  for i=1,#winners do seeds[#seeds+1] = winners[i].team end
  for i=1,#runners do seeds[#seeds+1] = runners[i].team end
  return seeds
end
end


local function _playoffState(season)
-- Build conference play-ins: #3 vs #4 in each division
    local seeds=getSeeds12(season)
local function getConferencePlayIns(seasonYear)
    local PI=getConferencePlayIns(season)
  seasonYear = tonumber(seasonYear) or DEFAULT_SEASON
    local poAbs=absDay(season+1,PO_FIRST)
 
    local state={seeds=seeds,PI=PI,rounds={}}
  local curr  = date.getCurrentDate()
  local dNow  = tonumber(curr:match("^(%d+),")) or END
  local yNow  = tonumber(curr:match(", (%d+) PSSC")) or seasonYear


    local WPI={}
  local cutoffDay = (yNow > seasonYear) and END or dNow
    local gamesPI={}
  local recs  = computeRecordsUpTo(cutoffDay)
    for i,g in ipairs(PI) do
        local H,A=g[1],g[2]; local hg,ag=_playScore(H,A,poAbs*100+i)
        WPI[i]=_winner(H,A,hg,ag); gamesPI[#gamesPI+1]={H,A,hg,ag,WPI[i],g[3]}
    end
    state.rounds[1]={name='Play-In',abs=poAbs,games=gamesPI}


    local r8Pairs={{seeds[5],WPI[4]},{seeds[6],WPI[3]},{seeds[7],WPI[2]},{seeds[8],WPI[1]}}
  local buckets = {}
    local WR8={}; local games8={}; local abs8=poAbs+PO_IV
  for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
    for i,g in ipairs(r8Pairs) do local hg,ag=_playScore(g[1],g[2],abs8*100+i); WR8[i]=_winner(g[1],g[2],hg,ag); games8[#games8+1]={g[1],g[2],hg,ag,WR8[i]} end
  for _, r in pairs(recs) do
     state.rounds[2]={name='Round of 8',abs=abs8,games=games8}
    buckets[r.Div] = buckets[r.Div] or {}
     table.insert(buckets[r.Div], r)
  end
  for div, list in pairs(buckets) do table.sort(list, tb_cmp) end


    table.sort(WR8,function(a,b) return _seedIndex(seeds,a)>_seedIndex(seeds,b) end)
  local PI = {}
     local r4Pairs={{seeds[1],WR8[1]},{seeds[2],WR8[2]},{seeds[3],WR8[3]},{seeds[4],WR8[4]}}
  for _, div in ipairs(DIV_ORDER) do
    local WR4={}; local games4={}; local abs4=poAbs+PO_IV*2
     local list = buckets[div] or {}
     for i,g in ipairs(r4Pairs) do local hg,ag=_playScore(g[1],g[2],abs4*100+i); WR4[i]=_winner(g[1],g[2],hg,ag); games4[#games4+1]={g[1],g[2],hg,ag,WR4[i]} end
    local t3 = (list[3] and list[3].team) or { logo='—', fullName='TBD', code='TBD' }
     state.rounds[3]={name='Quarterfinal',abs=abs4,games=games4}
     local t4 = (list[4] and list[4].team) or { logo='—', fullName='TBD', code='TBD' }
     PI[#PI+1] = { div=div, H=t3, A=t4 }
  end
  return PI
end


    table.sort(WR4,function(a,b) return _seedIndex(seeds,a)<_seedIndex(seeds,b) end)
    local sfPairs={{WR4[1],WR4[#WR4]},{WR4[2],WR4[#WR4-1]}}
    local WSF={}; local gamesSF={}; local absSF=poAbs+PO_IV*3
    for i,g in ipairs(sfPairs) do local hg,ag=_playScore(g[1],g[2],absSF*100+i); WSF[i]=_winner(g[1],g[2],hg,ag); gamesSF[#gamesSF+1]={g[1],g[2],hg,ag,WSF[i]} end
    state.rounds[4]={name='Semifinal',abs=absSF,games=gamesSF}


    local absF=poAbs+PO_IV*4; local fh,fa=_playScore(WSF[1],WSF[2],absF*100+1); local champion=_winner(WSF[1],WSF[2],fh,fa)
local function playScore(A,B,salt)
    state.rounds[5]={name='Final',abs=absF,games={{WSF[1],WSF[2],fh,fa,champion}}}
  math.randomseed(salt)
    state.champion=champion
  local p = math.random()
    return state
  if p < 0.10 then local g=math.random(0,12); return g,g end
  local wa,wb = (A.championships or 0)+1,(B.championships or 0)+1
  local home  = (p-0.10)/0.90 < wa/(wa+wb)
  local hi    = math.random(1,12)
  local lo    = math.random(0, math.min(hi-1,12))
  return home and hi or lo, home and lo or hi
end
end


local function _renderPlayoffTable(season,progressive,scheduleOnly)
local function mkRow(tag, absDate, H, A, salt, absNow)
    local S=_playoffState(season); local now=_nowAbs()
  local hs, win = '—','—'
    local out={'{| class="wikitable sortable"','! Round !! Date !! Home !! Score !! Away !! Winner'}
  if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then
    for _,round in ipairs(S.rounds) do
    local g1,g2 = playScore(H,A,salt)
        local visible=(not progressive) or now>=round.abs
    hs  = string.format("'''%d-%d'''", g1, g2)
        if visible or scheduleOnly then
    win = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
            for gi,g in ipairs(round.games) do
  end
                local H,A=g[1],g[2]; local score='—'; local win='—'
local matchKey = tostring(absDate or "") .. "|" .. tostring(tag or "") .. "|" .. (H.code or "") .. "v" .. (A.code or "")
                if not scheduleOnly and now>=round.abs then score=string.format("'''%d-%d'''",g[3],g[4]); win=_teamWithLogo(g[5]) end
return string.format("|-\n| %s || %s %s %s || %s || %s %s %s || %s",
                local key=string.format('PO|%d|%s|%d',season,round.name,gi)
  tag,
                out[#out+1]='|-'
  _kitImgForMatch(H, "home", matchKey), H.logo, H.fullName,
                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)
  hs,
            end
  _kitImgForMatch(A, "away", matchKey), A.logo, A.fullName,
        end
  win
    end
)
    out[#out+1]='|}'
 
    return table.concat(out,'\n')
end
end


function p.renderPlayoffSchedule(frame)
function p.renderPlayoffSchedule(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1)
local args = frame.args or {}
     return _renderPlayoffTable(season,false,true)
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
 
local seeds = getSeeds12(season)
local PI = getConferencePlayIns(season)
 
  local rows = { '{| class="wikitable sortable"',
    '! Round !! Home !! Score !! Away !! Winner'
  }
 
  local function add(round, day, H, A)
    rows[#rows+1] = string.format(
      '|-\n| %s || %s %s || — || %s %s || —',
      round,
      (H and H.logo) or '—',
      (H and H.fullName) or 'TBD',
      (A and A.logo) or '—',
      (A and A.fullName) or 'TBD'
     )
  end
 
  -- Play-ins (4): #3 vs #4 in each division
  for i,g in ipairs(PI) do
    add('PI-'..i, PO_FIRST, g.H, g.A)
  end
 
  -- Round-of-8: Seeds 5–8 vs PI winners (shown as TBD until results exist)
  add('R-8-1', PO_FIRST + PO_IV, seeds[5], {logo='—', fullName='TBD'})
  add('R-8-2', PO_FIRST + PO_IV, seeds[6], {logo='—', fullName='TBD'})
  add('R-8-3', PO_FIRST + PO_IV, seeds[7], {logo='—', fullName='TBD'})
  add('R-8-4', PO_FIRST + PO_IV, seeds[8], {logo='—', fullName='TBD'})
 
  -- Round-of-4: Seeds 1–4 join here
  add('R-4-1', PO_FIRST + PO_IV*2, seeds[1], {logo='—', fullName='TBD'})
  add('R-4-2', PO_FIRST + PO_IV*2, seeds[2], {logo='—', fullName='TBD'})
  add('R-4-3', PO_FIRST + PO_IV*2, seeds[3], {logo='—', fullName='TBD'})
  add('R-4-4', PO_FIRST + PO_IV*2, seeds[4], {logo='—', fullName='TBD'})
 
  -- Semi-finals
  add('SF-1', PO_FIRST + PO_IV*3, {logo='—', fullName='TBD'}, {logo='—', fullName='TBD'})
  add('SF-2', PO_FIRST + PO_IV*3, {logo='—', fullName='TBD'}, {logo='—', fullName='TBD'})
 
  -- Final
  add('Final', PO_FIRST + PO_IV*4, {logo='—', fullName='TBD'}, {logo='—', fullName='TBD'})
 
  rows[#rows+1] = '|}'
  return table.concat(rows, '\n')
end
end


function p.renderPlayoffResults(frame)
function p.renderPlayoffResults(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1)
local args = frame.args or {}
    local progressive=tostring(args.progressive or '1')~='0'
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
     return _renderPlayoffTable(season,progressive,false)
 
local seeds = getSeeds12(season)
local PIset = getConferencePlayIns(season)
 
  local now  = date.getCurrentDate()
  local dNow  = tonumber(now:match('^(%d+),')) or 0
  local yNow  = tonumber(now:match(', (%d+) PSSC')) or 0
  local absNow= absDay(yNow,dNow)
 
  local rows = { '{| class="wikitable sortable"', '! Round !! Home !! Score !! Away !! Winner' }
 
local progressive = tostring(args.progressive or args.live or "") ~= ""
 
local function maybeAddRow(tag, absDate, H, A, salt)
  -- If progressive/live mode is on, only show rows once that round's date has arrived.
  if (not progressive) or (absNow >= absDate) then
    rows[#rows+1] = mkRow(tag, absDate, H, A, salt, absNow)
  end
end
 
  --------------------------------------------------------------------
  -- Play-ins: 4 games (one per division): #3 vs #4
  --------------------------------------------------------------------
  local absPI = absDay(season + 1, PO_FIRST)
  local W_PI = {}
 
  for i,g in ipairs(PIset) do
    local H,A = g.H, g.A
    if absNow >= absPI and H.fullName~='TBD' and A.fullName~='TBD' then
      local g1,g2 = playScore(H,A,absPI*100+i)
      W_PI[i]    = (g1>g2) and H or A
    end
    maybeAddRow('PI-'..i, absPI, H, A, absPI*100+i)
  end
 
  --------------------------------------------------------------------
  -- Round-of-8: Seeds 5–8 vs the 4 play-in winners
  -- (Seeds 1–4 get BYE into Round-of-4)
  --------------------------------------------------------------------
  local absR8 = absPI + PO_IV
 
  local p1 = W_PI[1] or {logo='—',fullName='TBD'}
  local p2 = W_PI[2] or {logo='—',fullName='TBD'}
  local p3 = W_PI[3] or {logo='—',fullName='TBD'}
  local p4 = W_PI[4] or {logo='—',fullName='TBD'}
 
  local R8 = {
    { seeds[5], p4 },
    { seeds[6], p3 },
    { seeds[7], p2 },
    { seeds[8], p1 }
  }
 
  local W_R8 = {}
  for i,pair in ipairs(R8) do
    local H,A = pair[1], pair[2]
    if absNow >= absR8 and A.fullName~='TBD' then
      local g1,g2 = playScore(H,A,absR8*100+i)
      W_R8[i]    = (g1>g2) and H or A
    end
    maybeAddRow('R-8-'..i, absR8, H, A, absR8*100+i)
  end
 
  --------------------------------------------------------------------
  -- Round-of-4: Seeds 1–4 enter here vs the 4 R8 winners
  -- Pairing: Seed 1 vs worst remaining, Seed 2 vs next, etc.
  --------------------------------------------------------------------
  local absR4 = absR8 + PO_IV
  local R4, W_R4 = {}, {}
 
  -- Always show Seeds 1–4 as the Round-of-4 home teams.
  -- Opponents are TBD until all four R8 winners exist.
  R4 = {
    { seeds[1], {logo='—', fullName='TBD'} },
    { seeds[2], {logo='—', fullName='TBD'} },
    { seeds[3], {logo='—', fullName='TBD'} },
    { seeds[4], {logo='—', fullName='TBD'} }
  }
 
  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] or 99) < (idxByCode[b.code] or 99)
    end)
 
    -- Pairing: Seed 1 vs worst remaining, Seed 2 vs next, etc.
    R4[1][2] = W_R8[4]
    R4[2][2] = W_R8[3]
    R4[3][2] = W_R8[2]
    R4[4][2] = W_R8[1]
  end
 
  for i=1,4 do
    local H = R4[i][1]
    local A = R4[i][2]
 
    if absNow >= absR4 and A.fullName ~= 'TBD' then
      local g1,g2 = playScore(H, A, absR4*100+i)
      W_R4[i]    = (g1>g2) and H or A
    end
 
    maybeAddRow('R-4-'..i, absR4, H, A, absR4*100+i)
  end
 
  --------------------------------------------------------------------
  -- Semi-finals: best vs worst, 2nd vs 3rd
  --------------------------------------------------------------------
  local absSF = absR4 + PO_IV
  local SF, W_SF = {}, {}
 
  if #W_R4 == 4 then
    local idxByCode = {}
    for i,t in ipairs(seeds) do idxByCode[t.code] = i end
 
     table.sort(W_R4, function(a,b)
      return (idxByCode[a.code] or 99) < (idxByCode[b.code] or 99)
    end)
 
    SF = {
      { W_R4[1], W_R4[4] },
      { W_R4[2], W_R4[3] }
    }
  else
    SF = {
      { {logo='—',fullName='TBD'}, {logo='—',fullName='TBD'} },
      { {logo='—',fullName='TBD'}, {logo='—',fullName='TBD'} }
    }
  end
 
  for i=1,2 do
    local H,A = SF[i][1], SF[i][2]
    if absNow >= absSF and H.fullName~='TBD' and A.fullName~='TBD' then
      local g1,g2 = playScore(H,A,absSF*100+i)
      W_SF[i]    = (g1>g2) and H or A
    end
    maybeAddRow('SF-'..i, absSF, H, A, absSF*100+i)
  end
 
  --------------------------------------------------------------------
  -- Final
  --------------------------------------------------------------------
  local absF = absSF + PO_IV
  local FH = W_SF[1] or {logo='—',fullName='TBD'}
  local FA = W_SF[2] or {logo='—',fullName='TBD'}
  maybeAddRow('Final', absF, FH, FA, absF*100+1)
 
  rows[#rows+1] = '|}'
  return table.concat(rows, '\n')
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 7) MATCH STATS
-- 9) PER-MATCH STATS
------------------------------------------------------------------------
------------------------------------------------------------------------
local function _statsTable(r)
local function computeMatchStats(h, a, hg, ag, seed)
     local H=r.stats[r.home.code]; local A=r.stats[r.away.code]
  local function splitScore(total, salt)
     local out={
     local maxG = math.floor(total / 3)
        string.format("'''%s %d-%d %s'''",r.home.fullName,r.hg,r.ag,r.away.fullName),
    if maxG == 0 then return 0, total end
        '{| class="wikitable" style="width:100%;"',
    math.randomseed(salt)
        '! Stat !! '..r.home.fullName..' !! '..r.away.fullName,
    local g = maxG
        '|-','| Goals || '..H.goals..' || '..A.goals,
    if maxG >= 1 and math.random() < 0.40 then g = maxG - 1 end
        '|-','| Possession Points || '..H.poss_points..' || '..A.poss_points,
    local p = total - 3*g
        '|-','| Shots on Target || '..H.shots_on..' || '..A.shots_on,
     return g, p
        '|-','| Shots off Target || '..H.shots_off..' || '..A.shots_off,
  end
        '|-','| Fouls || '..H.fouls..' || '..A.fouls,
 
        '|-','| Free Kicks || '..H.fks..' || '..A.fks,
  local h_goals, h_poss = splitScore(hg, seed + 101)
        '|-','| Goal Kicks || '..H.gk..' || '..A.gk,
  local a_goals, a_poss = splitScore(ag, seed + 102)
        '|-','| Out of Bounds || '..H.oob..' || '..A.oob,
 
        '|-','| Throw-Ins || '..H.throws..' || '..A.throws,
  local function rrange(s, lo, hi) math.randomseed(s); return math.random(lo, hi) end
        '|-','| Ball Possession || '..H.poss_pct..'% || '..A.poss_pct..'%',
  local h_sh_on = (h_goals > 0) and rrange(seed+201, h_goals, h_goals+2) or rrange(seed+201, 0, 2)
        '|}'
  local a_sh_on = (a_goals > 0) and rrange(seed+202, a_goals, a_goals+2) or rrange(seed+202, 0, 2)
 
  local h_sh_off = rrange(seed+203, 0, 3)
  local a_sh_off = rrange(seed+204, 0, 3)
 
  local h_fouls = rrange(seed+205, 0, 3)
  local a_fouls = rrange(seed+206, 0, 3)
  local h_fks  = a_fouls
  local a_fks  = h_fouls
 
  local h_throws = rrange(seed+207, 0, 3)
  local a_throws = rrange(seed+208, 0, 3)
  local h_oob    = rrange(seed+209, 0, 2)
  local a_oob    = rrange(seed+210, 0, 2)
  local h_gk    = rrange(seed+211, 0, 2)
  local a_gk    = rrange(seed+212, 0, 2)
 
  local base = 50
  local swing = rrange(seed+213, 3, 6)
  local h_poss_pct, a_poss_pct
  if hg > ag then
    h_poss_pct, a_poss_pct = base + swing, base - swing
  elseif hg < ag then
    h_poss_pct, a_poss_pct = base - swing, base + swing
  else
    local tilt = rrange(seed+214, -3, 3)
    h_poss_pct, a_poss_pct = base + tilt, base - tilt
  end
 
  return {
    [h.code] = {
      final = hg, goals = h_goals, poss_points = h_poss,
      shots_on = h_sh_on, shots_off = h_sh_off,
      fouls = h_fouls, fks = h_fks, throws = h_throws,
      gk = h_gk, oob = h_oob, poss_pct = h_poss_pct
    },
    [a.code] = {
      final = ag, goals = a_goals, poss_points = a_poss,
      shots_on = a_sh_on, shots_off = a_sh_off,
      fouls = a_fouls, fks = a_fks, throws = a_throws,
      gk = a_gk, oob = a_oob, poss_pct = a_poss_pct
     }
     }
    return table.concat(out,'\n')
  }
end
end


function p.renderMatchStatsForDay(frame)
function p.renderMatchStatsForDay(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,nil)
  local curr  = date.getCurrentDate()
     local now,day,year=_nowAbs(); local target=tonumber(args.day)
  local dayNow = tonumber(curr:match('^(%d+),')) or 0
     if not target then
  local year  = tonumber(curr:match(', (%d+) PSSC')) or 0
        if year==season and mapDays()[day] then target=day else
  local dm     = mapDays()
            local cutoff=_regularSeasonCutoff(season)
 
            for d=cutoff,START,-1 do if mapDays()[d] then target=d; break end end
  local target = dm[dayNow] and dayNow or nil
         end
  if not target then
    for d = dayNow + 1, END do
      if dm[d] then target = d; break end
    end
  end
  if not target then return "'''No scheduled matches in window.'''" end
 
  local idx    = dm[target]
  local isToday = (target == dayNow)
 
  local mon, dom = getMonthDay(target)
  local label    = dom .. " " .. mon .. " " .. year .. " PSSC"
 
  local function hiPairNum(hVal, aVal, suffix)
    hVal = tonumber(hVal) or 0
    aVal = tonumber(aVal) or 0
    suffix = suffix or ""
    local function cell(v, hi)
      local txt = tostring(v) .. suffix
      return hi and ('<span style="background-color:#ccffcc;">'..txt..'</span>') or txt
    end
    return cell(hVal, hVal>aVal), cell(aVal, aVal>hVal)
  end
 
  local out = {
    '{| class="wikitable sortable"',
    '|+ Match Statistics — ' .. label .. (isToday and '' or ' (fixtures; stats available day-of)'),
     '! Match !! Team !! Final !! Goals (Pillar) !! Possession Pts !! Shots on !! Shots off !! Fouls !! Free Kicks !! Throw-Ins !! Goal Kicks !! OOB !! Poss %'
  }
 
  local matches = getRoundMatches(idx)
 
  if isToday then
    local results = simulateDayResults(target)
    for k, r in ipairs(results) do
      local seed = target*1000 + k + string.byte(r.home.code,1) + string.byte(r.away.code,1)
      local S    = computeMatchStats(r.home, r.away, r.hg, r.ag, seed)
      local H, A = S[r.home.code], S[r.away.code]
 
      local matchLabel = r.home.logo .. ' ' .. r.home.fullName .. ' vs ' .. r.away.logo .. ' ' .. r.away.fullName
 
      local hF,  aF  = hiPairNum(H.final,      A.final)
      local hG,  aG  = hiPairNum(H.goals,      A.goals)
      local hP,  aP  = hiPairNum(H.poss_points, A.poss_points)
      local hSON, aSON = hiPairNum(H.shots_on,    A.shots_on)
      local hSOF, aSOF = hiPairNum(H.shots_off,  A.shots_off)
      local hFO,  aFO  = hiPairNum(H.fouls,      A.fouls)
      local hFK,  aFK  = hiPairNum(H.fks,        A.fks)
      local hTH,  aTH  = hiPairNum(H.throws,      A.throws)
      local hGK,  aGK  = hiPairNum(H.gk,          A.gk)
      local hOOB, aOOB = hiPairNum(H.oob,         A.oob)
      local hPOS, aPOS = hiPairNum(H.poss_pct,    A.poss_pct, '%')
 
      out[#out+1] = '|-'
      out[#out+1] =
        '| rowspan="2" | ' .. matchLabel ..
        ' || ' .. r.home.logo .. ' ' .. r.home.fullName ..
        ' || ' .. hF ..
        ' || ' .. hG ..
        ' || ' .. hP ..
        ' || ' .. hSON ..
        ' || ' .. hSOF ..
        ' || ' .. hFO ..
        ' || ' .. hFK ..
        ' || ' .. hTH ..
        ' || ' .. hGK ..
        ' || ' .. hOOB ..
        ' || ' .. hPOS
 
      out[#out+1] = '|-'
      out[#out+1] =
        '| ' .. r.away.logo .. ' ' .. r.away.fullName ..
        ' || ' .. aF ..
        ' || ' .. aG ..
        ' || ' .. aP ..
        ' || ' .. aSON ..
        ' || ' .. aSOF ..
        ' || ' .. aFO ..
        ' || ' .. aFK ..
        ' || ' .. aTH ..
        ' || ' .. aGK ..
        ' || ' .. aOOB ..
        ' || ' .. aPOS
    end
  else
    for _, m in ipairs(matches) do
      local matchLabel = m[1].logo .. ' ' .. m[1].fullName .. ' vs ' .. m[2].logo .. ' ' .. m[2].fullName
      out[#out+1] = '|-'
      out[#out+1] =
        '| rowspan="2" | ' .. matchLabel ..
        ' || ' .. m[1].logo .. ' ' .. m[1].fullName ..
        ' || — || — || — || — || — || — || — || — || — || — || —'
      out[#out+1] = '|-'
      out[#out+1] =
         '| ' .. m[2].logo .. ' ' .. m[2].fullName ..
        ' || — || — || — || — || — || — || — || — || — || — || —'
     end
     end
    if not target or not mapDays()[target] then return "'''No Pillarion matchday is available for this date.'''" end
  end
    if now<absDay(season,target) then return "'''That matchday has not yet been played.'''" end
 
    local out={}
  out[#out+1] = '|}'
    for _,r in ipairs(simulateDayResults(target,season)) do out[#out+1]=_statsTable(r) end
  return table.concat(out, '\n')
    return table.concat(out,'\n\n')
end
end


function p.renderMatchStatsSingle(frame)
function p.renderMatchStatsSingle(frame)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,nil)
  local args = frame.args or {}
    local day=tonumber(args.day or '') or 0; local home=args.home; local away=args.away
  local day = tonumber(args.day or '') or 0
    if day==0 or not home or not away then return "'''Pass day, home, and away:''' day=###, home=Team, away=Team" end
  local home = args.home
     for _,r in ipairs(simulateDayResults(day,season)) do
  local away = args.away
        if r.home.code==home and r.away.code==away then return _statsTable(r) end
  if day == 0 or not home or not away then
    return "'''Pass day, home, and away:''' day=###, home=Team, away=Team"
  end
 
  local curr  = date.getCurrentDate()
  local dayNow = tonumber(curr:match('^(%d+),')) or 0
  local year  = tonumber(curr:match(', (%d+) PSSC')) or 0
  local dm     = mapDays()
  local idx    = dm[day]
  if not idx then return "'''No round mapped for that day.'''" end
 
  local matches = getRoundMatches(idx)
  local matchIdx
  for k, m in ipairs(matches) do
    if m[1].code == home and m[2].code == away then matchIdx = k; break end
  end
  if not matchIdx then return "'''Match not found on that day with that home/away.'''" end
 
  local m = matches[matchIdx]
  local mon, dom = getMonthDay(day)
  local dateLabel= dom .. " " .. mon .. " " .. year .. " PSSC"
 
  local out = {
    '{| class="wikitable"',
    '|+ Match Statistics — ' .. dateLabel .. ((day == dayNow) and '' or ' (fixture; stats available day-of)'),
    '! Statistic !! ' .. _teamWithLogo(m[1]) .. ' !! ' .. _teamWithLogo(m[2])
  }
 
  if day == dayNow then
    local resList = simulateDayResults(day)
    local r = resList[matchIdx]
    local seed = day*1000 + matchIdx + string.byte(home,1) + string.byte(away,1)
    local S = computeMatchStats(m[1], m[2], r.hg, r.ag, seed)
    local H, A = S[home], S[away]
 
    local function hi(hv, av, suffix)
      hv = tonumber(hv) or 0
      av = tonumber(av) or 0
      suffix = suffix or ""
      local h = tostring(hv) .. suffix
      local a = tostring(av) .. suffix
      if hv > av then h = '<span style="background-color:#ccffcc;">'..h..'</span>'
      elseif av > hv then a = '<span style="background-color:#ccffcc;">'..a..'</span>' end
      return h, a
     end
     end
     return "'''Match not found on that Pillarion matchday.'''"
 
     local hF,aF    = hi(H.final,      A.final)
    local hG,aG    = hi(H.goals,      A.goals)
    local hP,aP    = hi(H.poss_points, A.poss_points)
    local hSON,aSON = hi(H.shots_on,    A.shots_on)
    local hSOF,aSOF = hi(H.shots_off,  A.shots_off)
    local hFO,aFO  = hi(H.fouls,      A.fouls)
    local hFK,aFK  = hi(H.fks,        A.fks)
    local hTH,aTH  = hi(H.throws,      A.throws)
    local hGK,aGK  = hi(H.gk,          A.gk)
    local hOOB,aOOB = hi(H.oob,        A.oob)
    local hPOS,aPOS = hi(H.poss_pct,    A.poss_pct, '%')
 
    out[#out+1] = '|-'; out[#out+1] = '| Final Score || ' .. hF .. ' || ' .. aF
    out[#out+1] = '|-'; out[#out+1] = '| Goals (Pillar Strikes) || ' .. hG .. ' || ' .. aG
    out[#out+1] = '|-'; out[#out+1] = '| Possession Points || ' .. hP .. ' || ' .. aP
    out[#out+1] = '|-'; out[#out+1] = '| Shots on Target || ' .. hSON .. ' || ' .. aSON
    out[#out+1] = '|-'; out[#out+1] = '| Shots off Target || ' .. hSOF .. ' || ' .. aSOF
    out[#out+1] = '|-'; out[#out+1] = '| Fouls || ' .. hFO .. ' || ' .. aFO
    out[#out+1] = '|-'; out[#out+1] = '| Free Kicks Awarded || ' .. hFK .. ' || ' .. aFK
    out[#out+1] = '|-'; out[#out+1] = '| Throw-Ins || ' .. hTH .. ' || ' .. aTH
    out[#out+1] = '|-'; out[#out+1] = '| Goal Kicks || ' .. hGK .. ' || ' .. aGK
    out[#out+1] = '|-'; out[#out+1] = '| Out of Bounds || ' .. hOOB .. ' || ' .. aOOB
    out[#out+1] = '|-'; out[#out+1] = '| Ball Possession || ' .. hPOS .. ' || ' .. aPOS
  else
    local function blank(row) out[#out+1] = '|-'; out[#out+1] = row end
    blank('| Final Score || — || —')
    blank('| Goals (Pillar Strikes) || — || —')
    blank('| Possession Points || — || —')
    blank('| Shots on Target || — || —')
    blank('| Shots off Target || — || —')
    blank('| Fouls || — || —')
    blank('| Free Kicks Awarded || — || —')
    blank('| Throw-Ins || — || —')
    blank('| Goal Kicks || — || —')
    blank('| Out of Bounds || — || —')
    blank('| Ball Possession || — || —')
  end
 
  out[#out+1] = '|}'
  return table.concat(out, '\n')
end
end


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


local POSITIONS = {
  "Striker","Striker",
  "Playmaker","Playmaker","Playmaker",
  "Defender","Defender","Defender",
  "Keeper",
  "Utility","Utility","Utility"
}
-- deterministic PRNG (does NOT touch math.randomseed)
local function _seedFromString(s)
local function _seedFromString(s)
    local h=0; s=tostring(s or '')
  local h = 0
    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
    if h==0 then h=1 end
  return (h == 0) and 1 or h
    return h
end
end
local function _rngNext(st) st=(1103515245*st+12345)%2147483647; return st,st/2147483647 end
 
local function _pick(st,list) local r; st,r=_rngNext(st); return st,list[math.floor(r*#list)+1] end
local function _rngNext(st)
  st = (1103515245 * st + 12345) % 2147483647
  return st, st / 2147483647
end
 
local function _pick(st, arr)
  if not arr or #arr == 0 then return st, nil end
  local r; st, r = _rngNext(st)
  local idx = math.floor(r * #arr) + 1
  return st, arr[idx]
end
 
local function _normKey(s)
local function _normKey(s)
    if not s or s=='' then return nil end
  if not s or s == "" then return nil end
    if mw and mw.ustring then
  s = mw.ustring.lower(s)
        s=mw.ustring.lower(s)
  s = mw.ustring.gsub(s, "%s+", "_")
        s=mw.ustring.gsub(s,'%s+','_')
  s = mw.ustring.gsub(s, "[^%w_]", "")
        s=mw.ustring.gsub(s,'[^%w_]','')
  return s
        return s
    end
    return (string.lower(tostring(s)):gsub('%s+','_'):gsub('[^%w_]',''))
end
end


local NAME_STYLES={
local NAME_STYLES = {
bassarid={
  bassarid = {
  firstA={'Ka','Tha','Py','De','Ar','Lo','Ni','Sa','Eo','Ae','Ky','My','Ga','Do','Sy','Va'},
    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'},
    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'},
    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'},
    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'},
    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'}},
    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'},
  haifan = {
  firstB={'lim','han','mal','kan','suf','iz','yla','na','rya','fi','ru','hir','mir','zir','yif','sar'},
    firstA = {"Se","Or","Ke","Ha","Yu","Az","Le","Mi","De","Sa","Fa","Ri","Ta","Na","Su","Ba"},
  firstC={'','oğlu','an','ir','em','et','in','a','e','u',''},
    firstB = {"lim","han","mal","kan","suf","iz","yla","na","rya","fi","ru","hir","mir","zir","yif","sar"},
  lastA={'Yıldır','Dem','Kay','Arsl','Çel','Ayd','Gün','Korkm','Sarı','Taş','Karad','Özt','Ak','Boz','Şah'},
    firstC = {"","oğlu","an","ir","em","et","in","a","e","u",""},
  lastB={'im','ir','a','i','u','ü','o','ö',''},
    lastA = {"Yıldır","Dem","Kay","Arsl","Çel","Ayd","Gün","Korkm","Sarı","Taş","Karad","Özt","Ak","Boz","Şah"},
  lastC={'maz','er','kan','soy','taş','kaya','han','gül','lı','ci','oğlu',''}},
    lastB = {"im","ir","a","i","u","ü","o","ö",""},
normark={
    lastC = {"maz","er","kan","soy","taş","kaya","han","gül","lı","ci","oğlu",""}
  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'},
  normark = {
  firstC={'','r','d','n','k','s',''},
    firstA = {"Ei","Ha","Le","Bj","Si","As","In","To","Ka","Sk","Ry","Ul","Va","Sa","Jo","Th"},
  lastA={'Fjell','Ravn','Skog','Sund','Vinter','Knut','Thors','Haldor','Eirik','Bjorn','Isen','Storm','Sol','Hav','Ulv'},
    firstB = {"rik","kon","if","orn","grid","trid","ga","rsten","ri","ald","var","lva","ren","mund","nar","or"},
  lastB={'sen','son','sson','vik','lund','by','heim','holt','dal','gard','berg','mark'},
    firstC = {"","r","d","n","k","s",""},
  lastC={'','','',''}},
    lastA = {"Fjell","Ravn","Skog","Sund","Vinter","Knut","Thors","Haldor","Eirik","Bjorn","Isen","Storm","Sol","Hav","Ulv"},
imperial={
    lastB = {"sen","son","sson","vik","lund","by","heim","holt","dal","gard","berg","mark"},
  firstA={'Mar','Jul','Cas','Oct','Luc','Tib','Sab','Aurel','Flav','Dec','Val','Cor','Dom','Sev','Faust','Cris'},
    lastC = {"","","",""}
  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'},
  imperial = {
  lastA={'Valer','Marcell','Aquil','Sever','Corvin','Drus','Faustin','Liv','Domit','Crisp','Aurel','Cass','Octav','Lucan','Tiber','Flav'},
    firstA = {"Mar","Jul","Cas","Oct","Luc","Tib","Sab","Aurel","Flav","Dec","Val","Cor","Dom","Sev","Faust","Cris"},
  lastB={'ius','us','a','an','inus','ian','ensis','orum','atus','aris'},
    firstB = {"cus","ia","sian","avia","ian","er","ina","ius","ia","imus","eri","vin","itian","eran","inus","pus"},
  lastC={'','','',''}},
    firstC = {"","us","a","","us","a","us","a","us","a"},
    lastA = {"Valer","Marcell","Aquil","Sever","Corvin","Drus","Faustin","Liv","Domit","Crisp","Aurel","Cass","Octav","Lucan","Tiber","Flav"},
    lastB = {"ius","us","a","an","inus","ian","ensis","orum","atus","aris"},
    lastC = {"","","",""}
  }
}
}
local NAME_STYLE_KEYS={'bassarid','haifan','normark','imperial'}
local NAME_STYLE_KEYS = {"bassarid","haifan","normark","imperial"}


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


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


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


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


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


local function _buildBaseRosters(season)
local function _buildBaseRosters(season)
    local R={}; for _,t in ipairs(teams) do R[t.code]=_genBaseRoster(t.code,season) end; return R
  local rosters = {}
  for _,t in ipairs(teams) do rosters[t.code] = _genBaseRoster(t.code, season) end
  return rosters
end
 
local function _indexById(roster)
  local m = {}
  for i,p in ipairs(roster) do m[p.id] = i end
  return m
end
 
local function _buildPlayerNameLookup(baseRosters)
  local m = {}
  for _, roster in pairs(baseRosters or {}) do
    for _, pl in ipairs(roster or {}) do
      if pl and pl.id then
        m[pl.id] = pl.name or pl.id
      end
    end
  end
  return m
end
end


local function _copyRosters(base)
 
    local R={}
local function _findSwapCandidate(st, roster, wantPos)
    for tc,list in pairs(base) do R[tc]={}; for _,p0 in ipairs(list) do local q={}; for k,v in pairs(p0) do q[k]=v end; R[tc][#R[tc]+1]=q end end
  local candidates = {}
    return R
  for _,p in ipairs(roster) do if p.pos == wantPos then candidates[#candidates+1] = p end end
  if #candidates == 0 then candidates = roster end
  return _pick(st, candidates)
end
end


local function _genTransferDeals(season)
local function _genTransferDeals(season)
    local st=_seedFromString(season..'|TRANSFERWINDOW|MD20-30'); local deals={}
  local st = _seedFromString(tostring(season) .. "|TRANSFERWINDOW|MD" .. TRANSFER_MD_START .. "-" .. TRANSFER_MD_END)
    for md=TRANSFER_MD_START,TRANSFER_MD_END do
  local deals, teamCodes = {}, {}
        local r; st,r=_rngNext(st); local count=1+math.floor(r*3)
  for _,t in ipairs(teams) do teamCodes[#teamCodes+1] = t.code end
        for n=1,count do
 
            local ia,ib
  for md = TRANSFER_MD_START, TRANSFER_MD_END do
            st,r=_rngNext(st); ia=math.floor(r*#teams)+1
    local r; st, r = _rngNext(st)
            repeat st,r=_rngNext(st); ib=math.floor(r*#teams)+1 until ib~=ia
    local dealCount = 1 + math.floor(r * 3)
            local A,B=teams[ia].code,teams[ib].code
    for _=1, dealCount do
            local ra=_genBaseRoster(A,season); local rb=_genBaseRoster(B,season)
      local A,B
            st,r=_rngNext(st); local pa=ra[math.floor(r*#ra)+1]
      st, A = _pick(st, teamCodes)
            if pa.pos=='Keeper' then st,r=_rngNext(st); if r<.75 then pa=ra[1+math.floor(r*(#ra-1))] end end
      repeat st, B = _pick(st, teamCodes) until B ~= A
            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)
      local baseA = _genBaseRoster(A, season)
            deals[#deals+1]={md=md,A=A,B=B,A_id=pa.id,B_id=pb.id,pos=pa.pos}
      local baseB = _genBaseRoster(B, season)
 
      local pA; st, pA = _pick(st, baseA)
      if pA and pA.pos == "Keeper" then
        local rr; st, rr = _rngNext(st)
        if rr < 0.75 then st, pA = _pick(st, baseA) end
      end
 
      local pB; st, pB = _findSwapCandidate(st, baseB, (pA and pA.pos) or "Utility")
      if A and B and pA and pB then
        deals[#deals+1] = { md=md, A=A, B=B, A_id=pA.id, B_id=pB.id, pos=pA.pos }
      end
    end
  end
  return deals
end
 
local function _applyTransfersUpTo(baseRosters, deals, upToMd)
  local rosters = {}
  for code, r in pairs(baseRosters) do
    rosters[code] = {}
    for i,p in ipairs(r) do rosters[code][i] = { id=p.id, name=p.name, pos=p.pos, no=p.no, notes=p.notes } end
  end
 
  for _,d in ipairs(deals) do
    if d.md <= upToMd then
      local RA, RB = rosters[d.A], rosters[d.B]
      if RA and RB then
        local ia = _indexById(RA)[d.A_id]
        local ib = _indexById(RB)[d.B_id]
        if ia and ib then
          local tmp = RA[ia]
          RA[ia] = RB[ib]
          RB[ib] = tmp
         end
         end
      end
     end
     end
    return deals
  end
 
  return rosters
end
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 _alloc(st, roster, posWeights)
local function _applyTransfersUpTo(base,deals,upToMd)
  local pool = {}
     local R=_copyRosters(base)
  for _,p in ipairs(roster) do
     for _,d in ipairs(deals or {}) do
    local w = posWeights[p.pos] or 1
        if d.md<=(upToMd or 0) then
    for _=1,w do pool[#pool+1] = p end
            local ia,pa=_findPlayer(R[d.A],d.A_id); local ib,pb=_findPlayer(R[d.B],d.B_id)
  end
            if ia and ib then pa.team=d.B; pb.team=d.A; R[d.A][ia],R[d.B][ib]=pb,pa end
  return _pick(st, pool)
end
 
local function _ensure(map, pid)
  if not map[pid] then map[pid] = { goals=0, assists=0, poss=0, son=0, sof=0, fouls=0, apps=0 } end
  return map[pid]
end
 
local function _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster)
  local salt = day*1000 + matchIndex + string.byte(homeTeam.code,1) + string.byte(awayTeam.code,1)
  local hg, ag = simScoreFor(homeTeam, awayTeam, salt)
  local S = computeMatchStats(homeTeam, awayTeam, hg, ag, salt)
  local H = S[homeTeam.code] or {}
  local A = S[awayTeam.code] or {}
  local stats = { home = H, away = A }
 
  local st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)
  local events = { home={}, away={} }
 
  for _,p in ipairs(homeRoster) do _ensure(events.home, p.id).apps = 1 end
  for _,p in ipairs(awayRoster) do _ensure(events.away, p.id).apps = 1 end
 
  local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
  local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
  local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
 
  for _=1, (stats.home.goals or 0) do
    local pz; st, pz = _alloc(st, homeRoster, W_GOAL)
    _ensure(events.home, pz.id).goals = _ensure(events.home, pz.id).goals + 1
    local r; st, r = _rngNext(st)
    if r < 0.70 then
      local a; st, a = _alloc(st, homeRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
      if a and a.id ~= pz.id then _ensure(events.home, a.id).assists = _ensure(events.home, a.id).assists + 1 end
    end
  end
 
  for _=1, (stats.away.goals or 0) do
    local pz; st, pz = _alloc(st, awayRoster, W_GOAL)
    _ensure(events.away, pz.id).goals = _ensure(events.away, pz.id).goals + 1
    local r; st, r = _rngNext(st)
     if r < 0.70 then
      local a; st, a = _alloc(st, awayRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
      if a and a.id ~= pz.id then _ensure(events.away, a.id).assists = _ensure(events.away, a.id).assists + 1 end
     end
  end
 
  for _=1, (stats.home.poss_points or 0) do
    local pz; st, pz = _alloc(st, homeRoster, W_POSS)
    _ensure(events.home, pz.id).poss = _ensure(events.home, pz.id).poss + 1
  end
  for _=1, (stats.away.poss_points or 0) do
    local pz; st, pz = _alloc(st, awayRoster, W_POSS)
    _ensure(events.away, pz.id).poss = _ensure(events.away, pz.id).poss + 1
  end
 
  local function distributeShots(side, roster, on, off)
    for _=1, (on or 0) do
      local pz; st, pz = _alloc(st, roster, {Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0})
      _ensure(side, pz.id).son = _ensure(side, pz.id).son + 1
    end
    for _=1, (off or 0) do
      local pz; st, pz = _alloc(st, roster, {Striker=4, Playmaker=3, Utility=2, Defender=1, Keeper=0})
      _ensure(side, pz.id).sof = _ensure(side, pz.id).sof + 1
    end
  end
  distributeShots(events.home, homeRoster, stats.home.shots_on or 0, stats.home.shots_off or 0)
  distributeShots(events.away, awayRoster, stats.away.shots_on or 0, stats.away.shots_off or 0)
 
  for _=1, (stats.home.fouls or 0) do
    local pz; st, pz = _alloc(st, homeRoster, W_FOUL)
    _ensure(events.home, pz.id).fouls = _ensure(events.home, pz.id).fouls + 1
  end
  for _=1, (stats.away.fouls or 0) do
    local pz; st, pz = _alloc(st, awayRoster, W_FOUL)
    _ensure(events.away, pz.id).fouls = _ensure(events.away, pz.id).fouls + 1
  end
 
  return events
end
 
local function _computePlayerSeasonTotals(season, upToMd)
  local dm = mapDays()
  local inv = invertDayMap(dm)
 
  local deals = _genTransferDeals(season)
  local base = _buildBaseRosters(season)
  local totals = {}
 
  local function ensurePlayer(pid, name, pos)
    if not totals[pid] then
      totals[pid] = { id=pid, name=name or pid, pos=pos or "—", team="—",
                      apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0 }
    end
    return totals[pid]
  end
 
  for md=1, math.min(upToMd, TOTAL_ROUNDS) do
    local day = inv[md]
    if day then
      local rosters = _applyTransfersUpTo(base, deals, md)
      local fixtures = getRoundMatches(md)
 
      for matchIndex, m in ipairs(fixtures) do
        local h, a = m[1], m[2]
        local hr, ar = rosters[h.code] or {}, rosters[a.code] or {}
        local ev = _matchPlayerEvents(season, md, day, matchIndex, h, a, hr, ar)
 
        for pid, stt in pairs(ev.home) do
          local name, pos = pid, "—"
          for _,pp in ipairs(hr) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
          local row = ensurePlayer(pid, name, pos)
          row.team    = h.code
          row.apps    = row.apps + (stt.apps or 0)
          row.goals  = row.goals + (stt.goals or 0)
          row.assists = row.assists + (stt.assists or 0)
          row.poss    = row.poss + (stt.poss or 0)
          row.son    = row.son + (stt.son or 0)
          row.sof    = row.sof + (stt.sof or 0)
          row.fouls  = row.fouls + (stt.fouls or 0)
         end
         end
        for pid, stt in pairs(ev.away) do
          local name, pos = pid, "—"
          for _,pp in ipairs(ar) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
          local row = ensurePlayer(pid, name, pos)
          row.team    = a.code
          row.apps    = row.apps + (stt.apps or 0)
          row.goals  = row.goals + (stt.goals or 0)
          row.assists = row.assists + (stt.assists or 0)
          row.poss    = row.poss + (stt.poss or 0)
          row.son    = row.son + (stt.son or 0)
          row.sof    = row.sof + (stt.sof or 0)
          row.fouls  = row.fouls + (stt.fouls or 0)
        end
      end
     end
     end
     return R
  end
 
  return totals, deals, base
end
 
------------------------------------------------------------------------
-- 8b) OFF-SEASON REGIONAL CUP (Division Select Tournament)
--     Starts ~3 weeks (21 days) after the Playoff Final
------------------------------------------------------------------------
 
-- Your playoff final date (absolute day index):
-- In renderPlayoffResults you compute rounds using:
-- absPI = absDay(PO_YEAR, PO_FIRST)
-- absF  = absSF + PO_IV  where absSF = absR4 + PO_IV and absR4 = absPI + PO_IV
-- => Final happens at absPI + (PO_IV * 4)
local ABS_PLAYOFF_FINAL = absDay(PO_YEAR, PO_FIRST) + (PO_IV * 4)
 
-- Timing knobs
local RC_GAP_AFTER_FINAL = 21  -- "three weeks after playoffs"
local RC_ROUND_GAP      = 2  -- days between RR rounds
local RC_FINAL_GAP      = 3  -- days after RR for Final + 3rd
 
local function _regionalCupStartAbs(season)
  season = tonumber(season) or DEFAULT_SEASON
  local playoffFinalAbs = absDay(season + 1, PO_FIRST) + (PO_IV * 4)
  return playoffFinalAbs + RC_GAP_AFTER_FINAL
end
end


local function _buildPlayerNameLookup(base)
-- Branding (logos + display names)
     local out={}; for _,list in pairs(base) do for _,pl in ipairs(list) do out[pl.id]=pl.name end end; return out
-- Logos here should be FULL wikitext, like your clubs: [[File:Name.png|20px]]
local RC_META = {
  ["Morovian Division"] = {
    name = "Morovian Select",
    logo = "[[File:MorovianSelect.png|20px]]",
  },
  ["Southern Strait Division"] = {
    name = "Southern Strait Select",
    logo = "[[File:SouthernStraitSelect.png|20px]]",
  },
  ["Western Highlands Division"] = {
    name = "Western Highlands Select",
    logo = "[[File:WesternHighlandsSelect.png|20px]]",
  },
  ["Normarkian Division"] = {
    name = "Normarkian Select",
    logo = "[[File:NormarkianSelect.png|20px]]",
  },
}
 
-- Convert absolute day -> "DD Month YYYY PSSC" using your 183-day calendar
local function _absToYearDay(abs)
  if not abs or abs <= 0 then return 0, 0 end
  local y = math.floor((abs - 1) / DAYS_IN_YEAR)
  local d = ((abs - 1) % DAYS_IN_YEAR) + 1
  return y, d
end
 
local function _dateLabelFromAbs(abs)
  local y, d = _absToYearDay(abs)
  local mon, dom = getMonthDay(d)
  return string.format("%d %s %d PSSC", dom, mon, y)
end
 
-- Rating proxy for Regional Cup call-ups
-- Uses PER-APP rates + reliability scaling to prevent too many 10.0s.
local function _rcRating(row)
  row = row or {}
 
  local apps    = tonumber(row.apps) or 0
  local goals  = tonumber(row.goals) or 0
  local assists = tonumber(row.assists) or 0
  local poss    = tonumber(row.poss) or 0
  local son     = tonumber(row.son) or 0
  local sof    = tonumber(row.sof) or 0
  local fouls  = tonumber(row.fouls) or 0
  local pos    = tostring(row.pos or "")
 
  -- Avoid divide-by-zero; also prevents 1-game wonders from spiking.
  local denom = math.max(1, apps)
 
  -- Per-appearance rates (key change)
  local g  = goals  / denom
  local a  = assists / denom
  local pr = poss    / denom
  local sr = son    / denom
  local orr= sof    / denom
  local fr = fouls  / denom
 
  -- Reliability multiplier: full weight only after ~6 apps
  local rel = math.min(1, denom / 6)
 
  -- Base rating (tuned to spread the top end and reduce perfect 10s)
  local raw = 5.8
    + g  * 3.0
    + a  * 1.8
    + pr * 0.10
    + sr * 0.22
    - orr* 0.18
    - fr * 0.40
 
  -- Keepers: we don’t track saves/clean sheets in season totals,
  -- so give them a slightly different emphasis (discipline + involvement)
  if pos == "Keeper" then
    raw = 5.9
      + pr * 0.08
      + a  * 0.8
      - fr * 0.50
      - orr* 0.12
  end
 
  -- Apply reliability (small samples pulled toward 6)
  local rating = 6 + (raw - 6) * rel
 
  -- Clamp; slightly tighter ceiling to reduce “10.0 everywhere”
  if rating < 0 then rating = 0 end
  if rating > 9.7 then rating = 9.7 end
 
  return rating
end
 
-- Build player pools by DIVISION using your existing season totals
local function _buildRegionalPlayerPool(season)
  local totals = _computePlayerSeasonTotals(season, TOTAL_ROUNDS) -- pid -> totals row
  local poolByDiv = {}
  for div,_ in pairs(DIVISIONS) do poolByDiv[div] = {} end
 
  for _, row in pairs(totals or {}) do
    local club = row.team
    local div  = DIV_OF[club]
    if div and poolByDiv[div] then
      local entry = {
        id=row.id, name=row.name, pos=row.pos, team=row.team,
        apps=row.apps or 0, goals=row.goals or 0, assists=row.assists or 0,
        poss=row.poss or 0, son=row.son or 0, sof=row.sof or 0, fouls=row.fouls or 0
      }
      entry.rating = _rcRating(entry)
      table.insert(poolByDiv[div], entry)
    end
  end
 
  for div, list in pairs(poolByDiv) do
    table.sort(list, function(a,b)
      if a.rating ~= b.rating then return a.rating > b.rating end
      if (a.goals or 0) ~= (b.goals or 0) then return (a.goals or 0) > (b.goals or 0) end
      if (a.assists or 0) ~= (b.assists or 0) then return (a.assists or 0) > (b.assists or 0) end
      return (a.name or "") < (b.name or "")
    end)
  end
 
  return poolByDiv
end
end


local function _alloc(st, roster, posWeights)
-- Pick a balanced 12-player “call-up” squad
    local pool={}
local function _pickRegionalSquad(sortedPool)
     for _,pl in ipairs(roster or {}) do
  local need = { Keeper=1, Defender=4, Playmaker=3, Striker=3, Utility=1 }
         local w=(posWeights and posWeights[pl.pos]) or 1
  local picked, used = {}, {}
         for _=1,w do pool[#pool+1]=pl end
 
  local function takePos(pos)
    if (need[pos] or 0) <= 0 then return end
     for _, p in ipairs(sortedPool or {}) do
      if not used[p.id] and p.pos == pos then
         used[p.id] = true
        picked[#picked+1] = p
        need[pos] = need[pos] - 1
         if need[pos] <= 0 then return end
      end
    end
  end
 
  takePos("Keeper")
  takePos("Defender")
  takePos("Playmaker")
  takePos("Striker")
  takePos("Utility")
 
  while #picked < ROSTER_SIZE do
    local found = false
    for _, p in ipairs(sortedPool or {}) do
      if not used[p.id] then
        used[p.id] = true
        picked[#picked+1] = p
        found = true
        break
      end
     end
     end
     return _pick(st,pool)
     if not found then break end
  end
 
  return picked
end
 
local function _regionalTeams(season)
  local poolByDiv = _buildRegionalPlayerPool(season)
 
  local function mk(div)
    local meta = RC_META[div] or {}
    local base = div:gsub(" Division","")
    local name = meta.name or (base .. " Select")
    return {
      div = div,
      code = base,
      fullName = name,
      logo = meta.logo or "—",
      squad = _pickRegionalSquad(poolByDiv[div] or {}),
    }
  end
 
  return {
    mk("Morovian Division"),
    mk("Southern Strait Division"),
    mk("Western Highlands Division"),
    mk("Normarkian Division")
  }
end
 
local function _squadStrength(squad)
  local sum, n = 0, 0
  for _, p in ipairs(squad or {}) do
    sum = sum + (p.rating or 0)
    n = n + 1
  end
  if n == 0 then return 1 end
  return math.max(1, sum / n)
end
 
local function _rcPlayScore(teamA, teamB, salt)
  math.randomseed(salt)
  local p = math.random()
 
  -- 10% draw band
  if p < 0.10 then
    local g = math.random(0, 12)
    return g, g
  end
 
  local sa = _squadStrength(teamA.squad)
  local sb = _squadStrength(teamB.squad)
  local aWins = ((p - 0.10) / 0.90) < (sa / (sa + sb))
 
  local hi = math.random(1, 12)
  local lo = math.random(0, math.min(hi-1, 12))
  return aWins and hi or lo, aWins and lo or hi
end
 
local function _rcStandingsInit(regTeams)
  local st = {}
  for _, t in ipairs(regTeams or {}) do
    st[t.code] = { team=t, P=0, W=0, D=0, L=0, GF=0, GA=0, GD=0, Pts=0 }
  end
  return st
end
end


local function _ensure(map,pid)
local function _rcApplyResult(st, A, B, ga, gb)
    if not map[pid] then map[pid]={goals=0,assists=0,poss=0,son=0,sof=0,fouls=0,apps=0} end
  local a = st[A.code]
     return map[pid]
  local b = st[B.code]
  if not a or not b then return end
  a.P = a.P + 1; b.P = b.P + 1
  a.GF = a.GF + ga; a.GA = a.GA + gb
  b.GF = b.GF + gb; b.GA = b.GA + ga
  if ga > gb then
    a.W = a.W + 1; a.Pts = a.Pts + 3
    b.L = b.L + 1
  elseif ga < gb then
    b.W = b.W + 1; b.Pts = b.Pts + 3
    a.L = a.L + 1
  else
    a.D = a.D + 1; a.Pts = a.Pts + 1
     b.D = b.D + 1; b.Pts = b.Pts + 1
  end
end
end


local function _matchPlayerEvents(season,md,day,matchIndex,homeTeam,awayTeam,homeRoster,awayRoster)
local function _rcSortTable(st)
    local salt=_matchSeed(season,day,matchIndex,homeTeam,awayTeam)
  local list = {}
     local hg,ag=simScoreFor(homeTeam,awayTeam,salt)
  for _, r in pairs(st or {}) do
     local S=computeMatchStats(homeTeam,awayTeam,hg,ag,salt)
     r.GD = (r.GF or 0) - (r.GA or 0)
     local stats={home=S[homeTeam.code] or {},away=S[awayTeam.code] or {}}
     table.insert(list, r)
     local st=_seedFromString(tostring(season)..'|MD'..md..'|D'..day..'|M'..matchIndex..'|'..homeTeam.code..'|'..awayTeam.code)
  end
     local events={home={},away={}}
  table.sort(list, function(a,b)
    for _,pl in ipairs(homeRoster or {}) do _ensure(events.home,pl.id).apps=1 end
     if a.Pts ~= b.Pts then return a.Pts > b.Pts end
     for _,pl in ipairs(awayRoster or {}) do _ensure(events.away,pl.id).apps=1 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 or "") < (b.team.fullName or "")
  end)
  return list
end


    local W_GOAL={Striker=6,Playmaker=3,Utility=2,Defender=1,Keeper=0}
-- Fixed RR pairings (deterministic)
     local W_POSS={Playmaker=6,Defender=3,Utility=2,Striker=1,Keeper=1}
local function _rcFixtures(regTeams)
     local W_FOUL={Defender=4,Utility=3,Playmaker=2,Striker=1,Keeper=1}
  local M = regTeams[1]
     local W_ASSIST={Playmaker=6,Striker=3,Utility=2,Defender=1,Keeper=0}
  local S = regTeams[2]
  local W = regTeams[3]
  local N = regTeams[4]
  return {
     { round="RR-1", games = { {M,S}, {W,N} } },
     { round="RR-2", games = { {M,W}, {S,N} } },
     { round="RR-3", games = { {M,N}, {S,W} } },
  }
end


    for _=1,(stats.home.goals or 0) do
-- PUBLIC: Rosters (call-ups)
        local scorer; st,scorer=_alloc(st,homeRoster,W_GOAL)
function p.renderRegionalCupRosters(frame)
        if scorer then
  local args = frame.args or {}
            _ensure(events.home,scorer.id).goals=_ensure(events.home,scorer.id).goals+1
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
            local r; st,r=_rngNext(st)
  local T = _regionalTeams(season)
            if r<0.70 then
 
                local ast; st,ast=_alloc(st,homeRoster,W_ASSIST)
  local out = { "== Off-Season Regional Cup — Call-Ups (" .. season .. " PSSC) ==" }
                if ast and ast.id~=scorer.id then _ensure(events.home,ast.id).assists=_ensure(events.home,ast.id).assists+1 end
  for _, rt in ipairs(T) do
            end
    out[#out+1] = "=== " .. (rt.logo ~= "—" and (rt.logo .. " ") or "") .. rt.fullName .. " ==="
        end
    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 or {}) do
      out[#out+1] = '|-'
      out[#out+1] = string.format('| %s || %s || %s || %.2f || %d || %d',
        pl.name or pl.id, pl.pos or "—", _teamWithLogo(pl.team or "—"),
        pl.rating or 0, pl.goals or 0, pl.assists or 0
      )
     end
     end
     for _=1,(stats.away.goals or 0) do
     out[#out+1] = '|}'
        local scorer; st,scorer=_alloc(st,awayRoster,W_GOAL)
    out[#out+1] = ''
        if scorer then
  end
            _ensure(events.away,scorer.id).goals=_ensure(events.away,scorer.id).goals+1
  return table.concat(out, "\n")
            local r; st,r=_rngNext(st)
end
            if r<0.70 then
 
                local ast; st,ast=_alloc(st,awayRoster,W_ASSIST)
-- PUBLIC: Schedule table (includes kits via your existing kit function)
                if ast and ast.id~=scorer.id then _ensure(events.away,ast.id).assists=_ensure(events.away,ast.id).assists+1 end
function p.renderRegionalCupSchedule(frame)
            end
  local args = frame.args or {}
        end
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local T = _regionalTeams(season)
  local fixtures = _rcFixtures(T)
local rcStartAbs = _regionalCupStartAbs(season)
  local rows = { '{| class="wikitable sortable"', '! Stage !! Date !! Home (kit) !! Score !! Away (kit)' }
 
  local abs = rcStartAbs
  for i, blk in ipairs(fixtures) do
    local dateLabel = _dateLabelFromAbs(abs)
    for gi, g in ipairs(blk.games) do
      local H, A = g[1], g[2]
      local key = ("RC|"..season.."|"..blk.round.."|"..gi.."|"..dateLabel)
      local hk = _kitImgForMatch(H, "home", key, 20)
      local ak = _kitImgForMatch(A, "away", key, 20)
      rows[#rows+1] = "|-"
      rows[#rows+1] = string.format("| %s || %s || %s %s || — || %s %s",
        blk.round, dateLabel, hk, (H.logo ~= "—" and (H.logo.." ") or "") .. H.fullName,
        ak, (A.logo ~= "—" and (A.logo.." ") or "") .. A.fullName
      )
     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
     abs = abs + RC_ROUND_GAP
     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
  end
 
  local finalAbs = rcStartAbs + (RC_ROUND_GAP * (#fixtures - 1)) + RC_FINAL_GAP
  rows[#rows+1] = "|-"
  rows[#rows+1] = string.format("| Final || %s || TBD || — || TBD", _dateLabelFromAbs(finalAbs))
  rows[#rows+1] = "|-"
  rows[#rows+1] = string.format("| 3rd Place || %s || TBD || — || TBD", _dateLabelFromAbs(finalAbs))
 
  rows[#rows+1] = "|}"
  return table.concat(rows, "\n")
end
 
-- PUBLIC: Results + table + finals day
function p.renderRegionalCupResults(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local T = _regionalTeams(season)
  local fixtures = _rcFixtures(T)
  local st = _rcStandingsInit(T)
  local rcStartAbs = _regionalCupStartAbs(season)
  local now  = date.getCurrentDate()
  local dNow  = tonumber(now:match('^(%d+),')) or 0
  local yNow  = tonumber(now:match(', (%d+) PSSC')) or 0
  local absNow= absDay(yNow, dNow)
 
  local out = {}
  out[#out+1] = "== Off-Season Regional Cup (" .. season .. " PSSC) =="
  out[#out+1] = "''Begins " .. _dateLabelFromAbs(rcStartAbs) .. " (≈3 weeks after the playoff final).''"
  out[#out+1] = ""
  out[#out+1] = "=== Round Robin ==="
  out[#out+1] = '{| class="wikitable sortable"'
  out[#out+1] = '! Round !! Date !! Home (kit) !! Score !! Away (kit)'
 
  local abs = rcStartAbs
  for i, blk in ipairs(fixtures) do
    local dateLabel = _dateLabelFromAbs(abs)
     for gi, g in ipairs(blk.games) do
      local H, A = g[1], g[2]
      local key = ("RC|"..season.."|"..blk.round.."|"..gi.."|"..dateLabel)
      local hk = _kitImgForMatch(H, "home", key, 20)
      local ak = _kitImgForMatch(A, "away", key, 20)
 
      local score = "—"
      if absNow >= abs then
        local ga, gb = _rcPlayScore(H, A, abs*100 + gi)
        score = string.format("'''%d-%d'''", ga, gb)
        _rcApplyResult(st, H, A, ga, gb)
      end


    local function distributeShots(side,roster,on,off)
      out[#out+1] = "|-"
         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
      out[#out+1] = string.format("| %s || %s || %s %s || %s || %s %s",
         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
        blk.round, dateLabel,
         hk, (H.logo ~= "—" and (H.logo.." ") or "") .. H.fullName,
         score,
        ak, (A.logo ~= "—" and (A.logo.." ") or "") .. A.fullName
      )
     end
     end
     distributeShots(events.home,homeRoster,stats.home.shots_on or 0,stats.home.shots_off or 0)
     abs = abs + RC_ROUND_GAP
    distributeShots(events.away,awayRoster,stats.away.shots_on or 0,stats.away.shots_off or 0)
  end
    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
  out[#out+1] = "|}"
     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
  out[#out+1] = ""
     return events
 
end
  out[#out+1] = "=== Table ==="
  local list = _rcSortTable(st)
  out[#out+1] = '{| class="wikitable sortable"'
  out[#out+1] = '! Pos !! Team !! P !! W !! D !! L !! GF !! GA !! GD !! Pts'
  for i, r in ipairs(list) do
    out[#out+1] = "|-"
    out[#out+1] = string.format("| %d || %s || %d || %d || %d || %d || %d || %d || %d || %d",
      i, (r.team.logo ~= "—" and (r.team.logo.." ") or "") .. r.team.fullName,
      r.P, r.W, r.D, r.L, r.GF, r.GA, r.GD, r.Pts
    )
  end
  out[#out+1] = "|}"
  out[#out+1] = ""
 
  local finalAbs = rcStartAbs + (RC_ROUND_GAP * (#fixtures - 1)) + RC_FINAL_GAP
  out[#out+1] = "=== Finals Day ==="
  out[#out+1] = "''" .. _dateLabelFromAbs(finalAbs) .. "''"
 
  local A1 = list[1] and list[1].team or { fullName="TBD", logo="—", squad={} }
  local A2 = list[2] and list[2].team or { fullName="TBD", logo="—", squad={} }
  local A3 = list[3] and list[3].team or { fullName="TBD", logo="—", squad={} }
  local A4 = list[4] and list[4].team or { fullName="TBD", logo="—", squad={} }
 
  local function line(label, X, Y, saltK)
     local score = "—"
    local xName = ((X.logo and X.logo ~= "—") and (X.logo .. " ") or "") .. X.fullName
    local yName = ((Y.logo and Y.logo ~= "—") and (Y.logo .. " ") or "") .. Y.fullName
     local winner = "—"


local function _computePlayerSeasonTotals(season,upToMd)
     if absNow >= finalAbs and X.fullName ~= "TBD" and Y.fullName ~= "TBD" then
     upToMd=math.min(tonumber(upToMd) or 0,TOTAL_ROUNDS)
      local gx, gy = _rcPlayScore(X, Y, finalAbs*100 + saltK)
    local base=_buildBaseRosters(season); local deals=_genTransferDeals(season); local totals={}
      score = string.format("'''%d-%d'''", gx, gy)
    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
      winner = (gx > gy) and xName or ((gy > gx) and yName or "Draw")
    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
     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 string.format("* %s: %s %s %s (Winner: %s)", label, xName, score, yName, winner)
    return totals,deals,base
  end
 
  out[#out+1] = line("Final", A1, A2, 1)
  out[#out+1] = line("3rd Place", A3, A4, 2)
 
  return table.concat(out, "\n")
end
end


function p.renderRoster(frame)
function p.renderRoster(frame)
    local args=(frame and frame.args) or {}; local team=args.team or args[1]
  local args = frame.args or {}
    if not team or not TEAM_BY_CODE[team] then return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''" end
  local team = args.team or args[1]
    local season=_resolveSeason(args,2); local upToMd=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season)
  if not team or not TEAM_BY_CODE[team] then
    local R=_applyTransfersUpTo(_buildBaseRosters(season),_genTransferDeals(season),upToMd)[team] or {}
    return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''"
    local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! # !! Player !! Position !! ID !! Notes'}
  end
    for _,pl in ipairs(R) do out[#out+1]='|-'; out[#out+1]=string.format('| %d || %s || %s || <code>%s</code> || %s',pl.no or 0,pl.name or '—',pl.pos or '—',pl.id or '—',pl.notes or '—') end
 
    out[#out+1]='|}'
  local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
    return (TEAM_BY_CODE[team].logo or '').." '''"..team.."''' (Roster as of Matchday "..upToMd..')\n'..table.concat(out,'\n')
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
 
  local deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local rosters = _applyTransfersUpTo(base, deals, upToMd)
  local r = rosters[team] or {}
 
  local out = {
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    '! # !! Player !! Position !! ID !! Notes'
  }
  for _,pl in ipairs(r) do
    out[#out+1] = '|-'
    out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> || %s',
      pl.no or 0, pl.name or '—', pl.pos or '—', pl.id or '—', pl.notes or '—'
    )
  end
  out[#out+1] = '|}'
 
  return (TEAM_BY_CODE[team].logo or "") ..
    " '''" .. team .. "''' (Roster as of Matchday " .. tostring(upToMd) .. ")\n" ..
    table.concat(out, "\n")
end
end


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


function p.renderTransferLog(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 '') or _currentPlayedMatchday(season)
  local args = frame.args or {}
    local deals=_genTransferDeals(season); local base=_buildBaseRosters(season); local names=_buildPlayerNameLookup(base)
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
    local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! Matchday !! From !! To !! Swap (pos)'}
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    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]='|}'
  local deals = _genTransferDeals(season)
    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')
  local base = _buildBaseRosters(season)
  local nameOf = _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 <= upToMd then
      local aName = nameOf[d.A_id] or d.A_id
      local bName = nameOf[d.B_id] or d.B_id
 
      out[#out+1] = '|-'
      out[#out+1] = string.format('| %d || %s || %s || %s ⇄ %s (%s)',
        d.md, _teamWithLogo(d.A), _teamWithLogo(d.B),
        aName, bName, d.pos or '—'
      )
    end
  end
 
  out[#out+1] = '|}'
 
  return "'''Transfer window (Matchdays " .. TRANSFER_MD_START .. "–" .. TRANSFER_MD_END ..
    ") — season " .. season .. " (through Matchday " .. tostring(upToMd) .. ")'''\n" ..
    table.concat(out, "\n")
end
end


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


function p.renderLeaders(frame)
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 args = frame.args or {}
    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 season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
    if not ok[stat] then return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''" end
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    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 stat = (args.stat or args[2] or "goals")
    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}
  local ok = { goals=true, assists=true, poss=true, son=true, sof=true, fouls=true, apps=true }
    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
  if not ok[stat] then
    out[#out+1]='|}'
    return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''"
    return string.format("'''%d PSSC leaders through Matchday %d'''\n",season,md)..table.concat(out,'\n')
  end
 
  local totals = (_computePlayerSeasonTotals(season, upToMd))
  local list = {}
  for _,r in pairs(totals) do list[#list+1] = r end
 
  table.sort(list, function(a,b)
    if (a[stat] or 0) ~= (b[stat] or 0) then return (a[stat] or 0) > (b[stat] or 0) end
    return (a.name or "") < (b.name or "")
  end)
 
  local out = {
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    '! Rank !! Player !! Team (current) !! Pos !! ' .. stat
  }
 
  for i=1, math.min(20, #list) do
    local r = list[i]
    out[#out+1] = '|-'
    out[#out+1] = string.format('| %d || %s || %s || %s || %d',
      i, r.name or "—", _teamWithLogo(r.team or "—"), r.pos or "—", r[stat] or 0
    )
  end
 
  out[#out+1] = '|}'
 
  return "'''League leaders (" .. stat .. ") — season " .. season ..
    " through Matchday " .. tostring(upToMd) .. "'''\n" ..
    table.concat(out, "\n")
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 9) REGIONAL CUP
-- 11) MATCH REPORTS + DERBY TAG
------------------------------------------------------------------------
------------------------------------------------------------------------
local RC_META={
local function _fmtTime(seconds)
    ['Morovian']={fullName='Morovian Select',logo='[[File:MorovianSelect.png|25px]]'},
  local m = math.floor(seconds / 60)
    ['Southern Strait']={fullName='Southern Strait Select',logo='[[File:SouthernStraitSelect.png|25px]]'},
  local s = seconds % 60
     ['Western Highlands']={fullName='Western Highlands Select',logo='[[File:WesternHighlandsSelect.png|25px]]'},
  return string.format("%02d:%02d", m, s)
    ['Normarkian']={fullName='Normarkian Select',logo='[[File:NormarkianSelect.png|25px]]'},
end
}
 
local function _abbr(teamName)
  local letters = {}
  for w in mw.ustring.gmatch(teamName or "", "%S+") do
     letters[#letters+1] = mw.ustring.upper(mw.ustring.sub(w, 1, 1))
  end
  local a = table.concat(letters, "")
  if #a >= 2 then return mw.ustring.sub(a, 1, 2) end
  return (a ~= "" and a) or "XX"
end


local function _rcRating(r)
local function _randTime2(st, startSec, endSec)
    local apps=tonumber(r.apps) or 0; local denom=math.max(1,apps)
  local r; st, r = _rngNext(st)
    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 span = math.max(1, endSec - startSec - 2)
    local rel=math.min(1,denom/6); local raw
  local sec = startSec + 1 + math.floor(r * span)
    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
  return st, sec
    local rating=6+(raw-6)*rel; if rating<0 then rating=0 elseif rating>9.7 then rating=9.7 end; return rating
end
end


local function _regionalTeams(season)
local function _genTimes2(st, n, firstStart, firstEnd, secondStart, secondEnd)
    local totals=_computePlayerSeasonTotals(season,TOTAL_ROUNDS)
  local times = {}
    local byDiv={}; for _,d in ipairs(DIV_ORDER) do byDiv[d]={Keeper={},Defender={},Playmaker={},Striker={},Utility={}} end
  if n <= 0 then return st, times 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
  local split = math.ceil(n / 2)
    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
  for i=1, n do
    local need={Keeper=1,Defender=4,Playmaker=3,Striker=3,Utility=1}; local out={}
    if i <= split then
    for _,d in ipairs(DIV_ORDER) do
      st, times[i] = _randTime2(st, firstStart, firstEnd)
        local meta=RC_META[d]; local sq={}
    else
        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
      st, times[i] = _randTime2(st, secondStart, secondEnd)
        out[#out+1]={code=meta.fullName,fullName=meta.fullName,logo=meta.logo,division=d,squad=sq,championships=0}
     end
     end
    return out
  end
  table.sort(times)
  return st, times
end
end


local function _regionalCupStartAbs(season) return absDay(season+1,PO_FIRST)+PO_IV*4+RC_GAP_AFTER_FINAL end
local function _timesList2(times, maxn)
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
  if not times or #times == 0 then return "" end
local function _rcPlayScore(H,A,salt)
  maxn = maxn or 3
    math.randomseed(salt); local sh,sa=_rcStrength(H),_rcStrength(A); local p0=math.random(); local hg,ag
  local out = {}
    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
  for i = 1, math.min(maxn, #times) do out[#out+1] = _fmtTime(times[i]) end
    return hg,ag
  if #times > maxn then out[#out+1] = "…" end
  return " (" .. table.concat(out, ", ") .. ")"
end
end
local function _rcFixtures(T) return {
    {round='Round 1',games={{T[1],T[2]},{T[3],T[4]}}},
    {round='Round 2',games={{T[1],T[3]},{T[2],T[4]}}},
    {round='Round 3',games={{T[1],T[4]},{T[2],T[3]}}},
} 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 function _rivalryTag(homeCode, awayCode)
    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 dh, da = DIV_OF[homeCode], DIV_OF[awayCode]
    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
  if dh and da and dh == da then
    return table.concat(out,'\n')
    return " (Divisional derby — " .. dh .. ")"
  end
  return ""
end
end


function p.renderRegionalCupSchedule(frame)
local function _appendToBoldTitle(title, suffix)
    local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local T=_regionalTeams(season); local fixtures=_rcFixtures(T); local start=_regionalCupStartAbs(season)
  if not suffix or suffix == "" then return title end
    local out={'{| class="wikitable sortable"','! Stage !! Date !! Home (kit) !! Score !! Away (kit)'}
  if not title then return suffix end
    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
  if mw.ustring.match(title, "'''%s*$") then
    local finalAbs=start+RC_ROUND_GAP*2+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 mw.ustring.gsub(title, "'''%s*$", suffix .. "'''", 1)
    return table.concat(out,'\n')
  end
  return title .. suffix
end
end


function p.renderRegionalCupResults(frame)
local function _pickMatchIndexForDay(season, md, pickMode)
    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={}
  pickMode = pickMode or "best"
    out[#out+1]='{| class="wikitable sortable"';out[#out+1]='! Stage !! Date !! Home !! Score !! Away'
  local fixtures = getRoundMatches(md)
    for i,blk in ipairs(fixtures) do
  if not fixtures or #fixtures == 0 then return nil, "No fixtures found for this matchday." end
        local a=start+(i-1)*RC_ROUND_GAP
 
        for gi,g in ipairs(blk.games) do
  local inv = invertDayMap(mapDays())
            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 day = inv[md] or (START + md)
            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
  local bestI, bestScore = 1, -1e18
  for i, m in ipairs(fixtures) do
    local home, away = m[1], m[2]
    local salt = day * 1000 + i + string.byte(home.code,1) + string.byte(away.code,1)
    local hs, as = simScoreFor(home, away, salt)
    local S = computeMatchStats(home, away, hs, as, salt)
    local H = S[home.code] or {}
    local A = S[away.code] or {}
 
    local diff  = math.abs(hs - as)
    local total = hs + as
    local chaos = (tonumber(H.fouls) or 0) + (tonumber(A.fouls) or 0)
                + (tonumber(H.shots_on) or 0) + (tonumber(A.shots_on) or 0)
                + (tonumber(H.shots_off) or 0) + (tonumber(A.shots_off) or 0)
 
    local q
    if pickMode == "close" then
      q = (50 - diff * 10) + total + chaos * 0.25
    elseif pickMode == "high" then
      q = total * 12 - diff * 2 + chaos * 0.20
    elseif pickMode == "chaos" then
      q = chaos * 10 + total * 2 - diff
    else
      q = (20 - diff * 6) + total * 8 + chaos * 0.35
     end
     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]='|}'
     if q > bestScore then bestScore, bestI = q, i end
    local finalAbs=start+RC_ROUND_GAP*2+RC_FINAL_GAP;out[#out+1]='';out[#out+1]='=== Finals Day — '.._dateLabelFromAbs(finalAbs)..' ==='
  end
     local A1,A2,A3,A4=L[1].team,L[2].team,L[3].team,L[4].team
  return bestI
    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
end


------------------------------------------------------------------------
local function _pickPlayer(st, roster, posA, posB, posC)
-- 10) MATCH REPORTS / WEEKLY FEATURES
  local candidates = {}
------------------------------------------------------------------------
  for _,p in ipairs(roster or {}) do
local function _pickMatchIndexForDay(season,md,pick)
    if p.pos == posA or p.pos == posB or p.pos == posC then
    local day=invertDayMap(mapDays())[md]; if not day then return nil,'Unable to resolve matchday.' end
      candidates[#candidates+1] = p
    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
     end
    return bestI
  end
  if #candidates == 0 then candidates = roster or {} end
  local pl; st, pl = _pick(st, candidates)  -- uses the PRNG helper already defined in roster section
  return st, pl
end
end


local function _renderMatchReport(season,md,matchIndex,titleOverride)
 
    local day=invertDayMap(mapDays())[md]; if not day then return "'''Unable to resolve that matchday.'''" end
local function _renderMatchReport(season, md, matchIndex, titleOverride)
    local R=simulateDayResults(day,season);local r=R[matchIndex];if not r then return "'''Match not found.'''" end
  local fixtures = getRoundMatches(md)
     return (titleOverride or string.format("'''Match Report — %d PSSC (Matchday %d)'''",season,md))..'\n'.._statsTable(r)
  if not fixtures or not fixtures[matchIndex] then
    return "'''No such match (md=" .. tostring(md) .. ", match=" .. tostring(matchIndex) .. ").'''"
  end
 
  local inv = invertDayMap(mapDays())
  local day = inv[md] or (START + md)
 
  local home, away = fixtures[matchIndex][1], fixtures[matchIndex][2]
  local derby = _rivalryTag(home.code, away.code)
 
  local salt = day * 1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
  local hs, as = simScoreFor(home, away, salt)
 
  local S = computeMatchStats(home, away, hs, as, salt)
  local H = S[home.code] or {}
  local A = S[away.code] or {}
 
  local deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local rosters = _applyTransfersUpTo(base, deals, md)
  local homeRoster = rosters[home.code] or {}
  local awayRoster = rosters[away.code] or {}
 
  local st = _seedFromString(tostring(season).."|REPORT|MD"..md.."|M"..matchIndex.."|"..home.code.."|"..away.code)
  local FIRST_START, FIRST_END  = 0, 35*60
  local SECOND_START, SECOND_END = 35*60, 70*60
 
  local homeTag, awayTag = _abbr(home.code), _abbr(away.code)
 
  local homePP, awayPP, homeG, awayG
  st, homePP = _genTimes2(st, tonumber(H.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayPP = _genTimes2(st, tonumber(A.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, homeG  = _genTimes2(st, tonumber(H.goals) or 0,      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayG  = _genTimes2(st, tonumber(A.goals) or 0,      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
 
  local homeSON, awaySON, homeFOUL, awayFOUL, homeOOB, awayOOB
  st, homeSON  = _genTimes2(st, tonumber(H.shots_on) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awaySON  = _genTimes2(st, tonumber(A.shots_on) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, homeFOUL = _genTimes2(st, tonumber(H.fouls) or 0,    FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayFOUL = _genTimes2(st, tonumber(A.fouls) or 0,    FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, homeOOB  = _genTimes2(st, tonumber(H.oob) or 0,      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayOOB  = _genTimes2(st, tonumber(A.oob) or 0,      FIRST_START, FIRST_END, SECOND_START, SECOND_END)
 
  -- scoring timeline (+3 goal, +1 possession)
  local scoreEvents = {}
  local function queueScore(sec, which, kind) scoreEvents[#scoreEvents+1] = { t=sec, which=which, kind=kind } end
  for _,sec in ipairs(homePP) do queueScore(sec, "H", "PP") end
  for _,sec in ipairs(awayPP) do queueScore(sec, "A", "PP") end
  for _,sec in ipairs(homeG)  do queueScore(sec, "H", "G")  end
  for _,sec in ipairs(awayG)  do queueScore(sec, "A", "G")  end
  table.sort(scoreEvents, function(a,b) return a.t < b.t end)
 
  local events = {}
  local function addEvent(sec, text) events[#events+1] = { t=sec, text=text } end
 
  local hRun, aRun = 0, 0
  local hHalf, aHalf = 0, 0
 
  for _,e in ipairs(scoreEvents) do
    local add = (e.kind == "G") and 3 or 1
    if e.which == "H" then hRun = hRun + add else aRun = aRun + add end
    if e.t < 35*60 then hHalf, aHalf = hRun, aRun end
 
    local tag    = (e.which=="H") and homeTag or awayTag
    local roster = (e.which=="H") and homeRoster or awayRoster
    local scoreLine = string.format("%s %d–%d %s", _teamWithLogo(home), hRun, aRun, _teamWithLogo(away))
 
    if e.kind == "G" then
      local pz; st, pz = _pickPlayer(st, roster, "Striker", "Utility", "Playmaker")
      addEvent(e.t, string.format("%s – Goal (Pillar Strike) (%s): %s converts a direct pillar strike. (%s)",
        _fmtTime(e.t), tag, (pz and pz.name or "a forward"), scoreLine))
    else
      local pp; st, pp = _pickPlayer(st, roster, "Playmaker", "Utility", "Striker")
      addEvent(e.t, string.format("%s – Possession Play (%s): %s completes a 10-second controlled hold in the scoring zone. (%s)",
        _fmtTime(e.t), tag, (pp and pp.name or "a midfielder"), scoreLine))
    end
  end
 
  local function addHighlights(times, roster, tag, label, posA, posB, posC, template)
    local cap = math.min(2, #times)
    for i=1, cap do
      local sec = times[i]
      local px; st, px = _pickPlayer(st, roster, posA, posB, posC)
      addEvent(sec, string.format("%s – %s (%s): " .. template,
        _fmtTime(sec), label, tag, (px and px.name or "a player")))
    end
  end
 
  addHighlights(homeFOUL, homeRoster, homeTag, "Foul", "Defender", "Utility", "Playmaker",
    "%s clips a runner during a shielded carry; restart taken quickly.")
  addHighlights(awayFOUL, awayRoster, awayTag, "Foul", "Defender", "Utility", "Playmaker",
    "%s commits a tactical pull in transition; shape preserved on the restart.")
  addHighlights(homeSON, homeRoster, homeTag, "Shot on Target", "Striker", "Utility", "Playmaker",
     "%s forces a controlled save off a pillar-side angle; no strike awarded.")
  addHighlights(awaySON, awayRoster, awayTag, "Shot on Target", "Striker", "Utility", "Playmaker",
    "%s drives a low effort that glances the casing; play continues.")
  addHighlights(homeOOB, homeRoster, homeTag, "Out of Bounds", "Utility", "Defender", "Playmaker",
    "%s overhits a diagonal switch; possession turns over at the boundary.")
  addHighlights(awayOOB, awayRoster, awayTag, "Out of Bounds", "Utility", "Defender", "Playmaker",
    "%s sends a pressured clearance long; reset follows.")
 
  table.sort(events, function(a,b) return a.t < b.t end)
 
  local firstHalf, secondHalf = {}, {}
  for _,e in ipairs(events) do
    if e.t < 35*60 then firstHalf[#firstHalf+1] = e.text else secondHalf[#secondHalf+1] = e.text end
  end
 
  local diff = math.abs(hs - as)
  local mood = (diff <= 1) and "a tight, tactical contest" or "a high-variance clash"
  local summary = string.format(
    "Matchday %d featured %s defined by controlled zone entries and disciplined restarts. %s edged %s %d–%d, with decisive moments coming from timed possession holds and selective pillar pressure rather than sustained long-range attempts.",
    md, mood, _teamWithLogo(home), _teamWithLogo(away), hs, as
  )
 
  local baseTitle = titleOverride or string.format("'''Match of the Week — %d PSSC (Matchday %d)'''", season, md)
  local title = _appendToBoldTitle(baseTitle, derby)
 
  local out = {}
  out[#out+1] = title
  out[#out+1] = ""
  out[#out+1] = "=== Match Summary ==="
  out[#out+1] = summary
  out[#out+1] = ""
  out[#out+1] = "=== Key Events by Time ==="
  out[#out+1] = ""
  out[#out+1] = "==== First Half (0:00–35:00) ===="
  if #firstHalf == 0 then out[#out+1] = "—" else for _,ln in ipairs(firstHalf) do out[#out+1] = "* " .. ln end end
  out[#out+1] = ""
  out[#out+1] = string.format("35:00 – Halftime: %s %d–%d %s.", _teamWithLogo(home), hHalf, aHalf, _teamWithLogo(away))
  out[#out+1] = ""
  out[#out+1] = "==== Second Half (35:00–70:00) ===="
  if #secondHalf == 0 then out[#out+1] = "—" else for _,ln in ipairs(secondHalf) do out[#out+1] = "* " .. ln end end
  out[#out+1] = ""
  out[#out+1] = string.format("70:00 – Final Whistle: %s %d–%d %s.", _teamWithLogo(home), hs, as, _teamWithLogo(away))
  out[#out+1] = ""
  out[#out+1] = "=== Match Stats ==="
  out[#out+1] = '{| class="wikitable" style="width:100%; font-size:90%;"'
  out[#out+1] = string.format('! Statistic !! %s !! %s', _teamWithLogo(home), _teamWithLogo(away))
  out[#out+1] = '|-'; out[#out+1] = string.format('| Final Score || %d || %d', hs, as)
  out[#out+1] = '|-'
  out[#out+1] = string.format('| Goals (Pillar Strikes) || %d%s || %d%s',
    tonumber(H.goals) or 0, _timesList2(homeG, 3),
    tonumber(A.goals) or 0, _timesList2(awayG, 3)
  )
  out[#out+1] = '|-'
  out[#out+1] = string.format('| Possession Points || %d%s || %d%s',
    tonumber(H.poss_points) or 0, _timesList2(homePP, 4),
    tonumber(A.poss_points) or 0, _timesList2(awayPP, 4)
  )
  out[#out+1] = '|-'; out[#out+1] = string.format('| Shots on Target || %d || %d', tonumber(H.shots_on) or 0, tonumber(A.shots_on) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Shots off Target || %d || %d', tonumber(H.shots_off) or 0, tonumber(A.shots_off) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Fouls || %d || %d', tonumber(H.fouls) or 0, tonumber(A.fouls) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Free Kicks Awarded || %d || %d', tonumber(H.fks) or 0, tonumber(A.fks) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Out of Bounds || %d || %d', tonumber(H.oob) or 0, tonumber(A.oob) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Throw-Ins || %d || %d', tonumber(H.throws) or 0, tonumber(A.throws) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Goal Kicks || %d || %d', tonumber(H.gk) or 0, tonumber(A.gk) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Ball Possession || %d%% || %d%%', tonumber(H.poss_pct) or 0, tonumber(A.poss_pct) or 0)
  out[#out+1] = '|}'
  return table.concat(out, "\n")
end
end


function p.renderMatchOfWeek(frame)
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'
  local args = frame.args or {}
    if md<1 then return "'''No matchday has been played yet.'''" 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 season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
  local pick = args.pick or "best"
 
  if md < 1 then return "'''No matchday has been played yet.'''"
  end
  local fixtures = getRoundMatches(md)
  if not fixtures or #fixtures == 0 then return "'''No fixtures found for matchday " .. tostring(md) .. ".'''" end
 
  local idx, err = _pickMatchIndexForDay(season, md, pick)
  if not idx then return "'''" .. (err or "Unable to select match.") .. "'''" end
  return _renderMatchReport(season, md, idx)
end
 
function p.renderChaosOfWeek(frame)
  local args = frame.args or {}
  args.pick = args.pick or "chaos"
  args.md = nil
  return p.renderMatchOfWeek({ args = args })
end
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;return _renderMatchReport(season,md,mi,string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''",season,md,mi)) end


local function _playerRating(r)
function p.renderMatchReport(frame)
    if not r then return 0 end
  local args = frame.args or {}
    local apps=math.max(1,r.apps or 0)
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
    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 md = tonumber(args.md or args.matchday or args[2] or "") or _currentPlayedMatchday()
    if x<0 then x=0 elseif x>10 then x=10 end;return x
  local matchIndex = tonumber(args.match or args[3] or "1") or 1
  local titleOverride = string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''", season, md, matchIndex)
  return _renderMatchReport(season, md, matchIndex, titleOverride)
end
end


------------------------------------------------------------------------
-- 12) WEEKLY AWARDS + MATCHDAY CAPSULE
------------------------------------------------------------------------
function p.renderWeeklyAwards(frame)
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 md<1 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 list={}
  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={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
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
    table.sort(list,function(a,b) if a.rating~=b.rating then return a.rating>b.rating end;return a.name<b.name end)
  if md < 1 then return "'''No matchday has been played yet.'''" 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')
  local inv = invertDayMap(mapDays())
  local day = inv[md] or (START + md)
 
  local deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local rosters = _applyTransfersUpTo(base, deals, md)
 
  local function clamp(x, lo, hi)
    if x < lo then return lo end
    if x > hi then return hi end
    return x
  end
 
  local function scorePlayer(line, pos, oppShotsOn, oppGoals)
    local goals  = line.goals or 0
    local assists = line.assists or 0
    local poss    = line.poss or 0
    local son    = line.son or 0
    local sof    = line.sof or 0
    local fouls  = line.fouls or 0
 
    local rating = 6
      + goals*1.25
      + assists*0.85
      + poss*0.05
      + son*0.15
      - sof*0.08
      - fouls*0.16
 
    if pos == "Keeper" then
      local saves = math.max(0, (oppShotsOn or 0) - (oppGoals or 0))
      rating = 6 + saves*0.25 - fouls*0.10
      if (oppGoals or 0) == 0 then rating = rating + 1.0 end
    end
 
    return clamp(rating, 0, 10)
  end
 
  local best = { overall=nil, striker=nil, playmaker=nil, defender=nil, keeper=nil }
 
  local function consider(entry, bucket)
     if not best[bucket] or entry.rating > best[bucket].rating then best[bucket] = entry end
  end
 
  local fixtures = getRoundMatches(md)
  for matchIndex, m in ipairs(fixtures) do
    local home, away = m[1], m[2]
    local hr = rosters[home.code] or {}
    local ar = rosters[away.code] or {}
 
    local salt = day*1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
    local hs, as = simScoreFor(home, away, salt)
    local S = computeMatchStats(home, away, hs, as, salt)
    local H = S[home.code] or {}
    local A = S[away.code] or {}
 
    -- allocate simple per-player contributions deterministically for awards
    local st = _seedFromString(tostring(season).."|AWARDS|MD"..md.."|D"..day.."|M"..matchIndex.."|"..home.code.."|"..away.code)
    local linesH, linesA = {}, {}
 
    local function ensure(map, pid)
      if not map[pid] then map[pid] = {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0} end
      return map[pid]
    end
 
    local function pickWeighted(stt, roster, weights)
      local pool = {}
      for _,p in ipairs(roster or {}) do
        local w = weights[p.pos] or 1
        for _=1,w do pool[#pool+1] = p end
      end
      return _pick(stt, pool)
    end
 
    local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
    local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
    local W_SHOT = { Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
    local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
 
    for _=1, tonumber(H.goals) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_GOAL)
      ensure(linesH, pl.id).goals = ensure(linesH, pl.id).goals + 1
      local r; st, r = _rngNext(st)
      if r < 0.70 then
        local a; st, a = pickWeighted(st, hr, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
        if a and a.id ~= pl.id then ensure(linesH, a.id).assists = ensure(linesH, a.id).assists + 1 end
      end
    end
    for _=1, tonumber(A.goals) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_GOAL)
      ensure(linesA, pl.id).goals = ensure(linesA, pl.id).goals + 1
      local r; st, r = _rngNext(st)
      if r < 0.70 then
        local a; st, a = pickWeighted(st, ar, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
        if a and a.id ~= pl.id then ensure(linesA, a.id).assists = ensure(linesA, a.id).assists + 1 end
      end
    end
 
    for _=1, tonumber(H.poss_points) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_POSS)
      ensure(linesH, pl.id).poss = ensure(linesH, pl.id).poss + 1
    end
    for _=1, tonumber(A.poss_points) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_POSS)
      ensure(linesA, pl.id).poss = ensure(linesA, pl.id).poss + 1
    end
 
    for _=1, tonumber(H.shots_on) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_SHOT)
      ensure(linesH, pl.id).son = ensure(linesH, pl.id).son + 1
    end
    for _=1, tonumber(H.shots_off) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_SHOT)
      ensure(linesH, pl.id).sof = ensure(linesH, pl.id).sof + 1
    end
    for _=1, tonumber(A.shots_on) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_SHOT)
      ensure(linesA, pl.id).son = ensure(linesA, pl.id).son + 1
    end
    for _=1, tonumber(A.shots_off) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_SHOT)
      ensure(linesA, pl.id).sof = ensure(linesA, pl.id).sof + 1
    end
 
    for _=1, tonumber(H.fouls) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_FOUL)
      ensure(linesH, pl.id).fouls = ensure(linesH, pl.id).fouls + 1
    end
    for _=1, tonumber(A.fouls) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_FOUL)
      ensure(linesA, pl.id).fouls = ensure(linesA, pl.id).fouls + 1
    end
 
    local function evalTeam(teamObj, roster, lines, oppStats)
      local oppShotsOn = tonumber(oppStats.shots_on) or 0
      local oppGoals  = tonumber(oppStats.goals) or 0
      for _,pp in ipairs(roster or {}) do
        local line = lines[pp.id] or {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0}
        local rating = scorePlayer(line, pp.pos, oppShotsOn, oppGoals)
 
        local saves, clean = 0, false
        if pp.pos == "Keeper" then
          saves = math.max(0, oppShotsOn - oppGoals)
          clean = (oppGoals == 0)
        end
 
        local entry = {
          rating=rating, name=pp.name or pp.id, pos=pp.pos or "—",
          team=teamObj.code, goals=line.goals or 0, assists=line.assists or 0,
          poss=line.poss or 0, son=line.son or 0, sof=line.sof or 0, fouls=line.fouls or 0,
          saves=saves, clean=clean
        }
 
        consider(entry, "overall")
        if pp.pos == "Striker"  then consider(entry, "striker") end
        if pp.pos == "Playmaker" then consider(entry, "playmaker") end
        if pp.pos == "Defender"  then consider(entry, "defender") end
        if pp.pos == "Keeper"    then consider(entry, "keeper") end
      end
    end
 
    evalTeam(home, hr, linesH, A)
     evalTeam(away, ar, linesA, H)
  end
 
  local out = {}
  out[#out+1] = "=== Weekly Awards (Matchday " .. tostring(md) .. ") ==="
  out[#out+1] = '{| class="wikitable sortable" style="width:100%; font-size:90%;"'
  out[#out+1] = "! Award !! Winner !! Team !! Pos !! G !! A !! Poss !! S-on !! S-off !! Fouls !! Saves/CS !! Rating"
 
  local function row(label, e)
    if not e then return end
     local sc = "—"
    if e.pos == "Keeper" then sc = tostring(e.saves or 0) .. ((e.clean and " / CS") or "") end
    out[#out+1] = "|-"
    out[#out+1] = string.format("| %s || %s || %s || %s || %d || %d || %d || %d || %d || %d || %s || %.2f",
      label, e.name or "—", _teamWithLogo(e.team or "—"), e.pos or "—",
      e.goals or 0, e.assists or 0, e.poss or 0, e.son or 0, e.sof or 0, e.fouls or 0, sc, e.rating or 0
    )
  end
 
  row("Player of the Week", best.overall)
  row("Striker of the Week", best.striker)
  row("Playmaker of the Week", best.playmaker)
  row("Defender of the Week", best.defender)
  row("Keeper of the Week", best.keeper)
 
  out[#out+1] = "|}"
  return table.concat(out, "\n")
end
end


------------------------------------------------------------------------
-- TEAM OF THE WEEK (Best XI) — per matchday
------------------------------------------------------------------------
function p.renderTeamOfWeek(frame)
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 md<1 then return "'''No matchday has been played yet.'''" end
  local args = (frame and 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 md = tonumber(args.md or args.matchday or args[2] 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 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'}
  if md < 1 then
    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')
    return "'''No matchday has been played yet.'''"
  end
 
  local inv = invertDayMap(mapDays())
  local day = inv[md]
  if not day then
     return "'''Unable to resolve PSSC day for matchday " .. tostring(md) .. ".'''"
  end
 
  local curr = date.getCurrentDate()
  local today = tonumber(curr:match("^(%d+),")) or 0
  local year  = tonumber(curr:match(", (%d+) PSSC")) or 0
 
  if day > today then
    local mon, dom = getMonthDay(day)
    return string.format(
      "'''Team of the Week will be available after Matchday %d is played (%d %s %d PSSC).'''",
      md, dom, mon, year
    )
  end
 
  -- Rating logic (matches Weekly Awards)
  local function clamp(x, lo, hi)
    if x < lo then return lo end
    if x > hi then return hi end
    return x
  end
 
  local function scorePlayer(line, pos, oppShotsOn, oppGoals)
    line = line or {}
    local goals  = line.goals or 0
    local assists = line.assists or 0
    local poss    = line.poss or 0
    local son    = line.son or 0
    local sof    = line.sof or 0
    local fouls  = line.fouls or 0
 
    local rating = 6
      + goals*1.25
      + assists*0.85
      + poss*0.05
      + son*0.15
      - sof*0.08
      - fouls*0.16
 
    if pos == "Keeper" then
      local saves = math.max(0, (oppShotsOn or 0) - (oppGoals or 0))
      rating = 6 + saves*0.25 - fouls*0.10
      if (oppGoals or 0) == 0 then rating = rating + 1.0 end
    end
 
    return clamp(rating, 0, 10)
  end
 
  -- Rosters as of this matchday (transfers applied)
  local deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local rosters = _applyTransfersUpTo(base, deals, md)
 
  local fixtures = getRoundMatches(md)
  if not fixtures or #fixtures == 0 then
    return "'''No fixtures found for matchday " .. tostring(md) .. ".'''"
  end
 
  -- Collect best-per-position candidates across all matches this matchday
  local candidates = {} -- pid -> entry
 
  local function ensure(map, pid)
    if not map[pid] then map[pid] = {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0} end
    return map[pid]
  end
 
  local function pickWeighted(stt, roster, weights)
    local pool = {}
    for _,ppl in ipairs(roster or {}) do
      local w = weights[ppl.pos] or 1
      for _=1,w do pool[#pool+1] = ppl end
    end
    return _pick(stt, pool)
  end
 
  local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
  local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
  local W_SHOT = { Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
  local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
 
  for matchIndex, m in ipairs(fixtures) do
    local home, away = m[1], m[2]
    local hr = rosters[home.code] or {}
    local ar = rosters[away.code] or {}
 
    local salt = day*1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
    local hs, as = simScoreFor(home, away, salt)
    local S = computeMatchStats(home, away, hs, as, salt)
    local Hs = S[home.code] or {}
    local As = S[away.code] or {}
 
    local st = _seedFromString(tostring(season).."|TOTW|MD"..md.."|D"..day.."|M"..matchIndex.."|"..home.code.."|"..away.code)
    local linesH, linesA = {}, {}
 
    -- Allocate events (same style as Weekly Awards)
    for _=1, tonumber(Hs.goals) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_GOAL)
      ensure(linesH, pl.id).goals = ensure(linesH, pl.id).goals + 1
      local r; st, r = _rngNext(st)
      if r < 0.70 then
        local aPl; st, aPl = pickWeighted(st, hr, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
        if aPl and aPl.id ~= pl.id then ensure(linesH, aPl.id).assists = ensure(linesH, aPl.id).assists + 1 end
      end
    end
    for _=1, tonumber(As.goals) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_GOAL)
      ensure(linesA, pl.id).goals = ensure(linesA, pl.id).goals + 1
      local r; st, r = _rngNext(st)
      if r < 0.70 then
        local aPl; st, aPl = pickWeighted(st, ar, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
        if aPl and aPl.id ~= pl.id then ensure(linesA, aPl.id).assists = ensure(linesA, aPl.id).assists + 1 end
      end
    end
 
    for _=1, tonumber(Hs.poss_points) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_POSS)
      ensure(linesH, pl.id).poss = ensure(linesH, pl.id).poss + 1
    end
    for _=1, tonumber(As.poss_points) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_POSS)
      ensure(linesA, pl.id).poss = ensure(linesA, pl.id).poss + 1
    end
 
    for _=1, tonumber(Hs.shots_on) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_SHOT)
      ensure(linesH, pl.id).son = ensure(linesH, pl.id).son + 1
    end
    for _=1, tonumber(Hs.shots_off) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_SHOT)
      ensure(linesH, pl.id).sof = ensure(linesH, pl.id).sof + 1
    end
    for _=1, tonumber(As.shots_on) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_SHOT)
      ensure(linesA, pl.id).son = ensure(linesA, pl.id).son + 1
    end
    for _=1, tonumber(As.shots_off) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_SHOT)
      ensure(linesA, pl.id).sof = ensure(linesA, pl.id).sof + 1
    end
 
    for _=1, tonumber(Hs.fouls) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_FOUL)
      ensure(linesH, pl.id).fouls = ensure(linesH, pl.id).fouls + 1
    end
    for _=1, tonumber(As.fouls) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_FOUL)
      ensure(linesA, pl.id).fouls = ensure(linesA, pl.id).fouls + 1
    end
 
    local function considerTeam(teamObj, roster, lines, oppStats)
      local oppShotsOn = tonumber(oppStats.shots_on) or 0
      local oppGoals  = tonumber(oppStats.goals) or 0
 
      for _, pp in ipairs(roster or {}) do
        local pid = pp.id
        local line = lines[pid] or {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0}
        local rating = scorePlayer(line, pp.pos, oppShotsOn, oppGoals)
 
        local entry = candidates[pid]
        if not entry then
          entry = {
            id=pid, name=pp.name or pid, pos=pp.pos or "—",
            team=teamObj.code, rating=rating,
            goals=line.goals or 0, assists=line.assists or 0, poss=line.poss or 0,
            son=line.son or 0, sof=line.sof or 0, fouls=line.fouls or 0,
            saves=0, clean=false
          }
          candidates[pid] = entry
        else
          -- In practice a player appears once per matchday; keep the better rating if somehow duplicated.
          if rating > (entry.rating or 0) then entry.rating = rating end
        end
 
        if pp.pos == "Keeper" then
          entry.saves = math.max(0, oppShotsOn - oppGoals)
          entry.clean = (oppGoals == 0)
        end
      end
    end
 
     considerTeam(home, hr, linesH, As)
    considerTeam(away, ar, linesA, Hs)
  end
 
  local function sortByRating(list)
    table.sort(list, function(a,b)
      if a.rating ~= b.rating then return a.rating > b.rating end
      if a.goals  ~= b.goals  then return a.goals  > b.goals  end
      if a.assists~= b.assists then return a.assists> b.assists end
      if a.poss  ~= b.poss  then return a.poss  > b.poss  end
      if a.son    ~= b.son    then return a.son    > b.son    end
      return (a.name or "") < (b.name or "")
    end)
  end
 
  local buckets = { Keeper={}, Defender={}, Playmaker={}, Striker={}, Utility={} }
  for _, r in pairs(candidates) do
    local pos = r.pos or "—"
    if buckets[pos] then
      table.insert(buckets[pos], r)
    end
  end
 
  for _, list in pairs(buckets) do sortByRating(list) end
 
  local XI = {}
  local function take(role, n)
    for i=1, math.min(n, #(buckets[role] or {})) do
      XI[#XI+1] = { role=role, r=buckets[role][i] }
    end
  end
 
  take("Keeper", 1)
  take("Defender", 3)
  take("Playmaker", 3)
  take("Striker", 2)
  take("Utility", 3)
 
  local mon, dom = getMonthDay(day)
  local dateLabel = dom .. " " .. mon .. " " .. year .. " PSSC"
 
  local out = {}
  out[#out+1] = "=== Team of the Week (Matchday " .. tostring(md) .. ") ==="
  out[#out+1] = "''" .. dateLabel .. "''"
  out[#out+1] = '{| class="wikitable sortable" style="width:100%; font-size:90%;"'
  out[#out+1] = "! Role !! Player !! Team !! Rating !! Key stats"
 
  for _, e in ipairs(XI) do
    local r = e.r
    local stats = string.format("G:%d A:%d Poss:%d S-on:%d Fouls:%d", r.goals, r.assists, r.poss, r.son, r.fouls)
    if r.pos == "Keeper" then
      stats = stats .. string.format("; Saves:%d%s", r.saves or 0, (r.clean and "; CS" or ""))
    end
 
    out[#out+1] = "|-"
    out[#out+1] = string.format(
      "| %s || '''%s'''<br><code>%s</code> || %s || %.2f || %s",
      e.role,
      r.name or "—", r.id or "—",
      _teamWithLogo(r.team or "—"),
      tonumber(r.rating) or 0,
      stats
    )
  end
 
  out[#out+1] = "|}"
  return table.concat(out, "\n")
end
end


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


function p.renderMatchdayDensity(frame)
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)
  local totalDays = END - START + 1
  local totalRnds = #SCHED_LOCKED
  local iv = math.floor(totalDays / totalRnds)
  local ex = totalDays % totalRnds
 
  local dm = mapDays()
  local cnt = 0
  for d=START, END do
    if dm[d] then cnt = cnt + 1 end
  end
 
  return string.format(
    "Locked rounds: %d; season days: %d; iv=%d; ex=%d; matchdays placed=%d",
    totalRnds, totalDays, iv, ex, cnt
  )
end
end


function p.renderNextMatchdays(frame)
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
  local dm = mapDays()
    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')
  local curr = date.getCurrentDate()
  local today = tonumber(curr:match("^(%d+),")) or 0
  local year  = tonumber(curr:match(", (%d+) PSSC")) or 0
 
  local out = {
    '{| class="wikitable"',
    '! # !! Matchday !! Calendar Day !! Gap (days) !! Date'
  }
 
  local shown = 0
  local prevDay = nil
 
  for d = today, END do
    local md = dm[d]
    if md then
      local mon, dom = getMonthDay(d)
      local gap = prevDay and (d - prevDay) or "—"
      prevDay = d
 
      out[#out+1] = '|-'
      out[#out+1] =
        '| ' .. (shown + 1) ..
        ' || ' .. md ..
        ' || ' .. d ..
        ' || ' .. gap ..
        ' || ' .. dom .. ' ' .. mon .. ' ' .. year .. ' PSSC'
 
      shown = shown + 1
      if shown >= 20 then break end
    end
  end
 
  out[#out+1] = '|}'
  return table.concat(out, "\n")
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 11) SEASON AWARDS
-- 13) END-OF-SEASON AWARDS (regular season)
------------------------------------------------------------------------
------------------------------------------------------------------------
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 _clamp(x, lo, hi)
     local function best(stat,filter,ascending)
  if x < lo then return lo end
        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]
  if x > hi then return hi end
  return x
end
 
-- Same per-match rating logic as Weekly Awards (aggregated across season)
local function _scorePlayer(line, pos, oppShotsOn, oppGoals)
  line = line or {}
  local goals  = tonumber(line.goals) or 0
  local assists = tonumber(line.assists) or 0
  local poss    = tonumber(line.poss) or 0
  local son    = tonumber(line.son) or 0
  local sof    = tonumber(line.sof) or 0
  local fouls  = tonumber(line.fouls) or 0
 
  local rating = 6
    + goals*1.25
    + assists*0.85
    + poss*0.05
    + son*0.15
    - sof*0.08
    - fouls*0.16
 
  if pos == "Keeper" then
     local saves = math.max(0, (tonumber(oppShotsOn) or 0) - (tonumber(oppGoals) or 0))
    rating = 6 + saves*0.25 - fouls*0.10
    if (tonumber(oppGoals) or 0) == 0 then rating = rating + 1.0 end
  end
 
  return _clamp(rating, 0, 10)
end
 
local function _finalRegularSeasonDay()
  local inv = invertDayMap(mapDays())
  return inv[TOTAL_ROUNDS] or END
end
 
local function _isRegularSeasonComplete()
  local curr = date.getCurrentDate()
  local today = tonumber(curr:match("^(%d+),")) or 0
  return today >= _finalRegularSeasonDay()
end
 
local function _buildFinalPlayerInfo(season)
  local deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local rostersEnd = _applyTransfersUpTo(base, deals, TOTAL_ROUNDS)
 
  local info = {} -- pid -> {name,pos,team,no}
  for teamCode, roster in pairs(rostersEnd or {}) do
    for _, pl in ipairs(roster or {}) do
      if pl and pl.id then
        info[pl.id] = {
          name = pl.name or pl.id,
          pos  = pl.pos or "—",
          team = teamCode or "—",
          no  = pl.no or 0
        }
      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)}
  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 info, deals, base
     return awards,by
end
 
local function _ensureSeasonRow(T, pid, infoRow)
  if not T[pid] then
    T[pid] = {
      id = pid,
      name = (infoRow and infoRow.name) or pid,
      pos  = (infoRow and infoRow.pos)  or "—",
      team = (infoRow and infoRow.team) or "—",
      no  = (infoRow and infoRow.no)  or 0,
 
      apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0,
      ratingSum=0, ratingN=0,
      saves=0, clean=0
     }
  end
  return T[pid]
end
 
local function _computeSeasonAwardData(season)
  local dm  = mapDays()
  local inv = invertDayMap(dm)
 
  local finalInfo, deals, base = _buildFinalPlayerInfo(season)
 
  local totals = {}
 
  for md=1, TOTAL_ROUNDS do
    local day = inv[md]
    if day then
      local rostersNow = _applyTransfersUpTo(base, deals, md)
      local fixtures = getRoundMatches(md)
 
      for matchIndex, m in ipairs(fixtures or {}) do
        local home, away = m[1], m[2]
        local hr = rostersNow[home.code] or {}
        local ar = rostersNow[away.code] or {}
 
        -- Compute opponent stats (for keeper saves/clean + rating)
        local salt = day*1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
        local hg, ag = simScoreFor(home, away, salt)
        local S = computeMatchStats(home, away, hg, ag, salt)
        local Hs = S[home.code] or {}
        local As = S[away.code] or {}
 
        -- Per-player event allocation (consistent with your existing player stats logic)
        local ev = _matchPlayerEvents(season, md, day, matchIndex, home, away, hr, ar)
 
        local function processSide(roster, evSide, oppStats)
          local oppShotsOn = tonumber(oppStats.shots_on) or 0
          local oppGoals  = tonumber(oppStats.goals) or 0
 
          for _, pl in ipairs(roster or {}) do
            local pid = pl.id
            local infoRow = finalInfo[pid] or { name=pl.name or pid, pos=pl.pos, team=(pl.team or "—"), no=pl.no or 0 }
            local row = _ensureSeasonRow(totals, pid, infoRow)
 
            local line = (evSide and evSide[pid]) or {apps=0,goals=0,assists=0,poss=0,son=0,sof=0,fouls=0}
            local apps = tonumber(line.apps) or 0
 
            row.apps    = row.apps    + apps
            row.goals  = row.goals  + (tonumber(line.goals)   or 0)
            row.assists = row.assists + (tonumber(line.assists) or 0)
            row.poss    = row.poss    + (tonumber(line.poss)    or 0)
            row.son    = row.son    + (tonumber(line.son)    or 0)
            row.sof    = row.sof    + (tonumber(line.sof)    or 0)
            row.fouls  = row.fouls  + (tonumber(line.fouls)  or 0)
 
            if apps > 0 then
              local rating = _scorePlayer(line, row.pos, oppShotsOn, oppGoals)
              row.ratingSum = row.ratingSum + rating
              row.ratingN  = row.ratingN  + 1
 
              if row.pos == "Keeper" then
                local saves = math.max(0, oppShotsOn - oppGoals)
                row.saves = row.saves + saves
                if oppGoals == 0 then row.clean = row.clean + 1 end
              end
            end
          end
        end
 
        processSide(hr, ev.home, As)
        processSide(ar, ev.away, Hs)
      end
    end
  end
 
  -- Compute max apps for thresholding
  local maxApps = 0
  for _, r in pairs(totals) do
    if (r.apps or 0) > maxApps then maxApps = r.apps end
  end
  local minApps = math.max(10, math.floor(maxApps * 0.50))
 
  -- Helper: best by comparator
  local function pickBest(filterFn, scoreFn, tieFn)
    local best = nil
    for _, r in pairs(totals) do
      if not filterFn or filterFn(r) then
        local s = scoreFn(r)
        if best == nil then
          best = {r=r, s=s}
        else
          if s > best.s then
            best = {r=r, s=s}
          elseif s == best.s and tieFn then
            if tieFn(r, best.r) then best = {r=r, s=s} end
          end
        end
      end
    end
    return best and best.r or nil
  end
 
  local function pickMin(filterFn, scoreFn, tieFn)
    local best = nil
    for _, r in pairs(totals) do
      if not filterFn or filterFn(r) then
        local s = scoreFn(r)
        if best == nil then
          best = {r=r, s=s}
        else
          if s < best.s then
            best = {r=r, s=s}
          elseif s == best.s and tieFn then
            if tieFn(r, best.r) then best = {r=r, s=s} end
          end
        end
      end
    end
    return best and best.r or nil
  end
 
  local function avgRating(r)
    return (r.ratingN and r.ratingN > 0) and (r.ratingSum / r.ratingN) or 0
  end
 
  local tiebreakName = function(a,b) return (a.name or "") < (b.name or "") end
 
  local awards = {}
  awards.mvp = pickBest(
    function(r) return (r.apps or 0) >= minApps end,
    function(r) return avgRating(r) end,
    function(a,b) if a.goals ~= b.goals then return a.goals > b.goals end return tiebreakName(a,b) end
  )
 
  awards.goldenPillar = pickBest(nil, function(r) return r.goals or 0 end, tiebreakName)
  awards.playmaker    = pickBest(nil, function(r) return r.assists or 0 end, tiebreakName)
  awards.possession  = pickBest(nil, function(r) return r.poss or 0 end, tiebreakName)
  awards.sharpshooter = pickBest(nil, function(r) return r.son or 0 end, tiebreakName)
  awards.ironman      = pickBest(nil, function(r) return r.apps or 0 end, tiebreakName)
 
  awards.fairPlay = pickMin(
    function(r) return (r.apps or 0) >= minApps end,
    function(r) return r.fouls or 0 end,
    function(a,b) -- tie: higher apps wins; else name
      if (a.apps or 0) ~= (b.apps or 0) then return (a.apps or 0) > (b.apps or 0) end
      return tiebreakName(a,b)
    end
  )
 
  awards.goldenGloves = pickBest(
     function(r) return r.pos == "Keeper" and (r.apps or 0) >= minApps end,
    function(r) return avgRating(r) end,
    function(a,b) if (a.clean or 0) ~= (b.clean or 0) then return (a.clean or 0) > (b.clean or 0) end return tiebreakName(a,b) end
  )
 
  -- Team of the Season (Best XI) by avg rating within each position
  local function topN(pos, n)
    local list = {}
    for _, r in pairs(totals) do
      if r.pos == pos and (r.apps or 0) >= minApps then
        list[#list+1] = r
      end
    end
    table.sort(list, function(a,b)
      local ar, br = avgRating(a), avgRating(b)
      if ar ~= br then return ar > br end
      if (a.goals or 0) ~= (b.goals or 0) then return (a.goals or 0) > (b.goals or 0) end
      return (a.name or "") < (b.name or "")
    end)
    local out = {}
    for i=1, math.min(n, #list) do out[#out+1] = list[i] end
     return out
  end
 
  local teamXI = {
    Keeper    = topN("Keeper", 1),
    Defender  = topN("Defender", 3),
    Playmaker = topN("Playmaker", 3),
    Striker  = topN("Striker", 2),
    Utility  = topN("Utility", 2),
  }
 
  return awards, teamXI, minApps, maxApps
end
 
local function _fmtPlayerRow(r)
  if not r then return "—", "—", "—", "—" end
  local who = string.format("'''%s'''<br><code>%s</code>", r.name or "—", r.id or "—")
  local team = _teamWithLogo(r.team or "—")
  local pos = r.pos or "—"
  local gp = tostring(r.apps or 0)
  return who, team, pos, gp
end
end


function p.renderSeasonAwards(frame)
function p.renderSeasonAwards(frame)
    local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local now=_nowAbs();local finalAbs=absDay(season,_finalRegularSeasonDay())
  local args = (frame and 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 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 curr = date.getCurrentDate()
    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 today = tonumber(curr:match("^(%d+),")) or 0
    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 year  = tonumber(curr:match(", (%d+) PSSC")) or 0
    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')
 
  local finalDay = _finalRegularSeasonDay()
  if today < finalDay then
    local mon, dom = getMonthDay(finalDay)
    return string.format("'''End-of-season awards will be available after the regular season concludes (%d %s %d PSSC).'''", dom, mon, year)
  end
 
  local awards, teamXI, minApps, maxApps = _computeSeasonAwardData(season)
 
  local out = {}
  out[#out+1] = "== End-of-Season Awards =="
  out[#out+1] = string.format("''Eligibility threshold:'' at least %d appearances (max observed: %d).", minApps, maxApps)
  out[#out+1] = ""
 
  out[#out+1] = "=== Core Awards ==="
  out[#out+1] = '{| class="wikitable sortable" style="width:100%;"'
  out[#out+1] = "! Award !! Winner !! Team !! Pos !! GP !! Notes"
  local function addAwardRow(label, r, notes)
    local who, team, pos, gp = _fmtPlayerRow(r)
    out[#out+1] = "|-"
    out[#out+1] = string.format("| %s || %s || %s || %s || %s || %s", label, who, team, pos, gp, notes or "—")
  end
 
  local function avgR(r) return (r and r.ratingN and r.ratingN > 0) and (r.ratingSum / r.ratingN) or 0 end
 
  addAwardRow("MVP (Player of the Season)", awards.mvp,
    awards.mvp and string.format("Avg rating: %.2f; G:%d A:%d Poss:%d S-on:%d Fouls:%d", avgR(awards.mvp), awards.mvp.goals, awards.mvp.assists, awards.mvp.poss, awards.mvp.son, awards.mvp.fouls) or "—")
 
  addAwardRow("Golden Pillar (Top Scorer)", awards.goldenPillar,
    awards.goldenPillar and string.format("Goals: %d", awards.goldenPillar.goals) or "—")
 
  addAwardRow("Playmaker of the Season", awards.playmaker,
    awards.playmaker and string.format("Assists: %d", awards.playmaker.assists) or "—")
 
  addAwardRow("Possession Master", awards.possession,
    awards.possession and string.format("Possession points: %d", awards.possession.poss) or "—")
 
  addAwardRow("Golden Gloves (Goalkeeper of the Season)", awards.goldenGloves,
    awards.goldenGloves and string.format("Avg rating: %.2f; Saves:%d; Clean sheets:%d; Fouls:%d", avgR(awards.goldenGloves), awards.goldenGloves.saves, awards.goldenGloves.clean, awards.goldenGloves.fouls) or "—")
 
  out[#out+1] = "|}"
  out[#out+1] = ""
 
  out[#out+1] = "=== Performance Awards ==="
  out[#out+1] = '{| class="wikitable sortable" style="width:100%;"'
  out[#out+1] = "! Award !! Winner !! Team !! Pos !! GP !! Notes"
 
  addAwardRow("Sharpshooter (Shots on Target)", awards.sharpshooter,
    awards.sharpshooter and string.format("Shots on target: %d", awards.sharpshooter.son) or "—")
 
  addAwardRow("Discipline / Fair Play", awards.fairPlay,
    awards.fairPlay and string.format("Fouls: %d (min apps threshold applied)", awards.fairPlay.fouls) or "—")
 
  addAwardRow("Ironman (Most Appearances)", awards.ironman,
    awards.ironman and string.format("Appearances: %d", awards.ironman.apps) or "—")
 
  out[#out+1] = "|}"
  out[#out+1] = ""
 
  out[#out+1] = "=== Team of the Season (Best XI) ==="
  out[#out+1] = '{| class="wikitable" style="width:100%;"'
  out[#out+1] = "! Role !! Player !! Team !! Avg rating !! Key stats"
  local function addXI(role, list)
    for _, r in ipairs(list or {}) do
      out[#out+1] = "|-"
      out[#out+1] = string.format("| %s || '''%s'''<br><code>%s</code> || %s || %.2f || G:%d A:%d Poss:%d S-on:%d Fouls:%d",
        role,
        r.name or "—", r.id or "—",
        _teamWithLogo(r.team or "—"),
        avgR(r),
        r.goals or 0, r.assists or 0, r.poss or 0, r.son or 0, r.fouls or 0
      )
    end
  end
 
  addXI("Keeper", teamXI.Keeper)
  addXI("Defender", teamXI.Defender)
  addXI("Playmaker", teamXI.Playmaker)
  addXI("Striker", teamXI.Striker)
  addXI("Utility", teamXI.Utility)
 
  out[#out+1] = "|}"
 
  return table.concat(out, "\n")
end
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


------------------------------------------------------------------------
------------------------------------------------------------------------

Revision as of 19:18, 29 August 2026

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

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

------------------------------------------------------------------------
-- 0) CONFIG / CALENDAR
------------------------------------------------------------------------
local START, END = 62, 183     -- season window within the PSSC year
local DAYS_IN_YEAR = 183
local DEFAULT_SEASON = 53

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

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

------------------------------------------------------------------------
-- 1) TEAMS
------------------------------------------------------------------------
local teams = {
  { code="Allegro Symphonara",       fullName="Allegro Symphonara",        logo="[[File:AllegrosymphonaraB.png|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 },
}

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

------------------------------------------------------------------------
-- 1b) JERSEYS (manual list keyed by team FULL NAME)
------------------------------------------------------------------------
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" },

  -- As shown in your table (even if swapped)
  ["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" },

  -- Note: keep exact spelling from your table
  ["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" },
}

-- 1c) JERSEYS (regional select teams)
-- Put ONLY filenames here (no "File:" and no brackets) — same format as club kits above.
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" },
}

-- Stable 0..99 "random" from a key (so kits don't change every page view)
local function _pctFromKey(key)
  local h = mw.hash.hashValue("md5", key or "")
  local first8 = h:sub(1, 8)
  local n = tonumber(first8, 16) or 0
  return n % 100
end

-- 30% chance of THIRD kit (if it exists), otherwise HOME/AWAY
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 ~= "" then
    local pct = _pctFromKey((matchKey or "") .. "|" .. (team.fullName or "") .. "|" .. baseWhich)
    if pct < 30 then which = "third" end
  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 name = t.fullName or t.code or tostring(x)
  if bold then name = "'''" .. name .. "'''" end
  local logo = t.logo or ""
  return (logo ~= "" and logo ~= "—") and (logo .. " " .. name) or name
end

------------------------------------------------------------------------
-- 2) DIVISIONS (4 x 8)
------------------------------------------------------------------------
local DIVISIONS = {
  ["Morovian Division"] = {
    "Emberstone Vaeringheim","Vaeringheim Pillar","Imperial Delphica","Delphica Windborne",
    "Allegro Symphonara","Steppe Luminaria","Jezeraah City","Saluria Skylight"
  },
  ["Southern Strait Division"] = {
    "Sufriya Stormwake","Vine Fleet Mylecia","Port of Blore Heath","Jogi Regiment",
    "Free State Abeis","Sandveil Somniumpolis","Brightpath Aureum","Flameborne Erythros"
  },
  ["Western Highlands Division"] = {
    "Ashguard Pyralis","Aurelia Auric","Celestial Sancta Lunaris","Forgeward Nexa",
    "Hearthkeeper Koinon","Ironbough Sylvapolis","Riftwarden Acheron","Serena Tidesong"
  },
  ["Normarkian Division"] = {
    "Redcliff Citadel","Strider Myrene","Ascendant Aetherium","Pillarion Club Suncliff",
    "Ourid Pegasi","Pillarion Club Caspazani","Amberwatch Slevik","Fanghorn Rein"
  }
}

local DIV_OF = {}
for div, list in pairs(DIVISIONS) do
  for _, code in ipairs(list) do DIV_OF[code] = div end
end

------------------------------------------------------------------------
-- 3) SCHEDULE LOCK + BALANCED FUTURE
------------------------------------------------------------------------

-- === LOCKED schedule: EXACT original logic (preserves history) ===
local function homeFirst(codeA, codeB)
  local sA, sB = 0, 0
  for i=1,#codeA do sA = (sA + string.byte(codeA,i)) % 9973 end
  for i=1,#codeB do sB = (sB + string.byte(codeB,i)) % 9973 end
  return sA > sB
end

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

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

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

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

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

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

  return intra, inter
end

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

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

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

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

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

  return packRounds(merged)
end)()

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

local DM_LOCKED = mapDaysLocked()

local function lastPlayedLocked()
  local curr = date.getCurrentDate()
  local today = tonumber(curr:match("^(%d+),")) or 0
  local lastDay, lastMd = nil, 0
  for d=START, math.min(today, END) do
    local md = DM_LOCKED[d]
    if md and md > lastMd then lastMd = md; lastDay = d end
  end
  return lastDay, lastMd
end

-- === SEASON / DISPLAY ORDER ===
-- SCHED_LOCKED already contains every match needed for a 38-game season per team,
-- but it may require MORE than 38 matchdays due to byes created by packRounds().
-- To preserve already-played results, we keep SCHED_LOCKED and let the season run
-- for all locked matchdays.
local DIV_ORDER = { "Morovian Division","Southern Strait Division","Western Highlands Division","Normarkian Division" }
local TOTAL_ROUNDS = #SCHED_LOCKED

local function _copy(arr)
  local t = {}
  for i=1,#arr do t[i] = arr[i] end
  return t
end

local function roundRobin8(teamCodes)
  local n = #teamCodes
  if n ~= 8 then error("roundRobin8 expects exactly 8 teams") end
  local arr = _copy(teamCodes)
  local rounds = {}
  for r=1,7 do
    local matches = {}
    for i=1,4 do
      local a = TEAM_BY_CODE[arr[i]]
      local b = TEAM_BY_CODE[arr[n+1-i]]
      local home, away
      if (r+i) % 2 == 0 then home, away = a, b else home, away = b, a end
      matches[#matches+1] = { home, away }
    end
    rounds[#rounds+1] = matches
    local last = arr[n]
    for i=n,3,-1 do arr[i] = arr[i-1] end
    arr[2] = last
  end
  return rounds
end

local function swapHA(rounds)
  local out = {}
  for r=1,#rounds do
    out[r] = {}
    for i=1,#rounds[r] do
      local m = rounds[r][i]
      out[r][i] = { m[2], m[1] }
    end
  end
  return out
end

local function interPairRounds(divA_codes, divB_codes, blockIndex)
  local rounds = {}
  for t=0,7 do
    local matches = {}
    for i=1,8 do
      local a = TEAM_BY_CODE[divA_codes[i]]
      local b = TEAM_BY_CODE[divB_codes[((i+t-1) % 8) + 1]]
      local home, away
      if ((blockIndex + t) % 2 == 0) then home, away = a, b else home, away = b, a end
      matches[#matches+1] = { home, away }
    end
    rounds[#rounds+1] = matches
  end
  return rounds
end

local SCHED_BALANCED = (function()
  local rounds = {}

  -- 14 intra rounds
  local intraByDiv = {}
  for _, divName in ipairs(DIV_ORDER) do
    local rr7 = roundRobin8(DIVISIONS[divName])
    local rr14 = {}
    for i=1,7 do rr14[i] = rr7[i] end
    local rr7s = swapHA(rr7)
    for i=1,7 do rr14[7+i] = rr7s[i] end
    intraByDiv[divName] = rr14
  end

  for r=1,14 do
    local full = {}
    for _, divName in ipairs(DIV_ORDER) do
      for _, m in ipairs(intraByDiv[divName][r]) do full[#full+1] = m end
    end
    rounds[#rounds+1] = full
  end

  -- 24 inter rounds = 3 blocks × 8 rounds
  local A = DIVISIONS[DIV_ORDER[1]]
  local B = DIVISIONS[DIV_ORDER[2]]
  local C = DIVISIONS[DIV_ORDER[3]]
  local D = DIVISIONS[DIV_ORDER[4]]

  local blocks = {
    { {A,B}, {C,D} },
    { {A,C}, {B,D} },
    { {A,D}, {B,C} },
  }

  for blockIndex, pairset in ipairs(blocks) do
    local p1 = interPairRounds(pairset[1][1], pairset[1][2], blockIndex)
    local p2 = interPairRounds(pairset[2][1], pairset[2][2], blockIndex)
    for t=1,8 do
      local full = {}
      for _,m in ipairs(p1[t]) do full[#full+1] = m end
      for _,m in ipairs(p2[t]) do full[#full+1] = m end
      rounds[#rounds+1] = full
    end
  end

  return rounds
end)()

-- Composite mapDays: past days keep locked mapping; future days map to balanced rounds
local function mapDays()
  local pivotDay, pivotMd = lastPlayedLocked()
local curr = date.getCurrentDate()
local today = tonumber(curr:match("^(%d+),")) or 0


  -- If nothing has been played yet, keep the old even-spacing behavior.
  -- (You can also enforce 3–4 here if you want, but I’m matching the “don’t break history” spirit.)
  if not pivotDay or pivotMd == 0 then
    local totalDays  = END - START + 1
    local iv         = math.floor(totalDays / TOTAL_ROUNDS)
    local ex         = totalDays % TOTAL_ROUNDS
    local m, d       = {}, START
    for i=1,TOTAL_ROUNDS do
      m[d] = i
      d = d + iv + (ex > 0 and 1 or 0)
      if ex > 0 then ex = ex - 1 end
    end
    return m
  end

  -- Past: preserve exactly (locked schedule mapping)
  local dm = {}
  for d,md in pairs(DM_LOCKED) do
    if d <= pivotDay then dm[d] = md end
  end

       -- Future: schedule ALL remaining rounds; relax gap only if needed.
      local futureStartDay = math.max(pivotDay + 1, today + 1)
      local remainingDays = math.max(0, END - futureStartDay + 1)
      local remainingRnds = math.max(0, TOTAL_ROUNDS - pivotMd)
      if remainingDays == 0 or remainingRnds == 0 then return dm end

      -- Choose the smallest minGap that can still fit all remaining rounds.
      local function canFit(gap)
        return (1 + gap * (remainingRnds - 1)) <= remainingDays
      end

      local minGap = 3
      if not canFit(minGap) then minGap = 2 end
      if not canFit(minGap) then minGap = 1 end  -- last resort, but prevents truncation

      local gaps = math.max(0, remainingRnds - 1)
      local extra = (remainingDays - 1) - (minGap * gaps)  -- spare days after minimum spacing
      extra = math.max(0, extra)

      -- We’ll distribute +1 day into some gaps until we run out of 'extra'
      local day = futureStartDay
      for i=1, remainingRnds do
        local mdx = pivotMd + i
        if mdx > TOTAL_ROUNDS then break end
        if day > END then break end
        dm[day] = mdx

        if i < remainingRnds then
          local bump = (extra > 0) and 1 or 0
          if extra > 0 then extra = extra - 1 end
          day = day + minGap + bump
        end
      end

      return dm

end


local function invertDayMap(dm)
  local inv = {}
  for day, md in pairs(dm) do inv[md] = day end
  return inv
end

local function getRoundMatches(roundIdx)
  -- Always use the locked schedule so we eventually play every remaining match.
  return SCHED_LOCKED[roundIdx] or {}
end


local function _currentPlayedMatchday()
  local curr = date.getCurrentDate()
  local today = tonumber(curr:match("^(%d+),")) or 0
  local dm = mapDays()
  local lastMd = 0
  for d=START, math.min(today, END) do
    if dm[d] and dm[d] > lastMd then lastMd = dm[d] end
  end
  return lastMd
end

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

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

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

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

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

  local idx = dm[target]
  local matches = getRoundMatches(idx)

  if not upcoming then
    for k, m in ipairs(matches) do
      local h, a = m[1], m[2]
      local salt = target*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
      local hg, ag = simScoreFor(h, a, salt)
      local hc = (hg > ag and "green") or (hg < ag and "red") or "yellow"
      local ac = (ag > hg and "green") or (ag < hg and "red") or "yellow"
      local hs = string.format("<span style='color:%s;'>'''%d'''</span>", hc, hg)
      local as = string.format("<span style='color:%s;'>'''%d'''</span>", ac, ag)
local matchKey = tostring(target or "") .. "|" .. (h.code or "") .. "v" .. (a.code or "")
rows[#rows+1] =
  string.format("%s %s %s %s vs %s %s %s %s",
    _kitImgForMatch(h, "home", matchKey), h.logo, hs, h.fullName,
    _kitImgForMatch(a, "away", matchKey), a.logo, as, a.fullName
  )

    end
  else
    for _, m in ipairs(matches) do
      local h, a = m[1], m[2]
local matchKey = tostring(target or "") .. "|" .. (h.code or "") .. "v" .. (a.code or "")
rows[#rows+1] =
  string.format("%s %s %s vs %s %s %s",
    _kitImgForMatch(h, "home", matchKey), h.logo, h.fullName,
    _kitImgForMatch(a, "away", matchKey), a.logo, a.fullName
  )

    end
  end

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

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

------------------------------------------------------------------------
-- 6) DIVISION STANDINGS
------------------------------------------------------------------------
function p.renderStandings(frame)
  local curr   = date.getCurrentDate()
  local today  = tonumber(curr:match("^(%d+),")) or 0
  local last   = math.min(today, END)

  local recs   = {}
  for _, t in ipairs(teams) do
    recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0 }
  end

  local dm = mapDays()
  for d = START, last do
    local idx = dm[d]
    if idx then
      local matches = getRoundMatches(idx)
      for k, m in ipairs(matches) do
        local h, a = m[1], m[2]
        local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
        local hg, ag = simScoreFor(h, a, salt)

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

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

  local buckets = {}
  for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
  for code, r in pairs(recs) do
    r.PD = r.PF - r.PA
    local div = DIV_OF[code] or "—"
    buckets[div] = buckets[div] or {}
    table.insert(buckets[div], r)
  end

  local function cmp(a,b)
    if a.Pts ~= b.Pts then return a.Pts > b.Pts end
    if a.PD  ~= b.PD  then return a.PD  > b.PD  end
    if a.W   ~= b.W   then return a.W   > b.W   end
    return a.team.fullName < b.team.fullName
  end
  for div, list in pairs(buckets) do table.sort(list, cmp) end

  local order = DIV_ORDER
  local out = {}
  for _, div in ipairs(order) do
    local list = buckets[div] or {}
    out[#out+1] = "== " .. div .. " Standings =="
    out[#out+1] = '{| class="wikitable sortable"'
    out[#out+1] = "! Pos !! Team !! GP !! W !! D !! L !! Pts !! PF !! PA !! PD"
    for i, rec in ipairs(list) do
      local style = ""
      if     i <= 2 then style = ' style="background-color:#ccffcc;"'
      elseif i <= 4 then style = ' style="background-color:#cce5ff;"'
      elseif i <= 6 then style = ' style="background-color:#F1EB9C;"'
      else               style = ' style="background-color:#ffcccc;"'
      end
      out[#out+1] = "|-" .. style
out[#out+1] =
  "| " .. i
  .. " || " .. rec.team.logo .. " " .. rec.team.fullName
  .. " || " .. (rec.W + rec.D + rec.L)
  .. " || " .. rec.W
  .. " || " .. rec.D
  .. " || " .. rec.L
  .. " || " .. rec.Pts
  .. " || " .. rec.PF
  .. " || " .. rec.PA
  .. " || " .. rec.PD

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

------------------------------------------------------------------------
-- 7) PREVIOUS RESULTS (last 5 matchdays)
------------------------------------------------------------------------
function p.renderPreviousResults(frame)
  local curr   = date.getCurrentDate()
  local today  = tonumber(curr:match("^(%d+),")) or 0
  local year   = tonumber(curr:match(", (%d+) PSSC")) or 0
  local dm     = mapDays()
  local played = {}

  for d = today - 1, START, -1 do
    if dm[d] then played[#played+1] = d end
    if #played == 5 then break end
  end

  local out = {
    '{| class="wikitable sortable"',
    '! Date !! Home !! Score !! Away'
  }

  for _, d in ipairs(played) do
    local mon, dom  = getMonthDay(d)
    local dateLabel = dom .. " " .. mon .. " " .. year .. " PSSC"
    local idx = dm[d]
    local matches = getRoundMatches(idx)
    for k, m in ipairs(matches) do
      local h, a = m[1], m[2]
      local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
      local hg, ag = simScoreFor(h, a, salt)
      local hs = "'''" .. hg .. "-" .. ag .. "'''"
      out[#out+1] = "|-"
      out[#out+1] =
        "| " .. dateLabel
        .. " || " .. h.logo .. " " .. h.fullName
        .. " || " .. hs
        .. " || " .. a.logo .. " " .. a.fullName
    end
  end

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

------------------------------------------------------------------------
-- 8) PLAYOFFS (12 teams seeded from divisions)
------------------------------------------------------------------------
local PO_YEAR  = 53
local PO_FIRST = md2day(1,4)
local PO_IV    = 3

local function tb_cmp(a,b)
  if a.Pts ~= b.Pts then return a.Pts > b.Pts end
  if a.PD  ~= b.PD  then return a.PD  > b.PD  end
  if a.W   ~= b.W   then return a.W   > b.W   end
  return a.team.fullName < b.team.fullName
end

local function computeRecordsUpTo(dayEnd)
  local recs = {}
  for _, t in ipairs(teams) do
    recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0, Div=DIV_OF[t.code] }
  end

  local dm = mapDays()
  for d = START, math.min(dayEnd, END) do
    local idx = dm[d]
    if idx then
      local matches = getRoundMatches(idx)
      for k, m in ipairs(matches) do
        local h, a = m[1], m[2]
        local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
        local hg, ag = simScoreFor(h, a, salt)

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

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

  for _, r in pairs(recs) do r.PD = r.PF - r.PA end
  return recs
end

local function getSeeds12(seasonYear)
  seasonYear = tonumber(seasonYear) or DEFAULT_SEASON

  local curr   = date.getCurrentDate()
  local dNow   = tonumber(curr:match("^(%d+),")) or END
  local yNow   = tonumber(curr:match(", (%d+) PSSC")) or seasonYear

  -- If we're looking at a past season, lock standings to END (final table).
  local cutoffDay = (yNow > seasonYear) and END or dNow
  local recs   = computeRecordsUpTo(cutoffDay)

  local buckets = {}
  for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
  for _, r in pairs(recs) do
    buckets[r.Div] = buckets[r.Div] or {}
    table.insert(buckets[r.Div], r)
  end
  for div, list in pairs(buckets) do table.sort(list, tb_cmp) end

  local winners, runners = {}, {}
  for _, div in ipairs(DIV_ORDER) do
    local list = buckets[div] or {}
    if list[1] then table.insert(winners, list[1]) end
    if list[2] then table.insert(runners,  list[2]) end
  end
  table.sort(winners, tb_cmp)
  table.sort(runners, tb_cmp)

  local seeds = {}
  for i=1,#winners do seeds[#seeds+1] = winners[i].team end
  for i=1,#runners do seeds[#seeds+1] = runners[i].team end
  return seeds
end

-- Build conference play-ins: #3 vs #4 in each division
local function getConferencePlayIns(seasonYear)
  seasonYear = tonumber(seasonYear) or DEFAULT_SEASON

  local curr   = date.getCurrentDate()
  local dNow   = tonumber(curr:match("^(%d+),")) or END
  local yNow   = tonumber(curr:match(", (%d+) PSSC")) or seasonYear

  local cutoffDay = (yNow > seasonYear) and END or dNow
  local recs   = computeRecordsUpTo(cutoffDay)

  local buckets = {}
  for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
  for _, r in pairs(recs) do
    buckets[r.Div] = buckets[r.Div] or {}
    table.insert(buckets[r.Div], r)
  end
  for div, list in pairs(buckets) do table.sort(list, tb_cmp) end

  local PI = {}
  for _, div in ipairs(DIV_ORDER) do
    local list = buckets[div] or {}
    local t3 = (list[3] and list[3].team) or { logo='—', fullName='TBD', code='TBD' }
    local t4 = (list[4] and list[4].team) or { logo='—', fullName='TBD', code='TBD' }
    PI[#PI+1] = { div=div, H=t3, A=t4 }
  end
  return PI
end


local function playScore(A,B,salt)
  math.randomseed(salt)
  local p = math.random()
  if p < 0.10 then local g=math.random(0,12); return g,g end
  local wa,wb = (A.championships or 0)+1,(B.championships or 0)+1
  local home  = (p-0.10)/0.90 < wa/(wa+wb)
  local hi    = math.random(1,12)
  local lo    = math.random(0, math.min(hi-1,12))
  return home and hi or lo, home and lo or hi
end

local function mkRow(tag, absDate, H, A, salt, absNow)
  local hs, win = '—','—'
  if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then
    local g1,g2 = playScore(H,A,salt)
    hs  = string.format("'''%d-%d'''", g1, g2)
    win = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
  end
local matchKey = tostring(absDate or "") .. "|" .. tostring(tag or "") .. "|" .. (H.code or "") .. "v" .. (A.code or "")
return string.format("|-\n| %s || %s %s %s || %s || %s %s %s || %s",
  tag,
  _kitImgForMatch(H, "home", matchKey), H.logo, H.fullName,
  hs,
  _kitImgForMatch(A, "away", matchKey), A.logo, A.fullName,
  win
)

end

function p.renderPlayoffSchedule(frame)
local args = frame.args or {}
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON

local seeds = getSeeds12(season)
local PI = getConferencePlayIns(season)

  local rows = { '{| class="wikitable sortable"',
    '! Round !! Home !! Score !! Away !! Winner'
  }

  local function add(round, day, H, A)
    rows[#rows+1] = string.format(
      '|-\n| %s || %s %s || — || %s %s || —',
      round,
      (H and H.logo) or '—',
      (H and H.fullName) or 'TBD',
      (A and A.logo) or '—',
      (A and A.fullName) or 'TBD'
    )
  end

  -- Play-ins (4): #3 vs #4 in each division
  for i,g in ipairs(PI) do
    add('PI-'..i, PO_FIRST, g.H, g.A)
  end

  -- Round-of-8: Seeds 5–8 vs PI winners (shown as TBD until results exist)
  add('R-8-1', PO_FIRST + PO_IV, seeds[5], {logo='—', fullName='TBD'})
  add('R-8-2', PO_FIRST + PO_IV, seeds[6], {logo='—', fullName='TBD'})
  add('R-8-3', PO_FIRST + PO_IV, seeds[7], {logo='—', fullName='TBD'})
  add('R-8-4', PO_FIRST + PO_IV, seeds[8], {logo='—', fullName='TBD'})

  -- Round-of-4: Seeds 1–4 join here
  add('R-4-1', PO_FIRST + PO_IV*2, seeds[1], {logo='—', fullName='TBD'})
  add('R-4-2', PO_FIRST + PO_IV*2, seeds[2], {logo='—', fullName='TBD'})
  add('R-4-3', PO_FIRST + PO_IV*2, seeds[3], {logo='—', fullName='TBD'})
  add('R-4-4', PO_FIRST + PO_IV*2, seeds[4], {logo='—', fullName='TBD'})

  -- Semi-finals
  add('SF-1', PO_FIRST + PO_IV*3, {logo='—', fullName='TBD'}, {logo='—', fullName='TBD'})
  add('SF-2', PO_FIRST + PO_IV*3, {logo='—', fullName='TBD'}, {logo='—', fullName='TBD'})

  -- Final
  add('Final', PO_FIRST + PO_IV*4, {logo='—', fullName='TBD'}, {logo='—', fullName='TBD'})

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


function p.renderPlayoffResults(frame)
local args = frame.args or {}
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON

local seeds = getSeeds12(season)
local PIset = getConferencePlayIns(season)

  local now   = date.getCurrentDate()
  local dNow  = tonumber(now:match('^(%d+),')) or 0
  local yNow  = tonumber(now:match(', (%d+) PSSC')) or 0
  local absNow= absDay(yNow,dNow)

  local rows = { '{| class="wikitable sortable"', '! Round !! Home !! Score !! Away !! Winner' }

local progressive = tostring(args.progressive or args.live or "") ~= ""

local function maybeAddRow(tag, absDate, H, A, salt)
  -- If progressive/live mode is on, only show rows once that round's date has arrived.
  if (not progressive) or (absNow >= absDate) then
    rows[#rows+1] = mkRow(tag, absDate, H, A, salt, absNow)
  end
end

  --------------------------------------------------------------------
  -- Play-ins: 4 games (one per division): #3 vs #4
  --------------------------------------------------------------------
  local absPI = absDay(season + 1, PO_FIRST)
  local W_PI = {}

  for i,g in ipairs(PIset) do
    local H,A = g.H, g.A
    if absNow >= absPI and H.fullName~='TBD' and A.fullName~='TBD' then
      local g1,g2 = playScore(H,A,absPI*100+i)
      W_PI[i]     = (g1>g2) and H or A
    end
    maybeAddRow('PI-'..i, absPI, H, A, absPI*100+i)
  end

  --------------------------------------------------------------------
  -- Round-of-8: Seeds 5–8 vs the 4 play-in winners
  -- (Seeds 1–4 get BYE into Round-of-4)
  --------------------------------------------------------------------
  local absR8 = absPI + PO_IV

  local p1 = W_PI[1] or {logo='—',fullName='TBD'}
  local p2 = W_PI[2] or {logo='—',fullName='TBD'}
  local p3 = W_PI[3] or {logo='—',fullName='TBD'}
  local p4 = W_PI[4] or {logo='—',fullName='TBD'}

  local R8 = {
    { seeds[5], p4 },
    { seeds[6], p3 },
    { seeds[7], p2 },
    { seeds[8], p1 }
  }

  local W_R8 = {}
  for i,pair in ipairs(R8) do
    local H,A = pair[1], pair[2]
    if absNow >= absR8 and A.fullName~='TBD' then
      local g1,g2 = playScore(H,A,absR8*100+i)
      W_R8[i]     = (g1>g2) and H or A
    end
    maybeAddRow('R-8-'..i, absR8, H, A, absR8*100+i)
  end

  --------------------------------------------------------------------
  -- Round-of-4: Seeds 1–4 enter here vs the 4 R8 winners
  -- Pairing: Seed 1 vs worst remaining, Seed 2 vs next, etc.
  --------------------------------------------------------------------
  local absR4 = absR8 + PO_IV
  local R4, W_R4 = {}, {}

  -- Always show Seeds 1–4 as the Round-of-4 home teams.
  -- Opponents are TBD until all four R8 winners exist.
  R4 = {
    { seeds[1], {logo='—', fullName='TBD'} },
    { seeds[2], {logo='—', fullName='TBD'} },
    { seeds[3], {logo='—', fullName='TBD'} },
    { seeds[4], {logo='—', fullName='TBD'} }
  }

  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] or 99) < (idxByCode[b.code] or 99)
    end)

    -- Pairing: Seed 1 vs worst remaining, Seed 2 vs next, etc.
    R4[1][2] = W_R8[4]
    R4[2][2] = W_R8[3]
    R4[3][2] = W_R8[2]
    R4[4][2] = W_R8[1]
  end

  for i=1,4 do
    local H = R4[i][1]
    local A = R4[i][2]

    if absNow >= absR4 and A.fullName ~= 'TBD' then
      local g1,g2 = playScore(H, A, absR4*100+i)
      W_R4[i]     = (g1>g2) and H or A
    end

    maybeAddRow('R-4-'..i, absR4, H, A, absR4*100+i)
  end

  --------------------------------------------------------------------
  -- Semi-finals: best vs worst, 2nd vs 3rd
  --------------------------------------------------------------------
  local absSF = absR4 + PO_IV
  local SF, W_SF = {}, {}

  if #W_R4 == 4 then
    local idxByCode = {}
    for i,t in ipairs(seeds) do idxByCode[t.code] = i end

    table.sort(W_R4, function(a,b)
      return (idxByCode[a.code] or 99) < (idxByCode[b.code] or 99)
    end)

    SF = {
      { W_R4[1], W_R4[4] },
      { W_R4[2], W_R4[3] }
    }
  else
    SF = {
      { {logo='—',fullName='TBD'}, {logo='—',fullName='TBD'} },
      { {logo='—',fullName='TBD'}, {logo='—',fullName='TBD'} }
    }
  end

  for i=1,2 do
    local H,A = SF[i][1], SF[i][2]
    if absNow >= absSF and H.fullName~='TBD' and A.fullName~='TBD' then
      local g1,g2 = playScore(H,A,absSF*100+i)
      W_SF[i]     = (g1>g2) and H or A
    end
    maybeAddRow('SF-'..i, absSF, H, A, absSF*100+i)
  end

  --------------------------------------------------------------------
  -- Final
  --------------------------------------------------------------------
  local absF = absSF + PO_IV
  local FH = W_SF[1] or {logo='—',fullName='TBD'}
  local FA = W_SF[2] or {logo='—',fullName='TBD'}
  maybeAddRow('Final', absF, FH, FA, absF*100+1)

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

------------------------------------------------------------------------
-- 9) PER-MATCH STATS
------------------------------------------------------------------------
local function computeMatchStats(h, a, hg, ag, seed)
  local function splitScore(total, salt)
    local maxG = math.floor(total / 3)
    if maxG == 0 then return 0, total end
    math.randomseed(salt)
    local g = maxG
    if maxG >= 1 and math.random() < 0.40 then g = maxG - 1 end
    local p = total - 3*g
    return g, p
  end

  local h_goals, h_poss = splitScore(hg, seed + 101)
  local a_goals, a_poss = splitScore(ag, seed + 102)

  local function rrange(s, lo, hi) math.randomseed(s); return math.random(lo, hi) end
  local h_sh_on = (h_goals > 0) and rrange(seed+201, h_goals, h_goals+2) or rrange(seed+201, 0, 2)
  local a_sh_on = (a_goals > 0) and rrange(seed+202, a_goals, a_goals+2) or rrange(seed+202, 0, 2)

  local h_sh_off = rrange(seed+203, 0, 3)
  local a_sh_off = rrange(seed+204, 0, 3)

  local h_fouls = rrange(seed+205, 0, 3)
  local a_fouls = rrange(seed+206, 0, 3)
  local h_fks   = a_fouls
  local a_fks   = h_fouls

  local h_throws = rrange(seed+207, 0, 3)
  local a_throws = rrange(seed+208, 0, 3)
  local h_oob    = rrange(seed+209, 0, 2)
  local a_oob    = rrange(seed+210, 0, 2)
  local h_gk     = rrange(seed+211, 0, 2)
  local a_gk     = rrange(seed+212, 0, 2)

  local base = 50
  local swing = rrange(seed+213, 3, 6)
  local h_poss_pct, a_poss_pct
  if hg > ag then
    h_poss_pct, a_poss_pct = base + swing, base - swing
  elseif hg < ag then
    h_poss_pct, a_poss_pct = base - swing, base + swing
  else
    local tilt = rrange(seed+214, -3, 3)
    h_poss_pct, a_poss_pct = base + tilt, base - tilt
  end

  return {
    [h.code] = {
      final = hg, goals = h_goals, poss_points = h_poss,
      shots_on = h_sh_on, shots_off = h_sh_off,
      fouls = h_fouls, fks = h_fks, throws = h_throws,
      gk = h_gk, oob = h_oob, poss_pct = h_poss_pct
    },
    [a.code] = {
      final = ag, goals = a_goals, poss_points = a_poss,
      shots_on = a_sh_on, shots_off = a_sh_off,
      fouls = a_fouls, fks = a_fks, throws = a_throws,
      gk = a_gk, oob = a_oob, poss_pct = a_poss_pct
    }
  }
end

function p.renderMatchStatsForDay(frame)
  local curr   = date.getCurrentDate()
  local dayNow = tonumber(curr:match('^(%d+),')) or 0
  local year   = tonumber(curr:match(', (%d+) PSSC')) or 0
  local dm     = mapDays()

  local target = dm[dayNow] and dayNow or nil
  if not target then
    for d = dayNow + 1, END do
      if dm[d] then target = d; break end
    end
  end
  if not target then return "'''No scheduled matches in window.'''" end

  local idx     = dm[target]
  local isToday = (target == dayNow)

  local mon, dom = getMonthDay(target)
  local label    = dom .. " " .. mon .. " " .. year .. " PSSC"

  local function hiPairNum(hVal, aVal, suffix)
    hVal = tonumber(hVal) or 0
    aVal = tonumber(aVal) or 0
    suffix = suffix or ""
    local function cell(v, hi)
      local txt = tostring(v) .. suffix
      return hi and ('<span style="background-color:#ccffcc;">'..txt..'</span>') or txt
    end
    return cell(hVal, hVal>aVal), cell(aVal, aVal>hVal)
  end

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

  local matches = getRoundMatches(idx)

  if isToday then
    local results = simulateDayResults(target)
    for k, r in ipairs(results) do
      local seed = target*1000 + k + string.byte(r.home.code,1) + string.byte(r.away.code,1)
      local S    = computeMatchStats(r.home, r.away, r.hg, r.ag, seed)
      local H, A = S[r.home.code], S[r.away.code]

      local matchLabel = r.home.logo .. ' ' .. r.home.fullName .. ' vs ' .. r.away.logo .. ' ' .. r.away.fullName

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

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

      out[#out+1] = '|-'
      out[#out+1] =
        '| ' .. r.away.logo .. ' ' .. r.away.fullName ..
        ' || ' .. aF ..
        ' || ' .. aG ..
        ' || ' .. aP ..
        ' || ' .. aSON ..
        ' || ' .. aSOF ..
        ' || ' .. aFO ..
        ' || ' .. aFK ..
        ' || ' .. aTH ..
        ' || ' .. aGK ..
        ' || ' .. aOOB ..
        ' || ' .. aPOS
    end
  else
    for _, m in ipairs(matches) do
      local matchLabel = m[1].logo .. ' ' .. m[1].fullName .. ' vs ' .. m[2].logo .. ' ' .. m[2].fullName
      out[#out+1] = '|-'
      out[#out+1] =
        '| rowspan="2" | ' .. matchLabel ..
        ' || ' .. m[1].logo .. ' ' .. m[1].fullName ..
        ' || — || — || — || — || — || — || — || — || — || — || —'
      out[#out+1] = '|-'
      out[#out+1] =
        '| ' .. m[2].logo .. ' ' .. m[2].fullName ..
        ' || — || — || — || — || — || — || — || — || — || — || —'
    end
  end

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

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

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

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

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

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

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

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

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

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

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

------------------------------------------------------------------------
-- 10) ROSTERS + TRANSFERS + PLAYER STATS
------------------------------------------------------------------------
local ROSTER_SIZE = 12
local TRANSFER_MD_START = 20
local TRANSFER_MD_END   = 30

local POSITIONS = {
  "Striker","Striker",
  "Playmaker","Playmaker","Playmaker",
  "Defender","Defender","Defender",
  "Keeper",
  "Utility","Utility","Utility"
}

-- deterministic PRNG (does NOT touch math.randomseed)
local function _seedFromString(s)
  local h = 0
  for i=1,#s do h = (h*31 + string.byte(s,i)) % 2147483647 end
  return (h == 0) and 1 or h
end

local function _rngNext(st)
  st = (1103515245 * st + 12345) % 2147483647
  return st, st / 2147483647
end

local function _pick(st, arr)
  if not arr or #arr == 0 then return st, nil end
  local r; st, r = _rngNext(st)
  local idx = math.floor(r * #arr) + 1
  return st, arr[idx]
end

local function _normKey(s)
  if not s or s == "" then return nil end
  s = mw.ustring.lower(s)
  s = mw.ustring.gsub(s, "%s+", "_")
  s = mw.ustring.gsub(s, "[^%w_]", "")
  return s
end

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

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

local function _maybeDiacritics(st, s)
  local r; st, r = _rngNext(st)
  if r < 0.08 then s = mw.ustring.gsub(s, "a", "ä", 1)
  elseif r < 0.16 then s = mw.ustring.gsub(s, "i", "ï", 1)
  elseif r < 0.24 then s = mw.ustring.gsub(s, "o", "ö", 1)
  elseif r < 0.32 then s = mw.ustring.gsub(s, "u", "ü", 1)
  end
  return st, s
end

local function _buildNameFrom(style, st, isLast)
  local t = NAME_STYLES[style] or NAME_STYLES.bassarid
  local a,b,c
  if not isLast then
    st, a = _pick(st, t.firstA); st, b = _pick(st, t.firstB); st, c = _pick(st, t.firstC)
  else
    st, a = _pick(st, t.lastA);  st, b = _pick(st, t.lastB);  st, c = _pick(st, t.lastC)
  end

  local name = _cap((a or "") .. (b or "") .. (c or ""))

  local r; st, r = _rngNext(st)
  if r < 0.05 and #name >= 6 then
    local cut = math.floor(#name/2)
    name = mw.ustring.sub(name,1,cut) .. "-" .. mw.ustring.sub(name,cut+1)
  elseif r < 0.08 and #name >= 6 then
    local cut = math.floor(#name/2)
    name = mw.ustring.sub(name,1,cut) .. "’" .. mw.ustring.sub(name,cut+1)
  end

  st, name = _maybeDiacritics(st, name)
  return st, name
end

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

local function _genBaseRoster(teamCode, season)
  local key = _normKey(teamCode) or teamCode
  local st = _seedFromString(tostring(season) .. "|" .. key .. "|ROSTER")
  local style = _chooseStyleForTeam(teamCode, season)

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

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

local function _indexById(roster)
  local m = {}
  for i,p in ipairs(roster) do m[p.id] = i end
  return m
end

local function _buildPlayerNameLookup(baseRosters)
  local m = {}
  for _, roster in pairs(baseRosters or {}) do
    for _, pl in ipairs(roster or {}) do
      if pl and pl.id then
        m[pl.id] = pl.name or pl.id
      end
    end
  end
  return m
end


local function _findSwapCandidate(st, roster, wantPos)
  local candidates = {}
  for _,p in ipairs(roster) do if p.pos == wantPos then candidates[#candidates+1] = p end end
  if #candidates == 0 then candidates = roster end
  return _pick(st, candidates)
end

local function _genTransferDeals(season)
  local st = _seedFromString(tostring(season) .. "|TRANSFERWINDOW|MD" .. TRANSFER_MD_START .. "-" .. TRANSFER_MD_END)
  local deals, teamCodes = {}, {}
  for _,t in ipairs(teams) do teamCodes[#teamCodes+1] = t.code end

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

      local baseA = _genBaseRoster(A, season)
      local baseB = _genBaseRoster(B, season)

      local pA; st, pA = _pick(st, baseA)
      if pA and pA.pos == "Keeper" then
        local rr; st, rr = _rngNext(st)
        if rr < 0.75 then st, pA = _pick(st, baseA) end
      end

      local pB; st, pB = _findSwapCandidate(st, baseB, (pA and pA.pos) or "Utility")
      if A and B and pA and pB then
        deals[#deals+1] = { md=md, A=A, B=B, A_id=pA.id, B_id=pB.id, pos=pA.pos }
      end
    end
  end
  return deals
end

local function _applyTransfersUpTo(baseRosters, deals, upToMd)
  local rosters = {}
  for code, r in pairs(baseRosters) do
    rosters[code] = {}
    for i,p in ipairs(r) do rosters[code][i] = { id=p.id, name=p.name, pos=p.pos, no=p.no, notes=p.notes } end
  end

  for _,d in ipairs(deals) do
    if d.md <= upToMd then
      local RA, RB = rosters[d.A], rosters[d.B]
      if RA and RB then
        local ia = _indexById(RA)[d.A_id]
        local ib = _indexById(RB)[d.B_id]
        if ia and ib then
          local tmp = RA[ia]
          RA[ia] = RB[ib]
          RB[ib] = tmp
        end
      end
    end
  end

  return rosters
end

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

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

local function _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster)
  local salt = day*1000 + matchIndex + string.byte(homeTeam.code,1) + string.byte(awayTeam.code,1)
  local hg, ag = simScoreFor(homeTeam, awayTeam, salt)
  local S = computeMatchStats(homeTeam, awayTeam, hg, ag, salt)
  local H = S[homeTeam.code] or {}
  local A = S[awayTeam.code] or {}
  local stats = { home = H, away = A }

  local st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)
  local events = { home={}, away={} }

  for _,p in ipairs(homeRoster) do _ensure(events.home, p.id).apps = 1 end
  for _,p in ipairs(awayRoster) do _ensure(events.away, p.id).apps = 1 end

  local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
  local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
  local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }

  for _=1, (stats.home.goals or 0) do
    local pz; st, pz = _alloc(st, homeRoster, W_GOAL)
    _ensure(events.home, pz.id).goals = _ensure(events.home, pz.id).goals + 1
    local r; st, r = _rngNext(st)
    if r < 0.70 then
      local a; st, a = _alloc(st, homeRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
      if a and a.id ~= pz.id then _ensure(events.home, a.id).assists = _ensure(events.home, a.id).assists + 1 end
    end
  end

  for _=1, (stats.away.goals or 0) do
    local pz; st, pz = _alloc(st, awayRoster, W_GOAL)
    _ensure(events.away, pz.id).goals = _ensure(events.away, pz.id).goals + 1
    local r; st, r = _rngNext(st)
    if r < 0.70 then
      local a; st, a = _alloc(st, awayRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
      if a and a.id ~= pz.id then _ensure(events.away, a.id).assists = _ensure(events.away, a.id).assists + 1 end
    end
  end

  for _=1, (stats.home.poss_points or 0) do
    local pz; st, pz = _alloc(st, homeRoster, W_POSS)
    _ensure(events.home, pz.id).poss = _ensure(events.home, pz.id).poss + 1
  end
  for _=1, (stats.away.poss_points or 0) do
    local pz; st, pz = _alloc(st, awayRoster, W_POSS)
    _ensure(events.away, pz.id).poss = _ensure(events.away, pz.id).poss + 1
  end

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

  for _=1, (stats.home.fouls or 0) do
    local pz; st, pz = _alloc(st, homeRoster, W_FOUL)
    _ensure(events.home, pz.id).fouls = _ensure(events.home, pz.id).fouls + 1
  end
  for _=1, (stats.away.fouls or 0) do
    local pz; st, pz = _alloc(st, awayRoster, W_FOUL)
    _ensure(events.away, pz.id).fouls = _ensure(events.away, pz.id).fouls + 1
  end

  return events
end

local function _computePlayerSeasonTotals(season, upToMd)
  local dm = mapDays()
  local inv = invertDayMap(dm)

  local deals = _genTransferDeals(season)
  local base = _buildBaseRosters(season)
  local totals = {}

  local function ensurePlayer(pid, name, pos)
    if not totals[pid] then
      totals[pid] = { id=pid, name=name or pid, pos=pos or "—", team="—",
                      apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0 }
    end
    return totals[pid]
  end

  for md=1, math.min(upToMd, TOTAL_ROUNDS) do
    local day = inv[md]
    if day then
      local rosters = _applyTransfersUpTo(base, deals, md)
      local fixtures = getRoundMatches(md)

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

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

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

  return totals, deals, base
end

------------------------------------------------------------------------
-- 8b) OFF-SEASON REGIONAL CUP (Division Select Tournament)
--     Starts ~3 weeks (21 days) after the Playoff Final
------------------------------------------------------------------------

-- Your playoff final date (absolute day index):
-- In renderPlayoffResults you compute rounds using:
-- absPI = absDay(PO_YEAR, PO_FIRST)
-- absF  = absSF + PO_IV  where absSF = absR4 + PO_IV and absR4 = absPI + PO_IV
-- => Final happens at absPI + (PO_IV * 4)
local ABS_PLAYOFF_FINAL = absDay(PO_YEAR, PO_FIRST) + (PO_IV * 4)

-- Timing knobs
local RC_GAP_AFTER_FINAL = 21  -- "three weeks after playoffs"
local RC_ROUND_GAP       = 2   -- days between RR rounds
local RC_FINAL_GAP       = 3   -- days after RR for Final + 3rd

local function _regionalCupStartAbs(season)
  season = tonumber(season) or DEFAULT_SEASON
  local playoffFinalAbs = absDay(season + 1, PO_FIRST) + (PO_IV * 4)
  return playoffFinalAbs + RC_GAP_AFTER_FINAL
end

-- Branding (logos + display names)
-- Logos here should be FULL wikitext, like your clubs: [[File:Name.png|20px]]
local RC_META = {
  ["Morovian Division"] = {
    name = "Morovian Select",
    logo = "[[File:MorovianSelect.png|20px]]",
  },
  ["Southern Strait Division"] = {
    name = "Southern Strait Select",
    logo = "[[File:SouthernStraitSelect.png|20px]]",
  },
  ["Western Highlands Division"] = {
    name = "Western Highlands Select",
    logo = "[[File:WesternHighlandsSelect.png|20px]]",
  },
  ["Normarkian Division"] = {
    name = "Normarkian Select",
    logo = "[[File:NormarkianSelect.png|20px]]",
  },
}

-- Convert absolute day -> "DD Month YYYY PSSC" using your 183-day calendar
local function _absToYearDay(abs)
  if not abs or abs <= 0 then return 0, 0 end
  local y = math.floor((abs - 1) / DAYS_IN_YEAR)
  local d = ((abs - 1) % DAYS_IN_YEAR) + 1
  return y, d
end

local function _dateLabelFromAbs(abs)
  local y, d = _absToYearDay(abs)
  local mon, dom = getMonthDay(d)
  return string.format("%d %s %d PSSC", dom, mon, y)
end

-- Rating proxy for Regional Cup call-ups
-- Uses PER-APP rates + reliability scaling to prevent too many 10.0s.
local function _rcRating(row)
  row = row or {}

  local apps    = tonumber(row.apps) or 0
  local goals   = tonumber(row.goals) or 0
  local assists = tonumber(row.assists) or 0
  local poss    = tonumber(row.poss) or 0
  local son     = tonumber(row.son) or 0
  local sof     = tonumber(row.sof) or 0
  local fouls   = tonumber(row.fouls) or 0
  local pos     = tostring(row.pos or "")

  -- Avoid divide-by-zero; also prevents 1-game wonders from spiking.
  local denom = math.max(1, apps)

  -- Per-appearance rates (key change)
  local g  = goals   / denom
  local a  = assists / denom
  local pr = poss    / denom
  local sr = son     / denom
  local orr= sof     / denom
  local fr = fouls   / denom

  -- Reliability multiplier: full weight only after ~6 apps
  local rel = math.min(1, denom / 6)

  -- Base rating (tuned to spread the top end and reduce perfect 10s)
  local raw = 5.8
    + g  * 3.0
    + a  * 1.8
    + pr * 0.10
    + sr * 0.22
    - orr* 0.18
    - fr * 0.40

  -- Keepers: we don’t track saves/clean sheets in season totals,
  -- so give them a slightly different emphasis (discipline + involvement)
  if pos == "Keeper" then
    raw = 5.9
      + pr * 0.08
      + a  * 0.8
      - fr * 0.50
      - orr* 0.12
  end

  -- Apply reliability (small samples pulled toward 6)
  local rating = 6 + (raw - 6) * rel

  -- Clamp; slightly tighter ceiling to reduce “10.0 everywhere”
  if rating < 0 then rating = 0 end
  if rating > 9.7 then rating = 9.7 end

  return rating
end

-- Build player pools by DIVISION using your existing season totals
local function _buildRegionalPlayerPool(season)
  local totals = _computePlayerSeasonTotals(season, TOTAL_ROUNDS) -- pid -> totals row
  local poolByDiv = {}
  for div,_ in pairs(DIVISIONS) do poolByDiv[div] = {} end

  for _, row in pairs(totals or {}) do
    local club = row.team
    local div  = DIV_OF[club]
    if div and poolByDiv[div] then
      local entry = {
        id=row.id, name=row.name, pos=row.pos, team=row.team,
        apps=row.apps or 0, goals=row.goals or 0, assists=row.assists or 0,
        poss=row.poss or 0, son=row.son or 0, sof=row.sof or 0, fouls=row.fouls or 0
      }
      entry.rating = _rcRating(entry)
      table.insert(poolByDiv[div], entry)
    end
  end

  for div, list in pairs(poolByDiv) do
    table.sort(list, function(a,b)
      if a.rating ~= b.rating then return a.rating > b.rating end
      if (a.goals or 0) ~= (b.goals or 0) then return (a.goals or 0) > (b.goals or 0) end
      if (a.assists or 0) ~= (b.assists or 0) then return (a.assists or 0) > (b.assists or 0) end
      return (a.name or "") < (b.name or "")
    end)
  end

  return poolByDiv
end

-- Pick a balanced 12-player “call-up” squad
local function _pickRegionalSquad(sortedPool)
  local need = { Keeper=1, Defender=4, Playmaker=3, Striker=3, Utility=1 }
  local picked, used = {}, {}

  local function takePos(pos)
    if (need[pos] or 0) <= 0 then return end
    for _, p in ipairs(sortedPool or {}) do
      if not used[p.id] and p.pos == pos then
        used[p.id] = true
        picked[#picked+1] = p
        need[pos] = need[pos] - 1
        if need[pos] <= 0 then return end
      end
    end
  end

  takePos("Keeper")
  takePos("Defender")
  takePos("Playmaker")
  takePos("Striker")
  takePos("Utility")

  while #picked < ROSTER_SIZE do
    local found = false
    for _, p in ipairs(sortedPool or {}) do
      if not used[p.id] then
        used[p.id] = true
        picked[#picked+1] = p
        found = true
        break
      end
    end
    if not found then break end
  end

  return picked
end

local function _regionalTeams(season)
  local poolByDiv = _buildRegionalPlayerPool(season)

  local function mk(div)
    local meta = RC_META[div] or {}
    local base = div:gsub(" Division","")
    local name = meta.name or (base .. " Select")
    return {
      div = div,
      code = base,
      fullName = name,
      logo = meta.logo or "—",
      squad = _pickRegionalSquad(poolByDiv[div] or {}),
    }
  end

  return {
    mk("Morovian Division"),
    mk("Southern Strait Division"),
    mk("Western Highlands Division"),
    mk("Normarkian Division")
  }
end

local function _squadStrength(squad)
  local sum, n = 0, 0
  for _, p in ipairs(squad or {}) do
    sum = sum + (p.rating or 0)
    n = n + 1
  end
  if n == 0 then return 1 end
  return math.max(1, sum / n)
end

local function _rcPlayScore(teamA, teamB, salt)
  math.randomseed(salt)
  local p = math.random()

  -- 10% draw band
  if p < 0.10 then
    local g = math.random(0, 12)
    return g, g
  end

  local sa = _squadStrength(teamA.squad)
  local sb = _squadStrength(teamB.squad)
  local aWins = ((p - 0.10) / 0.90) < (sa / (sa + sb))

  local hi = math.random(1, 12)
  local lo = math.random(0, math.min(hi-1, 12))
  return aWins and hi or lo, aWins and lo or hi
end

local function _rcStandingsInit(regTeams)
  local st = {}
  for _, t in ipairs(regTeams or {}) do
    st[t.code] = { team=t, P=0, W=0, D=0, L=0, GF=0, GA=0, GD=0, Pts=0 }
  end
  return st
end

local function _rcApplyResult(st, A, B, ga, gb)
  local a = st[A.code]
  local b = st[B.code]
  if not a or not b then return end
  a.P = a.P + 1; b.P = b.P + 1
  a.GF = a.GF + ga; a.GA = a.GA + gb
  b.GF = b.GF + gb; b.GA = b.GA + ga
  if ga > gb then
    a.W = a.W + 1; a.Pts = a.Pts + 3
    b.L = b.L + 1
  elseif ga < gb then
    b.W = b.W + 1; b.Pts = b.Pts + 3
    a.L = a.L + 1
  else
    a.D = a.D + 1; a.Pts = a.Pts + 1
    b.D = b.D + 1; b.Pts = b.Pts + 1
  end
end

local function _rcSortTable(st)
  local list = {}
  for _, r in pairs(st or {}) do
    r.GD = (r.GF or 0) - (r.GA or 0)
    table.insert(list, r)
  end
  table.sort(list, 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 or "") < (b.team.fullName or "")
  end)
  return list
end

-- Fixed RR pairings (deterministic)
local function _rcFixtures(regTeams)
  local M = regTeams[1]
  local S = regTeams[2]
  local W = regTeams[3]
  local N = regTeams[4]
  return {
    { round="RR-1", games = { {M,S}, {W,N} } },
    { round="RR-2", games = { {M,W}, {S,N} } },
    { round="RR-3", games = { {M,N}, {S,W} } },
  }
end

-- PUBLIC: Rosters (call-ups)
function p.renderRegionalCupRosters(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  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 ~= "—" and (rt.logo .. " ") or "") .. 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 or {}) do
      out[#out+1] = '|-'
      out[#out+1] = string.format('| %s || %s || %s || %.2f || %d || %d',
        pl.name or pl.id, pl.pos or "—", _teamWithLogo(pl.team or "—"),
        pl.rating or 0, pl.goals or 0, pl.assists or 0
      )
    end
    out[#out+1] = '|}'
    out[#out+1] = ''
  end
  return table.concat(out, "\n")
end

-- PUBLIC: Schedule table (includes kits via your existing kit function)
function p.renderRegionalCupSchedule(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local T = _regionalTeams(season)
  local fixtures = _rcFixtures(T)
local rcStartAbs = _regionalCupStartAbs(season)
  local rows = { '{| class="wikitable sortable"', '! Stage !! Date !! Home (kit) !! Score !! Away (kit)' }

  local abs = rcStartAbs
  for i, blk in ipairs(fixtures) do
    local dateLabel = _dateLabelFromAbs(abs)
    for gi, g in ipairs(blk.games) do
      local H, A = g[1], g[2]
      local key = ("RC|"..season.."|"..blk.round.."|"..gi.."|"..dateLabel)
      local hk = _kitImgForMatch(H, "home", key, 20)
      local ak = _kitImgForMatch(A, "away", key, 20)
      rows[#rows+1] = "|-"
      rows[#rows+1] = string.format("| %s || %s || %s %s || — || %s %s",
        blk.round, dateLabel, hk, (H.logo ~= "—" and (H.logo.." ") or "") .. H.fullName,
        ak, (A.logo ~= "—" and (A.logo.." ") or "") .. A.fullName
      )
    end
    abs = abs + RC_ROUND_GAP
  end

  local finalAbs = rcStartAbs + (RC_ROUND_GAP * (#fixtures - 1)) + RC_FINAL_GAP
  rows[#rows+1] = "|-"
  rows[#rows+1] = string.format("| Final || %s || TBD || — || TBD", _dateLabelFromAbs(finalAbs))
  rows[#rows+1] = "|-"
  rows[#rows+1] = string.format("| 3rd Place || %s || TBD || — || TBD", _dateLabelFromAbs(finalAbs))

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

-- PUBLIC: Results + table + finals day
function p.renderRegionalCupResults(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local T = _regionalTeams(season)
  local fixtures = _rcFixtures(T)
  local st = _rcStandingsInit(T)
  local rcStartAbs = _regionalCupStartAbs(season)
  local now   = date.getCurrentDate()
  local dNow  = tonumber(now:match('^(%d+),')) or 0
  local yNow  = tonumber(now:match(', (%d+) PSSC')) or 0
  local absNow= absDay(yNow, dNow)

  local out = {}
  out[#out+1] = "== Off-Season Regional Cup (" .. season .. " PSSC) =="
  out[#out+1] = "''Begins " .. _dateLabelFromAbs(rcStartAbs) .. " (≈3 weeks after the playoff final).''"
  out[#out+1] = ""
  out[#out+1] = "=== Round Robin ==="
  out[#out+1] = '{| class="wikitable sortable"'
  out[#out+1] = '! Round !! Date !! Home (kit) !! Score !! Away (kit)'

  local abs = rcStartAbs
  for i, blk in ipairs(fixtures) do
    local dateLabel = _dateLabelFromAbs(abs)
    for gi, g in ipairs(blk.games) do
      local H, A = g[1], g[2]
      local key = ("RC|"..season.."|"..blk.round.."|"..gi.."|"..dateLabel)
      local hk = _kitImgForMatch(H, "home", key, 20)
      local ak = _kitImgForMatch(A, "away", key, 20)

      local score = "—"
      if absNow >= abs then
        local ga, gb = _rcPlayScore(H, A, abs*100 + gi)
        score = string.format("'''%d-%d'''", ga, gb)
        _rcApplyResult(st, H, A, ga, gb)
      end

      out[#out+1] = "|-"
      out[#out+1] = string.format("| %s || %s || %s %s || %s || %s %s",
        blk.round, dateLabel,
        hk, (H.logo ~= "—" and (H.logo.." ") or "") .. H.fullName,
        score,
        ak, (A.logo ~= "—" and (A.logo.." ") or "") .. A.fullName
      )
    end
    abs = abs + RC_ROUND_GAP
  end
  out[#out+1] = "|}"
  out[#out+1] = ""

  out[#out+1] = "=== Table ==="
  local list = _rcSortTable(st)
  out[#out+1] = '{| class="wikitable sortable"'
  out[#out+1] = '! Pos !! Team !! P !! W !! D !! L !! GF !! GA !! GD !! Pts'
  for i, r in ipairs(list) do
    out[#out+1] = "|-"
    out[#out+1] = string.format("| %d || %s || %d || %d || %d || %d || %d || %d || %d || %d",
      i, (r.team.logo ~= "—" and (r.team.logo.." ") or "") .. r.team.fullName,
      r.P, r.W, r.D, r.L, r.GF, r.GA, r.GD, r.Pts
    )
  end
  out[#out+1] = "|}"
  out[#out+1] = ""

  local finalAbs = rcStartAbs + (RC_ROUND_GAP * (#fixtures - 1)) + RC_FINAL_GAP
  out[#out+1] = "=== Finals Day ==="
  out[#out+1] = "''" .. _dateLabelFromAbs(finalAbs) .. "''"

  local A1 = list[1] and list[1].team or { fullName="TBD", logo="—", squad={} }
  local A2 = list[2] and list[2].team or { fullName="TBD", logo="—", squad={} }
  local A3 = list[3] and list[3].team or { fullName="TBD", logo="—", squad={} }
  local A4 = list[4] and list[4].team or { fullName="TBD", logo="—", squad={} }

  local function line(label, X, Y, saltK)
    local score = "—"
    local xName = ((X.logo and X.logo ~= "—") and (X.logo .. " ") or "") .. X.fullName
    local yName = ((Y.logo and Y.logo ~= "—") and (Y.logo .. " ") or "") .. Y.fullName
    local winner = "—"

    if absNow >= finalAbs and X.fullName ~= "TBD" and Y.fullName ~= "TBD" then
      local gx, gy = _rcPlayScore(X, Y, finalAbs*100 + saltK)
      score = string.format("'''%d-%d'''", gx, gy)
      winner = (gx > gy) and xName or ((gy > gx) and yName or "Draw")
    end

    return string.format("* %s: %s %s %s (Winner: %s)", label, xName, score, yName, 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

function p.renderRoster(frame)
  local args = frame.args or {}
  local team = args.team or args[1]
  if not team or not TEAM_BY_CODE[team] then
    return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''"
  end

  local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()

  local deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local rosters = _applyTransfersUpTo(base, deals, upToMd)
  local r = rosters[team] or {}

  local out = {
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    '! # !! Player !! Position !! ID !! Notes'
  }
  for _,pl in ipairs(r) do
    out[#out+1] = '|-'
    out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> || %s',
      pl.no or 0, pl.name or '—', pl.pos or '—', pl.id or '—', pl.notes or '—'
    )
  end
  out[#out+1] = '|}'

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

function p.renderAllRosters(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()

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

function p.renderTransferLog(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()

  local deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local nameOf = _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 <= upToMd then
      local aName = nameOf[d.A_id] or d.A_id
      local bName = nameOf[d.B_id] or d.B_id

      out[#out+1] = '|-'
      out[#out+1] = string.format('| %d || %s || %s || %s ⇄ %s (%s)',
        d.md, _teamWithLogo(d.A), _teamWithLogo(d.B),
        aName, bName, d.pos or '—'
      )
    end
  end

  out[#out+1] = '|}'

  return "'''Transfer window (Matchdays " .. TRANSFER_MD_START .. "–" .. TRANSFER_MD_END ..
    ") — season " .. season .. " (through Matchday " .. tostring(upToMd) .. ")'''\n" ..
    table.concat(out, "\n")
end


function p.renderPlayerStats(frame)
  local args = frame.args or {}
  local team = args.team or args[1]
  if not team or not TEAM_BY_CODE[team] then
    return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''"
  end

  local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()

  local totals, deals, base = _computePlayerSeasonTotals(season, upToMd)
  local rostersNow = _applyTransfersUpTo(base, deals, upToMd)
  local rosterNow = rostersNow[team] or {}

  local out = {
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    '! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'
  }

  for _,pl in ipairs(rosterNow) do
    local r = totals[pl.id]
    out[#out+1] = '|-'
    out[#out+1] = string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d',
      pl.name, pl.pos,
      r and r.apps or 0,
      r and r.goals or 0,
      r and r.assists or 0,
      r and r.poss or 0,
      r and r.son or 0,
      r and r.sof or 0,
      r and r.fouls or 0
    )
  end

  out[#out+1] = '|}'

  return (TEAM_BY_CODE[team].logo or "") ..
    " '''" .. team .. "''' — Player totals through Matchday " .. tostring(upToMd) .. "\n" ..
    table.concat(out, "\n")
end

function p.renderLeaders(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()

  local stat = (args.stat or args[2] or "goals")
  local ok = { goals=true, assists=true, poss=true, son=true, sof=true, fouls=true, apps=true }
  if not ok[stat] then
    return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''"
  end

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

  table.sort(list, function(a,b)
    if (a[stat] or 0) ~= (b[stat] or 0) then return (a[stat] or 0) > (b[stat] or 0) end
    return (a.name or "") < (b.name or "")
  end)

  local out = {
    '{| class="wikitable sortable" style="width:100%; font-size:90%;"',
    '! Rank !! Player !! Team (current) !! Pos !! ' .. stat
  }

  for i=1, math.min(20, #list) do
    local r = list[i]
    out[#out+1] = '|-'
    out[#out+1] = string.format('| %d || %s || %s || %s || %d',
      i, r.name or "—", _teamWithLogo(r.team or "—"), r.pos or "—", r[stat] or 0
    )
  end

  out[#out+1] = '|}'

  return "'''League leaders (" .. stat .. ") — season " .. season ..
    " through Matchday " .. tostring(upToMd) .. "'''\n" ..
    table.concat(out, "\n")
end

------------------------------------------------------------------------
-- 11) MATCH REPORTS + DERBY TAG
------------------------------------------------------------------------
local function _fmtTime(seconds)
  local m = math.floor(seconds / 60)
  local s = seconds % 60
  return string.format("%02d:%02d", m, s)
end

local function _abbr(teamName)
  local letters = {}
  for w in mw.ustring.gmatch(teamName or "", "%S+") do
    letters[#letters+1] = mw.ustring.upper(mw.ustring.sub(w, 1, 1))
  end
  local a = table.concat(letters, "")
  if #a >= 2 then return mw.ustring.sub(a, 1, 2) end
  return (a ~= "" and a) or "XX"
end

local function _randTime2(st, startSec, endSec)
  local r; st, r = _rngNext(st)
  local span = math.max(1, endSec - startSec - 2)
  local sec = startSec + 1 + math.floor(r * span)
  return st, sec
end

local function _genTimes2(st, n, firstStart, firstEnd, secondStart, secondEnd)
  local times = {}
  if n <= 0 then return st, times end
  local split = math.ceil(n / 2)
  for i=1, n do
    if i <= split then
      st, times[i] = _randTime2(st, firstStart, firstEnd)
    else
      st, times[i] = _randTime2(st, secondStart, secondEnd)
    end
  end
  table.sort(times)
  return st, times
end

local function _timesList2(times, maxn)
  if not times or #times == 0 then return "" end
  maxn = maxn or 3
  local out = {}
  for i = 1, math.min(maxn, #times) do out[#out+1] = _fmtTime(times[i]) end
  if #times > maxn then out[#out+1] = "…" end
  return " (" .. table.concat(out, ", ") .. ")"
end

local function _rivalryTag(homeCode, awayCode)
  local dh, da = DIV_OF[homeCode], DIV_OF[awayCode]
  if dh and da and dh == da then
    return " (Divisional derby — " .. dh .. ")"
  end
  return ""
end

local function _appendToBoldTitle(title, suffix)
  if not suffix or suffix == "" then return title end
  if not title then return suffix end
  if mw.ustring.match(title, "'''%s*$") then
    return mw.ustring.gsub(title, "'''%s*$", suffix .. "'''", 1)
  end
  return title .. suffix
end

local function _pickMatchIndexForDay(season, md, pickMode)
  pickMode = pickMode or "best"
  local fixtures = getRoundMatches(md)
  if not fixtures or #fixtures == 0 then return nil, "No fixtures found for this matchday." end

  local inv = invertDayMap(mapDays())
  local day = inv[md] or (START + md)

  local bestI, bestScore = 1, -1e18
  for i, m in ipairs(fixtures) do
    local home, away = m[1], m[2]
    local salt = day * 1000 + i + string.byte(home.code,1) + string.byte(away.code,1)
    local hs, as = simScoreFor(home, away, salt)
    local S = computeMatchStats(home, away, hs, as, salt)
    local H = S[home.code] or {}
    local A = S[away.code] or {}

    local diff  = math.abs(hs - as)
    local total = hs + as
    local chaos = (tonumber(H.fouls) or 0) + (tonumber(A.fouls) or 0)
                + (tonumber(H.shots_on) or 0) + (tonumber(A.shots_on) or 0)
                + (tonumber(H.shots_off) or 0) + (tonumber(A.shots_off) or 0)

    local q
    if pickMode == "close" then
      q = (50 - diff * 10) + total + chaos * 0.25
    elseif pickMode == "high" then
      q = total * 12 - diff * 2 + chaos * 0.20
    elseif pickMode == "chaos" then
      q = chaos * 10 + total * 2 - diff
    else
      q = (20 - diff * 6) + total * 8 + chaos * 0.35
    end

    if q > bestScore then bestScore, bestI = q, i end
  end
  return bestI
end

local function _pickPlayer(st, roster, posA, posB, posC)
  local candidates = {}
  for _,p in ipairs(roster or {}) do
    if p.pos == posA or p.pos == posB or p.pos == posC then
      candidates[#candidates+1] = p
    end
  end
  if #candidates == 0 then candidates = roster or {} end
  local pl; st, pl = _pick(st, candidates)  -- uses the PRNG helper already defined in roster section
  return st, pl
end


local function _renderMatchReport(season, md, matchIndex, titleOverride)
  local fixtures = getRoundMatches(md)
  if not fixtures or not fixtures[matchIndex] then
    return "'''No such match (md=" .. tostring(md) .. ", match=" .. tostring(matchIndex) .. ").'''"
  end

  local inv = invertDayMap(mapDays())
  local day = inv[md] or (START + md)

  local home, away = fixtures[matchIndex][1], fixtures[matchIndex][2]
  local derby = _rivalryTag(home.code, away.code)

  local salt = day * 1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
  local hs, as = simScoreFor(home, away, salt)

  local S = computeMatchStats(home, away, hs, as, salt)
  local H = S[home.code] or {}
  local A = S[away.code] or {}

  local deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local rosters = _applyTransfersUpTo(base, deals, md)
  local homeRoster = rosters[home.code] or {}
  local awayRoster = rosters[away.code] or {}

  local st = _seedFromString(tostring(season).."|REPORT|MD"..md.."|M"..matchIndex.."|"..home.code.."|"..away.code)
  local FIRST_START, FIRST_END   = 0, 35*60
  local SECOND_START, SECOND_END = 35*60, 70*60

  local homeTag, awayTag = _abbr(home.code), _abbr(away.code)

  local homePP, awayPP, homeG, awayG
  st, homePP = _genTimes2(st, tonumber(H.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayPP = _genTimes2(st, tonumber(A.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, homeG  = _genTimes2(st, tonumber(H.goals) or 0,       FIRST_START, FIRST_END, SECOND_START, SECOND_END)
  st, awayG  = _genTimes2(st, tonumber(A.goals) or 0,       FIRST_START, FIRST_END, SECOND_START, SECOND_END)

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

  -- scoring timeline (+3 goal, +1 possession)
  local scoreEvents = {}
  local function queueScore(sec, which, kind) scoreEvents[#scoreEvents+1] = { t=sec, which=which, kind=kind } end
  for _,sec in ipairs(homePP) do queueScore(sec, "H", "PP") end
  for _,sec in ipairs(awayPP) do queueScore(sec, "A", "PP") end
  for _,sec in ipairs(homeG)  do queueScore(sec, "H", "G")  end
  for _,sec in ipairs(awayG)  do queueScore(sec, "A", "G")  end
  table.sort(scoreEvents, function(a,b) return a.t < b.t end)

  local events = {}
  local function addEvent(sec, text) events[#events+1] = { t=sec, text=text } end

  local hRun, aRun = 0, 0
  local hHalf, aHalf = 0, 0

  for _,e in ipairs(scoreEvents) do
    local add = (e.kind == "G") and 3 or 1
    if e.which == "H" then hRun = hRun + add else aRun = aRun + add end
    if e.t < 35*60 then hHalf, aHalf = hRun, aRun end

    local tag    = (e.which=="H") and homeTag or awayTag
    local roster = (e.which=="H") and homeRoster or awayRoster
    local scoreLine = string.format("%s %d–%d %s", _teamWithLogo(home), hRun, aRun, _teamWithLogo(away))

    if e.kind == "G" then
      local pz; st, pz = _pickPlayer(st, roster, "Striker", "Utility", "Playmaker")
      addEvent(e.t, string.format("%s – Goal (Pillar Strike) (%s): %s converts a direct pillar strike. (%s)",
        _fmtTime(e.t), tag, (pz and pz.name or "a forward"), scoreLine))
    else
      local pp; st, pp = _pickPlayer(st, roster, "Playmaker", "Utility", "Striker")
      addEvent(e.t, string.format("%s – Possession Play (%s): %s completes a 10-second controlled hold in the scoring zone. (%s)",
        _fmtTime(e.t), tag, (pp and pp.name or "a midfielder"), scoreLine))
    end
  end

  local function addHighlights(times, roster, tag, label, posA, posB, posC, template)
    local cap = math.min(2, #times)
    for i=1, cap do
      local sec = times[i]
      local px; st, px = _pickPlayer(st, roster, posA, posB, posC)
      addEvent(sec, string.format("%s – %s (%s): " .. template,
        _fmtTime(sec), label, tag, (px and px.name or "a player")))
    end
  end

  addHighlights(homeFOUL, homeRoster, homeTag, "Foul", "Defender", "Utility", "Playmaker",
    "%s clips a runner during a shielded carry; restart taken quickly.")
  addHighlights(awayFOUL, awayRoster, awayTag, "Foul", "Defender", "Utility", "Playmaker",
    "%s commits a tactical pull in transition; shape preserved on the restart.")
  addHighlights(homeSON, homeRoster, homeTag, "Shot on Target", "Striker", "Utility", "Playmaker",
    "%s forces a controlled save off a pillar-side angle; no strike awarded.")
  addHighlights(awaySON, awayRoster, awayTag, "Shot on Target", "Striker", "Utility", "Playmaker",
    "%s drives a low effort that glances the casing; play continues.")
  addHighlights(homeOOB, homeRoster, homeTag, "Out of Bounds", "Utility", "Defender", "Playmaker",
    "%s overhits a diagonal switch; possession turns over at the boundary.")
  addHighlights(awayOOB, awayRoster, awayTag, "Out of Bounds", "Utility", "Defender", "Playmaker",
    "%s sends a pressured clearance long; reset follows.")

  table.sort(events, function(a,b) return a.t < b.t end)

  local firstHalf, secondHalf = {}, {}
  for _,e in ipairs(events) do
    if e.t < 35*60 then firstHalf[#firstHalf+1] = e.text else secondHalf[#secondHalf+1] = e.text end
  end

  local diff = math.abs(hs - as)
  local mood = (diff <= 1) and "a tight, tactical contest" or "a high-variance clash"
  local summary = string.format(
    "Matchday %d featured %s defined by controlled zone entries and disciplined restarts. %s edged %s %d–%d, with decisive moments coming from timed possession holds and selective pillar pressure rather than sustained long-range attempts.",
    md, mood, _teamWithLogo(home), _teamWithLogo(away), hs, as
  )

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

  local out = {}
  out[#out+1] = title
  out[#out+1] = ""
  out[#out+1] = "=== Match Summary ==="
  out[#out+1] = summary
  out[#out+1] = ""
  out[#out+1] = "=== Key Events by Time ==="
  out[#out+1] = ""
  out[#out+1] = "==== First Half (0:00–35:00) ===="
  if #firstHalf == 0 then out[#out+1] = "—" else for _,ln in ipairs(firstHalf) do out[#out+1] = "* " .. ln end end
  out[#out+1] = ""
  out[#out+1] = string.format("35:00 – Halftime: %s %d–%d %s.", _teamWithLogo(home), hHalf, aHalf, _teamWithLogo(away))
  out[#out+1] = ""
  out[#out+1] = "==== Second Half (35:00–70:00) ===="
  if #secondHalf == 0 then out[#out+1] = "—" else for _,ln in ipairs(secondHalf) do out[#out+1] = "* " .. ln end end
  out[#out+1] = ""
  out[#out+1] = string.format("70:00 – Final Whistle: %s %d–%d %s.", _teamWithLogo(home), hs, as, _teamWithLogo(away))
  out[#out+1] = ""
  out[#out+1] = "=== Match Stats ==="
  out[#out+1] = '{| class="wikitable" style="width:100%; font-size:90%;"'
  out[#out+1] = string.format('! Statistic !! %s !! %s', _teamWithLogo(home), _teamWithLogo(away))
  out[#out+1] = '|-'; out[#out+1] = string.format('| Final Score || %d || %d', hs, as)
  out[#out+1] = '|-'
  out[#out+1] = string.format('| Goals (Pillar Strikes) || %d%s || %d%s',
    tonumber(H.goals) or 0, _timesList2(homeG, 3),
    tonumber(A.goals) or 0, _timesList2(awayG, 3)
  )
  out[#out+1] = '|-'
  out[#out+1] = string.format('| Possession Points || %d%s || %d%s',
    tonumber(H.poss_points) or 0, _timesList2(homePP, 4),
    tonumber(A.poss_points) or 0, _timesList2(awayPP, 4)
  )
  out[#out+1] = '|-'; out[#out+1] = string.format('| Shots on Target || %d || %d', tonumber(H.shots_on) or 0, tonumber(A.shots_on) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Shots off Target || %d || %d', tonumber(H.shots_off) or 0, tonumber(A.shots_off) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Fouls || %d || %d', tonumber(H.fouls) or 0, tonumber(A.fouls) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Free Kicks Awarded || %d || %d', tonumber(H.fks) or 0, tonumber(A.fks) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Out of Bounds || %d || %d', tonumber(H.oob) or 0, tonumber(A.oob) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Throw-Ins || %d || %d', tonumber(H.throws) or 0, tonumber(A.throws) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Goal Kicks || %d || %d', tonumber(H.gk) or 0, tonumber(A.gk) or 0)
  out[#out+1] = '|-'; out[#out+1] = string.format('| Ball Possession || %d%% || %d%%', tonumber(H.poss_pct) or 0, tonumber(A.poss_pct) or 0)
  out[#out+1] = '|}'
  return table.concat(out, "\n")
end

function p.renderMatchOfWeek(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
  local pick = args.pick or "best"

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

  local idx, err = _pickMatchIndexForDay(season, md, pick)
  if not idx then return "'''" .. (err or "Unable to select match.") .. "'''" end
  return _renderMatchReport(season, md, idx)
end

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

function p.renderMatchReport(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local md = tonumber(args.md or args.matchday or args[2] or "") or _currentPlayedMatchday()
  local matchIndex = tonumber(args.match or args[3] or "1") or 1
  local titleOverride = string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''", season, md, matchIndex)
  return _renderMatchReport(season, md, matchIndex, titleOverride)
end

------------------------------------------------------------------------
-- 12) WEEKLY AWARDS + MATCHDAY CAPSULE
------------------------------------------------------------------------
function p.renderWeeklyAwards(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
  if md < 1 then return "'''No matchday has been played yet.'''" end

  local inv = invertDayMap(mapDays())
  local day = inv[md] or (START + md)

  local deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local rosters = _applyTransfersUpTo(base, deals, md)

  local function clamp(x, lo, hi)
    if x < lo then return lo end
    if x > hi then return hi end
    return x
  end

  local function scorePlayer(line, pos, oppShotsOn, oppGoals)
    local goals   = line.goals or 0
    local assists = line.assists or 0
    local poss    = line.poss or 0
    local son     = line.son or 0
    local sof     = line.sof or 0
    local fouls   = line.fouls or 0

    local rating = 6
      + goals*1.25
      + assists*0.85
      + poss*0.05
      + son*0.15
      - sof*0.08
      - fouls*0.16

    if pos == "Keeper" then
      local saves = math.max(0, (oppShotsOn or 0) - (oppGoals or 0))
      rating = 6 + saves*0.25 - fouls*0.10
      if (oppGoals or 0) == 0 then rating = rating + 1.0 end
    end

    return clamp(rating, 0, 10)
  end

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

  local function consider(entry, bucket)
    if not best[bucket] or entry.rating > best[bucket].rating then best[bucket] = entry end
  end

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

    local salt = day*1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
    local hs, as = simScoreFor(home, away, salt)
    local S = computeMatchStats(home, away, hs, as, salt)
    local H = S[home.code] or {}
    local A = S[away.code] or {}

    -- allocate simple per-player contributions deterministically for awards
    local st = _seedFromString(tostring(season).."|AWARDS|MD"..md.."|D"..day.."|M"..matchIndex.."|"..home.code.."|"..away.code)
    local linesH, linesA = {}, {}

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

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

    local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
    local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
    local W_SHOT = { Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
    local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }

    for _=1, tonumber(H.goals) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_GOAL)
      ensure(linesH, pl.id).goals = ensure(linesH, pl.id).goals + 1
      local r; st, r = _rngNext(st)
      if r < 0.70 then
        local a; st, a = pickWeighted(st, hr, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
        if a and a.id ~= pl.id then ensure(linesH, a.id).assists = ensure(linesH, a.id).assists + 1 end
      end
    end
    for _=1, tonumber(A.goals) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_GOAL)
      ensure(linesA, pl.id).goals = ensure(linesA, pl.id).goals + 1
      local r; st, r = _rngNext(st)
      if r < 0.70 then
        local a; st, a = pickWeighted(st, ar, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
        if a and a.id ~= pl.id then ensure(linesA, a.id).assists = ensure(linesA, a.id).assists + 1 end
      end
    end

    for _=1, tonumber(H.poss_points) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_POSS)
      ensure(linesH, pl.id).poss = ensure(linesH, pl.id).poss + 1
    end
    for _=1, tonumber(A.poss_points) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_POSS)
      ensure(linesA, pl.id).poss = ensure(linesA, pl.id).poss + 1
    end

    for _=1, tonumber(H.shots_on) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_SHOT)
      ensure(linesH, pl.id).son = ensure(linesH, pl.id).son + 1
    end
    for _=1, tonumber(H.shots_off) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_SHOT)
      ensure(linesH, pl.id).sof = ensure(linesH, pl.id).sof + 1
    end
    for _=1, tonumber(A.shots_on) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_SHOT)
      ensure(linesA, pl.id).son = ensure(linesA, pl.id).son + 1
    end
    for _=1, tonumber(A.shots_off) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_SHOT)
      ensure(linesA, pl.id).sof = ensure(linesA, pl.id).sof + 1
    end

    for _=1, tonumber(H.fouls) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_FOUL)
      ensure(linesH, pl.id).fouls = ensure(linesH, pl.id).fouls + 1
    end
    for _=1, tonumber(A.fouls) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_FOUL)
      ensure(linesA, pl.id).fouls = ensure(linesA, pl.id).fouls + 1
    end

    local function evalTeam(teamObj, roster, lines, oppStats)
      local oppShotsOn = tonumber(oppStats.shots_on) or 0
      local oppGoals   = tonumber(oppStats.goals) or 0
      for _,pp in ipairs(roster or {}) do
        local line = lines[pp.id] or {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0}
        local rating = scorePlayer(line, pp.pos, oppShotsOn, oppGoals)

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

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

        consider(entry, "overall")
        if pp.pos == "Striker"   then consider(entry, "striker") end
        if pp.pos == "Playmaker" then consider(entry, "playmaker") end
        if pp.pos == "Defender"  then consider(entry, "defender") end
        if pp.pos == "Keeper"    then consider(entry, "keeper") end
      end
    end

    evalTeam(home, hr, linesH, A)
    evalTeam(away, ar, linesA, H)
  end

  local out = {}
  out[#out+1] = "=== Weekly Awards (Matchday " .. tostring(md) .. ") ==="
  out[#out+1] = '{| class="wikitable sortable" style="width:100%; font-size:90%;"'
  out[#out+1] = "! Award !! Winner !! Team !! Pos !! G !! A !! Poss !! S-on !! S-off !! Fouls !! Saves/CS !! Rating"

  local function row(label, e)
    if not e then return end
    local sc = "—"
    if e.pos == "Keeper" then sc = tostring(e.saves or 0) .. ((e.clean and " / CS") or "") end
    out[#out+1] = "|-"
    out[#out+1] = string.format("| %s || %s || %s || %s || %d || %d || %d || %d || %d || %d || %s || %.2f",
      label, e.name or "—", _teamWithLogo(e.team or "—"), e.pos or "—",
      e.goals or 0, e.assists or 0, e.poss or 0, e.son or 0, e.sof or 0, e.fouls or 0, sc, e.rating or 0
    )
  end

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

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

------------------------------------------------------------------------
-- TEAM OF THE WEEK (Best XI) — per matchday
------------------------------------------------------------------------
function p.renderTeamOfWeek(frame)
  local args = (frame and frame.args) or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local md = tonumber(args.md or args.matchday or args[2] or "") or _currentPlayedMatchday()

  if md < 1 then
    return "'''No matchday has been played yet.'''"
  end

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

  local curr = date.getCurrentDate()
  local today = tonumber(curr:match("^(%d+),")) or 0
  local year  = tonumber(curr:match(", (%d+) PSSC")) or 0

  if day > today then
    local mon, dom = getMonthDay(day)
    return string.format(
      "'''Team of the Week will be available after Matchday %d is played (%d %s %d PSSC).'''",
      md, dom, mon, year
    )
  end

  -- Rating logic (matches Weekly Awards)
  local function clamp(x, lo, hi)
    if x < lo then return lo end
    if x > hi then return hi end
    return x
  end

  local function scorePlayer(line, pos, oppShotsOn, oppGoals)
    line = line or {}
    local goals   = line.goals or 0
    local assists = line.assists or 0
    local poss    = line.poss or 0
    local son     = line.son or 0
    local sof     = line.sof or 0
    local fouls   = line.fouls or 0

    local rating = 6
      + goals*1.25
      + assists*0.85
      + poss*0.05
      + son*0.15
      - sof*0.08
      - fouls*0.16

    if pos == "Keeper" then
      local saves = math.max(0, (oppShotsOn or 0) - (oppGoals or 0))
      rating = 6 + saves*0.25 - fouls*0.10
      if (oppGoals or 0) == 0 then rating = rating + 1.0 end
    end

    return clamp(rating, 0, 10)
  end

  -- Rosters as of this matchday (transfers applied)
  local deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local rosters = _applyTransfersUpTo(base, deals, md)

  local fixtures = getRoundMatches(md)
  if not fixtures or #fixtures == 0 then
    return "'''No fixtures found for matchday " .. tostring(md) .. ".'''"
  end

  -- Collect best-per-position candidates across all matches this matchday
  local candidates = {} -- pid -> entry

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

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

  local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
  local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
  local W_SHOT = { Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
  local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }

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

    local salt = day*1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
    local hs, as = simScoreFor(home, away, salt)
    local S = computeMatchStats(home, away, hs, as, salt)
    local Hs = S[home.code] or {}
    local As = S[away.code] or {}

    local st = _seedFromString(tostring(season).."|TOTW|MD"..md.."|D"..day.."|M"..matchIndex.."|"..home.code.."|"..away.code)
    local linesH, linesA = {}, {}

    -- Allocate events (same style as Weekly Awards)
    for _=1, tonumber(Hs.goals) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_GOAL)
      ensure(linesH, pl.id).goals = ensure(linesH, pl.id).goals + 1
      local r; st, r = _rngNext(st)
      if r < 0.70 then
        local aPl; st, aPl = pickWeighted(st, hr, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
        if aPl and aPl.id ~= pl.id then ensure(linesH, aPl.id).assists = ensure(linesH, aPl.id).assists + 1 end
      end
    end
    for _=1, tonumber(As.goals) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_GOAL)
      ensure(linesA, pl.id).goals = ensure(linesA, pl.id).goals + 1
      local r; st, r = _rngNext(st)
      if r < 0.70 then
        local aPl; st, aPl = pickWeighted(st, ar, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
        if aPl and aPl.id ~= pl.id then ensure(linesA, aPl.id).assists = ensure(linesA, aPl.id).assists + 1 end
      end
    end

    for _=1, tonumber(Hs.poss_points) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_POSS)
      ensure(linesH, pl.id).poss = ensure(linesH, pl.id).poss + 1
    end
    for _=1, tonumber(As.poss_points) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_POSS)
      ensure(linesA, pl.id).poss = ensure(linesA, pl.id).poss + 1
    end

    for _=1, tonumber(Hs.shots_on) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_SHOT)
      ensure(linesH, pl.id).son = ensure(linesH, pl.id).son + 1
    end
    for _=1, tonumber(Hs.shots_off) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_SHOT)
      ensure(linesH, pl.id).sof = ensure(linesH, pl.id).sof + 1
    end
    for _=1, tonumber(As.shots_on) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_SHOT)
      ensure(linesA, pl.id).son = ensure(linesA, pl.id).son + 1
    end
    for _=1, tonumber(As.shots_off) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_SHOT)
      ensure(linesA, pl.id).sof = ensure(linesA, pl.id).sof + 1
    end

    for _=1, tonumber(Hs.fouls) or 0 do
      local pl; st, pl = pickWeighted(st, hr, W_FOUL)
      ensure(linesH, pl.id).fouls = ensure(linesH, pl.id).fouls + 1
    end
    for _=1, tonumber(As.fouls) or 0 do
      local pl; st, pl = pickWeighted(st, ar, W_FOUL)
      ensure(linesA, pl.id).fouls = ensure(linesA, pl.id).fouls + 1
    end

    local function considerTeam(teamObj, roster, lines, oppStats)
      local oppShotsOn = tonumber(oppStats.shots_on) or 0
      local oppGoals   = tonumber(oppStats.goals) or 0

      for _, pp in ipairs(roster or {}) do
        local pid = pp.id
        local line = lines[pid] or {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0}
        local rating = scorePlayer(line, pp.pos, oppShotsOn, oppGoals)

        local entry = candidates[pid]
        if not entry then
          entry = {
            id=pid, name=pp.name or pid, pos=pp.pos or "—",
            team=teamObj.code, rating=rating,
            goals=line.goals or 0, assists=line.assists or 0, poss=line.poss or 0,
            son=line.son or 0, sof=line.sof or 0, fouls=line.fouls or 0,
            saves=0, clean=false
          }
          candidates[pid] = entry
        else
          -- In practice a player appears once per matchday; keep the better rating if somehow duplicated.
          if rating > (entry.rating or 0) then entry.rating = rating end
        end

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

    considerTeam(home, hr, linesH, As)
    considerTeam(away, ar, linesA, Hs)
  end

  local function sortByRating(list)
    table.sort(list, function(a,b)
      if a.rating ~= b.rating then return a.rating > b.rating end
      if a.goals  ~= b.goals  then return a.goals  > b.goals  end
      if a.assists~= b.assists then return a.assists> b.assists end
      if a.poss   ~= b.poss   then return a.poss   > b.poss   end
      if a.son    ~= b.son    then return a.son    > b.son    end
      return (a.name or "") < (b.name or "")
    end)
  end

  local buckets = { Keeper={}, Defender={}, Playmaker={}, Striker={}, Utility={} }
  for _, r in pairs(candidates) do
    local pos = r.pos or "—"
    if buckets[pos] then
      table.insert(buckets[pos], r)
    end
  end

  for _, list in pairs(buckets) do sortByRating(list) end

  local XI = {}
  local function take(role, n)
    for i=1, math.min(n, #(buckets[role] or {})) do
      XI[#XI+1] = { role=role, r=buckets[role][i] }
    end
  end

  take("Keeper", 1)
  take("Defender", 3)
  take("Playmaker", 3)
  take("Striker", 2)
  take("Utility", 3)

  local mon, dom = getMonthDay(day)
  local dateLabel = dom .. " " .. mon .. " " .. year .. " PSSC"

  local out = {}
  out[#out+1] = "=== Team of the Week (Matchday " .. tostring(md) .. ") ==="
  out[#out+1] = "''" .. dateLabel .. "''"
  out[#out+1] = '{| class="wikitable sortable" style="width:100%; font-size:90%;"'
  out[#out+1] = "! Role !! Player !! Team !! Rating !! Key stats"

  for _, e in ipairs(XI) do
    local r = e.r
    local stats = string.format("G:%d A:%d Poss:%d S-on:%d Fouls:%d", r.goals, r.assists, r.poss, r.son, r.fouls)
    if r.pos == "Keeper" then
      stats = stats .. string.format("; Saves:%d%s", r.saves or 0, (r.clean and "; CS" or ""))
    end

    out[#out+1] = "|-"
    out[#out+1] = string.format(
      "| %s || '''%s'''<br><code>%s</code> || %s || %.2f || %s",
      e.role,
      r.name or "—", r.id or "—",
      _teamWithLogo(r.team or "—"),
      tonumber(r.rating) or 0,
      stats
    )
  end

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


function p.renderMatchdayCapsule(frame)
  local args = frame.args or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
  local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
  local pick = args.pick or "chaos"

  if md < 1 then return "'''No matchday has been played yet.'''"
  end

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

  local results = simulateDayResults(day)

  local out = {}
  out[#out+1] = "== Matchday " .. tostring(md) .. " Capsule =="
  out[#out+1] = ""
  out[#out+1] = p.renderMatchOfWeek({ args = { season = season, md = md, pick = pick } })
  out[#out+1] = ""
  out[#out+1] = "=== Full Results ==="
  for _, r in ipairs(results or {}) do
    out[#out+1] = string.format("* %s '''%d''' %s vs %s '''%d''' %s",
      (r.home and r.home.logo) or "",
      tonumber(r.hg) or 0,
      (r.home and (r.home.fullName or r.home.code)) or "Home",
      (r.away and r.away.logo) or "",
      tonumber(r.ag) or 0,
      (r.away and (r.away.fullName or r.away.code)) or "Away"
    )
  end
  out[#out+1] = ""
  out[#out+1] = p.renderWeeklyAwards({ args = { season = season, md = md } })

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


function p.renderMatchdayDensity(frame)
  local totalDays = END - START + 1
  local totalRnds = #SCHED_LOCKED
  local iv = math.floor(totalDays / totalRnds)
  local ex = totalDays % totalRnds

  local dm = mapDays()
  local cnt = 0
  for d=START, END do
    if dm[d] then cnt = cnt + 1 end
  end

  return string.format(
    "Locked rounds: %d; season days: %d; iv=%d; ex=%d; matchdays placed=%d",
    totalRnds, totalDays, iv, ex, cnt
  )
end


function p.renderNextMatchdays(frame)
  local dm = mapDays()
  local curr = date.getCurrentDate()
  local today = tonumber(curr:match("^(%d+),")) or 0
  local year  = tonumber(curr:match(", (%d+) PSSC")) or 0

  local out = {
    '{| class="wikitable"',
    '! # !! Matchday !! Calendar Day !! Gap (days) !! Date'
  }

  local shown = 0
  local prevDay = nil

  for d = today, END do
    local md = dm[d]
    if md then
      local mon, dom = getMonthDay(d)
      local gap = prevDay and (d - prevDay) or "—"
      prevDay = d

      out[#out+1] = '|-'
      out[#out+1] =
        '| ' .. (shown + 1) ..
        ' || ' .. md ..
        ' || ' .. d ..
        ' || ' .. gap ..
        ' || ' .. dom .. ' ' .. mon .. ' ' .. year .. ' PSSC'

      shown = shown + 1
      if shown >= 20 then break end
    end
  end

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

------------------------------------------------------------------------
-- 13) END-OF-SEASON AWARDS (regular season)
------------------------------------------------------------------------

local function _clamp(x, lo, hi)
  if x < lo then return lo end
  if x > hi then return hi end
  return x
end

-- Same per-match rating logic as Weekly Awards (aggregated across season)
local function _scorePlayer(line, pos, oppShotsOn, oppGoals)
  line = line or {}
  local goals   = tonumber(line.goals) or 0
  local assists = tonumber(line.assists) or 0
  local poss    = tonumber(line.poss) or 0
  local son     = tonumber(line.son) or 0
  local sof     = tonumber(line.sof) or 0
  local fouls   = tonumber(line.fouls) or 0

  local rating = 6
    + goals*1.25
    + assists*0.85
    + poss*0.05
    + son*0.15
    - sof*0.08
    - fouls*0.16

  if pos == "Keeper" then
    local saves = math.max(0, (tonumber(oppShotsOn) or 0) - (tonumber(oppGoals) or 0))
    rating = 6 + saves*0.25 - fouls*0.10
    if (tonumber(oppGoals) or 0) == 0 then rating = rating + 1.0 end
  end

  return _clamp(rating, 0, 10)
end

local function _finalRegularSeasonDay()
  local inv = invertDayMap(mapDays())
  return inv[TOTAL_ROUNDS] or END
end

local function _isRegularSeasonComplete()
  local curr = date.getCurrentDate()
  local today = tonumber(curr:match("^(%d+),")) or 0
  return today >= _finalRegularSeasonDay()
end

local function _buildFinalPlayerInfo(season)
  local deals = _genTransferDeals(season)
  local base  = _buildBaseRosters(season)
  local rostersEnd = _applyTransfersUpTo(base, deals, TOTAL_ROUNDS)

  local info = {} -- pid -> {name,pos,team,no}
  for teamCode, roster in pairs(rostersEnd or {}) do
    for _, pl in ipairs(roster or {}) do
      if pl and pl.id then
        info[pl.id] = {
          name = pl.name or pl.id,
          pos  = pl.pos or "—",
          team = teamCode or "—",
          no   = pl.no or 0
        }
      end
    end
  end
  return info, deals, base
end

local function _ensureSeasonRow(T, pid, infoRow)
  if not T[pid] then
    T[pid] = {
      id = pid,
      name = (infoRow and infoRow.name) or pid,
      pos  = (infoRow and infoRow.pos)  or "—",
      team = (infoRow and infoRow.team) or "—",
      no   = (infoRow and infoRow.no)   or 0,

      apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0,
      ratingSum=0, ratingN=0,
      saves=0, clean=0
    }
  end
  return T[pid]
end

local function _computeSeasonAwardData(season)
  local dm  = mapDays()
  local inv = invertDayMap(dm)

  local finalInfo, deals, base = _buildFinalPlayerInfo(season)

  local totals = {}

  for md=1, TOTAL_ROUNDS do
    local day = inv[md]
    if day then
      local rostersNow = _applyTransfersUpTo(base, deals, md)
      local fixtures = getRoundMatches(md)

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

        -- Compute opponent stats (for keeper saves/clean + rating)
        local salt = day*1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
        local hg, ag = simScoreFor(home, away, salt)
        local S = computeMatchStats(home, away, hg, ag, salt)
        local Hs = S[home.code] or {}
        local As = S[away.code] or {}

        -- Per-player event allocation (consistent with your existing player stats logic)
        local ev = _matchPlayerEvents(season, md, day, matchIndex, home, away, hr, ar)

        local function processSide(roster, evSide, oppStats)
          local oppShotsOn = tonumber(oppStats.shots_on) or 0
          local oppGoals   = tonumber(oppStats.goals) or 0

          for _, pl in ipairs(roster or {}) do
            local pid = pl.id
            local infoRow = finalInfo[pid] or { name=pl.name or pid, pos=pl.pos, team=(pl.team or "—"), no=pl.no or 0 }
            local row = _ensureSeasonRow(totals, pid, infoRow)

            local line = (evSide and evSide[pid]) or {apps=0,goals=0,assists=0,poss=0,son=0,sof=0,fouls=0}
            local apps = tonumber(line.apps) or 0

            row.apps    = row.apps    + apps
            row.goals   = row.goals   + (tonumber(line.goals)   or 0)
            row.assists = row.assists + (tonumber(line.assists) or 0)
            row.poss    = row.poss    + (tonumber(line.poss)    or 0)
            row.son     = row.son     + (tonumber(line.son)     or 0)
            row.sof     = row.sof     + (tonumber(line.sof)     or 0)
            row.fouls   = row.fouls   + (tonumber(line.fouls)   or 0)

            if apps > 0 then
              local rating = _scorePlayer(line, row.pos, oppShotsOn, oppGoals)
              row.ratingSum = row.ratingSum + rating
              row.ratingN   = row.ratingN   + 1

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

        processSide(hr, ev.home, As)
        processSide(ar, ev.away, Hs)
      end
    end
  end

  -- Compute max apps for thresholding
  local maxApps = 0
  for _, r in pairs(totals) do
    if (r.apps or 0) > maxApps then maxApps = r.apps end
  end
  local minApps = math.max(10, math.floor(maxApps * 0.50))

  -- Helper: best by comparator
  local function pickBest(filterFn, scoreFn, tieFn)
    local best = nil
    for _, r in pairs(totals) do
      if not filterFn or filterFn(r) then
        local s = scoreFn(r)
        if best == nil then
          best = {r=r, s=s}
        else
          if s > best.s then
            best = {r=r, s=s}
          elseif s == best.s and tieFn then
            if tieFn(r, best.r) then best = {r=r, s=s} end
          end
        end
      end
    end
    return best and best.r or nil
  end

  local function pickMin(filterFn, scoreFn, tieFn)
    local best = nil
    for _, r in pairs(totals) do
      if not filterFn or filterFn(r) then
        local s = scoreFn(r)
        if best == nil then
          best = {r=r, s=s}
        else
          if s < best.s then
            best = {r=r, s=s}
          elseif s == best.s and tieFn then
            if tieFn(r, best.r) then best = {r=r, s=s} end
          end
        end
      end
    end
    return best and best.r or nil
  end

  local function avgRating(r)
    return (r.ratingN and r.ratingN > 0) and (r.ratingSum / r.ratingN) or 0
  end

  local tiebreakName = function(a,b) return (a.name or "") < (b.name or "") end

  local awards = {}
  awards.mvp = pickBest(
    function(r) return (r.apps or 0) >= minApps end,
    function(r) return avgRating(r) end,
    function(a,b) if a.goals ~= b.goals then return a.goals > b.goals end return tiebreakName(a,b) end
  )

  awards.goldenPillar = pickBest(nil, function(r) return r.goals or 0 end, tiebreakName)
  awards.playmaker    = pickBest(nil, function(r) return r.assists or 0 end, tiebreakName)
  awards.possession   = pickBest(nil, function(r) return r.poss or 0 end, tiebreakName)
  awards.sharpshooter = pickBest(nil, function(r) return r.son or 0 end, tiebreakName)
  awards.ironman      = pickBest(nil, function(r) return r.apps or 0 end, tiebreakName)

  awards.fairPlay = pickMin(
    function(r) return (r.apps or 0) >= minApps end,
    function(r) return r.fouls or 0 end,
    function(a,b) -- tie: higher apps wins; else name
      if (a.apps or 0) ~= (b.apps or 0) then return (a.apps or 0) > (b.apps or 0) end
      return tiebreakName(a,b)
    end
  )

  awards.goldenGloves = pickBest(
    function(r) return r.pos == "Keeper" and (r.apps or 0) >= minApps end,
    function(r) return avgRating(r) end,
    function(a,b) if (a.clean or 0) ~= (b.clean or 0) then return (a.clean or 0) > (b.clean or 0) end return tiebreakName(a,b) end
  )

  -- Team of the Season (Best XI) by avg rating within each position
  local function topN(pos, n)
    local list = {}
    for _, r in pairs(totals) do
      if r.pos == pos and (r.apps or 0) >= minApps then
        list[#list+1] = r
      end
    end
    table.sort(list, function(a,b)
      local ar, br = avgRating(a), avgRating(b)
      if ar ~= br then return ar > br end
      if (a.goals or 0) ~= (b.goals or 0) then return (a.goals or 0) > (b.goals or 0) end
      return (a.name or "") < (b.name or "")
    end)
    local out = {}
    for i=1, math.min(n, #list) do out[#out+1] = list[i] end
    return out
  end

  local teamXI = {
    Keeper    = topN("Keeper", 1),
    Defender  = topN("Defender", 3),
    Playmaker = topN("Playmaker", 3),
    Striker   = topN("Striker", 2),
    Utility   = topN("Utility", 2),
  }

  return awards, teamXI, minApps, maxApps
end

local function _fmtPlayerRow(r)
  if not r then return "—", "—", "—", "—" end
  local who = string.format("'''%s'''<br><code>%s</code>", r.name or "—", r.id or "—")
  local team = _teamWithLogo(r.team or "—")
  local pos = r.pos or "—"
  local gp = tostring(r.apps or 0)
  return who, team, pos, gp
end

function p.renderSeasonAwards(frame)
  local args = (frame and frame.args) or {}
  local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON

  local curr = date.getCurrentDate()
  local today = tonumber(curr:match("^(%d+),")) or 0
  local year  = tonumber(curr:match(", (%d+) PSSC")) or 0

  local finalDay = _finalRegularSeasonDay()
  if today < finalDay then
    local mon, dom = getMonthDay(finalDay)
    return string.format("'''End-of-season awards will be available after the regular season concludes (%d %s %d PSSC).'''", dom, mon, year)
  end

  local awards, teamXI, minApps, maxApps = _computeSeasonAwardData(season)

  local out = {}
  out[#out+1] = "== End-of-Season Awards =="
  out[#out+1] = string.format("''Eligibility threshold:'' at least %d appearances (max observed: %d).", minApps, maxApps)
  out[#out+1] = ""

  out[#out+1] = "=== Core Awards ==="
  out[#out+1] = '{| class="wikitable sortable" style="width:100%;"'
  out[#out+1] = "! Award !! Winner !! Team !! Pos !! GP !! Notes"
  local function addAwardRow(label, r, notes)
    local who, team, pos, gp = _fmtPlayerRow(r)
    out[#out+1] = "|-"
    out[#out+1] = string.format("| %s || %s || %s || %s || %s || %s", label, who, team, pos, gp, notes or "—")
  end

  local function avgR(r) return (r and r.ratingN and r.ratingN > 0) and (r.ratingSum / r.ratingN) or 0 end

  addAwardRow("MVP (Player of the Season)", awards.mvp,
    awards.mvp and string.format("Avg rating: %.2f; G:%d A:%d Poss:%d S-on:%d Fouls:%d", avgR(awards.mvp), awards.mvp.goals, awards.mvp.assists, awards.mvp.poss, awards.mvp.son, awards.mvp.fouls) or "—")

  addAwardRow("Golden Pillar (Top Scorer)", awards.goldenPillar,
    awards.goldenPillar and string.format("Goals: %d", awards.goldenPillar.goals) or "—")

  addAwardRow("Playmaker of the Season", awards.playmaker,
    awards.playmaker and string.format("Assists: %d", awards.playmaker.assists) or "—")

  addAwardRow("Possession Master", awards.possession,
    awards.possession and string.format("Possession points: %d", awards.possession.poss) or "—")

  addAwardRow("Golden Gloves (Goalkeeper of the Season)", awards.goldenGloves,
    awards.goldenGloves and string.format("Avg rating: %.2f; Saves:%d; Clean sheets:%d; Fouls:%d", avgR(awards.goldenGloves), awards.goldenGloves.saves, awards.goldenGloves.clean, awards.goldenGloves.fouls) or "—")

  out[#out+1] = "|}"
  out[#out+1] = ""

  out[#out+1] = "=== Performance Awards ==="
  out[#out+1] = '{| class="wikitable sortable" style="width:100%;"'
  out[#out+1] = "! Award !! Winner !! Team !! Pos !! GP !! Notes"

  addAwardRow("Sharpshooter (Shots on Target)", awards.sharpshooter,
    awards.sharpshooter and string.format("Shots on target: %d", awards.sharpshooter.son) or "—")

  addAwardRow("Discipline / Fair Play", awards.fairPlay,
    awards.fairPlay and string.format("Fouls: %d (min apps threshold applied)", awards.fairPlay.fouls) or "—")

  addAwardRow("Ironman (Most Appearances)", awards.ironman,
    awards.ironman and string.format("Appearances: %d", awards.ironman.apps) or "—")

  out[#out+1] = "|}"
  out[#out+1] = ""

  out[#out+1] = "=== Team of the Season (Best XI) ==="
  out[#out+1] = '{| class="wikitable" style="width:100%;"'
  out[#out+1] = "! Role !! Player !! Team !! Avg rating !! Key stats"
  local function addXI(role, list)
    for _, r in ipairs(list or {}) do
      out[#out+1] = "|-"
      out[#out+1] = string.format("| %s || '''%s'''<br><code>%s</code> || %s || %.2f || G:%d A:%d Poss:%d S-on:%d Fouls:%d",
        role,
        r.name or "—", r.id or "—",
        _teamWithLogo(r.team or "—"),
        avgR(r),
        r.goals or 0, r.assists or 0, r.poss or 0, r.son or 0, r.fouls or 0
      )
    end
  end

  addXI("Keeper", teamXI.Keeper)
  addXI("Defender", teamXI.Defender)
  addXI("Playmaker", teamXI.Playmaker)
  addXI("Striker", teamXI.Striker)
  addXI("Utility", teamXI.Utility)

  out[#out+1] = "|}"

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


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