Module:PillSeasonSchedule: Difference between revisions
From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs) No edit summary Tag: Manual revert |
NewZimiaGov (talk | contribs) No edit summary |
||
| (5 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
-- Module:PillSeasonSchedule | -- Module:PillSeasonSchedule | ||
-- COMPLETE 40-CLUB / 5-DIVISION EXPANSION REWRITE | |||
-- Built from the previously supplied season-lifecycle module. | |||
-- Historical 53 PSSC pairing data is retained in SCHED_LEGACY for reference; | |||
-- the active schedule uses the expanded 40-team competition. | |||
-- | |||
-- Purpose: | |||
-- * Preserve the established Pillarion regular-season structure. | |||
-- * Keep completed 53 PSSC results stable. | |||
-- * Automatically move from regular season -> playoffs -> Regional Cup | |||
-- -> offseason -> next regular season. | |||
-- * Freeze completed-season standings/statistics instead of resetting them | |||
-- when the PSSC calendar rolls into Atosiel of the following year. | |||
-- * Keep historical season= arguments working. | |||
-- * Give future seasons season-specific deterministic results so they do not | |||
-- simply replay 53 PSSC. | |||
-- | |||
-- The public render functions used by the existing Pillarion page are retained. | |||
local p = {} | local p = {} | ||
local date = require('Module:BassaridianCalendar') | local date = require('Module:BassaridianCalendar') | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- 0) CONFIG / CALENDAR | -- 0) CONFIG / CALENDAR / LIFECYCLE | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
local START, END = 62, 183 | local START, END = 62, 183 | ||
local DAYS_IN_YEAR = 183 | local DAYS_IN_YEAR = 183 | ||
local | local LEGACY_SEASON = 53 | ||
local PO_FIRST = 4 -- 4 Atosiel of season+1 | |||
local PO_IV = 3 -- playoff rounds every 3 days | |||
local RC_GAP_AFTER_FINAL = 21 | |||
local RC_ROUND_GAP = 2 | |||
local RC_FINAL_GAP = 3 | |||
local function getMonthDay(d) | local function getMonthDay(d) | ||
d = tonumber(d) or 1 | |||
if d <= 61 then | |||
return 'Atosiel', d | |||
elseif d <= 122 then | |||
return 'Thalassiel', d - 61 | |||
else | |||
return 'Opsitheiel', d - 122 | |||
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) | |||
local d = a - y * DAYS_IN_YEAR | |||
local | if d < 1 then | ||
y = y - 1 | |||
d = d + DAYS_IN_YEAR | |||
end | |||
return y, d | |||
end | |||
local | local function _dateLabel(y, d) | ||
local mon, dom = getMonthDay(d) | |||
return string.format('%d %s %d PSSC', dom, mon, y) | |||
end | |||
local function _dateLabelFromAbs(a) | |||
local y, d = _dateFromAbs(a) | |||
return _dateLabel(y, d) | |||
local | end | ||
local function _currentPSSC() | |||
local curr = date.getCurrentDate() or '' | |||
local day = tonumber(curr:match('^(%d+),')) or 0 | |||
local year = tonumber(curr:match(', (%d+) PSSC')) or 0 | |||
return day, year | |||
end | |||
local function _nowAbs() | |||
local d, y = _currentPSSC() | |||
return absDay(y, d), d, y | |||
end | |||
-- The active Pillarion competition season is the current year during | |||
-- Thalassiel/Opsitheiel, but the previous year during Atosiel because Atosiel | |||
-- carries that season's playoffs and Regional Cup. | |||
-- During Atosiel the previous season remains active only while its | |||
-- post-season is actually underway. Once the Regional Cup is finished, | |||
-- all default league/standings calls must advance to the upcoming year. | |||
-- Otherwise the next season displays the previous year's full table. | |||
local function _activeSeason() | |||
local day, year = _currentPSSC() | |||
if year <= 0 then return LEGACY_SEASON end | |||
if day >= START then return year end | |||
local previousRegionalFinalDay = PO_FIRST + (PO_IV * 3) | |||
+ RC_GAP_AFTER_FINAL + (RC_ROUND_GAP * 4) + RC_FINAL_GAP | |||
if day <= previousRegionalFinalDay then return year - 1 end | |||
return year | |||
end | |||
local function _resolveSeason(args, positional) | |||
args = args or {} | |||
local explicit = tonumber(args.season) | |||
if not explicit and positional then explicit = tonumber(args[positional]) end | |||
if explicit then return explicit end | |||
return _activeSeason() | |||
end | |||
local function _seasonTimes(season) | |||
local regularStart = absDay(season, START) | |||
local regularEnd = absDay(season, END) | |||
local playoffStart = absDay(season + 1, PO_FIRST) | |||
local playoffFinal = playoffStart + PO_IV * 3 | |||
local regionalStart = playoffFinal + RC_GAP_AFTER_FINAL | |||
local regionalFinal = regionalStart + RC_ROUND_GAP * 4 + RC_FINAL_GAP | |||
local nextRegular = absDay(season + 1, START) | |||
return { | |||
regularStart = regularStart, | |||
regularEnd = regularEnd, | |||
playoffStart = playoffStart, | |||
playoffFinal = playoffFinal, | |||
regionalStart = regionalStart, | |||
regionalFinal = regionalFinal, | |||
nextRegular = nextRegular, | |||
} | |||
end | |||
local function _seasonStage(season) | |||
local now = _nowAbs() | |||
local t = _seasonTimes(season) | |||
if now < t.regularStart then return 'before_regular', t end | |||
if now <= t.regularEnd then return 'regular', t end | |||
if now < t.playoffStart then return 'pre_playoff', t end | |||
if now <= t.playoffFinal then return 'playoffs', t end | |||
if now < t.regionalStart then return 'pre_regional', t end | |||
if now <= t.regionalFinal then return 'regional_cup', t end | |||
if now < t.nextRegular then return 'offseason', t end | |||
return 'complete', t | |||
end | |||
------------------------------------------------------------------------ | |||
[ | -- 1) TEAMS / DIVISIONS / KITS | ||
------------------------------------------------------------------------ | |||
[ | local teams = { | ||
{ code='Allegro Symphonara', fullName='Allegro Symphonara', logo='[[File:AllegrosymphonaraB.png|25px]]', championships=4 }, | |||
{ code='Ashguard Pyralis', fullName='Ashguard Pyralis', logo='[[File:AshguardpyralisB.png|25px]]', championships=1 }, | |||
{ code='Aurelia Auric', fullName='Aurelia Auric', logo='[[File:AureliaAuricB.png|25px]]', championships=2 }, | |||
{ code='Brightpath Aureum', fullName='Brightpath Aureum', logo='[[File:BrightpathaureumB.png|25px]]', championships=2 }, | |||
{ code='Celestial Sancta Lunaris', fullName='Celestial Sancta Lunaris', logo='[[File:CelestialsanctaB.png|25px]]', championships=1 }, | |||
{ code='Delphica Windborne', fullName='Delphica Windborne', logo='[[File:DelphicawindborneB.png|25px]]', championships=1 }, | |||
{ code='Emberstone Vaeringheim', fullName='Emberstone Vaeringheim', logo='[[File:EmberstonevaeringheimB.png|25px]]', championships=3 }, | |||
{ code='Flameborne Erythros', fullName='Flameborne Erythros', logo='[[File:FlameborneerythrosB.png|25px]]', championships=1 }, | |||
{ code='Forgeward Nexa', fullName='Forgeward Nexa', logo='[[File:ForgewardnexaB.png|25px]]', championships=0 }, | |||
{ code='Hearthkeeper Koinon', fullName='Hearthkeeper Koinon', logo='[[File:HearthkeeperkoinonB.png|25px]]', championships=0 }, | |||
{ code='Ironbough Sylvapolis', fullName='Ironbough Sylvapolis', logo='[[File:IronboughSylvapolisB.png|25px]]', championships=1 }, | |||
{ code='Redcliff Citadel', fullName='Redcliff Citadel', logo='[[File:RedcliffCitadel.png|25px]]', championships=1 }, | |||
{ code='Riftwarden Acheron', fullName='Riftwarden Acheron', logo='[[File:RiftwardenAcheronB.png|25px]]', championships=3 }, | |||
{ code='Saluria Skylight', fullName='Saluria Skylight', logo='[[File:SaluriaSkylightB.png|25px]]', championships=0 }, | |||
{ code='Sandveil Somniumpolis', fullName='Sandveil Somniumpolis', logo='[[File:SandVeilSomniumB.png|25px]]', championships=1 }, | |||
{ code='Serena Tidesong', fullName='Serena Tidesong', logo='[[File:Serena TidesongB.png|25px]]', championships=2 }, | |||
{ code='Steppe Luminaria', fullName='Steppe Luminaria', logo='[[File:Steppe LuminariaB.png|25px]]', championships=4 }, | |||
{ code='Strider Myrene', fullName='Strider Myrene', logo='[[File:StriderMyreneB.png|25px]]', championships=1 }, | |||
{ code='Vaeringheim Pillar', fullName='Vaeringheim Pillar', logo='[[File:VaeringheimPillarB.png|25px]]', championships=5 }, | |||
{ code='Imperial Delphica', fullName='Imperial Delphica', logo='[[File:ImperialDelphicaB.png|25px]]', championships=1 }, | |||
{ code='Ascendant Aetherium', fullName='Ascendant Aetherium', logo='[[File:AscendantAetheriumB.png|25px]]', championships=2 }, | |||
{ code='Sufriya Stormwake', fullName='Sufriya Stormwake', logo='[[File:SufriyaStormwake.png|25px]]', championships=1 }, | |||
{ code='Pillarion Club Suncliff', fullName='Pillarion Club Suncliff', logo='[[File:PCSuncliff.png|25px]]', championships=0 }, | |||
{ code='Jogi Regiment', fullName='Jogi Regiment', logo='[[File:JogiRegiment.png|25px]]', championships=0 }, | |||
{ code='Vine Fleet Mylecia', fullName='Vine Fleet Mylecia', logo='[[File:VineFleetMylecia.png|25px]]', championships=0 }, | |||
{ code='Port of Blore Heath', fullName='Port of Blore Heath', logo='[[File:PortBloreHeath.png|25px]]', championships=0 }, | |||
{ code='Free State Abeis', fullName='Free State Abeis', logo='[[File:FreeStateAbeis.png|25px]]', championships=0 }, | |||
{ code='Jezeraah City', fullName='Jezeraah City', logo='[[File:JezeraahCity.png|25px]]', championships=0 }, | |||
{ code='Ourid Pegasi', fullName='Ourid Pegasi', logo='[[File:OuridPegasiLogo.png|25px]]', championships=0 }, | |||
{ code='Pillarion Club Caspazani', fullName='Pillarion Club Caspazani', logo='[[File:PCCaspazani.png|25px]]', championships=0 }, | |||
{ code='Amberwatch Slevik', fullName='Amberwatch Slevik', logo='[[File:AmberwatchSlevik.png|25px]]', championships=0 }, | |||
{ code='Fanghorn Rein', fullName='Fanghorn Rein', logo='[[File:FanghornRein.png|25px]]', championships=0 }, | |||
{ code='Thyrsa Maena SC', fullName='Thyrsa Maena SC', logo='[[File:ThyrsaMaenaSC.png|25px]]', championships=0 }, | |||
{ code='Pharos-Fener Hayr', fullName='Pharos-Fener Hayr', logo='[[File:PharosFenerHayr.png|25px]]', championships=0 }, | |||
{ code='Khalkodromoi Chaskal', fullName='Khalkodromoi Chaskal', logo='[[File:KhalkodromoiChaskal.png|25px]]', championships=0 }, | |||
{ code='Lenaia Krene AC', fullName='Lenaia Krene AC', logo='[[File:LenaiaKreneAC.png|25px]]', championships=0 }, | |||
{ code='Pontikos Mirovia', fullName='Pontikos Mirovia', logo='[[File:PontikosMirovia.png|25px]]', championships=0 }, | |||
{ code='Hodos Selenarion', fullName='Hodos Selenarion', logo='[[File:HodosSelenarion.png|25px]]', championships=0 }, | |||
{ code='Lithos Kefaya SC', fullName='Lithos Kefaya SC', logo='[[File:LithosKefayaSC.png|25px]]', championships=0 }, | |||
{ code='Lacaran Velai PC', fullName='Lacaran Velai PC', logo='[[File:LacaranVelaiPC.png|25px]]', championships=0 }, | |||
} | |||
local TEAM_BY_CODE = {} | |||
for _, t in ipairs(teams) do TEAM_BY_CODE[t.code] = t end | |||
local JERSEYS_BY_TEAMNAME = { | |||
['Allegro Symphonara']={home='AllegroHome.png',away='AllegroAway25.png',third='Symphonara3rd.png'}, | |||
['Ashguard Pyralis']={home='AshguardHome.png',away='AshguardAway.png',third='Pyralis3rdB.png'}, | |||
['Aurelia Auric']={home='AureliaHome.png',away='AureliaAway.png',third='Aurelia3rd.png'}, | |||
['Brightpath Aureum']={home='BrightpathHome.png',away='BrightpathAway.png',third='Aureum3rd.png'}, | |||
['Celestial Sancta Lunaris']={home='CelestialSanctaHome.png',away='CelestialSanctaAway.png',third='Lunalis3rdB.png'}, | |||
['Delphica Windborne']={home='DelphicaWBAway.png',away='DelphicaWBHome.png',third='Delphica3rd.png'}, | |||
['Emberstone Vaeringheim']={home='EmberstoneHome.png',away='EmberstoneAway.png',third='Vaeringheim3rdA.png'}, | |||
['Flameborne Erythros']={home='FlameborneHome.png',away='FlameborneAway.png',third='Erythros3rd.png'}, | |||
['Forgeward Nexa']={home='ForgewardHome.png',away='ForegewardAway.png',third='Nexa3rd.png'}, | |||
['Hearthkeeper Koinon']={home='HearthkeepHome.png',away='HearthkeepAway.png',third='Koinon3rd.png'}, | |||
['Ironbough Sylvapolis']={home='IronboughHome.png',away='IronboughAway.png',third='Sylvapolis3rd.png'}, | |||
['Redcliff Citadel']={home='RedcliffHome.png',away='RedcliffAway.png',third='Ferrum3rd.png'}, | |||
['Riftwarden Acheron']={home='RiftwardenHome.png',away='RiftwardenAway.png',third='Acheron3rd.png'}, | |||
['Saluria Skylight']={home='SkylightHome.png',away='SkylightAway.png',third='Saluria3rdB.png'}, | |||
['Sandveil Somniumpolis']={home='SomniumpolisHome.png',away='SomniumPolisAway.png',third='Somniumpolis3rd.png'}, | |||
['Serena Tidesong']={home='SerenaHome.png',away='SerenaAway.png',third='Serena3rd.png'}, | |||
['Steppe Luminaria']={home='SteppeHome.png',away='SteppeAway25.png',third='Luminaria3rd.png'}, | |||
['Strider Myrene']={home='StriderHome.png',away='StriderAway.png',third='Myrene3rd.png'}, | |||
['Vaeringheim Pillar']={home='PillarHome.png',away='PillarAway.png',third='Vaeringheim3rdB.png'}, | |||
['Imperial Delphica']={home='ImperialHome.png',away='ImperialAway.png',third='Delphica3rdB.png'}, | |||
['Ascendant Aetherium']={home='AscendentHome.png',away='AscendentAway.png',third='Aetherium3rd.png'}, | |||
['Sufriya Stormwake']={home='SufriyaHome.png',away='SufriyaAway.png',third='Sufriya3rd.png'}, | |||
['Pillarion Club Suncliff']={home='SuncliffHome.png',away='SuncliffAway.png',third='Suncliff3rd.png'}, | |||
['Jogi Regiment']={home='JogiHome.png',away='JogiAway.png',third='Jogi3rd.png'}, | |||
['Vine Fleet Mylecia']={home='VineFleetHomeB.png',away='VineFleetAwayB.png',third='Mylecia3rd.png'}, | |||
['Port of Blore Heath']={home='BloreHeathHome.png',away='BloreHeathAway.png',third='BloreHeath3rd.png'}, | |||
['Free State Abeis']={home='AbeisHome.png',away='AbeisAway.png',third='Abeis3rd.png'}, | |||
['Jezeraah City']={home='JezeraahHome.png',away='JezeraahAway.png',third='Jezeraah3rd.png'}, | |||
['Ourid Pegasi']={home='OuridPegasiHome.png',away='OuridPegasiAwayB.png',third='OuridPegasi3rd.png'}, | |||
['Pillarion Club Caspazani']={home='PCCaspazaniHome.png',away='PCCCaspazaniAway.png',third='PCCaspazani3rd.png'}, | |||
['Amberwatch Slevik']={home='AmberwatchHome.png',away='AmberwatchAway.png',third='Amberwatch3rd.png'}, | |||
['Fanghorn Rein']={home='ReinHome.png',away='ReinAway.png',third='Rein3rd.png'}, | |||
['Thyrsa Maena SC']={home='ThyrsaMaenaHomeB.png',away='ThyrsaMaenaAwayB.png',third='ThyrsaMaena3rdB.png'}, | |||
['Pharos-Fener Hayr']={home='PharosFenerHome.png',away='PharosFenerAway.png',third='PharosFener3rd.png.png'}, | |||
['Khalkodromoi Chaskal']={home='KhalkodromoiHome.png',away='KhalkodromoiAway.png',third='Khalkodromoi3rd.png'}, | |||
['Lenaia Krene AC']={home='LenaiaKreneHome.png',away='LenaiaKreneAway.png',third='LenaiaKrene3rd.png'}, | |||
['Pontikos Mirovia']={home='PontikosMiroviaHome.png',away='PontikosMiroviaAway.png',third='PontikosMirovia3rd.png'}, | |||
['Hodos Selenarion']={home='HodosSelenarionHome.png',away='HodosSelenarionAway.png',third='HodosSelenarion3rd.png'}, | |||
['Lithos Kefaya SC']={home='LithosKefayaHome.png',away='LithosKefayaAway.png',third='LithosKefaya3rd.png'}, | |||
['Lacaran Velai PC']={home='LacaranVelaiHome.png',away='LacaranVelaiAwayC.png',third='LacaranVelai3rd.png'}, | |||
} | } | ||
local RC_JERSEYS_BY_TEAMNAME = { | local RC_JERSEYS_BY_TEAMNAME = { | ||
['Morovian Select']={home='MorovianSelectHome.png',away='MorovianSelectAway.png'}, | |||
['Southern Strait Select']={home='SouthernStraitSelectHome.png',away='SouthernStraitSelectAway.png'}, | |||
['Western Highlands Select']={home='WesternHighlandsSelectHome.png',away='WesternHighlandsSelectAway.png'}, | |||
['Normarkian Select']={home='NormarkianSelectHome.png',away='NormarkianSelectAway.png'}, | |||
-- Supply these optional kit files when available on the wiki: | |||
['Lacaran Hodos Select']={home='',away=''}, | |||
} | } | ||
- | local divisions = { | ||
['Morovian']={ | |||
'Emberstone Vaeringheim','Vaeringheim Pillar','Imperial Delphica','Delphica Windborne', | |||
'Allegro Symphonara','Steppe Luminaria','Jezeraah City','Saluria Skylight' | |||
}, | |||
['Southern Strait']={ | |||
'Sufriya Stormwake','Vine Fleet Mylecia','Port of Blore Heath','Jogi Regiment', | |||
'Free State Abeis','Sandveil Somniumpolis','Brightpath Aureum','Flameborne Erythros' | |||
}, | |||
['Western Highlands']={ | |||
'Ashguard Pyralis','Aurelia Auric','Celestial Sancta Lunaris','Forgeward Nexa', | |||
'Hearthkeeper Koinon','Ironbough Sylvapolis','Riftwarden Acheron','Serena Tidesong' | |||
}, | |||
['Normarkian']={ | |||
'Redcliff Citadel','Strider Myrene','Ascendant Aetherium','Pillarion Club Suncliff', | |||
'Ourid Pegasi','Pillarion Club Caspazani','Amberwatch Slevik','Fanghorn Rein' | |||
}, | |||
['Lacaran Hodos']={ | |||
'Thyrsa Maena SC','Pharos-Fener Hayr','Khalkodromoi Chaskal','Lenaia Krene AC', | |||
'Pontikos Mirovia','Hodos Selenarion','Lithos Kefaya SC','Lacaran Velai PC' | |||
}, | |||
} | |||
local DIV_ORDER={'Morovian','Southern Strait','Western Highlands','Normarkian','Lacaran Hodos'} | |||
local DIV_OF={} | |||
for div, list in pairs(divisions) do for _, c in ipairs(list) do DIV_OF[c]=div end end | |||
local function _safeHash(key) | |||
if mw and mw.hash and mw.hash.hashValue then | |||
return mw.hash.hashValue('md5', key or '') | |||
end | |||
local h=2166136261 | |||
key=tostring(key or '') | |||
for i=1,#key do h=(h*16777619 + string.byte(key,i)) % 4294967291 end | |||
return string.format('%08x',h) | |||
end | |||
local function _pctFromKey(key) | local function _pctFromKey(key) | ||
local h=_safeHash(key) | |||
return (tonumber(h:sub(1,8),16) or 0)%100 | |||
end | end | ||
local function _kitImgForMatch(team, baseWhich, matchKey, px) | 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] | local kits=JERSEYS_BY_TEAMNAME[team.fullName] or RC_JERSEYS_BY_TEAMNAME[team.fullName] | ||
if not kits then return '—' end | |||
local which=baseWhich | |||
if kits.third and kits.third~='' and _pctFromKey((matchKey or '')..'|'..team.fullName..'|'..baseWhich)<30 then which='third' end | |||
local f=kits[which] | |||
if not f or f=='' then return '—' end | |||
return string.format('[[File:%s|%dpx]]',f,px) | |||
end | end | ||
local function _asTeam(x) | 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 | end | ||
local function _teamWithLogo(x, bold) | local function _teamWithLogo(x, bold) | ||
local t=_asTeam(x) | |||
local n=bold and ("'''"..t.fullName.."'''") or t.fullName | |||
return (t.logo and t.logo~='' and (t.logo..' ') or '')..n | |||
end | end | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- 2) | -- 2) LOCKED FIXTURE LIST | ||
-- This reproduces the original 53 PSSC pairing/packing algorithm. | |||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
local | local function _homeFirst(codeA, codeB) | ||
local function sum(s) | |||
local n=0 | |||
for i=1,#s do n=(n+string.byte(s,i))%9973 end | |||
return n | |||
end | |||
return sum(codeA)>sum(codeB) | |||
end | end | ||
local function _buildPairings() | |||
local intra, inter = {}, {} | |||
for _, div in ipairs(DIV_ORDER) do | |||
local L=divisions[div] | |||
for i=1,#L-1 do | |||
local function | for j=i+1,#L do | ||
intra[#intra+1]={home=TEAM_BY_CODE[L[i]],away=TEAM_BY_CODE[L[j]]} | |||
intra[#intra+1]={home=TEAM_BY_CODE[L[j]],away=TEAM_BY_CODE[L[i]]} | |||
end | |||
end | |||
end | end | ||
for di=1,#DIV_ORDER-1 do | |||
for dj=di+1,#DIV_ORDER do | |||
local A,B=divisions[DIV_ORDER[di]],divisions[DIV_ORDER[dj]] | |||
for _,ca in ipairs(A) do | |||
for _,cb in ipairs(B) do | |||
local h,a | |||
if _homeFirst(ca,cb) then h,a=ca,cb else h,a=cb,ca end | |||
inter[#inter+1]={home=TEAM_BY_CODE[h],away=TEAM_BY_CODE[a]} | |||
end | |||
end | |||
end | |||
end | end | ||
table.sort(intra,function(a,b) | |||
local da,db=DIV_OF[a.home.code] or '',DIV_OF[b.home.code] or '' | |||
if da~=db then return da<db end | |||
return (a.home.code..a.away.code)<(b.home.code..b.away.code) | |||
end) | |||
table.sort(inter,function(a,b) | |||
local ka=(DIV_OF[a.home.code] or '')..'|'..(DIV_OF[a.away.code] or '') | |||
local kb=(DIV_OF[b.home.code] or '')..'|'..(DIV_OF[b.away.code] or '') | |||
if ka~=kb then return ka<kb end | |||
return (a.home.code..a.away.code)<(b.home.code..b.away.code) | |||
end) | |||
local merged={} | |||
local function appendBlock(src,start,count) | |||
for i=start,math.min(#src,start+count-1) do merged[#merged+1]=src[i] end | |||
end | end | ||
local ai,bi=1,1 | |||
while ai<=#intra or bi<=#inter do | |||
if ai<=#intra then appendBlock(intra,ai,64); ai=ai+64 end | |||
if bi<=#inter then appendBlock(inter,bi,128); bi=bi+128 end | |||
end | end | ||
return merged | |||
end | end | ||
local | local function _packRounds(pairings) | ||
local rounds={} | |||
for _,g in ipairs(pairings) do | |||
local placed=false | |||
local | for _,r in ipairs(rounds) do | ||
for | if not r.used[g.home.code] and not r.used[g.away.code] then | ||
r.matches[#r.matches+1]={g.home,g.away} | |||
r.used[g.home.code]=true; r.used[g.away.code]=true | |||
placed=true; break | |||
end | |||
end | |||
if not placed then | |||
rounds[#rounds+1]={matches={{g.home,g.away}},used={[g.home.code]=true,[g.away.code]=true}} | |||
end | |||
end | end | ||
local out={} | |||
for _,r in ipairs(rounds) do out[#out+1]=r.matches end | |||
return out | return out | ||
end | end | ||
-- Historic schedule remains available for explicitly requested 53 PSSC archives. | |||
-- Existing four-division pairing logic is evaluated with the original four divisions. | |||
local function | local function _legacyFixtures() | ||
local savedOrder = DIV_ORDER | |||
DIV_ORDER={'Morovian','Southern Strait','Western Highlands','Normarkian'} | |||
local fixtures=_packRounds(_buildPairings()) | |||
DIV_ORDER=savedOrder | |||
return fixtures | |||
end | end | ||
local SCHED_LEGACY=_legacyFixtures() | |||
-- | -- Expansion-era schedule: 14 divisional (home and away), 32 interdivisional. | ||
-- Round-robin pairings use the existing deterministic match orientation function. | |||
- | local function _expandedPairings() | ||
-- | local pairs={} | ||
for _,div in ipairs(DIV_ORDER) do | |||
local | local L=divisions[div] | ||
local | for i=1,#L-1 do for j=i+1,#L do | ||
pairs[#pairs+1]={home=TEAM_BY_CODE[L[i]],away=TEAM_BY_CODE[L[j]]} | |||
pairs[#pairs+1]={home=TEAM_BY_CODE[L[j]],away=TEAM_BY_CODE[L[i]]} | |||
end end | |||
end | |||
for i=1,#DIV_ORDER-1 do for j=i+1,#DIV_ORDER do | |||
for _,a in ipairs(divisions[DIV_ORDER[i]]) do | |||
for _,b in ipairs(divisions[DIV_ORDER[j]]) do | |||
local home,away=a,b | |||
if not _homeFirst(a,b) then home,away=b,a end | |||
pairs[#pairs+1]={home=TEAM_BY_CODE[home],away=TEAM_BY_CODE[away]} | |||
end | |||
end | |||
end end | |||
return pairs | |||
end | end | ||
-- Structured 54-matchday schedule, rather than greedily packing all fixtures. | |||
local function | -- 14 full divisional rounds + 40 interdivisional rounds (one division bye | ||
-- per interdivisional matchday). Every club plays 46 matches, 23 home/away | |||
-- split may vary by opponent but every club has 14 divisional and 32 external. | |||
local function _rotateEight(codes) | |||
local arr={}; for i=1,8 do arr[i]=codes[i] end | |||
local rounds={} | |||
for r=1,7 do | |||
local games={} | |||
for i=1,4 do | |||
local h,a=TEAM_BY_CODE[arr[i]],TEAM_BY_CODE[arr[9-i]] | |||
if (r+i)%2~=0 then h,a=a,h end | |||
games[#games+1]={h,a} | |||
end | |||
rounds[r]=games | |||
local last=arr[8];for i=8,3,-1 do arr[i]=arr[i-1] end;arr[2]=last | |||
end | end | ||
return rounds | |||
end | end | ||
local function _expandedFixtures() | |||
local function | local rounds={} | ||
local divisionRounds={} | |||
for _,div in ipairs(DIV_ORDER) do divisionRounds[div]=_rotateEight(divisions[div]) end | |||
for r=1,14 do | |||
local matchday={} | |||
for _,div in ipairs(DIV_ORDER) do | |||
local rr=divisionRounds[div][(r-1)%7+1] | |||
for _,g in ipairs(rr) do | |||
if r<=7 then matchday[#matchday+1]=g | |||
else matchday[#matchday+1]={g[2],g[1]} end | |||
end | |||
end | |||
rounds[#rounds+1]=matchday | |||
end | end | ||
-- Complete round robin over five divisions plus one rotating bye. | |||
local pairBlocks={ | |||
{{2,5},{3,4}}, | |||
{{1,3},{4,5}}, | |||
{{1,5},{2,4}}, | |||
{{1,2},{3,5}}, | |||
{{1,4},{2,3}}, | |||
} | |||
for block, pairs in ipairs(pairBlocks) do | |||
for t=0,7 do | |||
local matchday={} | |||
for _,pair in ipairs(pairs) do | |||
local A=divisions[DIV_ORDER[pair[1]]] | |||
local B=divisions[DIV_ORDER[pair[2]]] | |||
for i=1,8 do | |||
local h=TEAM_BY_CODE[A[i]] | |||
local a=TEAM_BY_CODE[B[((i+t-1)%8)+1]] | |||
if (block+t)%2~=0 then h,a=a,h end | |||
matchday[#matchday+1]={h,a} | |||
end | |||
end | |||
rounds[#rounds+1]=matchday | |||
end | |||
end | end | ||
return rounds | |||
end | end | ||
local SCHED_EXPANDED=_expandedFixtures() | |||
local SCHED_LOCKED=SCHED_EXPANDED | |||
local TOTAL_ROUNDS=#SCHED_LOCKED | |||
local function getRoundMatches(i) return SCHED_LOCKED[tonumber(i) or 0] or {} end | |||
-- Date helpers used by standings, awards, match reports, rosters and transfers. | |||
-- Keep one stable calendar mapping throughout the season. | |||
local _DAY_MAP, _INV_DAY_MAP = {}, {} | |||
do | |||
local totalDays=END-START+1 | |||
local totalDays | |||
for i=1,TOTAL_ROUNDS do | for i=1,TOTAL_ROUNDS do | ||
local d=START+math.floor((i-1)*(totalDays-1)/math.max(1,TOTAL_ROUNDS-1)) | |||
_DAY_MAP[d]=i | |||
_INV_DAY_MAP[i]=d | |||
end | end | ||
end | end | ||
local function mapDays() return _DAY_MAP end | |||
local function invertDayMap(dm) | local function invertDayMap(dm) | ||
if dm==_DAY_MAP then return _INV_DAY_MAP end | |||
local inv={};for day,md in pairs(dm or {}) do inv[md]=day end | |||
return inv | |||
end | end | ||
local function _finalRegularSeasonDay() return _INV_DAY_MAP[TOTAL_ROUNDS] or END end | |||
local function | local function _regularSeasonCutoff(season) | ||
season=tonumber(season) or _activeSeason() | |||
local now,day,year=_nowAbs() | |||
if year<season then return START-1 end | |||
if year>season then return _finalRegularSeasonDay() end | |||
return math.min(math.max(day,START-1),_finalRegularSeasonDay()) | |||
end | |||
-- A result is public only once its matchday has actually arrived in the | |||
-- specified PSSC season. This also protects explicit ?season=54&md=... calls. | |||
local function _isPlayedMatchday(season, md) | |||
md = tonumber(md) or 0 | |||
if md < 1 or md > TOTAL_ROUNDS then return false end | |||
local day = _INV_DAY_MAP[md] | |||
return day ~= nil and _nowAbs() >= absDay(season,day) | |||
end | end | ||
local function _currentPlayedMatchday(season) | |||
local function _currentPlayedMatchday() | local cutoff=_regularSeasonCutoff(season) | ||
local md=0 | |||
for d=START,cutoff do if _DAY_MAP[d] then md=_DAY_MAP[d] end end | |||
return md | |||
end | end | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- 3) DETERMINISTIC MATCH ENGINE | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
local function simScoreFor(h, a, seed) | local function simScoreFor(h,a,seed) | ||
math.randomseed(seed) | |||
local p0=math.random() | |||
local hg,ag | |||
if p0<0.10 then | |||
hg=math.random(0,12); ag=hg | |||
else | |||
local wh=(h.championships or 0)+1 | |||
local wa=(a.championships or 0)+1 | |||
local homeWin=((p0-0.10)/0.90)<(wh/(wh+wa)) | |||
local winG=math.random(1,12) | |||
local loseG=math.random(0,math.min(winG-1,12)) | |||
if homeWin then hg,ag=winG,loseG else hg,ag=loseG,winG end | |||
end | |||
return hg,ag | |||
end | end | ||
-- 53 PSSC uses the legacy seed exactly. Later seasons add the year so future | |||
-- seasons are deterministic but not identical replays of 53. | |||
local function _matchSeed(season,day,k,h,a) | |||
local legacy=day*1000+k+string.byte(h.code,1)+string.byte(a.code,1) | |||
if tonumber(season)==53 then return legacy end | |||
return legacy+(tonumber(season) or 0)*1000003 | |||
local | |||
end | end | ||
local function _rrange(seed,lo,hi) | |||
math.randomseed(seed) | |||
return math.random(lo,hi) | |||
local function | end | ||
local function _splitPillarScore(total,salt) | |||
total=tonumber(total) or 0 | |||
local maxG=math.floor(total/3) | |||
if maxG<=0 then return 0,total end | |||
math.randomseed(salt) | |||
local g=maxG | |||
if maxG>=1 and math.random()<0.40 then g=maxG-1 end | |||
return g,total-3*g | |||
end | |||
local function computeMatchStats(home,away,hg,ag,salt) | |||
local hGoals,hPoss=_splitPillarScore(hg,salt+101) | |||
local aGoals,aPoss=_splitPillarScore(ag,salt+102) | |||
local hOn=_rrange(salt+101,hGoals,hGoals+2) | |||
local aOn=_rrange(salt+102,aGoals,aGoals+2) | |||
local hOff=_rrange(salt+103,0,3); local aOff=_rrange(salt+104,0,3) | |||
local hF=_rrange(salt+105,0,3); local aF=_rrange(salt+106,0,3) | |||
local hThr=_rrange(salt+107,0,3); local aThr=_rrange(salt+108,0,3) | |||
local hGK=_rrange(salt+109,0,2); local aGK=_rrange(salt+110,0,2) | |||
local hOOB=_rrange(salt+111,0,2); local aOOB=_rrange(salt+112,0,2) | |||
local hp,ap=50,50 | |||
if hg~=ag then | |||
local sw=_rrange(salt+113,3,6) | |||
if hg>ag then hp,ap=50+sw,50-sw else hp,ap=50-sw,50+sw end | |||
else | |||
local tilt=_rrange(salt+113,-3,3); hp,ap=50+tilt,50-tilt | |||
end | end | ||
return { | |||
[home.code]={final=hg,goals=hGoals,poss_points=hPoss,shots_on=hOn,shots_off=hOff,fouls=hF,fks=aF,throws=hThr,gk=hGK,oob=hOOB,poss_pct=hp}, | |||
[away.code]={final=ag,goals=aGoals,poss_points=aPoss,shots_on=aOn,shots_off=aOff,fouls=aF,fks=hF,throws=aThr,gk=aGK,oob=aOOB,poss_pct=ap}, | |||
} | |||
end | |||
local function simulateDayResults(day,season) | |||
season=tonumber(season) or _activeSeason() | |||
local md=mapDays()[day] | |||
local out={} | |||
if not md then return out end | |||
for k,m in ipairs(getRoundMatches(md)) do | |||
local h,a=m[1],m[2] | |||
local seed=_matchSeed(season,day,k,h,a) | |||
local hg,ag=simScoreFor(h,a,seed) | |||
out[#out+1]={home=h,away=a,hg=hg,ag=ag,seed=seed,stats=computeMatchStats(h,a,hg,ag,seed),index=k,md=md,day=day} | |||
end | end | ||
return out | |||
end | end | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- 4) STANDINGS / SEEDING | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
function | local function _emptyStandings() | ||
local recs={} | |||
for _,t in ipairs(teams) do recs[t.code]={team=t,W=0,D=0,L=0,Pts=0,PF=0,PA=0,PD=0,GP=0} end | |||
return recs | |||
end | |||
local function _applyResult(recH,recA,hg,ag) | |||
recH.GP=recH.GP+1; recA.GP=recA.GP+1 | |||
recH.PF=recH.PF+hg; recH.PA=recH.PA+ag | |||
recA.PF=recA.PF+ag; recA.PA=recA.PA+hg | |||
if hg>ag then recH.W=recH.W+1; recA.L=recA.L+1; recH.Pts=recH.Pts+3 | |||
elseif ag>hg then recA.W=recA.W+1; recH.L=recH.L+1; recA.Pts=recA.Pts+3 | |||
else recH.D=recH.D+1; recA.D=recA.D+1; recH.Pts=recH.Pts+1; recA.Pts=recA.Pts+1 end | |||
recH.PD=recH.PF-recH.PA; recA.PD=recA.PF-recA.PA | |||
end | |||
local function _standingsThrough(season,lastDay) | |||
local recs=_emptyStandings(); local dm=mapDays() | |||
-- Clamp requested dates to the most recently played matchday. | |||
-- This is essential: future fixtures must never affect live tables. | |||
local playedCutoff=_regularSeasonCutoff(season) | |||
local effectiveEnd=math.min(lastDay or playedCutoff, playedCutoff, _finalRegularSeasonDay()) | |||
for d=START,effectiveEnd do | |||
if dm[d] then | |||
for _,r in ipairs(simulateDayResults(d,season)) do _applyResult(recs[r.home.code],recs[r.away.code],r.hg,r.ag) end | |||
end | end | ||
end | end | ||
return recs | |||
end | |||
local function _standCmp(a,b) | |||
if a.Pts~=b.Pts then return a.Pts>b.Pts end | |||
if a.PD~=b.PD then return a.PD>b.PD end | |||
if a.W~=b.W then return a.W>b.W end | |||
return a.team.fullName<b.team.fullName | |||
end | |||
local function _divisionRows(season,div,lastDay) | |||
local recs=_standingsThrough(season,lastDay); local list={} | |||
for _,c in ipairs(divisions[div]) do list[#list+1]=recs[c] end | |||
table.sort(list,_standCmp); return list | |||
end | |||
local function getSeeds12(season) | |||
local winners,runners={},{} | |||
for _,div in ipairs(DIV_ORDER) do | |||
local L=_divisionRows(season,div,_finalRegularSeasonDay()) | |||
winners[#winners+1]=L[1]; runners[#runners+1]=L[2] | |||
end | end | ||
table.sort(winners,_standCmp); table.sort(runners,_standCmp) | |||
local seeds={} | |||
for _,r in ipairs(winners) do seeds[#seeds+1]=r.team end | |||
for _,r in ipairs(runners) do seeds[#seeds+1]=r.team end | |||
return seeds | |||
end | end | ||
local function getConferencePlayIns(season) | |||
local games={} | |||
for _,div in ipairs(DIV_ORDER) do | |||
function | local L=_divisionRows(season,div,_finalRegularSeasonDay()) | ||
games[#games+1]={L[3].team,L[4].team,div} | |||
end | end | ||
return games | |||
end | end | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- 5) REGULAR-SEASON RENDERERS / AUTOMATIC LIFECYCLE | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
local | local function _renderRegularRound(day,season) | ||
local dm=mapDays() | |||
local | local target=nil | ||
for d=math.max(day,START),_finalRegularSeasonDay() do if dm[d] then target=d; break end end | |||
if not target then return "|-\n| colspan=3 | '''Regular season complete.'''" end | |||
local md=dm[target]; local mon,dom=getMonthDay(target); local rows={} | |||
for k,m in ipairs(getRoundMatches(md)) do | |||
local h,a=m[1],m[2] | |||
local key=string.format('REG|%d|%d|%d|%s|%s',season,target,k,h.code,a.code) | |||
local score='—' | |||
local now=_nowAbs() | |||
if now>=absDay(season,target) then | |||
local hg,ag=simScoreFor(h,a,_matchSeed(season,target,k,h,a)); score=string.format("'''%d-%d'''",hg,ag) | |||
local | |||
local h, a = m[1], m[2] | |||
local | |||
if | |||
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 | ||
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 args=(frame and frame.args) or {} | |||
local season=_resolveSeason(args,1) | |||
local stage,t=_seasonStage(season) | |||
local now,day,year=_nowAbs() | |||
if stage=='regular' or stage=='before_regular' then | |||
local startDay=(year==season and day or START) | |||
return table.concat({ | |||
'{| class="wikitable sortable"', | |||
'! Day !! Date !! Matches', | |||
_renderRegularRound(startDay,season), | |||
'|}' | |||
},'\n') | |||
elseif stage=='pre_playoff' then | |||
return string.format("'''%d PSSC regular season complete. Play-offs begin %s.'''",season,_dateLabelFromAbs(t.playoffStart)) | |||
elseif stage=='playoffs' then | |||
return "=== Current Play-off Results ===\n"..p.renderPlayoffResults({args={season=season,progressive='1'}}) | |||
elseif stage=='pre_regional' then | |||
return "'''Play-offs complete. The Off-Season Regional Cup begins ".._dateLabelFromAbs(t.regionalStart)..".'''" | |||
elseif stage=='regional_cup' then | |||
return "=== Off-Season Regional Cup — Live Results ===\n"..p.renderRegionalCupResults({args={season=season}}) | |||
elseif stage=='offseason' then | |||
return string.format("'''%d PSSC competition cycle complete. %d PSSC regular season begins %s.'''",season,season+1,_dateLabelFromAbs(t.nextRegular)) | |||
else | |||
return string.format("'''%d PSSC competition cycle is complete.'''",season) | |||
end | |||
end | end | ||
-- | -- Explicit alias for pages that want a semantically named lifecycle call. | ||
p.renderLifecycle=p.renderSchedule | |||
function p.renderStandings(frame) | |||
local args=(frame and frame.args) or {} | |||
-- Tables represent the upcoming/current regular season, even when | |||
-- Atosiel is hosting the previous year's playoffs or Regional Cup. | |||
-- Explicit |season=53 still permits historical standings. | |||
local explicit=tonumber(args.season or args[1]) | |||
local _,calendarYear=_currentPSSC() | |||
local season=explicit or (calendarYear > 0 and calendarYear or _activeSeason()) | |||
local last=_regularSeasonCutoff(season) | |||
local out={} | |||
for _,div in ipairs(DIV_ORDER) do | |||
out[#out+1]='=== '..div..' Division ===' | |||
out[#out+1]='{| class="wikitable sortable" style="width:100%;"' | |||
out[#out+1]='! # !! Team !! GP !! W !! D !! L !! Pts !! PF !! PA !! PD' | |||
local list=_divisionRows(season,div,last) | |||
for i,r in ipairs(list) do | |||
-- Original positional color bands: 1-2 green, 3-4 blue, 5-6 yellow, 7-8 red. | |||
local shade = (i <= 2 and '#ccffcc') or (i <= 4 and '#cce5ff') or (i <= 6 and '#F1EB9C') or '#ffcccc' | |||
out[#out+1]='|- style="background-color:'..shade..';"' | |||
out[#out+1]=string.format('| %d || %s || %d || %d || %d || %d || %d || %d || %d || %+d',i,_teamWithLogo(r.team),r.GP,r.W,r.D,r.L,r.Pts,r.PF,r.PA,r.PD) | |||
end | |||
out[#out+1]='|}' | |||
end | |||
return table.concat(out,'\n') | |||
end | end | ||
function p.renderPreviousResults(frame) | |||
local | local args=(frame and frame.args) or {} | ||
local season=_resolveSeason(args,1) | |||
local now,day,year=_nowAbs(); local dm=mapDays(); local played={} | |||
local fromDay | |||
if now>absDay(season,_finalRegularSeasonDay()) then fromDay=_finalRegularSeasonDay() | |||
elseif year==season then fromDay=day-1 else fromDay=START-1 end | |||
for d=fromDay,START,-1 do if dm[d] then played[#played+1]=d end; if #played>=5 then break end end | |||
if #played==0 then return "'''No regular-season matches have been played in "..season.." PSSC. Opening matchday: 1 Thalassiel "..season.." PSSC.'''" end | |||
local out={'{| class="wikitable sortable"','! Date !! Home !! Score !! Away'} | |||
for _,d in ipairs(played) do | |||
for _,r in ipairs(simulateDayResults(d,season)) do | |||
out[#out+1]='|-' | |||
out[#out+1]=string.format('| %s || %s || \'\'\'%d-%d\'\'\' || %s',_dateLabel(season,d),_teamWithLogo(r.home),r.hg,r.ag,_teamWithLogo(r.away)) | |||
end | |||
end | |||
out[#out+1]='|}' | |||
return table.concat(out,'\n') | |||
end | end | ||
------------------------------------------------------------------------ | |||
-- 6) PLAYOFFS | |||
------------------------------------------------------------------------ | |||
local function _playScore(H,A,salt) | |||
return simScoreFor(H,A,salt) | |||
return | |||
) | |||
end | end | ||
local function _winner(H,A,hg,ag) | |||
-- Preserve the legacy deterministic tiebreak: away side advances on a draw. | |||
if hg>ag then return H end | |||
return A | |||
end | end | ||
local function _seedIndex(seeds,team) | |||
function | for i,t in ipairs(seeds) do if t.code==team.code then return i end end | ||
return 999 | |||
end | end | ||
local function _playoffState(season) | |||
local qualifiers, fourths={},{} | |||
for _,div in ipairs(DIV_ORDER) do | |||
local L=_divisionRows(season,div,_finalRegularSeasonDay()) | |||
for rank=1,3 do qualifiers[#qualifiers+1]=L[rank] end | |||
fourths[#fourths+1]=L[4] | |||
end | end | ||
table.sort(fourths,_standCmp) | |||
qualifiers[#qualifiers+1]=fourths[1] | |||
table.sort(qualifiers,_standCmp) | |||
local seeds={} | |||
for _,r in ipairs(qualifiers) do seeds[#seeds+1]=r.team end | |||
local poAbs=absDay(season+1,PO_FIRST) | |||
local state={seeds=seeds,rounds={}} | |||
local remaining=seeds | |||
for ri=1,4 do | |||
local name=({'Round of 16','Quarterfinal','Semifinal','Final'})[ri] | |||
local abs=poAbs+(ri-1)*PO_IV | |||
local games,advancing={},{} | |||
for i=1,math.floor(#remaining/2) do | |||
local H,A=remaining[i],remaining[#remaining-i+1] | |||
local hg,ag=_playScore(H,A,abs*100+i) | |||
local winner=_winner(H,A,hg,ag) | |||
games[#games+1]={H,A,hg,ag,winner} | |||
advancing[#advancing+1]=winner | |||
end | |||
state.rounds[ri]={name=name,abs=abs,games=games} | |||
-- Preserve initial seeding, strongest remaining vs weakest. | |||
table.sort(advancing,function(a,b) return _seedIndex(seeds,a)<_seedIndex(seeds,b) end) | |||
remaining=advancing | |||
end | end | ||
state.champion=remaining[1] | |||
return state | |||
end | |||
local function _renderPlayoffTable(season,progressive,scheduleOnly) | |||
local S=_playoffState(season); local now=_nowAbs() | |||
local out={'{| class="wikitable sortable"','! Round !! Date !! Home !! Score !! Away !! Winner'} | |||
for _,round in ipairs(S.rounds) do | |||
local visible=(not progressive) or now>=round.abs | |||
if visible or scheduleOnly then | |||
for gi,g in ipairs(round.games) do | |||
local H,A=g[1],g[2]; local score='—'; local win='—' | |||
if not scheduleOnly and now>=round.abs then score=string.format("'''%d-%d'''",g[3],g[4]); win=_teamWithLogo(g[5]) end | |||
local key=string.format('PO|%d|%s|%d',season,round.name,gi) | |||
out[#out+1]='|-' | |||
out[#out+1]=string.format('| %s || %s || %s %s || %s || %s %s || %s',round.name,_dateLabelFromAbs(round.abs),_kitImgForMatch(H,'home',key,20),_teamWithLogo(H),score,_kitImgForMatch(A,'away',key,20),_teamWithLogo(A),win) | |||
end | |||
end | |||
end | end | ||
out[#out+1]='|}' | |||
return table.concat(out,'\n') | |||
end | |||
function p.renderPlayoffSchedule(frame) | |||
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1) | |||
return _renderPlayoffTable(season,false,true) | |||
end | |||
function p.renderPlayoffResults(frame) | |||
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1) | |||
local progressive=tostring(args.progressive or '1')~='0' | |||
return _renderPlayoffTable(season,progressive,false) | |||
local | |||
end | end | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- 7) MATCH STATS | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
local function | local function _statsTable(r) | ||
local H=r.stats[r.home.code]; local A=r.stats[r.away.code] | |||
local | local out={ | ||
string.format("'''%s %d-%d %s'''",r.home.fullName,r.hg,r.ag,r.away.fullName), | |||
'{| class="wikitable" style="width:100%;"', | |||
local | '! Stat !! '..r.home.fullName..' !! '..r.away.fullName, | ||
'|-','| Goals || '..H.goals..' || '..A.goals, | |||
'|-','| Possession Points || '..H.poss_points..' || '..A.poss_points, | |||
'|-','| Shots on Target || '..H.shots_on..' || '..A.shots_on, | |||
'|-','| Shots off Target || '..H.shots_off..' || '..A.shots_off, | |||
'|-','| Fouls || '..H.fouls..' || '..A.fouls, | |||
'|-','| Free Kicks || '..H.fks..' || '..A.fks, | |||
'|-','| Goal Kicks || '..H.gk..' || '..A.gk, | |||
'|-','| Out of Bounds || '..H.oob..' || '..A.oob, | |||
'|-','| Throw-Ins || '..H.throws..' || '..A.throws, | |||
'|-','| Ball Possession || '..H.poss_pct..'% || '..A.poss_pct..'%', | |||
'|}' | |||
} | } | ||
return table.concat(out,'\n') | |||
end | end | ||
function p.renderMatchStatsForDay(frame) | function p.renderMatchStatsForDay(frame) | ||
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,nil) | |||
local now,day,year=_nowAbs(); local target=tonumber(args.day) | |||
if not target then | |||
if year==season and mapDays()[day] then target=day else | |||
local cutoff=_regularSeasonCutoff(season) | |||
for d=cutoff,START,-1 do if mapDays()[d] then target=d; break end end | |||
end | |||
end | end | ||
if not target or not mapDays()[target] then return "'''No Pillarion matchday is available for this date.'''" end | |||
if now<absDay(season,target) then return "'''That matchday has not yet been played.'''" end | |||
local out={} | |||
for _,r in ipairs(simulateDayResults(target,season)) do out[#out+1]=_statsTable(r) end | |||
return table.concat(out,'\n\n') | |||
for _, | |||
end | end | ||
function p.renderMatchStatsSingle(frame) | function p.renderMatchStatsSingle(frame) | ||
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,nil) | |||
local day=tonumber(args.day or '') or 0; local home=args.home; local away=args.away | |||
if day==0 or not home or not away then return "'''Pass day, home, and away:''' day=###, home=Team, away=Team" end | |||
if _nowAbs()<absDay(season,day) then return "'''That matchday has not yet been played.'''" end | |||
for _,r in ipairs(simulateDayResults(day,season)) do | |||
if r.home.code==home and r.away.code==away then return _statsTable(r) end | |||
end | end | ||
return "'''Match not found on that Pillarion matchday.'''" | |||
end | end | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- 8) ROSTERS / TRANSFERS / PLAYER EVENT ENGINE | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
local ROSTER_SIZE = 12 | local ROSTER_SIZE=12 | ||
local TRANSFER_MD_START = 20 | local TRANSFER_MD_START,TRANSFER_MD_END=20,30 | ||
local POSITIONS={'Striker','Striker','Playmaker','Playmaker','Playmaker','Defender','Defender','Defender','Keeper','Utility','Utility','Utility'} | |||
local POSITIONS = { | |||
} | |||
local function _seedFromString(s) | local function _seedFromString(s) | ||
local h=0; s=tostring(s or '') | |||
for i=1,#s do h=(h*31+string.byte(s,i))%2147483647 end | |||
if h==0 then h=1 end | |||
return h | |||
end | end | ||
local function _rngNext(st) st=(1103515245*st+12345)%2147483647; return st,st/2147483647 end | |||
local function _rngNext(st) | local function _pick(st,list) local r; st,r=_rngNext(st); return st,list[math.floor(r*#list)+1] end | ||
end | |||
local function _pick(st, | |||
end | |||
local function _normKey(s) | local function _normKey(s) | ||
if not s or s=='' then return nil end | |||
if mw and mw.ustring then | |||
s=mw.ustring.lower(s) | |||
s=mw.ustring.gsub(s,'%s+','_') | |||
s=mw.ustring.gsub(s,'[^%w_]','') | |||
return s | |||
end | |||
return (string.lower(tostring(s)):gsub('%s+','_'):gsub('[^%w_]','')) | |||
end | end | ||
local NAME_STYLES = { | 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 = { | 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 mw and mw.ustring then return mw.ustring.upper(mw.ustring.sub(s,1,1))..mw.ustring.sub(s,2) end | |||
return string.upper(s:sub(1,1))..s:sub(2) | |||
end | end | ||
local function _maybeDiacritics(st, s) | local function _maybeDiacritics(st,s) | ||
local r; st,r=_rngNext(st) | |||
if r<.08 then s=s:gsub('a','ä',1) | |||
elseif r<.16 then s=s:gsub('i','ï',1) | |||
elseif r<.24 then s=s:gsub('o','ö',1) | |||
elseif r<.32 then s=s:gsub('u','ü',1) end | |||
return st,s | |||
end | end | ||
local function _buildNameFrom(style, st, isLast) | local function _buildNameFrom(style, st, isLast) | ||
local t=NAME_STYLES[style] or NAME_STYLES.bassarid | |||
local a,b,c | |||
if not isLast then | |||
st,a=_pick(st,t.firstA); st,b=_pick(st,t.firstB); st,c=_pick(st,t.firstC) | |||
else | |||
st,a=_pick(st,t.lastA); st,b=_pick(st,t.lastB); st,c=_pick(st,t.lastC) | |||
end | |||
local name=_cap((a or '')..(b or '')..(c or '')) | |||
local r; st,r=_rngNext(st) | |||
if r<0.05 and #name>=6 then | |||
local cut=math.floor(#name/2) | |||
if mw and mw.ustring then name=mw.ustring.sub(name,1,cut)..'-'..mw.ustring.sub(name,cut+1) | |||
else name=name:sub(1,cut)..'-'..name:sub(cut+1) end | |||
elseif r<0.08 and #name>=6 then | |||
local cut=math.floor(#name/2) | |||
if mw and mw.ustring then name=mw.ustring.sub(name,1,cut)..'’'..mw.ustring.sub(name,cut+1) | |||
else name=name:sub(1,cut)..'’'..name:sub(cut+1) end | |||
end | |||
st,name=_maybeDiacritics(st,name) | |||
return st,name | |||
end | end | ||
local function _chooseStyleForTeam(teamCode, season) | 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 | end | ||
local function _genBaseRoster(teamCode, season) | local function _genBaseRoster(teamCode, season) | ||
local key=_normKey(teamCode) or teamCode | |||
local st=_seedFromString(tostring(season)..'|'..key..'|ROSTER') | |||
local style=_chooseStyleForTeam(teamCode,season) | |||
local roster,used={},{} | |||
for i=1,ROSTER_SIZE do | |||
local id=key..'-'..string.format('%02d',i) | |||
local full | |||
for _=1,10 do | |||
local first,last | |||
st,first=_buildNameFrom(style,st,false) | |||
st,last=_buildNameFrom(style,st,true) | |||
full=first..' '..last | |||
if not used[full] then used[full]=true; break end | |||
end | |||
roster[i]={id=id,name=full or ('Player '..id),pos=POSITIONS[i] or 'Utility',no=i} | |||
end | 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 | |||
end | end | ||
local function | local function _copyRosters(base) | ||
local R={} | |||
for tc,list in pairs(base) do R[tc]={}; for _,p0 in ipairs(list) do local q={}; for k,v in pairs(p0) do q[k]=v end; R[tc][#R[tc]+1]=q end end | |||
return R | |||
end | end | ||
local function _genTransferDeals(season) | local function _genTransferDeals(season) | ||
local st=_seedFromString(season..'|TRANSFERWINDOW|MD20-30'); local deals={} | |||
for md=TRANSFER_MD_START,TRANSFER_MD_END do | |||
local r; st,r=_rngNext(st); local count=1+math.floor(r*3) | |||
for n=1,count do | |||
local ia,ib | |||
st,r=_rngNext(st); ia=math.floor(r*#teams)+1 | |||
repeat st,r=_rngNext(st); ib=math.floor(r*#teams)+1 until ib~=ia | |||
local A,B=teams[ia].code,teams[ib].code | |||
local ra=_genBaseRoster(A,season); local rb=_genBaseRoster(B,season) | |||
st,r=_rngNext(st); local pa=ra[math.floor(r*#ra)+1] | |||
if pa.pos=='Keeper' then st,r=_rngNext(st); if r<.75 then pa=ra[1+math.floor(r*(#ra-1))] end end | |||
local candidates={}; for _,pl in ipairs(rb) do if pl.pos==pa.pos then candidates[#candidates+1]=pl end end | |||
local pb; st,pb=_pick(st,candidates) | |||
deals[#deals+1]={md=md,A=A,B=B,A_id=pa.id,B_id=pb.id,pos=pa.pos} | |||
end | |||
end | end | ||
return deals | |||
end | end | ||
local function | local function _findPlayer(list,id) for i,p0 in ipairs(list or {}) do if p0.id==id then return i,p0 end end end | ||
local function _applyTransfersUpTo(base,deals,upToMd) | |||
local R=_copyRosters(base) | |||
for _,d in ipairs(deals or {}) do | |||
if d.md<=(upToMd or 0) then | |||
local ia,pa=_findPlayer(R[d.A],d.A_id); local ib,pb=_findPlayer(R[d.B],d.B_id) | |||
if ia and ib then pa.team=d.B; pb.team=d.A; R[d.A][ia],R[d.B][ib]=pb,pa end | |||
end | end | ||
end | end | ||
return R | |||
end | |||
local function _buildPlayerNameLookup(base) | |||
local out={}; for _,list in pairs(base) do for _,pl in ipairs(list) do out[pl.id]=pl.name end end; return out | |||
end | end | ||
local function _alloc(st, roster, posWeights) | local function _alloc(st, roster, posWeights) | ||
local pool={} | |||
for _,pl in ipairs(roster or {}) do | |||
local w=(posWeights and posWeights[pl.pos]) or 1 | |||
for _=1,w do pool[#pool+1]=pl end | |||
end | |||
return _pick(st,pool) | |||
end | end | ||
local function _ensure(map, pid) | local function _ensure(map,pid) | ||
if not map[pid] then map[pid]={goals=0,assists=0,poss=0,son=0,sof=0,fouls=0,apps=0} end | |||
return map[pid] | |||
end | end | ||
local function _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster) | local function _matchPlayerEvents(season,md,day,matchIndex,homeTeam,awayTeam,homeRoster,awayRoster) | ||
local salt=_matchSeed(season,day,matchIndex,homeTeam,awayTeam) | |||
local hg,ag=simScoreFor(homeTeam,awayTeam,salt) | |||
local S=computeMatchStats(homeTeam,awayTeam,hg,ag,salt) | |||
local stats={home=S[homeTeam.code] or {},away=S[awayTeam.code] or {}} | |||
local st=_seedFromString(tostring(season)..'|MD'..md..'|D'..day..'|M'..matchIndex..'|'..homeTeam.code..'|'..awayTeam.code) | |||
local events={home={},away={}} | |||
for _,pl in ipairs(homeRoster or {}) do _ensure(events.home,pl.id).apps=1 end | |||
for _,pl in ipairs(awayRoster or {}) do _ensure(events.away,pl.id).apps=1 end | |||
local W_GOAL={Striker=6,Playmaker=3,Utility=2,Defender=1,Keeper=0} | |||
local W_POSS={Playmaker=6,Defender=3,Utility=2,Striker=1,Keeper=1} | |||
local W_FOUL={Defender=4,Utility=3,Playmaker=2,Striker=1,Keeper=1} | |||
local W_ASSIST={Playmaker=6,Striker=3,Utility=2,Defender=1,Keeper=0} | |||
for _=1,(stats.home.goals or 0) do | |||
local scorer; st,scorer=_alloc(st,homeRoster,W_GOAL) | |||
if scorer then | |||
_ensure(events.home,scorer.id).goals=_ensure(events.home,scorer.id).goals+1 | |||
local r; st,r=_rngNext(st) | |||
if r<0.70 then | |||
local ast; st,ast=_alloc(st,homeRoster,W_ASSIST) | |||
if ast and ast.id~=scorer.id then _ensure(events.home,ast.id).assists=_ensure(events.home,ast.id).assists+1 end | |||
end | |||
end | |||
end | end | ||
for _=1,(stats.away.goals or 0) do | |||
local scorer; st,scorer=_alloc(st,awayRoster,W_GOAL) | |||
if scorer then | |||
_ensure(events.away,scorer.id).goals=_ensure(events.away,scorer.id).goals+1 | |||
local r; st,r=_rngNext(st) | |||
if r<0.70 then | |||
local ast; st,ast=_alloc(st,awayRoster,W_ASSIST) | |||
if ast and ast.id~=scorer.id then _ensure(events.away,ast.id).assists=_ensure(events.away,ast.id).assists+1 end | |||
end | |||
end | |||
end | end | ||
for _=1,(stats.home.poss_points or 0) do local pl;st,pl=_alloc(st,homeRoster,W_POSS);if pl then _ensure(events.home,pl.id).poss=_ensure(events.home,pl.id).poss+1 end end | |||
for _=1,(stats.away.poss_points or 0) do local pl;st,pl=_alloc(st,awayRoster,W_POSS);if pl then _ensure(events.away,pl.id).poss=_ensure(events.away,pl.id).poss+1 end end | |||
local function distributeShots(side,roster,on,off) | |||
for _=1,(on or 0) do local pl;st,pl=_alloc(st,roster,{Striker=5,Playmaker=3,Utility=2,Defender=1,Keeper=0});if pl then _ensure(side,pl.id).son=_ensure(side,pl.id).son+1 end end | |||
for _=1,(off or 0) do local pl;st,pl=_alloc(st,roster,{Striker=4,Playmaker=3,Utility=2,Defender=1,Keeper=0});if pl then _ensure(side,pl.id).sof=_ensure(side,pl.id).sof+1 end end | |||
end | end | ||
distributeShots(events.home,homeRoster,stats.home.shots_on or 0,stats.home.shots_off or 0) | |||
distributeShots(events.away,awayRoster,stats.away.shots_on or 0,stats.away.shots_off or 0) | |||
for _=1,(stats.home.fouls or 0) do local pl;st,pl=_alloc(st,homeRoster,W_FOUL);if pl then _ensure(events.home,pl.id).fouls=_ensure(events.home,pl.id).fouls+1 end end | |||
for _=1,(stats.away.fouls or 0) do local pl;st,pl=_alloc(st,awayRoster,W_FOUL);if pl then _ensure(events.away,pl.id).fouls=_ensure(events.away,pl.id).fouls+1 end end | |||
return events | |||
end | end | ||
local function _computePlayerSeasonTotals(season, upToMd) | local function _computePlayerSeasonTotals(season,upToMd) | ||
upToMd=math.min(tonumber(upToMd) or 0,TOTAL_ROUNDS) | |||
local base=_buildBaseRosters(season); local deals=_genTransferDeals(season); local totals={} | |||
for _,list in pairs(base) do for _,pl in ipairs(list) do totals[pl.id]={id=pl.id,name=pl.name,pos=pl.pos,team=pl.team,apps=0,goals=0,assists=0,poss=0,son=0,sof=0,fouls=0} end end | |||
local inv=invertDayMap(mapDays()) | |||
for md=1,upToMd do | |||
local day=inv[md] | |||
if day then | |||
local R=_applyTransfersUpTo(base,deals,md) | |||
for mi,m in ipairs(getRoundMatches(md)) do | |||
local h,a=m[1],m[2]; local hr,ar=R[h.code] or {},R[a.code] or {} | |||
local ev=_matchPlayerEvents(season,md,day,mi,h,a,hr,ar) | |||
for pid,line in pairs(ev.home) do local z=totals[pid]; if z then for _,k in ipairs({'apps','goals','assists','poss','son','sof','fouls'}) do z[k]=z[k]+(line[k] or 0) end; z.team=h.code end end | |||
for pid,line in pairs(ev.away) do local z=totals[pid]; if z then for _,k in ipairs({'apps','goals','assists','poss','son','sof','fouls'}) do z[k]=z[k]+(line[k] or 0) end; z.team=a.code end end | |||
end | |||
end | end | ||
end | end | ||
local Rnow=_applyTransfersUpTo(base,deals,upToMd) | |||
for tc,list in pairs(Rnow) do for _,pl in ipairs(list) do if totals[pl.id] then totals[pl.id].team=tc end end end | |||
return totals,deals,base | |||
end | end | ||
function p.renderRoster(frame) | function p.renderRoster(frame) | ||
local args=(frame and frame.args) or {}; local team=args.team or args[1] | |||
if not team or not TEAM_BY_CODE[team] then return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''" end | |||
local season=_resolveSeason(args,2); local upToMd=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season) | |||
local R=_applyTransfersUpTo(_buildBaseRosters(season),_genTransferDeals(season),upToMd)[team] or {} | |||
local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! # !! Player !! Position !! ID !! Notes'} | |||
for _,pl in ipairs(R) do out[#out+1]='|-'; out[#out+1]=string.format('| %d || %s || %s || <code>%s</code> || %s',pl.no or 0,pl.name or '—',pl.pos or '—',pl.id or '—',pl.notes or '—') end | |||
out[#out+1]='|}' | |||
return (TEAM_BY_CODE[team].logo or '').." '''"..team.."''' (Roster as of Matchday "..upToMd..')\n'..table.concat(out,'\n') | |||
end | end | ||
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={} | |||
for _,t in ipairs(teams) do | |||
out[#out+1]='<div class="mw-collapsible mw-collapsed" data-expandtext="Show roster" data-collapsetext="Hide roster" style="margin:0.6em 0;"><div>'..(t.logo or '')..' <b>'..t.code..'</b></div><div class="mw-collapsible-content">'..p.renderRoster({args={team=t.code,season=season,md=md}})..'</div></div>' | |||
end | |||
return table.concat(out,'\n') | |||
end | 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 deals=_genTransferDeals(season); local base=_buildBaseRosters(season); local names=_buildPlayerNameLookup(base) | |||
local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! Matchday !! From !! To !! Swap (pos)'} | |||
for _,d in ipairs(deals) do if d.md<=md then out[#out+1]='|-'; out[#out+1]=string.format('| %d || %s || %s || %s ⇄ %s (%s)',d.md,_teamWithLogo(d.A),_teamWithLogo(d.B),names[d.A_id] or d.A_id,names[d.B_id] or d.B_id,d.pos or '—') end end | |||
out[#out+1]='|}' | |||
return string.format("'''Transfer window (Matchdays %d–%d) — season %d (through Matchday %d)'''\n",TRANSFER_MD_START,TRANSFER_MD_END,season,md)..table.concat(out,'\n') | |||
end | end | ||
function p.renderPlayerStats(frame) | function p.renderPlayerStats(frame) | ||
local args=(frame and frame.args) or {}; local team=args.team or args[1] | |||
if not team or not TEAM_BY_CODE[team] then return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''" end | |||
local season=_resolveSeason(args,2); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season) | |||
local totals,deals,base=_computePlayerSeasonTotals(season,md); local roster=_applyTransfersUpTo(base,deals,md)[team] or {} | |||
local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'} | |||
for _,pl in ipairs(roster) do local r=totals[pl.id] or {}; out[#out+1]='|-'; out[#out+1]=string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d',pl.name,pl.pos,r.apps or 0,r.goals or 0,r.assists or 0,r.poss or 0,r.son or 0,r.sof or 0,r.fouls or 0) end | |||
out[#out+1]='|}' | |||
return (TEAM_BY_CODE[team].logo or '').." '''"..team.."''' — Player totals through Matchday "..md..'\n'..table.concat(out,'\n') | |||
end | 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 stat=args.stat or args[2] or 'goals'; local ok={goals=true,assists=true,poss=true,son=true,sof=true,fouls=true,apps=true} | |||
if not ok[stat] then return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''" end | |||
local totals=_computePlayerSeasonTotals(season,md); local list={}; for _,r in pairs(totals) do list[#list+1]=r end | |||
table.sort(list,function(a,b) if (a[stat] or 0)~=(b[stat] or 0) then return (a[stat] or 0)>(b[stat] or 0) end return (a.name or '')<(b.name or '') end) | |||
local lim=tonumber(args.limit or '10') or 10; local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! Rank !! Player !! Team (current) !! Pos !! '..stat} | |||
for i=1,math.min(lim,#list) do local r=list[i]; out[#out+1]='|-'; out[#out+1]=string.format('| %d || %s || %s || %s || %d',i,r.name or '—',_teamWithLogo(r.team or '—'),r.pos or '—',r[stat] or 0) end | |||
out[#out+1]='|}' | |||
return string.format("'''%d PSSC leaders through Matchday %d'''\n",season,md)..table.concat(out,'\n') | |||
end | end | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- 9) REGIONAL CUP | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
local function | local RC_META={ | ||
['Morovian']={fullName='Morovian Select',logo='[[File:MorovianSelect.png|25px]]'}, | |||
['Southern Strait']={fullName='Southern Strait Select',logo='[[File:SouthernStraitSelect.png|25px]]'}, | |||
['Western Highlands']={fullName='Western Highlands Select',logo='[[File:WesternHighlandsSelect.png|25px]]'}, | |||
['Normarkian']={fullName='Normarkian Select',logo='[[File:NormarkianSelect.png|25px]]'}, | |||
['Lacaran Hodos']={fullName='Lacaran Hodos Select',logo=''}, | |||
} | |||
local function _rcRating(r) | |||
local apps=tonumber(r.apps) or 0; local denom=math.max(1,apps) | |||
local g=(r.goals or 0)/denom; local a=(r.assists or 0)/denom; local pr=(r.poss or 0)/denom; local sr=(r.son or 0)/denom; local orr=(r.sof or 0)/denom; local fr=(r.fouls or 0)/denom | |||
local rel=math.min(1,denom/6); local raw | |||
if r.pos=='Keeper' then raw=5.9+pr*.08+a*.8-fr*.50-orr*.12 else raw=5.8+g*3+a*1.8+pr*.10+sr*.22-orr*.18-fr*.40 end | |||
local rating=6+(raw-6)*rel; if rating<0 then rating=0 elseif rating>9.7 then rating=9.7 end; return rating | |||
end | end | ||
local function | local function _regionalTeams(season) | ||
local totals=_computePlayerSeasonTotals(season,TOTAL_ROUNDS) | |||
local byDiv={}; for _,d in ipairs(DIV_ORDER) do byDiv[d]={Keeper={},Defender={},Playmaker={},Striker={},Utility={}} end | |||
for _,r in pairs(totals) do local d=DIV_OF[r.team]; if d and byDiv[d] and byDiv[d][r.pos] then r.rating=_rcRating(r); byDiv[d][r.pos][#byDiv[d][r.pos]+1]=r end end | |||
for _,d in ipairs(DIV_ORDER) do for _,list in pairs(byDiv[d]) do table.sort(list,function(a,b) if a.rating~=b.rating then return a.rating>b.rating end return a.name<b.name end) end end | |||
local need={Keeper=1,Defender=4,Playmaker=3,Striker=3,Utility=1}; local out={} | |||
for _,d in ipairs(DIV_ORDER) do | |||
local meta=RC_META[d]; local sq={} | |||
for _,pos in ipairs({'Keeper','Defender','Playmaker','Striker','Utility'}) do for i=1,math.min(need[pos],#byDiv[d][pos]) do sq[#sq+1]=byDiv[d][pos][i] end end | |||
out[#out+1]={code=meta.fullName,fullName=meta.fullName,logo=meta.logo,division=d,squad=sq,championships=0} | |||
end | |||
return out | |||
end | end | ||
local function | local function _regionalCupStartAbs(season) return absDay(season+1,PO_FIRST)+PO_IV*3+RC_GAP_AFTER_FINAL end | ||
local function _rcStrength(T) local s=0; for _,pl in ipairs(T.squad or {}) do s=s+(pl.rating or 6) end; return s/math.max(1,#(T.squad or {})) end | |||
local function _rcPlayScore(H,A,salt) | |||
math.randomseed(salt); local sh,sa=_rcStrength(H),_rcStrength(A); local p0=math.random(); local hg,ag | |||
if p0<.10 then hg=math.random(0,12);ag=hg else local ph=sh/(sh+sa); local homeWin=((p0-.10)/.90)<ph; local w=math.random(1,12); local l=math.random(0,w-1); if homeWin then hg,ag=w,l else hg,ag=l,w end end | |||
return hg,ag | |||
end | end | ||
local function _rcFixtures(T) | |||
local function | local fixtures={} | ||
-- Berger table for five entrants plus a rotating bye. | |||
local rot={T[1],T[2],T[3],T[4],T[5],false} | |||
for r=1,5 do | |||
local games={} | |||
for i=1,3 do | |||
local h,a=rot[i],rot[7-i] | |||
if h and a then | |||
if r%2==0 then h,a=a,h end | |||
games[#games+1]={h,a} | |||
end | |||
end | |||
fixtures[#fixtures+1]={round='Round '..r,games=games} | |||
local last=rot[6] | |||
for i=6,3,-1 do rot[i]=rot[i-1] end | |||
rot[2]=last | |||
end | end | ||
return fixtures | |||
end | end | ||
local function _rcStandingsInit(T) local st={}; for _,x in ipairs(T) do st[x.fullName]={team=x,GP=0,W=0,D=0,L=0,GF=0,GA=0,GD=0,Pts=0} end; return st end | |||
local function _rcApply(st,H,A,hg,ag) local h,a=st[H.fullName],st[A.fullName]; h.GP=h.GP+1;a.GP=a.GP+1;h.GF=h.GF+hg;h.GA=h.GA+ag;a.GF=a.GF+ag;a.GA=a.GA+hg;if hg>ag then h.W=h.W+1;a.L=a.L+1;h.Pts=h.Pts+3 elseif ag>hg then a.W=a.W+1;h.L=h.L+1;a.Pts=a.Pts+3 else h.D=h.D+1;a.D=a.D+1;h.Pts=h.Pts+1;a.Pts=a.Pts+1 end;h.GD=h.GF-h.GA;a.GD=a.GF-a.GA end | |||
local function _rcSorted(st) local L={};for _,r in pairs(st) do L[#L+1]=r end;table.sort(L,function(a,b) if a.Pts~=b.Pts then return a.Pts>b.Pts end;if a.GD~=b.GD then return a.GD>b.GD end;if a.GF~=b.GF then return a.GF>b.GF end;return a.team.fullName<b.team.fullName end);return L end | |||
function p.renderRegionalCupRosters(frame) | |||
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local T=_regionalTeams(season); local out={'== Off-Season Regional Cup — Call-Ups ('..season..' PSSC) =='} | |||
for _,rt in ipairs(T) do out[#out+1]='=== '..rt.logo..' '..rt.fullName..' ===';out[#out+1]='{| class="wikitable sortable" style="width:100%; font-size:90%;"';out[#out+1]='! Player !! Pos !! Club !! Rating !! G !! A';for _,pl in ipairs(rt.squad) do out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %.2f || %d || %d',pl.name,pl.pos,_teamWithLogo(pl.team),pl.rating or 0,pl.goals or 0,pl.assists or 0) end;out[#out+1]='|}' end | |||
return table.concat(out,'\n') | |||
end | end | ||
local | function p.renderRegionalCupSchedule(frame) | ||
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local T=_regionalTeams(season); local fixtures=_rcFixtures(T); local start=_regionalCupStartAbs(season) | |||
local out={'{| class="wikitable sortable"','! Stage !! Date !! Home (kit) !! Score !! Away (kit)'} | |||
for i,blk in ipairs(fixtures) do local a=start+(i-1)*RC_ROUND_GAP; for gi,g in ipairs(blk.games) do local key='RC|'..season..'|'..blk.round..'|'..gi;out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s %s || — || %s %s',blk.round,_dateLabelFromAbs(a),_kitImgForMatch(g[1],'home',key,20),_teamWithLogo(g[1]),_kitImgForMatch(g[2],'away',key,20),_teamWithLogo(g[2])) end end | |||
local finalAbs=start+RC_ROUND_GAP*4+RC_FINAL_GAP;out[#out+1]='|-';out[#out+1]=string.format('| Final || %s || TBD || — || TBD',_dateLabelFromAbs(finalAbs));out[#out+1]='|-';out[#out+1]=string.format('| 3rd Place || %s || TBD || — || TBD',_dateLabelFromAbs(finalAbs));out[#out+1]='|}' | |||
return table.concat(out,'\n') | |||
end | end | ||
function p.renderRegionalCupResults(frame) | |||
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local T=_regionalTeams(season); local fixtures=_rcFixtures(T); local st=_rcStandingsInit(T); local start=_regionalCupStartAbs(season); local now=_nowAbs(); local out={} | |||
out[#out+1]='{| class="wikitable sortable"';out[#out+1]='! Stage !! Date !! Home !! Score !! Away' | |||
for i,blk in ipairs(fixtures) do | |||
local a=start+(i-1)*RC_ROUND_GAP | |||
for gi,g in ipairs(blk.games) do | |||
local score='—'; if now>=a then local hg,ag=_rcPlayScore(g[1],g[2],a*100+gi);score=string.format("'''%d-%d'''",hg,ag);_rcApply(st,g[1],g[2],hg,ag) end | |||
out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %s || %s',blk.round,_dateLabelFromAbs(a),_teamWithLogo(g[1]),score,_teamWithLogo(g[2])) | |||
end | |||
local | |||
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' | |||
if | local L=_rcSorted(st);for i,r in ipairs(L) do out[#out+1]='|-';out[#out+1]=string.format('| %d || %s || %d || %d || %d || %d || %d || %d || %+d || %d',i,_teamWithLogo(r.team),r.GP,r.W,r.D,r.L,r.GF,r.GA,r.GD,r.Pts) end;out[#out+1]='|}' | ||
local finalAbs=start+RC_ROUND_GAP*4+RC_FINAL_GAP;out[#out+1]='';out[#out+1]='=== Finals Day — '.._dateLabelFromAbs(finalAbs)..' ===' | |||
local A1,A2,A3,A4=L[1].team,L[2].team,L[3].team,L[4].team | |||
local function line(label,X,Y,k) local score='—';local winner='TBD';if now>=finalAbs then local gx,gy=_rcPlayScore(X,Y,finalAbs*100+k);score=string.format("'''%d-%d'''",gx,gy);winner=(gx>gy and X.fullName) or (gy>gx and Y.fullName) or 'Draw' end;return string.format('* %s: %s %s %s (Winner: %s)',label,_teamWithLogo(X),score,_teamWithLogo(Y),winner) end | |||
out[#out+1]=line('Final',A1,A2,1);out[#out+1]=line('3rd Place',A3,A4,2) | |||
return table.concat(out,'\n') | |||
end | end | ||
local function | ------------------------------------------------------------------------ | ||
-- 10) MATCH REPORTS / WEEKLY FEATURES | |||
------------------------------------------------------------------------ | |||
local function _pickMatchIndexForDay(season,md,pick) | |||
local day=invertDayMap(mapDays())[md]; if not day then return nil,'Unable to resolve matchday.' end | |||
local R=simulateDayResults(day,season); if #R==0 then return nil,'No fixtures found.' end | |||
local bestI,bestScore=1,nil | |||
for i,r in ipairs(R) do | |||
local s | |||
if pick=='chaos' then s=r.hg+r.ag+math.abs(r.hg-r.ag) | |||
else s=(r.hg+r.ag)-math.abs(r.hg-r.ag)*0.5 end | |||
if not bestScore or s>bestScore then bestScore=s;bestI=i end | |||
end | end | ||
return bestI | |||
end | end | ||
local function _renderMatchReport(season,md,matchIndex,titleOverride) | |||
local function _renderMatchReport(season, md, matchIndex, titleOverride) | local day=invertDayMap(mapDays())[md]; if not day then return "'''Unable to resolve that matchday.'''" end | ||
if not _isPlayedMatchday(season,md) then return "'''That matchday has not yet been played.'''" end | |||
local R=simulateDayResults(day,season);local r=R[matchIndex];if not r then return "'''Match not found.'''" end | |||
return (titleOverride or string.format("'''Match Report — %d PSSC (Matchday %d)'''",season,md))..'\n'.._statsTable(r) | |||
local | |||
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' | |||
if not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet (or the requested matchday is in the future).'''" end;local idx,err=_pickMatchIndexForDay(season,md,pick);if not idx then return "'''"..(err or 'Unable to select match.').."'''" end;return _renderMatchReport(season,md,idx) | |||
end | end | ||
function p.renderChaosOfWeek(frame) local args=(frame and frame.args) or {};args.pick=args.pick or 'chaos';return p.renderMatchOfWeek({args=args}) end | |||
function p.renderMatchReport(frame) local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local md=tonumber(args.md or args.matchday or args[2] or '') or _currentPlayedMatchday(season);local mi=tonumber(args.match or args[3] or '1') or 1;if not _isPlayedMatchday(season,md) then return "'''That matchday has not yet been played.'''" end;return _renderMatchReport(season,md,mi,string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''",season,md,mi)) end | |||
function | local function _playerRating(r) | ||
if not r then return 0 end | |||
local apps=math.max(1,r.apps or 0) | |||
local x=6+(r.goals or 0)*1.25/apps+(r.assists or 0)*.85/apps+(r.poss or 0)*.05/apps+(r.son or 0)*.15/apps-(r.sof or 0)*.08/apps-(r.fouls or 0)*.16/apps | |||
if x<0 then x=0 elseif x>10 then x=10 end;return x | |||
end | end | ||
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 not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet.'''" end | |||
local totals=_computePlayerSeasonTotals(season,md);local prior=_computePlayerSeasonTotals(season,md-1);local list={} | |||
for id,r in pairs(totals) do local q=prior[id] or {};local d={id=id,name=r.name,pos=r.pos,team=r.team,apps=(r.apps or 0)-(q.apps or 0),goals=(r.goals or 0)-(q.goals or 0),assists=(r.assists or 0)-(q.assists or 0),poss=(r.poss or 0)-(q.poss or 0),son=(r.son or 0)-(q.son or 0),sof=(r.sof or 0)-(q.sof or 0),fouls=(r.fouls or 0)-(q.fouls or 0)};if d.apps>0 then d.rating=_playerRating(d);list[#list+1]=d end end | |||
table.sort(list,function(a,b) if a.rating~=b.rating then return a.rating>b.rating end;return a.name<b.name end) | |||
local out={"'''Weekly Awards — Matchday "..md.."'''",'{| class="wikitable sortable"','! Award !! Player !! Team !! Pos !! Rating'} | |||
local labels={'Player of the Week','Second Star','Third Star'};for i=1,math.min(3,#list) do local r=list[i];out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %s || %.2f',labels[i],r.name,_teamWithLogo(r.team),r.pos,r.rating) end;out[#out+1]='|}';return table.concat(out,'\n') | |||
local | |||
end | end | ||
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 not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet.'''" end | |||
local totals=_computePlayerSeasonTotals(season,md);local prior=_computePlayerSeasonTotals(season,md-1);local by={Keeper={},Defender={},Playmaker={},Striker={},Utility={}} | |||
for id,r in pairs(totals) do local q=prior[id] or {};local d={name=r.name,pos=r.pos,team=r.team,apps=(r.apps or 0)-(q.apps or 0),goals=(r.goals or 0)-(q.goals or 0),assists=(r.assists or 0)-(q.assists or 0),poss=(r.poss or 0)-(q.poss or 0),son=(r.son or 0)-(q.son or 0),sof=(r.sof or 0)-(q.sof or 0),fouls=(r.fouls or 0)-(q.fouls or 0)};if d.apps>0 and by[d.pos] then d.rating=_playerRating(d);by[d.pos][#by[d.pos]+1]=d end end | |||
for _,L in pairs(by) do table.sort(L,function(a,b) if a.rating~=b.rating then return a.rating>b.rating end;return a.name<b.name end) end | |||
local need={Keeper=1,Defender=3,Playmaker=3,Striker=2,Utility=2};local out={"'''Team of the Week — Matchday "..md.."'''",'{| class="wikitable"','! Role !! Player !! Team !! Rating'} | |||
for _,pos in ipairs({'Keeper','Defender','Playmaker','Striker','Utility'}) do for i=1,math.min(need[pos],#by[pos]) do local r=by[pos][i];out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %.2f',pos,r.name,_teamWithLogo(r.team),r.rating) end end;out[#out+1]='|}';return table.concat(out,'\n') | |||
end | 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 not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet.'''" end | |||
local day=invertDayMap(mapDays())[md];if not day then return "'''Unable to resolve PSSC day for matchday "..md..".'''" end;local out={'== Matchday '..md..' Capsule ==','',p.renderMatchOfWeek({args={season=season,md=md,pick=args.pick or 'chaos'}}),'','=== Full Results ==='} | |||
for _,r in ipairs(simulateDayResults(day,season)) do out[#out+1]=string.format('* %s \'\'\'%d\'\'\' %s vs %s \'\'\'%d\'\'\' %s',r.home.logo,r.hg,r.home.fullName,r.away.logo,r.ag,r.away.fullName) end;out[#out+1]='';out[#out+1]=p.renderWeeklyAwards({args={season=season,md=md}});return table.concat(out,'\n') | |||
end | 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) | |||
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 | |||
for d=from,END do local md=mapDays()[d];if md and absDay(season,d)>=now then local gap=prev and d-prev or '—';prev=d;shown=shown+1;out[#out+1]='|-';out[#out+1]=string.format('| %d || %d || %d || %s || %s',shown,md,d,tostring(gap),_dateLabel(season,d));if shown>=20 then break end end end;out[#out+1]='|}';return table.concat(out,'\n') | |||
end | end | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- 11) SEASON AWARDS | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
local function _seasonAwardData(season) | |||
local function | local totals=_computePlayerSeasonTotals(season,TOTAL_ROUNDS);local list={};for _,r in pairs(totals) do r.rating=_playerRating(r);list[#list+1]=r end | ||
local function best(stat,filter,ascending) | |||
local L={};for _,r in ipairs(list) do if not filter or filter(r) then L[#L+1]=r end end;table.sort(L,function(a,b) local av,bv=a[stat] or 0,b[stat] or 0;if av~=bv then if ascending then return av<bv else return av>bv end end;return a.name<b.name end);return L[1] | |||
local | |||
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)} | |||
local by={Keeper={},Defender={},Playmaker={},Striker={},Utility={}};for _,r in ipairs(list) do if by[r.pos] then by[r.pos][#by[r.pos]+1]=r end end;for _,L in pairs(by) do table.sort(L,function(a,b)if a.rating~=b.rating then return a.rating>b.rating end;return a.name<b.name end)end | |||
return awards,by | |||
end | end | ||
local | function p.renderSeasonAwards(frame) | ||
local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local now=_nowAbs();local finalAbs=absDay(season,_finalRegularSeasonDay()) | |||
if now<finalAbs then return string.format("'''End-of-season awards will be available after the regular season concludes (%s).'''",_dateLabelFromAbs(finalAbs)) end | |||
local A,by=_seasonAwardData(season);local out={'== End-of-Season Awards ==','{| class="wikitable sortable"','! Award !! Winner !! Team !! Pos !! Notes'} | |||
local function row(label,r,note) out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %s || %s',label,r and r.name or '—',r and _teamWithLogo(r.team) or '—',r and r.pos or '—',note and note(r) or '—') end | |||
row('MVP (Player of the Season)',A.mvp,function(r)return string.format('Rating: %.2f',r.rating)end);row('Golden Pillar (Top Scorer)',A.goldenPillar,function(r)return 'Goals: '..(r.goals or 0)end);row('Playmaker of the Season',A.playmaker,function(r)return 'Assists: '..(r.assists or 0)end);row('Possession Master',A.possession,function(r)return 'Possession points: '..(r.poss or 0)end);row('Golden Gloves',A.goldenGloves,function(r)return string.format('Rating: %.2f',r.rating)end);row('Sharpshooter',A.sharpshooter,function(r)return 'Shots on target: '..(r.son or 0)end);row('Discipline / Fair Play',A.fairPlay,function(r)return 'Fouls: '..(r.fouls or 0)end);row('Ironman',A.ironman,function(r)return 'Appearances: '..(r.apps or 0)end);out[#out+1]='|}' | |||
out[#out+1]='';out[#out+1]='=== Team of the Season (Best XI) ===';out[#out+1]='{| class="wikitable"';out[#out+1]='! Role !! Player !! Team !! Rating' | |||
local need={Keeper=1,Defender=3,Playmaker=3,Striker=2,Utility=2};for _,pos in ipairs({'Keeper','Defender','Playmaker','Striker','Utility'}) do for i=1,math.min(need[pos],#by[pos]) do local r=by[pos][i];out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %.2f',pos,r.name,_teamWithLogo(r.team),r.rating)end end;out[#out+1]='|}';return table.concat(out,'\n') | |||
end | 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)) | |||
function p. | |||
end | end | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
Latest revision as of 16:52, 12 October 2026
Documentation for this module may be created at Module:PillSeasonSchedule/doc
-- Module:PillSeasonSchedule
-- COMPLETE 40-CLUB / 5-DIVISION EXPANSION REWRITE
-- Built from the previously supplied season-lifecycle module.
-- Historical 53 PSSC pairing data is retained in SCHED_LEGACY for reference;
-- the active schedule uses the expanded 40-team competition.
--
-- Purpose:
-- * Preserve the established Pillarion regular-season structure.
-- * Keep completed 53 PSSC results stable.
-- * Automatically move from regular season -> playoffs -> Regional Cup
-- -> offseason -> next regular season.
-- * Freeze completed-season standings/statistics instead of resetting them
-- when the PSSC calendar rolls into Atosiel of the following year.
-- * Keep historical season= arguments working.
-- * Give future seasons season-specific deterministic results so they do not
-- simply replay 53 PSSC.
--
-- The public render functions used by the existing Pillarion page are retained.
local p = {}
local date = require('Module:BassaridianCalendar')
------------------------------------------------------------------------
-- 0) CONFIG / CALENDAR / LIFECYCLE
------------------------------------------------------------------------
local START, END = 62, 183
local DAYS_IN_YEAR = 183
local LEGACY_SEASON = 53
local PO_FIRST = 4 -- 4 Atosiel of season+1
local PO_IV = 3 -- playoff rounds every 3 days
local RC_GAP_AFTER_FINAL = 21
local RC_ROUND_GAP = 2
local RC_FINAL_GAP = 3
local function getMonthDay(d)
d = tonumber(d) or 1
if d <= 61 then
return 'Atosiel', d
elseif d <= 122 then
return 'Thalassiel', d - 61
else
return 'Opsitheiel', d - 122
end
end
local function md2day(m, d) return (m - 1) * 61 + d end
local function absDay(y, d) return y * DAYS_IN_YEAR + d end
local function _dateFromAbs(a)
local y = math.floor((a - 1) / DAYS_IN_YEAR)
local d = a - y * DAYS_IN_YEAR
if d < 1 then
y = y - 1
d = d + DAYS_IN_YEAR
end
return y, d
end
local function _dateLabel(y, d)
local mon, dom = getMonthDay(d)
return string.format('%d %s %d PSSC', dom, mon, y)
end
local function _dateLabelFromAbs(a)
local y, d = _dateFromAbs(a)
return _dateLabel(y, d)
end
local function _currentPSSC()
local curr = date.getCurrentDate() or ''
local day = tonumber(curr:match('^(%d+),')) or 0
local year = tonumber(curr:match(', (%d+) PSSC')) or 0
return day, year
end
local function _nowAbs()
local d, y = _currentPSSC()
return absDay(y, d), d, y
end
-- The active Pillarion competition season is the current year during
-- Thalassiel/Opsitheiel, but the previous year during Atosiel because Atosiel
-- carries that season's playoffs and Regional Cup.
-- During Atosiel the previous season remains active only while its
-- post-season is actually underway. Once the Regional Cup is finished,
-- all default league/standings calls must advance to the upcoming year.
-- Otherwise the next season displays the previous year's full table.
local function _activeSeason()
local day, year = _currentPSSC()
if year <= 0 then return LEGACY_SEASON end
if day >= START then return year end
local previousRegionalFinalDay = PO_FIRST + (PO_IV * 3)
+ RC_GAP_AFTER_FINAL + (RC_ROUND_GAP * 4) + RC_FINAL_GAP
if day <= previousRegionalFinalDay then return year - 1 end
return year
end
local function _resolveSeason(args, positional)
args = args or {}
local explicit = tonumber(args.season)
if not explicit and positional then explicit = tonumber(args[positional]) end
if explicit then return explicit end
return _activeSeason()
end
local function _seasonTimes(season)
local regularStart = absDay(season, START)
local regularEnd = absDay(season, END)
local playoffStart = absDay(season + 1, PO_FIRST)
local playoffFinal = playoffStart + PO_IV * 3
local regionalStart = playoffFinal + RC_GAP_AFTER_FINAL
local regionalFinal = regionalStart + RC_ROUND_GAP * 4 + RC_FINAL_GAP
local nextRegular = absDay(season + 1, START)
return {
regularStart = regularStart,
regularEnd = regularEnd,
playoffStart = playoffStart,
playoffFinal = playoffFinal,
regionalStart = regionalStart,
regionalFinal = regionalFinal,
nextRegular = nextRegular,
}
end
local function _seasonStage(season)
local now = _nowAbs()
local t = _seasonTimes(season)
if now < t.regularStart then return 'before_regular', t end
if now <= t.regularEnd then return 'regular', t end
if now < t.playoffStart then return 'pre_playoff', t end
if now <= t.playoffFinal then return 'playoffs', t end
if now < t.regionalStart then return 'pre_regional', t end
if now <= t.regionalFinal then return 'regional_cup', t end
if now < t.nextRegular then return 'offseason', t end
return 'complete', t
end
------------------------------------------------------------------------
-- 1) TEAMS / DIVISIONS / KITS
------------------------------------------------------------------------
local teams = {
{ code='Allegro Symphonara', fullName='Allegro Symphonara', logo='[[File:AllegrosymphonaraB.png|25px]]', championships=4 },
{ code='Ashguard Pyralis', fullName='Ashguard Pyralis', logo='[[File:AshguardpyralisB.png|25px]]', championships=1 },
{ code='Aurelia Auric', fullName='Aurelia Auric', logo='[[File:AureliaAuricB.png|25px]]', championships=2 },
{ code='Brightpath Aureum', fullName='Brightpath Aureum', logo='[[File:BrightpathaureumB.png|25px]]', championships=2 },
{ code='Celestial Sancta Lunaris', fullName='Celestial Sancta Lunaris', logo='[[File:CelestialsanctaB.png|25px]]', championships=1 },
{ code='Delphica Windborne', fullName='Delphica Windborne', logo='[[File:DelphicawindborneB.png|25px]]', championships=1 },
{ code='Emberstone Vaeringheim', fullName='Emberstone Vaeringheim', logo='[[File:EmberstonevaeringheimB.png|25px]]', championships=3 },
{ code='Flameborne Erythros', fullName='Flameborne Erythros', logo='[[File:FlameborneerythrosB.png|25px]]', championships=1 },
{ code='Forgeward Nexa', fullName='Forgeward Nexa', logo='[[File:ForgewardnexaB.png|25px]]', championships=0 },
{ code='Hearthkeeper Koinon', fullName='Hearthkeeper Koinon', logo='[[File:HearthkeeperkoinonB.png|25px]]', championships=0 },
{ code='Ironbough Sylvapolis', fullName='Ironbough Sylvapolis', logo='[[File:IronboughSylvapolisB.png|25px]]', championships=1 },
{ code='Redcliff Citadel', fullName='Redcliff Citadel', logo='[[File:RedcliffCitadel.png|25px]]', championships=1 },
{ code='Riftwarden Acheron', fullName='Riftwarden Acheron', logo='[[File:RiftwardenAcheronB.png|25px]]', championships=3 },
{ code='Saluria Skylight', fullName='Saluria Skylight', logo='[[File:SaluriaSkylightB.png|25px]]', championships=0 },
{ code='Sandveil Somniumpolis', fullName='Sandveil Somniumpolis', logo='[[File:SandVeilSomniumB.png|25px]]', championships=1 },
{ code='Serena Tidesong', fullName='Serena Tidesong', logo='[[File:Serena TidesongB.png|25px]]', championships=2 },
{ code='Steppe Luminaria', fullName='Steppe Luminaria', logo='[[File:Steppe LuminariaB.png|25px]]', championships=4 },
{ code='Strider Myrene', fullName='Strider Myrene', logo='[[File:StriderMyreneB.png|25px]]', championships=1 },
{ code='Vaeringheim Pillar', fullName='Vaeringheim Pillar', logo='[[File:VaeringheimPillarB.png|25px]]', championships=5 },
{ code='Imperial Delphica', fullName='Imperial Delphica', logo='[[File:ImperialDelphicaB.png|25px]]', championships=1 },
{ code='Ascendant Aetherium', fullName='Ascendant Aetherium', logo='[[File:AscendantAetheriumB.png|25px]]', championships=2 },
{ code='Sufriya Stormwake', fullName='Sufriya Stormwake', logo='[[File:SufriyaStormwake.png|25px]]', championships=1 },
{ code='Pillarion Club Suncliff', fullName='Pillarion Club Suncliff', logo='[[File:PCSuncliff.png|25px]]', championships=0 },
{ code='Jogi Regiment', fullName='Jogi Regiment', logo='[[File:JogiRegiment.png|25px]]', championships=0 },
{ code='Vine Fleet Mylecia', fullName='Vine Fleet Mylecia', logo='[[File:VineFleetMylecia.png|25px]]', championships=0 },
{ code='Port of Blore Heath', fullName='Port of Blore Heath', logo='[[File:PortBloreHeath.png|25px]]', championships=0 },
{ code='Free State Abeis', fullName='Free State Abeis', logo='[[File:FreeStateAbeis.png|25px]]', championships=0 },
{ code='Jezeraah City', fullName='Jezeraah City', logo='[[File:JezeraahCity.png|25px]]', championships=0 },
{ code='Ourid Pegasi', fullName='Ourid Pegasi', logo='[[File:OuridPegasiLogo.png|25px]]', championships=0 },
{ code='Pillarion Club Caspazani', fullName='Pillarion Club Caspazani', logo='[[File:PCCaspazani.png|25px]]', championships=0 },
{ code='Amberwatch Slevik', fullName='Amberwatch Slevik', logo='[[File:AmberwatchSlevik.png|25px]]', championships=0 },
{ code='Fanghorn Rein', fullName='Fanghorn Rein', logo='[[File:FanghornRein.png|25px]]', championships=0 },
{ code='Thyrsa Maena SC', fullName='Thyrsa Maena SC', logo='[[File:ThyrsaMaenaSC.png|25px]]', championships=0 },
{ code='Pharos-Fener Hayr', fullName='Pharos-Fener Hayr', logo='[[File:PharosFenerHayr.png|25px]]', championships=0 },
{ code='Khalkodromoi Chaskal', fullName='Khalkodromoi Chaskal', logo='[[File:KhalkodromoiChaskal.png|25px]]', championships=0 },
{ code='Lenaia Krene AC', fullName='Lenaia Krene AC', logo='[[File:LenaiaKreneAC.png|25px]]', championships=0 },
{ code='Pontikos Mirovia', fullName='Pontikos Mirovia', logo='[[File:PontikosMirovia.png|25px]]', championships=0 },
{ code='Hodos Selenarion', fullName='Hodos Selenarion', logo='[[File:HodosSelenarion.png|25px]]', championships=0 },
{ code='Lithos Kefaya SC', fullName='Lithos Kefaya SC', logo='[[File:LithosKefayaSC.png|25px]]', championships=0 },
{ code='Lacaran Velai PC', fullName='Lacaran Velai PC', logo='[[File:LacaranVelaiPC.png|25px]]', championships=0 },
}
local TEAM_BY_CODE = {}
for _, t in ipairs(teams) do TEAM_BY_CODE[t.code] = t end
local JERSEYS_BY_TEAMNAME = {
['Allegro Symphonara']={home='AllegroHome.png',away='AllegroAway25.png',third='Symphonara3rd.png'},
['Ashguard Pyralis']={home='AshguardHome.png',away='AshguardAway.png',third='Pyralis3rdB.png'},
['Aurelia Auric']={home='AureliaHome.png',away='AureliaAway.png',third='Aurelia3rd.png'},
['Brightpath Aureum']={home='BrightpathHome.png',away='BrightpathAway.png',third='Aureum3rd.png'},
['Celestial Sancta Lunaris']={home='CelestialSanctaHome.png',away='CelestialSanctaAway.png',third='Lunalis3rdB.png'},
['Delphica Windborne']={home='DelphicaWBAway.png',away='DelphicaWBHome.png',third='Delphica3rd.png'},
['Emberstone Vaeringheim']={home='EmberstoneHome.png',away='EmberstoneAway.png',third='Vaeringheim3rdA.png'},
['Flameborne Erythros']={home='FlameborneHome.png',away='FlameborneAway.png',third='Erythros3rd.png'},
['Forgeward Nexa']={home='ForgewardHome.png',away='ForegewardAway.png',third='Nexa3rd.png'},
['Hearthkeeper Koinon']={home='HearthkeepHome.png',away='HearthkeepAway.png',third='Koinon3rd.png'},
['Ironbough Sylvapolis']={home='IronboughHome.png',away='IronboughAway.png',third='Sylvapolis3rd.png'},
['Redcliff Citadel']={home='RedcliffHome.png',away='RedcliffAway.png',third='Ferrum3rd.png'},
['Riftwarden Acheron']={home='RiftwardenHome.png',away='RiftwardenAway.png',third='Acheron3rd.png'},
['Saluria Skylight']={home='SkylightHome.png',away='SkylightAway.png',third='Saluria3rdB.png'},
['Sandveil Somniumpolis']={home='SomniumpolisHome.png',away='SomniumPolisAway.png',third='Somniumpolis3rd.png'},
['Serena Tidesong']={home='SerenaHome.png',away='SerenaAway.png',third='Serena3rd.png'},
['Steppe Luminaria']={home='SteppeHome.png',away='SteppeAway25.png',third='Luminaria3rd.png'},
['Strider Myrene']={home='StriderHome.png',away='StriderAway.png',third='Myrene3rd.png'},
['Vaeringheim Pillar']={home='PillarHome.png',away='PillarAway.png',third='Vaeringheim3rdB.png'},
['Imperial Delphica']={home='ImperialHome.png',away='ImperialAway.png',third='Delphica3rdB.png'},
['Ascendant Aetherium']={home='AscendentHome.png',away='AscendentAway.png',third='Aetherium3rd.png'},
['Sufriya Stormwake']={home='SufriyaHome.png',away='SufriyaAway.png',third='Sufriya3rd.png'},
['Pillarion Club Suncliff']={home='SuncliffHome.png',away='SuncliffAway.png',third='Suncliff3rd.png'},
['Jogi Regiment']={home='JogiHome.png',away='JogiAway.png',third='Jogi3rd.png'},
['Vine Fleet Mylecia']={home='VineFleetHomeB.png',away='VineFleetAwayB.png',third='Mylecia3rd.png'},
['Port of Blore Heath']={home='BloreHeathHome.png',away='BloreHeathAway.png',third='BloreHeath3rd.png'},
['Free State Abeis']={home='AbeisHome.png',away='AbeisAway.png',third='Abeis3rd.png'},
['Jezeraah City']={home='JezeraahHome.png',away='JezeraahAway.png',third='Jezeraah3rd.png'},
['Ourid Pegasi']={home='OuridPegasiHome.png',away='OuridPegasiAwayB.png',third='OuridPegasi3rd.png'},
['Pillarion Club Caspazani']={home='PCCaspazaniHome.png',away='PCCCaspazaniAway.png',third='PCCaspazani3rd.png'},
['Amberwatch Slevik']={home='AmberwatchHome.png',away='AmberwatchAway.png',third='Amberwatch3rd.png'},
['Fanghorn Rein']={home='ReinHome.png',away='ReinAway.png',third='Rein3rd.png'},
['Thyrsa Maena SC']={home='ThyrsaMaenaHomeB.png',away='ThyrsaMaenaAwayB.png',third='ThyrsaMaena3rdB.png'},
['Pharos-Fener Hayr']={home='PharosFenerHome.png',away='PharosFenerAway.png',third='PharosFener3rd.png.png'},
['Khalkodromoi Chaskal']={home='KhalkodromoiHome.png',away='KhalkodromoiAway.png',third='Khalkodromoi3rd.png'},
['Lenaia Krene AC']={home='LenaiaKreneHome.png',away='LenaiaKreneAway.png',third='LenaiaKrene3rd.png'},
['Pontikos Mirovia']={home='PontikosMiroviaHome.png',away='PontikosMiroviaAway.png',third='PontikosMirovia3rd.png'},
['Hodos Selenarion']={home='HodosSelenarionHome.png',away='HodosSelenarionAway.png',third='HodosSelenarion3rd.png'},
['Lithos Kefaya SC']={home='LithosKefayaHome.png',away='LithosKefayaAway.png',third='LithosKefaya3rd.png'},
['Lacaran Velai PC']={home='LacaranVelaiHome.png',away='LacaranVelaiAwayC.png',third='LacaranVelai3rd.png'},
}
local RC_JERSEYS_BY_TEAMNAME = {
['Morovian Select']={home='MorovianSelectHome.png',away='MorovianSelectAway.png'},
['Southern Strait Select']={home='SouthernStraitSelectHome.png',away='SouthernStraitSelectAway.png'},
['Western Highlands Select']={home='WesternHighlandsSelectHome.png',away='WesternHighlandsSelectAway.png'},
['Normarkian Select']={home='NormarkianSelectHome.png',away='NormarkianSelectAway.png'},
-- Supply these optional kit files when available on the wiki:
['Lacaran Hodos Select']={home='',away=''},
}
local divisions = {
['Morovian']={
'Emberstone Vaeringheim','Vaeringheim Pillar','Imperial Delphica','Delphica Windborne',
'Allegro Symphonara','Steppe Luminaria','Jezeraah City','Saluria Skylight'
},
['Southern Strait']={
'Sufriya Stormwake','Vine Fleet Mylecia','Port of Blore Heath','Jogi Regiment',
'Free State Abeis','Sandveil Somniumpolis','Brightpath Aureum','Flameborne Erythros'
},
['Western Highlands']={
'Ashguard Pyralis','Aurelia Auric','Celestial Sancta Lunaris','Forgeward Nexa',
'Hearthkeeper Koinon','Ironbough Sylvapolis','Riftwarden Acheron','Serena Tidesong'
},
['Normarkian']={
'Redcliff Citadel','Strider Myrene','Ascendant Aetherium','Pillarion Club Suncliff',
'Ourid Pegasi','Pillarion Club Caspazani','Amberwatch Slevik','Fanghorn Rein'
},
['Lacaran Hodos']={
'Thyrsa Maena SC','Pharos-Fener Hayr','Khalkodromoi Chaskal','Lenaia Krene AC',
'Pontikos Mirovia','Hodos Selenarion','Lithos Kefaya SC','Lacaran Velai PC'
},
}
local DIV_ORDER={'Morovian','Southern Strait','Western Highlands','Normarkian','Lacaran Hodos'}
local DIV_OF={}
for div, list in pairs(divisions) do for _, c in ipairs(list) do DIV_OF[c]=div end end
local function _safeHash(key)
if mw and mw.hash and mw.hash.hashValue then
return mw.hash.hashValue('md5', key or '')
end
local h=2166136261
key=tostring(key or '')
for i=1,#key do h=(h*16777619 + string.byte(key,i)) % 4294967291 end
return string.format('%08x',h)
end
local function _pctFromKey(key)
local h=_safeHash(key)
return (tonumber(h:sub(1,8),16) or 0)%100
end
local function _kitImgForMatch(team, baseWhich, matchKey, px)
px=px or 25
if not team or not team.fullName then return '—' end
local kits=JERSEYS_BY_TEAMNAME[team.fullName] or RC_JERSEYS_BY_TEAMNAME[team.fullName]
if not kits then return '—' end
local which=baseWhich
if kits.third and kits.third~='' and _pctFromKey((matchKey or '')..'|'..team.fullName..'|'..baseWhich)<30 then which='third' end
local f=kits[which]
if not f or f=='' then return '—' end
return string.format('[[File:%s|%dpx]]',f,px)
end
local function _asTeam(x)
if type(x)=='table' then return x end
return TEAM_BY_CODE[x] or {code=tostring(x),fullName=tostring(x),logo=''}
end
local function _teamWithLogo(x, bold)
local t=_asTeam(x)
local n=bold and ("'''"..t.fullName.."'''") or t.fullName
return (t.logo and t.logo~='' and (t.logo..' ') or '')..n
end
------------------------------------------------------------------------
-- 2) LOCKED FIXTURE LIST
-- This reproduces the original 53 PSSC pairing/packing algorithm.
------------------------------------------------------------------------
local function _homeFirst(codeA, codeB)
local function sum(s)
local n=0
for i=1,#s do n=(n+string.byte(s,i))%9973 end
return n
end
return sum(codeA)>sum(codeB)
end
local function _buildPairings()
local intra, inter = {}, {}
for _, div in ipairs(DIV_ORDER) do
local L=divisions[div]
for i=1,#L-1 do
for j=i+1,#L do
intra[#intra+1]={home=TEAM_BY_CODE[L[i]],away=TEAM_BY_CODE[L[j]]}
intra[#intra+1]={home=TEAM_BY_CODE[L[j]],away=TEAM_BY_CODE[L[i]]}
end
end
end
for di=1,#DIV_ORDER-1 do
for dj=di+1,#DIV_ORDER do
local A,B=divisions[DIV_ORDER[di]],divisions[DIV_ORDER[dj]]
for _,ca in ipairs(A) do
for _,cb in ipairs(B) do
local h,a
if _homeFirst(ca,cb) then h,a=ca,cb else h,a=cb,ca end
inter[#inter+1]={home=TEAM_BY_CODE[h],away=TEAM_BY_CODE[a]}
end
end
end
end
table.sort(intra,function(a,b)
local da,db=DIV_OF[a.home.code] or '',DIV_OF[b.home.code] or ''
if da~=db then return da<db end
return (a.home.code..a.away.code)<(b.home.code..b.away.code)
end)
table.sort(inter,function(a,b)
local ka=(DIV_OF[a.home.code] or '')..'|'..(DIV_OF[a.away.code] or '')
local kb=(DIV_OF[b.home.code] or '')..'|'..(DIV_OF[b.away.code] or '')
if ka~=kb then return ka<kb end
return (a.home.code..a.away.code)<(b.home.code..b.away.code)
end)
local merged={}
local function appendBlock(src,start,count)
for i=start,math.min(#src,start+count-1) do merged[#merged+1]=src[i] end
end
local ai,bi=1,1
while ai<=#intra or bi<=#inter do
if ai<=#intra then appendBlock(intra,ai,64); ai=ai+64 end
if bi<=#inter then appendBlock(inter,bi,128); bi=bi+128 end
end
return merged
end
local function _packRounds(pairings)
local rounds={}
for _,g in ipairs(pairings) do
local placed=false
for _,r in ipairs(rounds) do
if not r.used[g.home.code] and not r.used[g.away.code] then
r.matches[#r.matches+1]={g.home,g.away}
r.used[g.home.code]=true; r.used[g.away.code]=true
placed=true; break
end
end
if not placed then
rounds[#rounds+1]={matches={{g.home,g.away}},used={[g.home.code]=true,[g.away.code]=true}}
end
end
local out={}
for _,r in ipairs(rounds) do out[#out+1]=r.matches end
return out
end
-- Historic schedule remains available for explicitly requested 53 PSSC archives.
-- Existing four-division pairing logic is evaluated with the original four divisions.
local function _legacyFixtures()
local savedOrder = DIV_ORDER
DIV_ORDER={'Morovian','Southern Strait','Western Highlands','Normarkian'}
local fixtures=_packRounds(_buildPairings())
DIV_ORDER=savedOrder
return fixtures
end
local SCHED_LEGACY=_legacyFixtures()
-- Expansion-era schedule: 14 divisional (home and away), 32 interdivisional.
-- Round-robin pairings use the existing deterministic match orientation function.
local function _expandedPairings()
local pairs={}
for _,div in ipairs(DIV_ORDER) do
local L=divisions[div]
for i=1,#L-1 do for j=i+1,#L do
pairs[#pairs+1]={home=TEAM_BY_CODE[L[i]],away=TEAM_BY_CODE[L[j]]}
pairs[#pairs+1]={home=TEAM_BY_CODE[L[j]],away=TEAM_BY_CODE[L[i]]}
end end
end
for i=1,#DIV_ORDER-1 do for j=i+1,#DIV_ORDER do
for _,a in ipairs(divisions[DIV_ORDER[i]]) do
for _,b in ipairs(divisions[DIV_ORDER[j]]) do
local home,away=a,b
if not _homeFirst(a,b) then home,away=b,a end
pairs[#pairs+1]={home=TEAM_BY_CODE[home],away=TEAM_BY_CODE[away]}
end
end
end end
return pairs
end
-- Structured 54-matchday schedule, rather than greedily packing all fixtures.
-- 14 full divisional rounds + 40 interdivisional rounds (one division bye
-- per interdivisional matchday). Every club plays 46 matches, 23 home/away
-- split may vary by opponent but every club has 14 divisional and 32 external.
local function _rotateEight(codes)
local arr={}; for i=1,8 do arr[i]=codes[i] end
local rounds={}
for r=1,7 do
local games={}
for i=1,4 do
local h,a=TEAM_BY_CODE[arr[i]],TEAM_BY_CODE[arr[9-i]]
if (r+i)%2~=0 then h,a=a,h end
games[#games+1]={h,a}
end
rounds[r]=games
local last=arr[8];for i=8,3,-1 do arr[i]=arr[i-1] end;arr[2]=last
end
return rounds
end
local function _expandedFixtures()
local rounds={}
local divisionRounds={}
for _,div in ipairs(DIV_ORDER) do divisionRounds[div]=_rotateEight(divisions[div]) end
for r=1,14 do
local matchday={}
for _,div in ipairs(DIV_ORDER) do
local rr=divisionRounds[div][(r-1)%7+1]
for _,g in ipairs(rr) do
if r<=7 then matchday[#matchday+1]=g
else matchday[#matchday+1]={g[2],g[1]} end
end
end
rounds[#rounds+1]=matchday
end
-- Complete round robin over five divisions plus one rotating bye.
local pairBlocks={
{{2,5},{3,4}},
{{1,3},{4,5}},
{{1,5},{2,4}},
{{1,2},{3,5}},
{{1,4},{2,3}},
}
for block, pairs in ipairs(pairBlocks) do
for t=0,7 do
local matchday={}
for _,pair in ipairs(pairs) do
local A=divisions[DIV_ORDER[pair[1]]]
local B=divisions[DIV_ORDER[pair[2]]]
for i=1,8 do
local h=TEAM_BY_CODE[A[i]]
local a=TEAM_BY_CODE[B[((i+t-1)%8)+1]]
if (block+t)%2~=0 then h,a=a,h end
matchday[#matchday+1]={h,a}
end
end
rounds[#rounds+1]=matchday
end
end
return rounds
end
local SCHED_EXPANDED=_expandedFixtures()
local SCHED_LOCKED=SCHED_EXPANDED
local TOTAL_ROUNDS=#SCHED_LOCKED
local function getRoundMatches(i) return SCHED_LOCKED[tonumber(i) or 0] or {} end
-- Date helpers used by standings, awards, match reports, rosters and transfers.
-- Keep one stable calendar mapping throughout the season.
local _DAY_MAP, _INV_DAY_MAP = {}, {}
do
local totalDays=END-START+1
for i=1,TOTAL_ROUNDS do
local d=START+math.floor((i-1)*(totalDays-1)/math.max(1,TOTAL_ROUNDS-1))
_DAY_MAP[d]=i
_INV_DAY_MAP[i]=d
end
end
local function mapDays() return _DAY_MAP end
local function invertDayMap(dm)
if dm==_DAY_MAP then return _INV_DAY_MAP end
local inv={};for day,md in pairs(dm or {}) do inv[md]=day end
return inv
end
local function _finalRegularSeasonDay() return _INV_DAY_MAP[TOTAL_ROUNDS] or END end
local function _regularSeasonCutoff(season)
season=tonumber(season) or _activeSeason()
local now,day,year=_nowAbs()
if year<season then return START-1 end
if year>season then return _finalRegularSeasonDay() end
return math.min(math.max(day,START-1),_finalRegularSeasonDay())
end
-- A result is public only once its matchday has actually arrived in the
-- specified PSSC season. This also protects explicit ?season=54&md=... calls.
local function _isPlayedMatchday(season, md)
md = tonumber(md) or 0
if md < 1 or md > TOTAL_ROUNDS then return false end
local day = _INV_DAY_MAP[md]
return day ~= nil and _nowAbs() >= absDay(season,day)
end
local function _currentPlayedMatchday(season)
local cutoff=_regularSeasonCutoff(season)
local md=0
for d=START,cutoff do if _DAY_MAP[d] then md=_DAY_MAP[d] end end
return md
end
------------------------------------------------------------------------
-- 3) DETERMINISTIC MATCH ENGINE
------------------------------------------------------------------------
local function simScoreFor(h,a,seed)
math.randomseed(seed)
local p0=math.random()
local hg,ag
if p0<0.10 then
hg=math.random(0,12); ag=hg
else
local wh=(h.championships or 0)+1
local wa=(a.championships or 0)+1
local homeWin=((p0-0.10)/0.90)<(wh/(wh+wa))
local winG=math.random(1,12)
local loseG=math.random(0,math.min(winG-1,12))
if homeWin then hg,ag=winG,loseG else hg,ag=loseG,winG end
end
return hg,ag
end
-- 53 PSSC uses the legacy seed exactly. Later seasons add the year so future
-- seasons are deterministic but not identical replays of 53.
local function _matchSeed(season,day,k,h,a)
local legacy=day*1000+k+string.byte(h.code,1)+string.byte(a.code,1)
if tonumber(season)==53 then return legacy end
return legacy+(tonumber(season) or 0)*1000003
end
local function _rrange(seed,lo,hi)
math.randomseed(seed)
return math.random(lo,hi)
end
local function _splitPillarScore(total,salt)
total=tonumber(total) or 0
local maxG=math.floor(total/3)
if maxG<=0 then return 0,total end
math.randomseed(salt)
local g=maxG
if maxG>=1 and math.random()<0.40 then g=maxG-1 end
return g,total-3*g
end
local function computeMatchStats(home,away,hg,ag,salt)
local hGoals,hPoss=_splitPillarScore(hg,salt+101)
local aGoals,aPoss=_splitPillarScore(ag,salt+102)
local hOn=_rrange(salt+101,hGoals,hGoals+2)
local aOn=_rrange(salt+102,aGoals,aGoals+2)
local hOff=_rrange(salt+103,0,3); local aOff=_rrange(salt+104,0,3)
local hF=_rrange(salt+105,0,3); local aF=_rrange(salt+106,0,3)
local hThr=_rrange(salt+107,0,3); local aThr=_rrange(salt+108,0,3)
local hGK=_rrange(salt+109,0,2); local aGK=_rrange(salt+110,0,2)
local hOOB=_rrange(salt+111,0,2); local aOOB=_rrange(salt+112,0,2)
local hp,ap=50,50
if hg~=ag then
local sw=_rrange(salt+113,3,6)
if hg>ag then hp,ap=50+sw,50-sw else hp,ap=50-sw,50+sw end
else
local tilt=_rrange(salt+113,-3,3); hp,ap=50+tilt,50-tilt
end
return {
[home.code]={final=hg,goals=hGoals,poss_points=hPoss,shots_on=hOn,shots_off=hOff,fouls=hF,fks=aF,throws=hThr,gk=hGK,oob=hOOB,poss_pct=hp},
[away.code]={final=ag,goals=aGoals,poss_points=aPoss,shots_on=aOn,shots_off=aOff,fouls=aF,fks=hF,throws=aThr,gk=aGK,oob=aOOB,poss_pct=ap},
}
end
local function simulateDayResults(day,season)
season=tonumber(season) or _activeSeason()
local md=mapDays()[day]
local out={}
if not md then return out end
for k,m in ipairs(getRoundMatches(md)) do
local h,a=m[1],m[2]
local seed=_matchSeed(season,day,k,h,a)
local hg,ag=simScoreFor(h,a,seed)
out[#out+1]={home=h,away=a,hg=hg,ag=ag,seed=seed,stats=computeMatchStats(h,a,hg,ag,seed),index=k,md=md,day=day}
end
return out
end
------------------------------------------------------------------------
-- 4) STANDINGS / SEEDING
------------------------------------------------------------------------
local function _emptyStandings()
local recs={}
for _,t in ipairs(teams) do recs[t.code]={team=t,W=0,D=0,L=0,Pts=0,PF=0,PA=0,PD=0,GP=0} end
return recs
end
local function _applyResult(recH,recA,hg,ag)
recH.GP=recH.GP+1; recA.GP=recA.GP+1
recH.PF=recH.PF+hg; recH.PA=recH.PA+ag
recA.PF=recA.PF+ag; recA.PA=recA.PA+hg
if hg>ag then recH.W=recH.W+1; recA.L=recA.L+1; recH.Pts=recH.Pts+3
elseif ag>hg then recA.W=recA.W+1; recH.L=recH.L+1; recA.Pts=recA.Pts+3
else recH.D=recH.D+1; recA.D=recA.D+1; recH.Pts=recH.Pts+1; recA.Pts=recA.Pts+1 end
recH.PD=recH.PF-recH.PA; recA.PD=recA.PF-recA.PA
end
local function _standingsThrough(season,lastDay)
local recs=_emptyStandings(); local dm=mapDays()
-- Clamp requested dates to the most recently played matchday.
-- This is essential: future fixtures must never affect live tables.
local playedCutoff=_regularSeasonCutoff(season)
local effectiveEnd=math.min(lastDay or playedCutoff, playedCutoff, _finalRegularSeasonDay())
for d=START,effectiveEnd do
if dm[d] then
for _,r in ipairs(simulateDayResults(d,season)) do _applyResult(recs[r.home.code],recs[r.away.code],r.hg,r.ag) end
end
end
return recs
end
local function _standCmp(a,b)
if a.Pts~=b.Pts then return a.Pts>b.Pts end
if a.PD~=b.PD then return a.PD>b.PD end
if a.W~=b.W then return a.W>b.W end
return a.team.fullName<b.team.fullName
end
local function _divisionRows(season,div,lastDay)
local recs=_standingsThrough(season,lastDay); local list={}
for _,c in ipairs(divisions[div]) do list[#list+1]=recs[c] end
table.sort(list,_standCmp); return list
end
local function getSeeds12(season)
local winners,runners={},{}
for _,div in ipairs(DIV_ORDER) do
local L=_divisionRows(season,div,_finalRegularSeasonDay())
winners[#winners+1]=L[1]; runners[#runners+1]=L[2]
end
table.sort(winners,_standCmp); table.sort(runners,_standCmp)
local seeds={}
for _,r in ipairs(winners) do seeds[#seeds+1]=r.team end
for _,r in ipairs(runners) do seeds[#seeds+1]=r.team end
return seeds
end
local function getConferencePlayIns(season)
local games={}
for _,div in ipairs(DIV_ORDER) do
local L=_divisionRows(season,div,_finalRegularSeasonDay())
games[#games+1]={L[3].team,L[4].team,div}
end
return games
end
------------------------------------------------------------------------
-- 5) REGULAR-SEASON RENDERERS / AUTOMATIC LIFECYCLE
------------------------------------------------------------------------
local function _renderRegularRound(day,season)
local dm=mapDays()
local target=nil
for d=math.max(day,START),_finalRegularSeasonDay() do if dm[d] then target=d; break end end
if not target then return "|-\n| colspan=3 | '''Regular season complete.'''" end
local md=dm[target]; local mon,dom=getMonthDay(target); local rows={}
for k,m in ipairs(getRoundMatches(md)) do
local h,a=m[1],m[2]
local key=string.format('REG|%d|%d|%d|%s|%s',season,target,k,h.code,a.code)
local score='—'
local now=_nowAbs()
if now>=absDay(season,target) then
local hg,ag=simScoreFor(h,a,_matchSeed(season,target,k,h,a)); score=string.format("'''%d-%d'''",hg,ag)
end
rows[#rows+1]=string.format('%s %s %s %s %s %s',_kitImgForMatch(h,'home',key),h.logo,h.fullName,score,_kitImgForMatch(a,'away',key),a.logo..' '..a.fullName)
end
return string.format('|-\n| %d (MD %d) || %d %s %d PSSC || %s',target,md,dom,mon,season,table.concat(rows,'<br>'))
end
function p.renderSchedule(frame)
local args=(frame and frame.args) or {}
local season=_resolveSeason(args,1)
local stage,t=_seasonStage(season)
local now,day,year=_nowAbs()
if stage=='regular' or stage=='before_regular' then
local startDay=(year==season and day or START)
return table.concat({
'{| class="wikitable sortable"',
'! Day !! Date !! Matches',
_renderRegularRound(startDay,season),
'|}'
},'\n')
elseif stage=='pre_playoff' then
return string.format("'''%d PSSC regular season complete. Play-offs begin %s.'''",season,_dateLabelFromAbs(t.playoffStart))
elseif stage=='playoffs' then
return "=== Current Play-off Results ===\n"..p.renderPlayoffResults({args={season=season,progressive='1'}})
elseif stage=='pre_regional' then
return "'''Play-offs complete. The Off-Season Regional Cup begins ".._dateLabelFromAbs(t.regionalStart)..".'''"
elseif stage=='regional_cup' then
return "=== Off-Season Regional Cup — Live Results ===\n"..p.renderRegionalCupResults({args={season=season}})
elseif stage=='offseason' then
return string.format("'''%d PSSC competition cycle complete. %d PSSC regular season begins %s.'''",season,season+1,_dateLabelFromAbs(t.nextRegular))
else
return string.format("'''%d PSSC competition cycle is complete.'''",season)
end
end
-- Explicit alias for pages that want a semantically named lifecycle call.
p.renderLifecycle=p.renderSchedule
function p.renderStandings(frame)
local args=(frame and frame.args) or {}
-- Tables represent the upcoming/current regular season, even when
-- Atosiel is hosting the previous year's playoffs or Regional Cup.
-- Explicit |season=53 still permits historical standings.
local explicit=tonumber(args.season or args[1])
local _,calendarYear=_currentPSSC()
local season=explicit or (calendarYear > 0 and calendarYear or _activeSeason())
local last=_regularSeasonCutoff(season)
local out={}
for _,div in ipairs(DIV_ORDER) do
out[#out+1]='=== '..div..' Division ==='
out[#out+1]='{| class="wikitable sortable" style="width:100%;"'
out[#out+1]='! # !! Team !! GP !! W !! D !! L !! Pts !! PF !! PA !! PD'
local list=_divisionRows(season,div,last)
for i,r in ipairs(list) do
-- Original positional color bands: 1-2 green, 3-4 blue, 5-6 yellow, 7-8 red.
local shade = (i <= 2 and '#ccffcc') or (i <= 4 and '#cce5ff') or (i <= 6 and '#F1EB9C') or '#ffcccc'
out[#out+1]='|- style="background-color:'..shade..';"'
out[#out+1]=string.format('| %d || %s || %d || %d || %d || %d || %d || %d || %d || %+d',i,_teamWithLogo(r.team),r.GP,r.W,r.D,r.L,r.Pts,r.PF,r.PA,r.PD)
end
out[#out+1]='|}'
end
return table.concat(out,'\n')
end
function p.renderPreviousResults(frame)
local args=(frame and frame.args) or {}
local season=_resolveSeason(args,1)
local now,day,year=_nowAbs(); local dm=mapDays(); local played={}
local fromDay
if now>absDay(season,_finalRegularSeasonDay()) then fromDay=_finalRegularSeasonDay()
elseif year==season then fromDay=day-1 else fromDay=START-1 end
for d=fromDay,START,-1 do if dm[d] then played[#played+1]=d end; if #played>=5 then break end end
if #played==0 then return "'''No regular-season matches have been played in "..season.." PSSC. Opening matchday: 1 Thalassiel "..season.." PSSC.'''" end
local out={'{| class="wikitable sortable"','! Date !! Home !! Score !! Away'}
for _,d in ipairs(played) do
for _,r in ipairs(simulateDayResults(d,season)) do
out[#out+1]='|-'
out[#out+1]=string.format('| %s || %s || \'\'\'%d-%d\'\'\' || %s',_dateLabel(season,d),_teamWithLogo(r.home),r.hg,r.ag,_teamWithLogo(r.away))
end
end
out[#out+1]='|}'
return table.concat(out,'\n')
end
------------------------------------------------------------------------
-- 6) PLAYOFFS
------------------------------------------------------------------------
local function _playScore(H,A,salt)
return simScoreFor(H,A,salt)
end
local function _winner(H,A,hg,ag)
-- Preserve the legacy deterministic tiebreak: away side advances on a draw.
if hg>ag then return H end
return A
end
local function _seedIndex(seeds,team)
for i,t in ipairs(seeds) do if t.code==team.code then return i end end
return 999
end
local function _playoffState(season)
local qualifiers, fourths={},{}
for _,div in ipairs(DIV_ORDER) do
local L=_divisionRows(season,div,_finalRegularSeasonDay())
for rank=1,3 do qualifiers[#qualifiers+1]=L[rank] end
fourths[#fourths+1]=L[4]
end
table.sort(fourths,_standCmp)
qualifiers[#qualifiers+1]=fourths[1]
table.sort(qualifiers,_standCmp)
local seeds={}
for _,r in ipairs(qualifiers) do seeds[#seeds+1]=r.team end
local poAbs=absDay(season+1,PO_FIRST)
local state={seeds=seeds,rounds={}}
local remaining=seeds
for ri=1,4 do
local name=({'Round of 16','Quarterfinal','Semifinal','Final'})[ri]
local abs=poAbs+(ri-1)*PO_IV
local games,advancing={},{}
for i=1,math.floor(#remaining/2) do
local H,A=remaining[i],remaining[#remaining-i+1]
local hg,ag=_playScore(H,A,abs*100+i)
local winner=_winner(H,A,hg,ag)
games[#games+1]={H,A,hg,ag,winner}
advancing[#advancing+1]=winner
end
state.rounds[ri]={name=name,abs=abs,games=games}
-- Preserve initial seeding, strongest remaining vs weakest.
table.sort(advancing,function(a,b) return _seedIndex(seeds,a)<_seedIndex(seeds,b) end)
remaining=advancing
end
state.champion=remaining[1]
return state
end
local function _renderPlayoffTable(season,progressive,scheduleOnly)
local S=_playoffState(season); local now=_nowAbs()
local out={'{| class="wikitable sortable"','! Round !! Date !! Home !! Score !! Away !! Winner'}
for _,round in ipairs(S.rounds) do
local visible=(not progressive) or now>=round.abs
if visible or scheduleOnly then
for gi,g in ipairs(round.games) do
local H,A=g[1],g[2]; local score='—'; local win='—'
if not scheduleOnly and now>=round.abs then score=string.format("'''%d-%d'''",g[3],g[4]); win=_teamWithLogo(g[5]) end
local key=string.format('PO|%d|%s|%d',season,round.name,gi)
out[#out+1]='|-'
out[#out+1]=string.format('| %s || %s || %s %s || %s || %s %s || %s',round.name,_dateLabelFromAbs(round.abs),_kitImgForMatch(H,'home',key,20),_teamWithLogo(H),score,_kitImgForMatch(A,'away',key,20),_teamWithLogo(A),win)
end
end
end
out[#out+1]='|}'
return table.concat(out,'\n')
end
function p.renderPlayoffSchedule(frame)
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1)
return _renderPlayoffTable(season,false,true)
end
function p.renderPlayoffResults(frame)
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1)
local progressive=tostring(args.progressive or '1')~='0'
return _renderPlayoffTable(season,progressive,false)
end
------------------------------------------------------------------------
-- 7) MATCH STATS
------------------------------------------------------------------------
local function _statsTable(r)
local H=r.stats[r.home.code]; local A=r.stats[r.away.code]
local out={
string.format("'''%s %d-%d %s'''",r.home.fullName,r.hg,r.ag,r.away.fullName),
'{| class="wikitable" style="width:100%;"',
'! Stat !! '..r.home.fullName..' !! '..r.away.fullName,
'|-','| Goals || '..H.goals..' || '..A.goals,
'|-','| Possession Points || '..H.poss_points..' || '..A.poss_points,
'|-','| Shots on Target || '..H.shots_on..' || '..A.shots_on,
'|-','| Shots off Target || '..H.shots_off..' || '..A.shots_off,
'|-','| Fouls || '..H.fouls..' || '..A.fouls,
'|-','| Free Kicks || '..H.fks..' || '..A.fks,
'|-','| Goal Kicks || '..H.gk..' || '..A.gk,
'|-','| Out of Bounds || '..H.oob..' || '..A.oob,
'|-','| Throw-Ins || '..H.throws..' || '..A.throws,
'|-','| Ball Possession || '..H.poss_pct..'% || '..A.poss_pct..'%',
'|}'
}
return table.concat(out,'\n')
end
function p.renderMatchStatsForDay(frame)
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,nil)
local now,day,year=_nowAbs(); local target=tonumber(args.day)
if not target then
if year==season and mapDays()[day] then target=day else
local cutoff=_regularSeasonCutoff(season)
for d=cutoff,START,-1 do if mapDays()[d] then target=d; break end end
end
end
if not target or not mapDays()[target] then return "'''No Pillarion matchday is available for this date.'''" end
if now<absDay(season,target) then return "'''That matchday has not yet been played.'''" end
local out={}
for _,r in ipairs(simulateDayResults(target,season)) do out[#out+1]=_statsTable(r) end
return table.concat(out,'\n\n')
end
function p.renderMatchStatsSingle(frame)
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,nil)
local day=tonumber(args.day or '') or 0; local home=args.home; local away=args.away
if day==0 or not home or not away then return "'''Pass day, home, and away:''' day=###, home=Team, away=Team" end
if _nowAbs()<absDay(season,day) then return "'''That matchday has not yet been played.'''" end
for _,r in ipairs(simulateDayResults(day,season)) do
if r.home.code==home and r.away.code==away then return _statsTable(r) end
end
return "'''Match not found on that Pillarion matchday.'''"
end
------------------------------------------------------------------------
-- 8) ROSTERS / TRANSFERS / PLAYER EVENT ENGINE
------------------------------------------------------------------------
local ROSTER_SIZE=12
local TRANSFER_MD_START,TRANSFER_MD_END=20,30
local POSITIONS={'Striker','Striker','Playmaker','Playmaker','Playmaker','Defender','Defender','Defender','Keeper','Utility','Utility','Utility'}
local function _seedFromString(s)
local h=0; s=tostring(s or '')
for i=1,#s do h=(h*31+string.byte(s,i))%2147483647 end
if h==0 then h=1 end
return h
end
local function _rngNext(st) st=(1103515245*st+12345)%2147483647; return st,st/2147483647 end
local function _pick(st,list) local r; st,r=_rngNext(st); return st,list[math.floor(r*#list)+1] end
local function _normKey(s)
if not s or s=='' then return nil end
if mw and mw.ustring then
s=mw.ustring.lower(s)
s=mw.ustring.gsub(s,'%s+','_')
s=mw.ustring.gsub(s,'[^%w_]','')
return s
end
return (string.lower(tostring(s)):gsub('%s+','_'):gsub('[^%w_]',''))
end
local NAME_STYLES={
bassarid={
firstA={'Ka','Tha','Py','De','Ar','Lo','Ni','Sa','Eo','Ae','Ky','My','Ga','Do','Sy','Va'},
firstB={'li','the','ra','me','ri','phi','no','lo','re','so','te','xa','ro','na','di','mos'},
firstC={'thros','rios','nax','dros','lion','menos','kar','thes','sos','lios','zian','phoros','kris','dor','thes','ron'},
lastA={'Amin','Delph','Kalli','Vareng','Caspaz','Myren','Aurel','Koin','Therm','Skyr','Erythr','Loth','Chrys','Silen','Nexa','Morov'},
lastB={'a','i','o','e','u','y','ae','io','ou','ea'},
lastC={'dis','kos','tron','ides','aris','eas','ion','oros','anos','inos','elis','akis','eth','on','eus','yr'}},
haifan={
firstA={'Se','Or','Ke','Ha','Yu','Az','Le','Mi','De','Sa','Fa','Ri','Ta','Na','Su','Ba'},
firstB={'lim','han','mal','kan','suf','iz','yla','na','rya','fi','ru','hir','mir','zir','yif','sar'},
firstC={'','oğlu','an','ir','em','et','in','a','e','u',''},
lastA={'Yıldır','Dem','Kay','Arsl','Çel','Ayd','Gün','Korkm','Sarı','Taş','Karad','Özt','Ak','Boz','Şah'},
lastB={'im','ir','a','i','u','ü','o','ö',''},
lastC={'maz','er','kan','soy','taş','kaya','han','gül','lı','ci','oğlu',''}},
normark={
firstA={'Ei','Ha','Le','Bj','Si','As','In','To','Ka','Sk','Ry','Ul','Va','Sa','Jo','Th'},
firstB={'rik','kon','if','orn','grid','trid','ga','rsten','ri','ald','var','lva','ren','mund','nar','or'},
firstC={'','r','d','n','k','s',''},
lastA={'Fjell','Ravn','Skog','Sund','Vinter','Knut','Thors','Haldor','Eirik','Bjorn','Isen','Storm','Sol','Hav','Ulv'},
lastB={'sen','son','sson','vik','lund','by','heim','holt','dal','gard','berg','mark'},
lastC={'','','',''}},
imperial={
firstA={'Mar','Jul','Cas','Oct','Luc','Tib','Sab','Aurel','Flav','Dec','Val','Cor','Dom','Sev','Faust','Cris'},
firstB={'cus','ia','sian','avia','ian','er','ina','ius','ia','imus','eri','vin','itian','eran','inus','pus'},
firstC={'','us','a','','us','a','us','a','us','a'},
lastA={'Valer','Marcell','Aquil','Sever','Corvin','Drus','Faustin','Liv','Domit','Crisp','Aurel','Cass','Octav','Lucan','Tiber','Flav'},
lastB={'ius','us','a','an','inus','ian','ensis','orum','atus','aris'},
lastC={'','','',''}},
}
local NAME_STYLE_KEYS={'bassarid','haifan','normark','imperial'}
local function _cap(s)
if not s or s=='' then return '' end
if mw and mw.ustring then return mw.ustring.upper(mw.ustring.sub(s,1,1))..mw.ustring.sub(s,2) end
return string.upper(s:sub(1,1))..s:sub(2)
end
local function _maybeDiacritics(st,s)
local r; st,r=_rngNext(st)
if r<.08 then s=s:gsub('a','ä',1)
elseif r<.16 then s=s:gsub('i','ï',1)
elseif r<.24 then s=s:gsub('o','ö',1)
elseif r<.32 then s=s:gsub('u','ü',1) end
return st,s
end
local function _buildNameFrom(style, st, isLast)
local t=NAME_STYLES[style] or NAME_STYLES.bassarid
local a,b,c
if not isLast then
st,a=_pick(st,t.firstA); st,b=_pick(st,t.firstB); st,c=_pick(st,t.firstC)
else
st,a=_pick(st,t.lastA); st,b=_pick(st,t.lastB); st,c=_pick(st,t.lastC)
end
local name=_cap((a or '')..(b or '')..(c or ''))
local r; st,r=_rngNext(st)
if r<0.05 and #name>=6 then
local cut=math.floor(#name/2)
if mw and mw.ustring then name=mw.ustring.sub(name,1,cut)..'-'..mw.ustring.sub(name,cut+1)
else name=name:sub(1,cut)..'-'..name:sub(cut+1) end
elseif r<0.08 and #name>=6 then
local cut=math.floor(#name/2)
if mw and mw.ustring then name=mw.ustring.sub(name,1,cut)..'’'..mw.ustring.sub(name,cut+1)
else name=name:sub(1,cut)..'’'..name:sub(cut+1) end
end
st,name=_maybeDiacritics(st,name)
return st,name
end
local function _chooseStyleForTeam(teamCode, season)
local st=_seedFromString(tostring(season)..'|'..tostring(teamCode)..'|STYLE')
local key; st,key=_pick(st,NAME_STYLE_KEYS)
return key or 'bassarid'
end
local function _genBaseRoster(teamCode, season)
local key=_normKey(teamCode) or teamCode
local st=_seedFromString(tostring(season)..'|'..key..'|ROSTER')
local style=_chooseStyleForTeam(teamCode,season)
local roster,used={},{}
for i=1,ROSTER_SIZE do
local id=key..'-'..string.format('%02d',i)
local full
for _=1,10 do
local first,last
st,first=_buildNameFrom(style,st,false)
st,last=_buildNameFrom(style,st,true)
full=first..' '..last
if not used[full] then used[full]=true; break end
end
roster[i]={id=id,name=full or ('Player '..id),pos=POSITIONS[i] or 'Utility',no=i}
end
roster[1].notes='Captain'
return roster
end
local function _buildBaseRosters(season)
local R={}; for _,t in ipairs(teams) do R[t.code]=_genBaseRoster(t.code,season) end; return R
end
local function _copyRosters(base)
local R={}
for tc,list in pairs(base) do R[tc]={}; for _,p0 in ipairs(list) do local q={}; for k,v in pairs(p0) do q[k]=v end; R[tc][#R[tc]+1]=q end end
return R
end
local function _genTransferDeals(season)
local st=_seedFromString(season..'|TRANSFERWINDOW|MD20-30'); local deals={}
for md=TRANSFER_MD_START,TRANSFER_MD_END do
local r; st,r=_rngNext(st); local count=1+math.floor(r*3)
for n=1,count do
local ia,ib
st,r=_rngNext(st); ia=math.floor(r*#teams)+1
repeat st,r=_rngNext(st); ib=math.floor(r*#teams)+1 until ib~=ia
local A,B=teams[ia].code,teams[ib].code
local ra=_genBaseRoster(A,season); local rb=_genBaseRoster(B,season)
st,r=_rngNext(st); local pa=ra[math.floor(r*#ra)+1]
if pa.pos=='Keeper' then st,r=_rngNext(st); if r<.75 then pa=ra[1+math.floor(r*(#ra-1))] end end
local candidates={}; for _,pl in ipairs(rb) do if pl.pos==pa.pos then candidates[#candidates+1]=pl end end
local pb; st,pb=_pick(st,candidates)
deals[#deals+1]={md=md,A=A,B=B,A_id=pa.id,B_id=pb.id,pos=pa.pos}
end
end
return deals
end
local function _findPlayer(list,id) for i,p0 in ipairs(list or {}) do if p0.id==id then return i,p0 end end end
local function _applyTransfersUpTo(base,deals,upToMd)
local R=_copyRosters(base)
for _,d in ipairs(deals or {}) do
if d.md<=(upToMd or 0) then
local ia,pa=_findPlayer(R[d.A],d.A_id); local ib,pb=_findPlayer(R[d.B],d.B_id)
if ia and ib then pa.team=d.B; pb.team=d.A; R[d.A][ia],R[d.B][ib]=pb,pa end
end
end
return R
end
local function _buildPlayerNameLookup(base)
local out={}; for _,list in pairs(base) do for _,pl in ipairs(list) do out[pl.id]=pl.name end end; return out
end
local function _alloc(st, roster, posWeights)
local pool={}
for _,pl in ipairs(roster or {}) do
local w=(posWeights and posWeights[pl.pos]) or 1
for _=1,w do pool[#pool+1]=pl end
end
return _pick(st,pool)
end
local function _ensure(map,pid)
if not map[pid] then map[pid]={goals=0,assists=0,poss=0,son=0,sof=0,fouls=0,apps=0} end
return map[pid]
end
local function _matchPlayerEvents(season,md,day,matchIndex,homeTeam,awayTeam,homeRoster,awayRoster)
local salt=_matchSeed(season,day,matchIndex,homeTeam,awayTeam)
local hg,ag=simScoreFor(homeTeam,awayTeam,salt)
local S=computeMatchStats(homeTeam,awayTeam,hg,ag,salt)
local stats={home=S[homeTeam.code] or {},away=S[awayTeam.code] or {}}
local st=_seedFromString(tostring(season)..'|MD'..md..'|D'..day..'|M'..matchIndex..'|'..homeTeam.code..'|'..awayTeam.code)
local events={home={},away={}}
for _,pl in ipairs(homeRoster or {}) do _ensure(events.home,pl.id).apps=1 end
for _,pl in ipairs(awayRoster or {}) do _ensure(events.away,pl.id).apps=1 end
local W_GOAL={Striker=6,Playmaker=3,Utility=2,Defender=1,Keeper=0}
local W_POSS={Playmaker=6,Defender=3,Utility=2,Striker=1,Keeper=1}
local W_FOUL={Defender=4,Utility=3,Playmaker=2,Striker=1,Keeper=1}
local W_ASSIST={Playmaker=6,Striker=3,Utility=2,Defender=1,Keeper=0}
for _=1,(stats.home.goals or 0) do
local scorer; st,scorer=_alloc(st,homeRoster,W_GOAL)
if scorer then
_ensure(events.home,scorer.id).goals=_ensure(events.home,scorer.id).goals+1
local r; st,r=_rngNext(st)
if r<0.70 then
local ast; st,ast=_alloc(st,homeRoster,W_ASSIST)
if ast and ast.id~=scorer.id then _ensure(events.home,ast.id).assists=_ensure(events.home,ast.id).assists+1 end
end
end
end
for _=1,(stats.away.goals or 0) do
local scorer; st,scorer=_alloc(st,awayRoster,W_GOAL)
if scorer then
_ensure(events.away,scorer.id).goals=_ensure(events.away,scorer.id).goals+1
local r; st,r=_rngNext(st)
if r<0.70 then
local ast; st,ast=_alloc(st,awayRoster,W_ASSIST)
if ast and ast.id~=scorer.id then _ensure(events.away,ast.id).assists=_ensure(events.away,ast.id).assists+1 end
end
end
end
for _=1,(stats.home.poss_points or 0) do local pl;st,pl=_alloc(st,homeRoster,W_POSS);if pl then _ensure(events.home,pl.id).poss=_ensure(events.home,pl.id).poss+1 end end
for _=1,(stats.away.poss_points or 0) do local pl;st,pl=_alloc(st,awayRoster,W_POSS);if pl then _ensure(events.away,pl.id).poss=_ensure(events.away,pl.id).poss+1 end end
local function distributeShots(side,roster,on,off)
for _=1,(on or 0) do local pl;st,pl=_alloc(st,roster,{Striker=5,Playmaker=3,Utility=2,Defender=1,Keeper=0});if pl then _ensure(side,pl.id).son=_ensure(side,pl.id).son+1 end end
for _=1,(off or 0) do local pl;st,pl=_alloc(st,roster,{Striker=4,Playmaker=3,Utility=2,Defender=1,Keeper=0});if pl then _ensure(side,pl.id).sof=_ensure(side,pl.id).sof+1 end end
end
distributeShots(events.home,homeRoster,stats.home.shots_on or 0,stats.home.shots_off or 0)
distributeShots(events.away,awayRoster,stats.away.shots_on or 0,stats.away.shots_off or 0)
for _=1,(stats.home.fouls or 0) do local pl;st,pl=_alloc(st,homeRoster,W_FOUL);if pl then _ensure(events.home,pl.id).fouls=_ensure(events.home,pl.id).fouls+1 end end
for _=1,(stats.away.fouls or 0) do local pl;st,pl=_alloc(st,awayRoster,W_FOUL);if pl then _ensure(events.away,pl.id).fouls=_ensure(events.away,pl.id).fouls+1 end end
return events
end
local function _computePlayerSeasonTotals(season,upToMd)
upToMd=math.min(tonumber(upToMd) or 0,TOTAL_ROUNDS)
local base=_buildBaseRosters(season); local deals=_genTransferDeals(season); local totals={}
for _,list in pairs(base) do for _,pl in ipairs(list) do totals[pl.id]={id=pl.id,name=pl.name,pos=pl.pos,team=pl.team,apps=0,goals=0,assists=0,poss=0,son=0,sof=0,fouls=0} end end
local inv=invertDayMap(mapDays())
for md=1,upToMd do
local day=inv[md]
if day then
local R=_applyTransfersUpTo(base,deals,md)
for mi,m in ipairs(getRoundMatches(md)) do
local h,a=m[1],m[2]; local hr,ar=R[h.code] or {},R[a.code] or {}
local ev=_matchPlayerEvents(season,md,day,mi,h,a,hr,ar)
for pid,line in pairs(ev.home) do local z=totals[pid]; if z then for _,k in ipairs({'apps','goals','assists','poss','son','sof','fouls'}) do z[k]=z[k]+(line[k] or 0) end; z.team=h.code end end
for pid,line in pairs(ev.away) do local z=totals[pid]; if z then for _,k in ipairs({'apps','goals','assists','poss','son','sof','fouls'}) do z[k]=z[k]+(line[k] or 0) end; z.team=a.code end end
end
end
end
local Rnow=_applyTransfersUpTo(base,deals,upToMd)
for tc,list in pairs(Rnow) do for _,pl in ipairs(list) do if totals[pl.id] then totals[pl.id].team=tc end end end
return totals,deals,base
end
function p.renderRoster(frame)
local args=(frame and frame.args) or {}; local team=args.team or args[1]
if not team or not TEAM_BY_CODE[team] then return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''" end
local season=_resolveSeason(args,2); local upToMd=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season)
local R=_applyTransfersUpTo(_buildBaseRosters(season),_genTransferDeals(season),upToMd)[team] or {}
local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! # !! Player !! Position !! ID !! Notes'}
for _,pl in ipairs(R) do out[#out+1]='|-'; out[#out+1]=string.format('| %d || %s || %s || <code>%s</code> || %s',pl.no or 0,pl.name or '—',pl.pos or '—',pl.id or '—',pl.notes or '—') end
out[#out+1]='|}'
return (TEAM_BY_CODE[team].logo or '').." '''"..team.."''' (Roster as of Matchday "..upToMd..')\n'..table.concat(out,'\n')
end
function p.renderAllRosters(frame)
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season); local out={}
for _,t in ipairs(teams) do
out[#out+1]='<div class="mw-collapsible mw-collapsed" data-expandtext="Show roster" data-collapsetext="Hide roster" style="margin:0.6em 0;"><div>'..(t.logo or '')..' <b>'..t.code..'</b></div><div class="mw-collapsible-content">'..p.renderRoster({args={team=t.code,season=season,md=md}})..'</div></div>'
end
return table.concat(out,'\n')
end
function p.renderTransferLog(frame)
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season)
local deals=_genTransferDeals(season); local base=_buildBaseRosters(season); local names=_buildPlayerNameLookup(base)
local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! Matchday !! From !! To !! Swap (pos)'}
for _,d in ipairs(deals) do if d.md<=md then out[#out+1]='|-'; out[#out+1]=string.format('| %d || %s || %s || %s ⇄ %s (%s)',d.md,_teamWithLogo(d.A),_teamWithLogo(d.B),names[d.A_id] or d.A_id,names[d.B_id] or d.B_id,d.pos or '—') end end
out[#out+1]='|}'
return string.format("'''Transfer window (Matchdays %d–%d) — season %d (through Matchday %d)'''\n",TRANSFER_MD_START,TRANSFER_MD_END,season,md)..table.concat(out,'\n')
end
function p.renderPlayerStats(frame)
local args=(frame and frame.args) or {}; local team=args.team or args[1]
if not team or not TEAM_BY_CODE[team] then return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''" end
local season=_resolveSeason(args,2); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season)
local totals,deals,base=_computePlayerSeasonTotals(season,md); local roster=_applyTransfersUpTo(base,deals,md)[team] or {}
local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'}
for _,pl in ipairs(roster) do local r=totals[pl.id] or {}; out[#out+1]='|-'; out[#out+1]=string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d',pl.name,pl.pos,r.apps or 0,r.goals or 0,r.assists or 0,r.poss or 0,r.son or 0,r.sof or 0,r.fouls or 0) end
out[#out+1]='|}'
return (TEAM_BY_CODE[team].logo or '').." '''"..team.."''' — Player totals through Matchday "..md..'\n'..table.concat(out,'\n')
end
function p.renderLeaders(frame)
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season)
local stat=args.stat or args[2] or 'goals'; local ok={goals=true,assists=true,poss=true,son=true,sof=true,fouls=true,apps=true}
if not ok[stat] then return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''" end
local totals=_computePlayerSeasonTotals(season,md); local list={}; for _,r in pairs(totals) do list[#list+1]=r end
table.sort(list,function(a,b) if (a[stat] or 0)~=(b[stat] or 0) then return (a[stat] or 0)>(b[stat] or 0) end return (a.name or '')<(b.name or '') end)
local lim=tonumber(args.limit or '10') or 10; local out={'{| class="wikitable sortable" style="width:100%; font-size:90%;"','! Rank !! Player !! Team (current) !! Pos !! '..stat}
for i=1,math.min(lim,#list) do local r=list[i]; out[#out+1]='|-'; out[#out+1]=string.format('| %d || %s || %s || %s || %d',i,r.name or '—',_teamWithLogo(r.team or '—'),r.pos or '—',r[stat] or 0) end
out[#out+1]='|}'
return string.format("'''%d PSSC leaders through Matchday %d'''\n",season,md)..table.concat(out,'\n')
end
------------------------------------------------------------------------
-- 9) REGIONAL CUP
------------------------------------------------------------------------
local RC_META={
['Morovian']={fullName='Morovian Select',logo='[[File:MorovianSelect.png|25px]]'},
['Southern Strait']={fullName='Southern Strait Select',logo='[[File:SouthernStraitSelect.png|25px]]'},
['Western Highlands']={fullName='Western Highlands Select',logo='[[File:WesternHighlandsSelect.png|25px]]'},
['Normarkian']={fullName='Normarkian Select',logo='[[File:NormarkianSelect.png|25px]]'},
['Lacaran Hodos']={fullName='Lacaran Hodos Select',logo=''},
}
local function _rcRating(r)
local apps=tonumber(r.apps) or 0; local denom=math.max(1,apps)
local g=(r.goals or 0)/denom; local a=(r.assists or 0)/denom; local pr=(r.poss or 0)/denom; local sr=(r.son or 0)/denom; local orr=(r.sof or 0)/denom; local fr=(r.fouls or 0)/denom
local rel=math.min(1,denom/6); local raw
if r.pos=='Keeper' then raw=5.9+pr*.08+a*.8-fr*.50-orr*.12 else raw=5.8+g*3+a*1.8+pr*.10+sr*.22-orr*.18-fr*.40 end
local rating=6+(raw-6)*rel; if rating<0 then rating=0 elseif rating>9.7 then rating=9.7 end; return rating
end
local function _regionalTeams(season)
local totals=_computePlayerSeasonTotals(season,TOTAL_ROUNDS)
local byDiv={}; for _,d in ipairs(DIV_ORDER) do byDiv[d]={Keeper={},Defender={},Playmaker={},Striker={},Utility={}} end
for _,r in pairs(totals) do local d=DIV_OF[r.team]; if d and byDiv[d] and byDiv[d][r.pos] then r.rating=_rcRating(r); byDiv[d][r.pos][#byDiv[d][r.pos]+1]=r end end
for _,d in ipairs(DIV_ORDER) do for _,list in pairs(byDiv[d]) do table.sort(list,function(a,b) if a.rating~=b.rating then return a.rating>b.rating end return a.name<b.name end) end end
local need={Keeper=1,Defender=4,Playmaker=3,Striker=3,Utility=1}; local out={}
for _,d in ipairs(DIV_ORDER) do
local meta=RC_META[d]; local sq={}
for _,pos in ipairs({'Keeper','Defender','Playmaker','Striker','Utility'}) do for i=1,math.min(need[pos],#byDiv[d][pos]) do sq[#sq+1]=byDiv[d][pos][i] end end
out[#out+1]={code=meta.fullName,fullName=meta.fullName,logo=meta.logo,division=d,squad=sq,championships=0}
end
return out
end
local function _regionalCupStartAbs(season) return absDay(season+1,PO_FIRST)+PO_IV*3+RC_GAP_AFTER_FINAL end
local function _rcStrength(T) local s=0; for _,pl in ipairs(T.squad or {}) do s=s+(pl.rating or 6) end; return s/math.max(1,#(T.squad or {})) end
local function _rcPlayScore(H,A,salt)
math.randomseed(salt); local sh,sa=_rcStrength(H),_rcStrength(A); local p0=math.random(); local hg,ag
if p0<.10 then hg=math.random(0,12);ag=hg else local ph=sh/(sh+sa); local homeWin=((p0-.10)/.90)<ph; local w=math.random(1,12); local l=math.random(0,w-1); if homeWin then hg,ag=w,l else hg,ag=l,w end end
return hg,ag
end
local function _rcFixtures(T)
local fixtures={}
-- Berger table for five entrants plus a rotating bye.
local rot={T[1],T[2],T[3],T[4],T[5],false}
for r=1,5 do
local games={}
for i=1,3 do
local h,a=rot[i],rot[7-i]
if h and a then
if r%2==0 then h,a=a,h end
games[#games+1]={h,a}
end
end
fixtures[#fixtures+1]={round='Round '..r,games=games}
local last=rot[6]
for i=6,3,-1 do rot[i]=rot[i-1] end
rot[2]=last
end
return fixtures
end
local function _rcStandingsInit(T) local st={}; for _,x in ipairs(T) do st[x.fullName]={team=x,GP=0,W=0,D=0,L=0,GF=0,GA=0,GD=0,Pts=0} end; return st end
local function _rcApply(st,H,A,hg,ag) local h,a=st[H.fullName],st[A.fullName]; h.GP=h.GP+1;a.GP=a.GP+1;h.GF=h.GF+hg;h.GA=h.GA+ag;a.GF=a.GF+ag;a.GA=a.GA+hg;if hg>ag then h.W=h.W+1;a.L=a.L+1;h.Pts=h.Pts+3 elseif ag>hg then a.W=a.W+1;h.L=h.L+1;a.Pts=a.Pts+3 else h.D=h.D+1;a.D=a.D+1;h.Pts=h.Pts+1;a.Pts=a.Pts+1 end;h.GD=h.GF-h.GA;a.GD=a.GF-a.GA end
local function _rcSorted(st) local L={};for _,r in pairs(st) do L[#L+1]=r end;table.sort(L,function(a,b) if a.Pts~=b.Pts then return a.Pts>b.Pts end;if a.GD~=b.GD then return a.GD>b.GD end;if a.GF~=b.GF then return a.GF>b.GF end;return a.team.fullName<b.team.fullName end);return L end
function p.renderRegionalCupRosters(frame)
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local T=_regionalTeams(season); local out={'== Off-Season Regional Cup — Call-Ups ('..season..' PSSC) =='}
for _,rt in ipairs(T) do out[#out+1]='=== '..rt.logo..' '..rt.fullName..' ===';out[#out+1]='{| class="wikitable sortable" style="width:100%; font-size:90%;"';out[#out+1]='! Player !! Pos !! Club !! Rating !! G !! A';for _,pl in ipairs(rt.squad) do out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %.2f || %d || %d',pl.name,pl.pos,_teamWithLogo(pl.team),pl.rating or 0,pl.goals or 0,pl.assists or 0) end;out[#out+1]='|}' end
return table.concat(out,'\n')
end
function p.renderRegionalCupSchedule(frame)
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local T=_regionalTeams(season); local fixtures=_rcFixtures(T); local start=_regionalCupStartAbs(season)
local out={'{| class="wikitable sortable"','! Stage !! Date !! Home (kit) !! Score !! Away (kit)'}
for i,blk in ipairs(fixtures) do local a=start+(i-1)*RC_ROUND_GAP; for gi,g in ipairs(blk.games) do local key='RC|'..season..'|'..blk.round..'|'..gi;out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s %s || — || %s %s',blk.round,_dateLabelFromAbs(a),_kitImgForMatch(g[1],'home',key,20),_teamWithLogo(g[1]),_kitImgForMatch(g[2],'away',key,20),_teamWithLogo(g[2])) end end
local finalAbs=start+RC_ROUND_GAP*4+RC_FINAL_GAP;out[#out+1]='|-';out[#out+1]=string.format('| Final || %s || TBD || — || TBD',_dateLabelFromAbs(finalAbs));out[#out+1]='|-';out[#out+1]=string.format('| 3rd Place || %s || TBD || — || TBD',_dateLabelFromAbs(finalAbs));out[#out+1]='|}'
return table.concat(out,'\n')
end
function p.renderRegionalCupResults(frame)
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local T=_regionalTeams(season); local fixtures=_rcFixtures(T); local st=_rcStandingsInit(T); local start=_regionalCupStartAbs(season); local now=_nowAbs(); local out={}
out[#out+1]='{| class="wikitable sortable"';out[#out+1]='! Stage !! Date !! Home !! Score !! Away'
for i,blk in ipairs(fixtures) do
local a=start+(i-1)*RC_ROUND_GAP
for gi,g in ipairs(blk.games) do
local score='—'; if now>=a then local hg,ag=_rcPlayScore(g[1],g[2],a*100+gi);score=string.format("'''%d-%d'''",hg,ag);_rcApply(st,g[1],g[2],hg,ag) end
out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %s || %s',blk.round,_dateLabelFromAbs(a),_teamWithLogo(g[1]),score,_teamWithLogo(g[2]))
end
end
out[#out+1]='|}';out[#out+1]='';out[#out+1]='=== Regional Cup Table ===';out[#out+1]='{| class="wikitable sortable"';out[#out+1]='! # !! Team !! GP !! W !! D !! L !! GF !! GA !! GD !! Pts'
local L=_rcSorted(st);for i,r in ipairs(L) do out[#out+1]='|-';out[#out+1]=string.format('| %d || %s || %d || %d || %d || %d || %d || %d || %+d || %d',i,_teamWithLogo(r.team),r.GP,r.W,r.D,r.L,r.GF,r.GA,r.GD,r.Pts) end;out[#out+1]='|}'
local finalAbs=start+RC_ROUND_GAP*4+RC_FINAL_GAP;out[#out+1]='';out[#out+1]='=== Finals Day — '.._dateLabelFromAbs(finalAbs)..' ==='
local A1,A2,A3,A4=L[1].team,L[2].team,L[3].team,L[4].team
local function line(label,X,Y,k) local score='—';local winner='TBD';if now>=finalAbs then local gx,gy=_rcPlayScore(X,Y,finalAbs*100+k);score=string.format("'''%d-%d'''",gx,gy);winner=(gx>gy and X.fullName) or (gy>gx and Y.fullName) or 'Draw' end;return string.format('* %s: %s %s %s (Winner: %s)',label,_teamWithLogo(X),score,_teamWithLogo(Y),winner) end
out[#out+1]=line('Final',A1,A2,1);out[#out+1]=line('3rd Place',A3,A4,2)
return table.concat(out,'\n')
end
------------------------------------------------------------------------
-- 10) MATCH REPORTS / WEEKLY FEATURES
------------------------------------------------------------------------
local function _pickMatchIndexForDay(season,md,pick)
local day=invertDayMap(mapDays())[md]; if not day then return nil,'Unable to resolve matchday.' end
local R=simulateDayResults(day,season); if #R==0 then return nil,'No fixtures found.' end
local bestI,bestScore=1,nil
for i,r in ipairs(R) do
local s
if pick=='chaos' then s=r.hg+r.ag+math.abs(r.hg-r.ag)
else s=(r.hg+r.ag)-math.abs(r.hg-r.ag)*0.5 end
if not bestScore or s>bestScore then bestScore=s;bestI=i end
end
return bestI
end
local function _renderMatchReport(season,md,matchIndex,titleOverride)
local day=invertDayMap(mapDays())[md]; if not day then return "'''Unable to resolve that matchday.'''" end
if not _isPlayedMatchday(season,md) then return "'''That matchday has not yet been played.'''" end
local R=simulateDayResults(day,season);local r=R[matchIndex];if not r then return "'''Match not found.'''" end
return (titleOverride or string.format("'''Match Report — %d PSSC (Matchday %d)'''",season,md))..'\n'.._statsTable(r)
end
function p.renderMatchOfWeek(frame)
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1); local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season);local pick=args.pick or 'best'
if not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet (or the requested matchday is in the future).'''" end;local idx,err=_pickMatchIndexForDay(season,md,pick);if not idx then return "'''"..(err or 'Unable to select match.').."'''" end;return _renderMatchReport(season,md,idx)
end
function p.renderChaosOfWeek(frame) local args=(frame and frame.args) or {};args.pick=args.pick or 'chaos';return p.renderMatchOfWeek({args=args}) end
function p.renderMatchReport(frame) local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local md=tonumber(args.md or args.matchday or args[2] or '') or _currentPlayedMatchday(season);local mi=tonumber(args.match or args[3] or '1') or 1;if not _isPlayedMatchday(season,md) then return "'''That matchday has not yet been played.'''" end;return _renderMatchReport(season,md,mi,string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''",season,md,mi)) end
local function _playerRating(r)
if not r then return 0 end
local apps=math.max(1,r.apps or 0)
local x=6+(r.goals or 0)*1.25/apps+(r.assists or 0)*.85/apps+(r.poss or 0)*.05/apps+(r.son or 0)*.15/apps-(r.sof or 0)*.08/apps-(r.fouls or 0)*.16/apps
if x<0 then x=0 elseif x>10 then x=10 end;return x
end
function p.renderWeeklyAwards(frame)
local args=(frame and frame.args) or {}; local season=_resolveSeason(args,1);local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season);if not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet.'''" end
local totals=_computePlayerSeasonTotals(season,md);local prior=_computePlayerSeasonTotals(season,md-1);local list={}
for id,r in pairs(totals) do local q=prior[id] or {};local d={id=id,name=r.name,pos=r.pos,team=r.team,apps=(r.apps or 0)-(q.apps or 0),goals=(r.goals or 0)-(q.goals or 0),assists=(r.assists or 0)-(q.assists or 0),poss=(r.poss or 0)-(q.poss or 0),son=(r.son or 0)-(q.son or 0),sof=(r.sof or 0)-(q.sof or 0),fouls=(r.fouls or 0)-(q.fouls or 0)};if d.apps>0 then d.rating=_playerRating(d);list[#list+1]=d end end
table.sort(list,function(a,b) if a.rating~=b.rating then return a.rating>b.rating end;return a.name<b.name end)
local out={"'''Weekly Awards — Matchday "..md.."'''",'{| class="wikitable sortable"','! Award !! Player !! Team !! Pos !! Rating'}
local labels={'Player of the Week','Second Star','Third Star'};for i=1,math.min(3,#list) do local r=list[i];out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %s || %.2f',labels[i],r.name,_teamWithLogo(r.team),r.pos,r.rating) end;out[#out+1]='|}';return table.concat(out,'\n')
end
function p.renderTeamOfWeek(frame)
local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local md=tonumber(args.md or args.matchday or args[2] or '') or _currentPlayedMatchday(season);if not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet.'''" end
local totals=_computePlayerSeasonTotals(season,md);local prior=_computePlayerSeasonTotals(season,md-1);local by={Keeper={},Defender={},Playmaker={},Striker={},Utility={}}
for id,r in pairs(totals) do local q=prior[id] or {};local d={name=r.name,pos=r.pos,team=r.team,apps=(r.apps or 0)-(q.apps or 0),goals=(r.goals or 0)-(q.goals or 0),assists=(r.assists or 0)-(q.assists or 0),poss=(r.poss or 0)-(q.poss or 0),son=(r.son or 0)-(q.son or 0),sof=(r.sof or 0)-(q.sof or 0),fouls=(r.fouls or 0)-(q.fouls or 0)};if d.apps>0 and by[d.pos] then d.rating=_playerRating(d);by[d.pos][#by[d.pos]+1]=d end end
for _,L in pairs(by) do table.sort(L,function(a,b) if a.rating~=b.rating then return a.rating>b.rating end;return a.name<b.name end) end
local need={Keeper=1,Defender=3,Playmaker=3,Striker=2,Utility=2};local out={"'''Team of the Week — Matchday "..md.."'''",'{| class="wikitable"','! Role !! Player !! Team !! Rating'}
for _,pos in ipairs({'Keeper','Defender','Playmaker','Striker','Utility'}) do for i=1,math.min(need[pos],#by[pos]) do local r=by[pos][i];out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %.2f',pos,r.name,_teamWithLogo(r.team),r.rating) end end;out[#out+1]='|}';return table.concat(out,'\n')
end
function p.renderMatchdayCapsule(frame)
local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local md=tonumber(args.md or args.matchday or '') or _currentPlayedMatchday(season);if not _isPlayedMatchday(season,md) then return "'''No matchday has been played yet.'''" end
local day=invertDayMap(mapDays())[md];if not day then return "'''Unable to resolve PSSC day for matchday "..md..".'''" end;local out={'== Matchday '..md..' Capsule ==','',p.renderMatchOfWeek({args={season=season,md=md,pick=args.pick or 'chaos'}}),'','=== Full Results ==='}
for _,r in ipairs(simulateDayResults(day,season)) do out[#out+1]=string.format('* %s \'\'\'%d\'\'\' %s vs %s \'\'\'%d\'\'\' %s',r.home.logo,r.hg,r.home.fullName,r.away.logo,r.ag,r.away.fullName) end;out[#out+1]='';out[#out+1]=p.renderWeeklyAwards({args={season=season,md=md}});return table.concat(out,'\n')
end
function p.renderMatchdayDensity(frame)
local totalDays=END-START+1;local iv=math.floor(totalDays/TOTAL_ROUNDS);local ex=totalDays%TOTAL_ROUNDS;local cnt=0;for d=START,END do if mapDays()[d] then cnt=cnt+1 end end;return string.format('Locked rounds: %d; season days: %d; iv=%d; ex=%d; matchdays placed=%d',TOTAL_ROUNDS,totalDays,iv,ex,cnt)
end
function p.renderNextMatchdays(frame)
local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local now,day,year=_nowAbs();local from=(year==season and math.max(day,START) or START);local out={'{| class="wikitable"','! # !! Matchday !! Calendar Day !! Gap (days) !! Date'};local shown=0;local prev=nil
for d=from,END do local md=mapDays()[d];if md and absDay(season,d)>=now then local gap=prev and d-prev or '—';prev=d;shown=shown+1;out[#out+1]='|-';out[#out+1]=string.format('| %d || %d || %d || %s || %s',shown,md,d,tostring(gap),_dateLabel(season,d));if shown>=20 then break end end end;out[#out+1]='|}';return table.concat(out,'\n')
end
------------------------------------------------------------------------
-- 11) SEASON AWARDS
------------------------------------------------------------------------
local function _seasonAwardData(season)
local totals=_computePlayerSeasonTotals(season,TOTAL_ROUNDS);local list={};for _,r in pairs(totals) do r.rating=_playerRating(r);list[#list+1]=r end
local function best(stat,filter,ascending)
local L={};for _,r in ipairs(list) do if not filter or filter(r) then L[#L+1]=r end end;table.sort(L,function(a,b) local av,bv=a[stat] or 0,b[stat] or 0;if av~=bv then if ascending then return av<bv else return av>bv end end;return a.name<b.name end);return L[1]
end
local awards={mvp=best('rating'),goldenPillar=best('goals'),playmaker=best('assists'),possession=best('poss'),sharpshooter=best('son'),ironman=best('apps'),fairPlay=best('fouls',function(r)return (r.apps or 0)>=10 end,true),goldenGloves=best('rating',function(r)return r.pos=='Keeper' end)}
local by={Keeper={},Defender={},Playmaker={},Striker={},Utility={}};for _,r in ipairs(list) do if by[r.pos] then by[r.pos][#by[r.pos]+1]=r end end;for _,L in pairs(by) do table.sort(L,function(a,b)if a.rating~=b.rating then return a.rating>b.rating end;return a.name<b.name end)end
return awards,by
end
function p.renderSeasonAwards(frame)
local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local now=_nowAbs();local finalAbs=absDay(season,_finalRegularSeasonDay())
if now<finalAbs then return string.format("'''End-of-season awards will be available after the regular season concludes (%s).'''",_dateLabelFromAbs(finalAbs)) end
local A,by=_seasonAwardData(season);local out={'== End-of-Season Awards ==','{| class="wikitable sortable"','! Award !! Winner !! Team !! Pos !! Notes'}
local function row(label,r,note) out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %s || %s',label,r and r.name or '—',r and _teamWithLogo(r.team) or '—',r and r.pos or '—',note and note(r) or '—') end
row('MVP (Player of the Season)',A.mvp,function(r)return string.format('Rating: %.2f',r.rating)end);row('Golden Pillar (Top Scorer)',A.goldenPillar,function(r)return 'Goals: '..(r.goals or 0)end);row('Playmaker of the Season',A.playmaker,function(r)return 'Assists: '..(r.assists or 0)end);row('Possession Master',A.possession,function(r)return 'Possession points: '..(r.poss or 0)end);row('Golden Gloves',A.goldenGloves,function(r)return string.format('Rating: %.2f',r.rating)end);row('Sharpshooter',A.sharpshooter,function(r)return 'Shots on target: '..(r.son or 0)end);row('Discipline / Fair Play',A.fairPlay,function(r)return 'Fouls: '..(r.fouls or 0)end);row('Ironman',A.ironman,function(r)return 'Appearances: '..(r.apps or 0)end);out[#out+1]='|}'
out[#out+1]='';out[#out+1]='=== Team of the Season (Best XI) ===';out[#out+1]='{| class="wikitable"';out[#out+1]='! Role !! Player !! Team !! Rating'
local need={Keeper=1,Defender=3,Playmaker=3,Striker=2,Utility=2};for _,pos in ipairs({'Keeper','Defender','Playmaker','Striker','Utility'}) do for i=1,math.min(need[pos],#by[pos]) do local r=by[pos][i];out[#out+1]='|-';out[#out+1]=string.format('| %s || %s || %s || %.2f',pos,r.name,_teamWithLogo(r.team),r.rating)end end;out[#out+1]='|}';return table.concat(out,'\n')
end
------------------------------------------------------------------------
-- 12) DIAGNOSTIC STATUS (optional but useful for the wiki page)
------------------------------------------------------------------------
function p.renderSeasonStatus(frame)
local args=(frame and frame.args) or {};local season=_resolveSeason(args,1);local stage,t=_seasonStage(season);local day,year=_currentPSSC();local labels={before_regular='Before regular season',regular='Regular season',pre_playoff='Awaiting playoffs',playoffs='Playoffs',pre_regional='Awaiting Regional Cup',regional_cup='Regional Cup',offseason='Off-season',complete='Completed/archive'}
return string.format("'''Pillarion season engine:''' active season %d PSSC; calendar %s; phase: '''%s'''.<br>Regular season: %s–%s; playoffs: %s–%s; Regional Cup: %s–%s; next regular season: %s.",season,_dateLabel(year,day),labels[stage] or stage,_dateLabelFromAbs(t.regularStart),_dateLabelFromAbs(t.regularEnd),_dateLabelFromAbs(t.playoffStart),_dateLabelFromAbs(t.playoffFinal),_dateLabelFromAbs(t.regionalStart),_dateLabelFromAbs(t.regionalFinal),_dateLabelFromAbs(t.nextRegular))
end
------------------------------------------------------------------------
-- EXPORT
------------------------------------------------------------------------
return p