Module:PillSeasonSchedule: Difference between revisions
From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs) No edit summary |
NewZimiaGov (talk | contribs) No edit summary |
||
| Line 10: | Line 10: | ||
local function getMonthDay(d) | local function getMonthDay(d) | ||
if d <= 61 then | |||
return "Atosiel", d | |||
elseif d <= 122 then | |||
return "Thalassiel", d - 61 | |||
else | |||
return "Opsitheiel", d - 122 | |||
end | |||
end | 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 | ||
| Line 26: | Line 26: | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
local teams = { | local teams = { | ||
{ code="Allegro Symphonara", fullName="Allegro Symphonara", logo="[[File:AllegrosymphonaraB.png|20px]]", championships=4 }, | |||
{ code="Ashguard Pyralis", fullName="Ashguard Pyralis", logo="[[File:AshguardpyralisB.png|20px]]", championships=1 }, | |||
{ code="Aurelia Auric", fullName="Aurelia Auric", logo="[[File:AureliaAuricB.png|20px]]", championships=2 }, | |||
{ code="Brightpath Aureum", fullName="Brightpath Aureum", logo="[[File:BrightpathaureumB.png|20px]]", championships=2 }, | |||
{ code="Celestial Sancta Lunaris", fullName="Celestial Sancta Lunaris", logo="[[File:CelestialsanctaB.png|20px]]", championships=1 }, | |||
{ code="Delphica Windborne", fullName="Delphica Windborne", logo="[[File:DelphicawindborneB.png|20px]]", championships=1 }, | |||
{ code="Emberstone Vaeringheim", fullName="Emberstone Vaeringheim", logo="[[File:EmberstonevaeringheimB.png|20px]]", championships=3 }, | |||
{ code="Flameborne Erythros", fullName="Flameborne Erythros", logo="[[File:FlameborneerythrosB.png|20px]]", championships=1 }, | |||
{ code="Forgeward Nexa", fullName="Forgeward Nexa", logo="[[File:ForgewardnexaB.png|20px]]", championships=0 }, | |||
{ code="Hearthkeeper Koinon", fullName="Hearthkeeper Koinon", logo="[[File:HearthkeeperkoinonB.png|20px]]", championships=0 }, | |||
{ code="Ironbough Sylvapolis", fullName="Ironbough Sylvapolis", logo="[[File:IronboughSylvapolisB.png|20px]]", championships=1 }, | |||
{ code="Redcliff Citadel", fullName="Redcliff Citadel", logo="[[File:RedcliffCitadel.png|20px]]", championships=1 }, | |||
{ code="Riftwarden Acheron", fullName="Riftwarden Acheron", logo="[[File:RiftwardenAcheronB.png|20px]]", championships=3 }, | |||
{ code="Saluria Skylight", fullName="Saluria Skylight", logo="[[File:SaluriaSkylightB.png|20px]]", championships=0 }, | |||
{ code="Sandveil Somniumpolis", fullName="Sandveil Somniumpolis", logo="[[File:SandVeilSomniumB.png|20px]]", championships=1 }, | |||
{ code="Serena Tidesong", fullName="Serena Tidesong", logo="[[File:Serena TidesongB.png|20px]]", championships=2 }, | |||
{ code="Steppe Luminaria", fullName="Steppe Luminaria", logo="[[File:Steppe LuminariaB.png|20px]]", championships=4 }, | |||
{ code="Strider Myrene", fullName="Strider Myrene", logo="[[File:StriderMyreneB.png|20px]]", championships=1 }, | |||
{ code="Vaeringheim Pillar", fullName="Vaeringheim Pillar", logo="[[File:VaeringheimPillarB.png|20px]]", championships=5 }, | |||
{ code="Imperial Delphica", fullName="Imperial Delphica", logo="[[File:ImperialDelphicaB.png|20px]]", championships=1 }, | |||
{ code="Ascendant Aetherium", fullName="Ascendant Aetherium", logo="[[File:AscendantAetheriumB.png|20px]]", championships=2 }, | |||
{ code="Sufriya Stormwake", fullName="Sufriya Stormwake", logo="[[File:SufriyaStormwake.png|20px]]", championships=1 }, | |||
{ code="Pillarion Club Suncliff", fullName="Pillarion Club Suncliff", logo="[[File:PCSuncliff.png|20px]]", championships=0 }, | |||
{ code="Jogi Regiment", fullName="Jogi Regiment", logo="[[File:JogiRegiment.png|20px]]", championships=0 }, | |||
{ code="Vine Fleet Mylecia", fullName="Vine Fleet Mylecia", logo="[[File:VineFleetMylecia.png|20px]]", championships=0 }, | |||
{ code="Port of Blore Heath", fullName="Port of Blore Heath", logo="[[File:PortBloreHeath.png|20px]]", championships=0 }, | |||
{ code="Free State Abeis", fullName="Free State Abeis", logo="[[File:FreeStateAbeis.png|20px]]", championships=0 }, | |||
{ code="Jezeraah City", fullName="Jezeraah City", logo="[[File:JezeraahCity.png|20px]]", championships=0 }, | |||
{ code="Ourid Pegasi", fullName="Ourid Pegasi", logo="[[File:OuridPegasiLogo.png|20px]]", championships=0 }, | |||
{ code="Pillarion Club Caspazani", fullName="Pillarion Club Caspazani", logo="[[File:PCCaspazani.png|20px]]", championships=0 }, | |||
{ code="Amberwatch Slevik", fullName="Amberwatch Slevik", logo="[[File:AmberwatchSlevik.png|20px]]", championships=0 }, | |||
{ code="Fanghorn Rein", fullName="Fanghorn Rein", logo="[[File:FanghornRein.png|20px]]", championships=0 }, | |||
} | } | ||
| Line 63: | Line 63: | ||
for _,t in ipairs(teams) do TEAM_BY_CODE[t.code] = t end | for _,t in ipairs(teams) do TEAM_BY_CODE[t.code] = t end | ||
local function _asTeam(x) | local function _asTeam(x) | ||
if type(x) == "table" then return x end | if type(x) == "table" then return x end | ||
| Line 108: | Line 107: | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- 3) | -- 3) SCHEDULE LOCK + BALANCED FUTURE SCHEDULE | ||
-- • | -- • SCHED_LOCKED preserves history (your existing generator) | ||
-- • | -- • SCHED_BALANCED ensures equal games going forward (38 full rounds) | ||
-- | -- • mapDays() locks past days and remaps future days to balanced rounds | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- ====== Existing schedule generator (history) ====== | |||
local function homeFirst(codeA, codeB) | local function homeFirst(codeA, codeB) | ||
local sA, sB = 0, 0 | local sA, sB = 0, 0 | ||
| Line 120: | Line 121: | ||
end | end | ||
local function | local function buildPairings_greedy() | ||
local intra, inter = {}, {} | local intra, inter = {}, {} | ||
| Line 127: | Line 128: | ||
for j=i+1,#list do | for j=i+1,#list do | ||
local A, B = TEAM_BY_CODE[list[i]], TEAM_BY_CODE[list[j]] | local A, B = TEAM_BY_CODE[list[i]], TEAM_BY_CODE[list[j]] | ||
intra[#intra+1] = {home=A, away=B} | |||
intra[#intra+1] = {home=B, away=A} | |||
end | end | ||
end | end | ||
| Line 134: | Line 135: | ||
local allCodes = {} | local allCodes = {} | ||
for _,t in ipairs(teams) do | for _,t in ipairs(teams) do allCodes[#allCodes+1] = t.code end | ||
table.sort(allCodes) | table.sort(allCodes) | ||
| Line 143: | Line 144: | ||
local a, b = TEAM_BY_CODE[ca], TEAM_BY_CODE[cb] | local a, b = TEAM_BY_CODE[ca], TEAM_BY_CODE[cb] | ||
local hFirst = homeFirst(ca, cb) | local hFirst = homeFirst(ca, cb) | ||
inter[#inter+1] = {home = hFirst and a or b, away = hFirst and b or a} | |||
end | end | ||
end | end | ||
end | end | ||
return intra, inter | return intra, inter | ||
end | end | ||
local function | local function packRounds_greedy(matches) | ||
local rounds, used = {}, {} | local rounds, used = {}, {} | ||
local function canPlace(r, m) | local function canPlace(r, m) | ||
| Line 184: | Line 171: | ||
if not placed then | if not placed then | ||
rounds[#rounds+1] = { { m.home, m.away } } | rounds[#rounds+1] = { { m.home, m.away } } | ||
used[#rounds] = { | used[#rounds] = { [m.home.code]=true, [m.away.code]=true } | ||
end | end | ||
end | end | ||
| Line 192: | Line 177: | ||
end | end | ||
local | local SCHED_LOCKED = (function() | ||
local intra, inter = | local intra, inter = buildPairings_greedy() | ||
local function chunk(arr, size) | local function chunk(arr, size) | ||
| Line 220: | Line 205: | ||
end | end | ||
return | return packRounds_greedy(merged) | ||
end)() | end)() | ||
-- ====== Balanced schedule (38 full rounds, no byes) ====== | |||
local DIV_ORDER = { | |||
"Morovian Division", | |||
"Southern Strait Division", | |||
"Western Highlands Division", | |||
"Normarkian Division" | |||
} | |||
local function | local function _copy(arr) | ||
local | local t = {} | ||
local | for i=1,#arr do t[i] = arr[i] end | ||
local | return t | ||
local | end | ||
local | |||
local function roundRobin8(teamCodes) | |||
local n = #teamCodes | |||
if n ~= 8 then error("roundRobin8 expects exactly 8 teams") end | |||
local arr = _copy(teamCodes) | |||
local rounds = {} | |||
for r=1,7 do | |||
local matches = {} | |||
for i=1,4 do | |||
local a = TEAM_BY_CODE[arr[i]] | |||
local b = TEAM_BY_CODE[arr[n+1-i]] | |||
local home, away | |||
if (r+i) % 2 == 0 then home, away = a, b else home, away = b, a end | |||
matches[#matches+1] = { home, away } | |||
end | |||
rounds[#rounds+1] = matches | |||
local last = arr[n] | |||
for i=n,3,-1 do arr[i] = arr[i-1] end | |||
arr[2] = last | |||
end | end | ||
return | return rounds | ||
end | end | ||
local function swapHA(rounds) | |||
local out = {} | |||
for r=1,#rounds do | |||
local function | out[r] = {} | ||
for i=1,#rounds[r] do | |||
local | local m = rounds[r][i] | ||
out[r][i] = { m[2], m[1] } | |||
end | |||
end | end | ||
return | return out | ||
end | end | ||
local function | local function interPairRounds(divA_codes, divB_codes, blockIndex) | ||
local | local rounds = {} | ||
for t=0,7 do | |||
local matches = {} | |||
for i=1,8 do | |||
local a = TEAM_BY_CODE[divA_codes[i]] | |||
local b = TEAM_BY_CODE[divB_codes[((i+t-1) % 8) + 1]] | |||
local home, away | |||
if ((blockIndex + t) % 2 == 0) then home, away = a, b else home, away = b, a end | |||
matches[#matches+1] = { home, away } | |||
end | |||
rounds[#rounds+1] = matches | |||
end | end | ||
return | return rounds | ||
end | end | ||
local SCHED_BALANCED = (function() | |||
local rounds = {} | |||
local function | |||
-- Intra: 14 rounds (4 matches per division per round = 16 total) | |||
local intraByDiv = {} | |||
for _, divName in ipairs(DIV_ORDER) do | |||
local rr7 = roundRobin8(DIVISIONS[divName]) | |||
local rr14 = {} | |||
for i=1,7 do rr14[i] = rr7[i] end | |||
local rr7s = swapHA(rr7) | |||
for i=1,7 do rr14[7+i] = rr7s[i] end | |||
intraByDiv[divName] = rr14 | |||
local | end | ||
for r=1,14 do | |||
local full = {} | |||
for _, divName in ipairs(DIV_ORDER) do | |||
for _, m in ipairs(intraByDiv[divName][r]) do full[#full+1] = m end | |||
end | end | ||
rounds[#rounds+1] = full | |||
end | |||
-- Inter: 24 rounds = 3 blocks × 8 rounds | |||
local A = DIVISIONS[DIV_ORDER[1]] | |||
local B = DIVISIONS[DIV_ORDER[2]] | |||
local C = DIVISIONS[DIV_ORDER[3]] | |||
local D = DIVISIONS[DIV_ORDER[4]] | |||
local blocks = { | |||
{ {A,B}, {C,D} }, | |||
{ {A,C}, {B,D} }, | |||
{ {A,D}, {B,C} }, | |||
} | |||
for blockIndex, pairset in ipairs(blocks) do | |||
local p1 = interPairRounds(pairset[1][1], pairset[1][2], blockIndex) | |||
local p2 = interPairRounds(pairset[2][1], pairset[2][2], blockIndex) | |||
for t=1,8 do | |||
local full = {} | |||
for _,m in ipairs(p1[t]) do full[#full+1] = m end | |||
for _,m in ipairs(p2[t]) do full[#full+1] = m end | |||
rounds[#rounds+1] = full | |||
end | end | ||
end | |||
if #rounds ~= 38 then error("Balanced schedule should have 38 rounds, got "..tostring(#rounds)) end | |||
for i=1,#rounds do | |||
if #rounds[i] ~= 16 then error("Balanced round "..i.." has "..tostring(#rounds[i]).." matches") end | |||
end | |||
return rounds | |||
end)() | |||
local TOTAL_ROUNDS = 38 | |||
-- Day map builder for a schedule length | |||
local function mapDaysFor(schedule) | |||
local totalDays = END - START + 1 | |||
local totalRnds = #schedule | |||
local iv = math.floor(totalDays / totalRnds) | |||
local ex = totalDays % totalRnds | |||
local m, d = {}, START | |||
for i=1,totalRnds do | |||
m[d] = i | |||
d = d + iv + (ex > 0 and 1 or 0) | |||
if ex > 0 then ex = ex - 1 end | |||
end | |||
return m | |||
end | |||
local DM_LOCKED = mapDaysFor(SCHED_LOCKED) | |||
-- Find last played matchday under locked mapping (history boundary) | |||
local function lastPlayedLocked() | |||
local curr = date.getCurrentDate() | |||
local today = tonumber(curr:match("^(%d+),")) or 0 | |||
local lastDay, lastMd = nil, 0 | |||
for d=START, math.min(today, END) do | |||
local md = DM_LOCKED[d] | |||
if md and md > lastMd then | |||
lastMd = md | |||
lastDay = d | |||
end | end | ||
end | |||
return lastDay, lastMd | |||
end | |||
-- Composite mapDays: locked in the past, balanced in the future | |||
local function mapDays() | |||
local pivotDay, pivotMd = lastPlayedLocked() | |||
if not pivotDay or pivotMd == 0 then | |||
return mapDaysFor(SCHED_BALANCED) | |||
end | |||
local dm = {} | |||
for d,md in pairs(DM_LOCKED) do | |||
if d <= pivotDay then dm[d] = md end | |||
end | |||
local futureStartDay = pivotDay + 1 | |||
local remainingDays = math.max(0, END - futureStartDay + 1) | |||
local remainingRnds = math.max(0, TOTAL_ROUNDS - pivotMd) | |||
if remainingDays == 0 or remainingRnds == 0 then | |||
return dm | |||
end | |||
local iv = math.floor(remainingDays / remainingRnds) | |||
local ex = remainingDays % remainingRnds | |||
local day = futureStartDay | |||
for i=1,remainingRnds do | |||
local mdx = pivotMd + i | |||
if mdx > TOTAL_ROUNDS then break end | |||
dm[day] = mdx | |||
day = day + iv + (ex > 0 and 1 or 0) | |||
if ex > 0 then ex = ex - 1 end | |||
if day > END then break end | |||
end | |||
return dm | |||
end | end | ||
local function invertDayMap(dm) | |||
local inv = {} | |||
for day, md in pairs(dm) do inv[md] = day end | |||
local | return inv | ||
return | |||
end | end | ||
local function | local function _currentPlayedMatchday() | ||
local | local curr = date.getCurrentDate() | ||
local today = tonumber(curr:match("^(%d+),")) or 0 | |||
local dm = mapDays() | |||
local lastMd = 0 | |||
for d=START, math.min(today, END) do | |||
if dm[d] and dm[d] > lastMd then lastMd = dm[d] end | |||
end | end | ||
local | return lastMd | ||
end | |||
local function schedForRound(roundIdx) | |||
local _, pivotMd = lastPlayedLocked() | |||
if roundIdx <= pivotMd then return SCHED_LOCKED end | |||
return SCHED_BALANCED | |||
end | |||
local function getRoundMatches(roundIdx) | |||
local sched = schedForRound(roundIdx) | |||
return sched[roundIdx] or {} | |||
end | |||
------------------------------------------------------------------------ | |||
-- 4) MATCH SIMULATION (deterministic by day & pairing) | |||
------------------------------------------------------------------------ | |||
local function simScoreFor(h, a, seed) | |||
math.randomseed(seed) | |||
local p = math.random() | |||
local hg, ag | |||
if p < 0.1 then | |||
hg = math.random(0, 12); ag = hg | |||
else | |||
local wh, wa = (h.championships or 0) + 1, (a.championships or 0) + 1 | |||
end | local rem = (p - 0.1) / 0.9 | ||
local homeWin= rem < (wh / (wh + wa)) | |||
local winG = math.random(1, 12) | |||
local loseG = math.random(0, math.min(winG - 1, 12)) | |||
if homeWin then hg, ag = winG, loseG else hg, ag = loseG, winG end | |||
end | end | ||
return hg, ag | |||
end | end | ||
local function | local function simulateDayResults(day) | ||
local | local dm = mapDays() | ||
local | local idx = dm[day] | ||
local | if not idx then return {} end | ||
local matches = getRoundMatches(idx) | |||
local out = {} | |||
for k, m in ipairs(matches) do | |||
local h, a = m[1], m[2] | |||
local salt = day*1000 + k + string.byte(h.code,1) + string.byte(a.code,1) | |||
local hg, ag = simScoreFor(h, a, salt) | |||
out[#out+1] = { home=h, away=a, hg=hg, ag=ag } | |||
end | |||
return out | |||
end | |||
------------------------------------------------------------------------ | |||
-- 5) TODAY / NEXT MATCHDAY RENDER | |||
------------------------------------------------------------------------ | |||
local function renderRound(curDay, year) | |||
local dm = mapDays() | |||
local target = curDay | |||
local upcoming= false | |||
if not dm[target] then | |||
for d = curDay + 1, END do | |||
if dm[d] then target = d; upcoming = true; break end | |||
end | |||
end | |||
if not dm[target] then | |||
return "'''No more matches this season.'''" | |||
end | end | ||
local | local prefix = upcoming and "'''Next matchday:''' " or "" | ||
local mon, dom = getMonthDay(target) | |||
local label = dom .. " " .. mon .. " " .. year .. " PSSC" | |||
local rows = {} | |||
local | local idx = dm[target] | ||
local matches = getRoundMatches(idx) | |||
if not upcoming then | |||
for k, m in ipairs(matches) do | |||
local h, a = m[1], m[2] | |||
local salt = target*1000 + k + string.byte(h.code,1) + string.byte(a.code,1) | |||
local hg, ag = simScoreFor(h, a, salt) | |||
for _, | local hc = (hg > ag and "green") or (hg < ag and "red") or "yellow" | ||
local ac = (ag > hg and "green") or (ag < hg and "red") or "yellow" | |||
local hs = string.format("<span style='color:%s;'>'''%d'''</span>", hc, hg) | |||
local as = string.format("<span style='color:%s;'>'''%d'''</span>", ac, ag) | |||
rows[#rows+1] = | |||
string.format("%s %s %s vs %s %s %s", | |||
h.logo, hs, h.fullName, | |||
a.logo, as, a.fullName | |||
) | |||
end | |||
else | |||
for _, m in ipairs(matches) do | |||
local h, a = m[1], m[2] | |||
rows[#rows+1] = | |||
string.format("%s %s vs %s %s", | |||
h.logo, h.fullName, | |||
a.logo, a.fullName | |||
) | |||
end | end | ||
end | end | ||
return string.format("|-\n| %s%d || %s || %s", prefix, target, label, table.concat(rows, "<br>")) | |||
end | end | ||
function p.renderSchedule(frame) | |||
local curr = date.getCurrentDate() | |||
local | local day = tonumber(curr:match('^(%d+),')) or 0 | ||
local year = tonumber(curr:match(', (%d+) PSSC')) or 0 | |||
local | return table.concat({ | ||
'{| class="wikitable sortable"', | |||
'! Day !! Date !! Matches', | |||
renderRound(day, year), | |||
'|}' | |||
}, "\n") | |||
end | end | ||
------------------------------------------------------------------------ | |||
local | -- 6) DIVISION STANDINGS | ||
------------------------------------------------------------------------ | |||
function p.renderStandings(frame) | |||
local curr = date.getCurrentDate() | |||
local today = tonumber(curr:match("^(%d+),")) or 0 | |||
local last = math.min(today, END) | |||
local recs = {} | |||
for _, t in ipairs(teams) do | |||
recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0 } | |||
end | end | ||
local dm = mapDays() | |||
local | for d = START, last do | ||
local idx = dm[d] | |||
if idx then | |||
local matches = getRoundMatches(idx) | |||
for k, m in ipairs(matches) do | |||
local h, a = m[1], m[2] | |||
local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1) | |||
local hg, ag = simScoreFor(h, a, salt) | |||
local H, A = recs[h.code], recs[a.code] | |||
H.PF, H.PA = H.PF + hg, H.PA + ag | |||
A.PF, A.PA = A.PF + ag, A.PA + hg | |||
if hg > ag then | |||
H.W, H.Pts = H.W + 1, H.Pts + 3 | |||
A.L = A.L + 1 | |||
elseif hg < ag then | |||
{ | A.W, A.Pts = A.W + 1, A.Pts + 3 | ||
} | H.L = H.L + 1 | ||
for | else | ||
H.D, H.Pts = H.D + 1, H.Pts + 1 | |||
A.D, A.Pts = A.D + 1, A.Pts + 1 | |||
end | |||
end | |||
end | |||
end | |||
local buckets = {} | |||
for div,_ in pairs(DIVISIONS) do buckets[div] = {} end | |||
for code, r in pairs(recs) do | |||
r.PD = r.PF - r.PA | |||
local div = DIV_OF[code] or "—" | |||
buckets[div] = buckets[div] or {} | |||
table.insert(buckets[div], r) | |||
end | end | ||
function | local function cmp(a,b) | ||
if a.Pts ~= b.Pts then return a.Pts > b.Pts end | |||
if a.PD ~= b.PD then return a.PD > b.PD end | |||
if a.W ~= b.W then return a.W > b.W end | |||
return a.team.fullName < b.team.fullName | |||
end | |||
for div, list in pairs(buckets) do table.sort(list, cmp) end | |||
local | local order = { | ||
"Morovian Division","Southern Strait Division", | |||
"Western Highlands Division","Normarkian Division" | |||
} | } | ||
local | local out = {} | ||
for _, div in ipairs(order) do | |||
local list = buckets[div] or {} | |||
out[#out+1] = "== " .. div .. " Standings ==" | |||
out[#out+1] = '{| class="wikitable sortable"' | |||
out[#out+1] = "! Pos !! Team !! W !! D !! L !! Pts !! PF !! PA !! PD" | |||
for i, rec in ipairs(list) do | |||
local style = "" | |||
if i <= 2 then style = ' style="background-color:#ccffcc;"' | |||
elseif i <= 4 then style = ' style="background-color:#cce5ff;"' | |||
elseif i <= 6 then style = ' style="background-color:#F1EB9C;"' | |||
else style = ' style="background-color:#ffcccc;"' | |||
end | |||
out[#out+1] = "|-" .. style | |||
out[#out+1] = | |||
"| " .. i | |||
.. " || " .. rec.team.logo .. " " .. rec.team.fullName | |||
.. " || " .. rec.W | |||
.. " || " .. rec.D | |||
.. " || " .. rec.L | |||
.. " || " .. rec.Pts | |||
.. " || " .. rec.PF | |||
.. " || " .. rec.PA | |||
.. " || " .. rec.PD | |||
end | end | ||
out[#out+1] = "|}" | |||
out[#out+1] = "" | |||
end | end | ||
local | return table.concat(out, "\n") | ||
local | end | ||
local | ------------------------------------------------------------------------ | ||
-- 7) PREVIOUS RESULTS (last 5 matchdays) | |||
------------------------------------------------------------------------ | |||
function p.renderPreviousResults(frame) | |||
local curr = date.getCurrentDate() | |||
local today = tonumber(curr:match("^(%d+),")) or 0 | |||
local year = tonumber(curr:match(", (%d+) PSSC")) or 0 | |||
local dm = mapDays() | |||
local played = {} | |||
for d = today - 1, START, -1 do | |||
if dm[d] then played[#played+1] = d end | |||
if #played == 5 then break end | |||
if | |||
end | end | ||
local | local out = { | ||
'{| class="wikitable sortable"', | |||
'! Date !! Home !! Score !! Away' | |||
} | |||
local | for _, d in ipairs(played) do | ||
local mon, dom = getMonthDay(d) | |||
local | local dateLabel = dom .. " " .. mon .. " " .. year .. " PSSC" | ||
local idx = dm[d] | |||
local matches = getRoundMatches(idx) | |||
local | for k, m in ipairs(matches) do | ||
local h, a = m[1], m[2] | |||
local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1) | |||
local hg, ag = simScoreFor(h, a, salt) | |||
local hs = "'''" .. hg .. "-" .. ag .. "'''" | |||
out[#out+1] = "|-" | |||
out[#out+1] = | |||
"| " .. dateLabel | |||
.. " || " .. h.logo .. " " .. h.fullName | |||
.. " || " .. hs | |||
.. " || " .. a.logo .. " " .. a.fullName | |||
end | end | ||
end | end | ||
out[#out+1] = "|}" | |||
return table.concat(out, "\n") | |||
end | |||
------------------------------------------------------------------------ | |||
-- 8) PLAYOFFS (12 teams seeded from divisions) | |||
------------------------------------------------------------------------ | |||
local PO_YEAR = 52 | |||
local PO_FIRST = md2day(1,4) | |||
local PO_IV = 3 | |||
local function tb_cmp(a,b) | |||
if a.Pts ~= b.Pts then return a.Pts > b.Pts end | |||
if a.PD ~= b.PD then return a.PD > b.PD end | |||
if a.W ~= b.W then return a.W > b.W end | |||
return a.team.fullName < b.team.fullName | |||
return | |||
end | end | ||
local function computeRecordsUpTo(dayEnd) | |||
local recs = {} | |||
for _, t in ipairs(teams) do | |||
recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0, Div=DIV_OF[t.code] } | |||
local function | |||
local | |||
end | end | ||
local | local dm = mapDays() | ||
for d = START, math.min(dayEnd, END) do | |||
local idx = dm[d] | |||
if idx then | |||
local matches = getRoundMatches(idx) | |||
for k, m in ipairs(matches) do | |||
local h, a = m[1], m[2] | |||
local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1) | |||
local hg, ag = simScoreFor(h, a, salt) | |||
local H, A = recs[h.code], recs[a.code] | |||
H.PF, H.PA = H.PF + hg, H.PA + ag | |||
A.PF, A.PA = A.PF + ag, A.PA + hg | |||
if hg > ag then | |||
H.W, H.Pts = H.W + 1, H.Pts + 3 | |||
A.L = A.L + 1 | |||
elseif hg < ag then | |||
A.W, A.Pts = A.W + 1, A.Pts + 3 | |||
H.L = H.L + 1 | |||
else | |||
H.D, H.Pts = H.D + 1, H.Pts + 1 | |||
A.D, A.Pts = A.D + 1, A.Pts + 1 | |||
end | |||
end | |||
end | |||
end | |||
for _, r in pairs(recs) do r.PD = r.PF - r.PA end | |||
return recs | |||
end | |||
local | local function getSeeds12() | ||
local | local curr = date.getCurrentDate() | ||
local | local today = tonumber(curr:match("^(%d+),")) or END | ||
local recs = computeRecordsUpTo(today) | |||
local buckets = {} | |||
for div,_ in pairs(DIVISIONS) do buckets[div] = {} end | |||
for _, r in pairs(recs) do | |||
buckets[r.Div] = buckets[r.Div] or {} | |||
table.insert(buckets[r.Div], r) | |||
end | end | ||
for div, list in pairs(buckets) do table.sort(list, tb_cmp) end | |||
local winners, runners = {}, {} | |||
[ | for _, div in ipairs(DIV_ORDER) do | ||
local list = buckets[div] or {} | |||
if list[1] then table.insert(winners, list[1]) end | |||
if list[2] then table.insert(runners, list[2]) end | |||
end | |||
table.sort(winners, tb_cmp) | |||
table.sort(runners, tb_cmp) | |||
local chosen = {} | |||
for _,r in ipairs(winners) do chosen[r.team.code] = true end | |||
for _,r in ipairs(runners) do chosen[r.team.code] = true end | |||
local allList = {} | |||
for _, r in pairs(recs) do table.insert(allList, r) end | |||
table.sort(allList, tb_cmp) | |||
local wild = {} | |||
local | for _,r in ipairs(allList) do | ||
if not chosen[r.team.code] then | |||
if | table.insert(wild, r) | ||
if #wild == 4 then break end | |||
end | end | ||
end | end | ||
local seeds = {} | |||
local | for i=1,#winners do seeds[#seeds+1] = winners[i].team end | ||
for i=1,#runners do seeds[#seeds+1] = runners[i].team end | |||
for i=1,#wild do seeds[#seeds+1] = wild[i].team end | |||
return seeds | |||
end | |||
local | local function playScore(A,B,salt) | ||
if | math.randomseed(salt) | ||
local p = math.random() | |||
if p < 0.10 then local g=math.random(0,12); return g,g end | |||
local wa,wb = (A.championships or 0)+1,(B.championships or 0)+1 | |||
local home = (p-0.10)/0.90 < wa/(wa+wb) | |||
local hi = math.random(1,12) | |||
local lo = math.random(0, math.min(hi-1,12)) | |||
return home and hi or lo, home and lo or hi | |||
end | |||
local function mkRow(tag, absDate, H, A, salt, absNow) | |||
local hs, win = '—','—' | |||
if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then | |||
local g1,g2 = playScore(H,A,salt) | |||
hs = string.format("'''%d-%d'''", g1, g2) | |||
win = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName | |||
local | |||
end | end | ||
return string.format("|-\n| %s || %s %s || %s || %s %s || %s", | |||
tag, H.logo,H.fullName, hs, A.logo,A.fullName, win) | |||
end | |||
function p.renderPlayoffSchedule(frame) | |||
local seeds = getSeeds12() | |||
local out = { | local out = { | ||
'{| class="wikitable sortable"', | '{| class="wikitable sortable"', | ||
' | '! Round !! Date !! Match-up' | ||
} | } | ||
local function dateCell(day) | |||
local mon,dom = getMonthDay(day) | |||
return dom .. ' ' .. mon .. ' ' .. PO_YEAR .. ' PSSC' | |||
end | |||
local function add(tag, day, H, A) | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| %s || %s || %s %s vs %s %s', | |||
tag, dateCell(day), H.logo,H.fullName, A.logo,A.fullName) | |||
end | |||
local p1, p2 = seeds[9], seeds[12] | |||
local p3, p4 = seeds[10], seeds[11] | |||
add('Play-in-1', PO_FIRST, p1, p2) | |||
add('Play-in-2', PO_FIRST, p3, p4) | |||
local banners = { | |||
{ 'Round-of-8', 1 }, | |||
{ 'Round-of-4', 2 }, | |||
{ 'Semi-finals (Seeds 1–2 BYE)', 3 }, | |||
{ 'Final', 4 } | |||
} | |||
for _,b in ipairs(banners) do | |||
local d = PO_FIRST + PO_IV * b[2] | |||
out[#out+1] = '|-' | |||
out[#out+1] = '| colspan="3" | ' .. "'''" .. b[1] .. ":'''" .. ' ' .. dateCell(d) | |||
end | |||
out[#out+1] = '|}' | |||
return table.concat(out, '\n') | |||
end | |||
function p.renderPlayoffResults(frame) | |||
local seeds = getSeeds12() | |||
local now = date.getCurrentDate() | |||
local dNow = tonumber(now:match('^(%d+),')) or 0 | |||
local yNow = tonumber(now:match(', (%d+) PSSC')) or 0 | |||
local absNow= absDay(yNow,dNow) | |||
local rows = { | |||
'{| class="wikitable sortable"', | |||
'! Round !! Home !! Score !! Away !! Winner' | |||
} | |||
local absPI = absDay(PO_YEAR, PO_FIRST) | |||
local PI = { { seeds[9], seeds[12] }, { seeds[10], seeds[11] } } | |||
local W_PI = {} | |||
for i,pair in ipairs(PI) do | |||
local H,A = pair[1], pair[2] | |||
if absNow >= absPI then | |||
local g1,g2 = playScore(H,A,absPI*100+i) | |||
W_PI[i] = (g1>g2) and H or A | |||
end | end | ||
rows[#rows+1] = mkRow('PI-'..i, absPI, H, A, absPI*100+i, absNow) | |||
end | |||
local absR8 = absPI + PO_IV | |||
local pool = { seeds[3],seeds[4],seeds[5],seeds[6],seeds[7],seeds[8], | |||
W_PI[1] or {logo='—',fullName='TBD'}, W_PI[2] or {logo='—',fullName='TBD'} } | |||
local R8 = { {pool[3], pool[6]}, {pool[4], pool[5]}, {pool[1], pool[8]}, {pool[2], pool[7]} } | |||
local W_R8 = {} | |||
for i,pair in ipairs(R8) do | |||
local H,A = pair[1], pair[2] | |||
if absNow >= absR8 and A.fullName~='TBD' then | |||
local g1,g2 = playScore(H,A,absR8*100+i) | |||
end | W_R8[i] = (g1>g2) and H or A | ||
end | |||
rows[#rows+1] = mkRow('R-8-'..i, absR8, H, A, absR8*100+i, absNow) | |||
end | |||
local absR4 = absR8 + PO_IV | |||
local R4, W_R4 = {}, {} | |||
if #W_R8 == 4 then | |||
local idxByCode = {} | |||
for i,t in ipairs(seeds) do idxByCode[t.code] = i end | |||
table.sort(W_R8, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end) | |||
R4 = { { W_R8[1], W_R8[4] }, { W_R8[2], W_R8[3] } } | |||
end | |||
for i=1,2 do | |||
local H = R4[i] and R4[i][1] or {logo='—',fullName='TBD'} | |||
local A = R4[i] and R4[i][2] or {logo='—',fullName='TBD'} | |||
if absNow >= absR4 and R4[i] then | |||
local g1,g2 = playScore(H,A,absR4*100+i) | |||
W_R4[i] = (g1>g2) and H or A | |||
-- | end | ||
rows[#rows+1] = mkRow('R-4-'..i, absR4, H, A, absR4*100+i, absNow) | |||
end | |||
local absSF = absR4 + PO_IV | |||
local | local SF = {} | ||
} | |||
if #W_R4 == 2 then | |||
local idxByCode = {} | |||
for i,t in ipairs(seeds) do idxByCode[t.code] = i end | |||
table.sort(W_R4, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end) | |||
local | SF = { { seeds[1], W_R4[2] }, { seeds[2], W_R4[1] } } | ||
for i=1, | else | ||
SF = { { seeds[1], {logo='—',fullName='TBD'} }, { seeds[2], {logo='—',fullName='TBD'} } } | |||
end | |||
local | local W_SF = {} | ||
for i=1,2 do | |||
local H,A = SF[i][1], SF[i][2] | |||
if absNow >= absSF and A.fullName~='TBD' then | |||
local g1,g2 = playScore(H,A,absSF*100+i) | |||
W_SF[i] = (g1>g2) and H or A | |||
end | |||
rows[#rows+1] = mkRow('SF-'..i, absSF, H, A, absSF*100+i, absNow) | |||
end | |||
local absF = absSF + PO_IV | |||
local FH = W_SF[1] or {logo='—',fullName='TBD'} | |||
local FA = W_SF[2] or {logo='—',fullName='TBD'} | |||
rows[#rows+1] = mkRow('Final', absF, FH, FA, absF*100+1, absNow) | |||
rows[#rows+1] = '|}' | |||
return table.concat(rows, '\n') | |||
end | end | ||
local function | ------------------------------------------------------------------------ | ||
if | -- 9) PER-MATCH STATS | ||
------------------------------------------------------------------------ | |||
local function computeMatchStats(h, a, hg, ag, seed) | |||
local function splitScore(total, salt) | |||
local maxG = math.floor(total / 3) | |||
if maxG == 0 then return 0, total end | |||
math.randomseed(salt) | |||
local g = maxG | |||
if maxG >= 1 and math.random() < 0.40 then g = maxG - 1 end | |||
local p = total - 3*g | |||
return g, p | |||
end | |||
local h_goals, h_poss = splitScore(hg, seed + 101) | |||
local a_goals, a_poss = splitScore(ag, seed + 102) | |||
local function rrange(s, lo, hi) math.randomseed(s); return math.random(lo, hi) end | |||
local h_sh_on = (h_goals > 0) and rrange(seed+201, h_goals, h_goals+2) or rrange(seed+201, 0, 2) | |||
local a_sh_on = (a_goals > 0) and rrange(seed+202, a_goals, a_goals+2) or rrange(seed+202, 0, 2) | |||
local h_sh_off = rrange(seed+203, 0, 3) | |||
local a_sh_off = rrange(seed+204, 0, 3) | |||
local h_fouls = rrange(seed+205, 0, 3) | |||
local a_fouls = rrange(seed+206, 0, 3) | |||
local h_fks = a_fouls | |||
local a_fks = h_fouls | |||
local h_throws = rrange(seed+207, 0, 3) | |||
local a_throws = rrange(seed+208, 0, 3) | |||
local h_oob = rrange(seed+209, 0, 2) | |||
local a_oob = rrange(seed+210, 0, 2) | |||
local h_gk = rrange(seed+211, 0, 2) | |||
local a_gk = rrange(seed+212, 0, 2) | |||
local | local base = 50 | ||
local swing = rrange(seed+213, 3, 6) | |||
local h_poss_pct, a_poss_pct | |||
if hg > ag then | |||
h_poss_pct, a_poss_pct = base + swing, base - swing | |||
elseif hg < ag then | |||
h_poss_pct, a_poss_pct = base - swing, base + swing | |||
else | |||
local tilt = rrange(seed+214, -3, 3) | |||
h_poss_pct, a_poss_pct = base + tilt, base - tilt | |||
end | |||
return { | |||
[h.code] = { | |||
final = hg, goals = h_goals, poss_points = h_poss, | |||
shots_on = h_sh_on, shots_off = h_sh_off, | |||
fouls = h_fouls, fks = h_fks, throws = h_throws, | |||
gk = h_gk, oob = h_oob, poss_pct = h_poss_pct | |||
}, | |||
[a.code] = { | |||
final = ag, goals = a_goals, poss_points = a_poss, | |||
shots_on = a_sh_on, shots_off = a_sh_off, | |||
fouls = a_fouls, fks = a_fks, throws = a_throws, | |||
gk = a_gk, oob = a_oob, poss_pct = a_poss_pct | |||
} | |||
} | |||
end | end | ||
function p.renderMatchStatsForDay(frame) | |||
local curr = date.getCurrentDate() | |||
local | local dayNow = tonumber(curr:match('^(%d+),')) or 0 | ||
local year = tonumber(curr:match(', (%d+) PSSC')) or 0 | |||
local dm = mapDays() | |||
local target = dm[dayNow] and dayNow or nil | |||
if not target then | |||
for d = dayNow + 1, END do | |||
if dm[d] then target = d; break end | |||
end | |||
end | end | ||
return | if not target then return "'''No scheduled matches in window.'''" end | ||
end | |||
local idx = dm[target] | |||
local | local isToday = (target == dayNow) | ||
local | |||
local | local mon, dom = getMonthDay(target) | ||
local label = dom .. " " .. mon .. " " .. year .. " PSSC" | |||
local function hiPairNum(hVal, aVal, suffix) | |||
local | hVal = tonumber(hVal) or 0 | ||
aVal = tonumber(aVal) or 0 | |||
local | suffix = suffix or "" | ||
local function cell(v, hi) | |||
local txt = tostring(v) .. suffix | |||
return hi and ('<span style="background-color:#ccffcc;">'..txt..'</span>') or txt | |||
end | |||
return cell(hVal, hVal>aVal), cell(aVal, aVal>hVal) | |||
end | end | ||
local out = { | |||
'{| class="wikitable sortable"', | |||
'|+ Match Statistics — ' .. label .. (isToday and '' or ' (fixtures; stats available day-of)'), | |||
'! Match !! Team !! Final !! Goals (Pillar) !! Possession Pts !! Shots on !! Shots off !! Fouls !! Free Kicks !! Throw-Ins !! Goal Kicks !! OOB !! Poss %' | |||
} | |||
local matches = getRoundMatches(idx) | |||
local | |||
if isToday then | |||
local results = simulateDayResults(target) | |||
for k, r in ipairs(results) do | |||
local seed = target*1000 + k + string.byte(r.home.code,1) + string.byte(r.away.code,1) | |||
local S = computeMatchStats(r.home, r.away, r.hg, r.ag, seed) | |||
local H, A = S[r.home.code], S[r.away.code] | |||
local matchLabel = r.home.logo .. ' ' .. r.home.fullName .. ' vs ' .. r.away.logo .. ' ' .. r.away.fullName | |||
local hF, aF = hiPairNum(H.final, A.final) | |||
local hG, aG = hiPairNum(H.goals, A.goals) | |||
local | local hP, aP = hiPairNum(H.poss_points, A.poss_points) | ||
local hSON, aSON = hiPairNum(H.shots_on, A.shots_on) | |||
local hSOF, aSOF = hiPairNum(H.shots_off, A.shots_off) | |||
local hFO, aFO = hiPairNum(H.fouls, A.fouls) | |||
local hFK, aFK = hiPairNum(H.fks, A.fks) | |||
local hTH, aTH = hiPairNum(H.throws, A.throws) | |||
local hGK, aGK = hiPairNum(H.gk, A.gk) | |||
local hOOB, aOOB = hiPairNum(H.oob, A.oob) | |||
local hPOS, aPOS = hiPairNum(H.poss_pct, A.poss_pct, '%') | |||
out[#out+1] = '|-' | |||
out[#out+1] = | |||
'| rowspan="2" | ' .. matchLabel .. | |||
' || ' .. r.home.logo .. ' ' .. r.home.fullName .. | |||
' || ' .. hF .. | |||
' || ' .. hG .. | |||
' || ' .. hP .. | |||
' || ' .. hSON .. | |||
' || ' .. hSOF .. | |||
' || ' .. hFO .. | |||
' || ' .. hFK .. | |||
' || ' .. hTH .. | |||
' || ' .. hGK .. | |||
' || ' .. hOOB .. | |||
' || ' .. hPOS | |||
out[#out+1] = '|-' | |||
out[#out+1] = | |||
for _, | '| ' .. r.away.logo .. ' ' .. r.away.fullName .. | ||
' || ' .. aF .. | |||
end | ' || ' .. aG .. | ||
return | ' || ' .. aP .. | ||
' || ' .. aSON .. | |||
' || ' .. aSOF .. | |||
' || ' .. aFO .. | |||
' || ' .. aFK .. | |||
' || ' .. aTH .. | |||
' || ' .. aGK .. | |||
' || ' .. aOOB .. | |||
' || ' .. aPOS | |||
end | |||
else | |||
for _, m in ipairs(matches) do | |||
local matchLabel = m[1].logo .. ' ' .. m[1].fullName .. ' vs ' .. m[2].logo .. ' ' .. m[2].fullName | |||
out[#out+1] = '|-' | |||
out[#out+1] = | |||
'| rowspan="2" | ' .. matchLabel .. | |||
' || ' .. m[1].logo .. ' ' .. m[1].fullName .. | |||
' || — || — || — || — || — || — || — || — || — || — || —' | |||
out[#out+1] = '|-' | |||
out[#out+1] = | |||
'| ' .. m[2].logo .. ' ' .. m[2].fullName .. | |||
' || — || — || — || — || — || — || — || — || — || — || —' | |||
end | |||
end | |||
out[#out+1] = '|}' | |||
return table.concat(out, '\n') | |||
end | end | ||
function p.renderMatchStatsSingle(frame) | |||
local args = frame.args or {} | |||
local day = tonumber(args.day or '') or 0 | |||
local | local home = args.home | ||
local | local away = args.away | ||
if day == 0 or not home or not away then | |||
return | return "'''Pass day, home, and away:''' day=###, home=Team, away=Team" | ||
end | end | ||
local curr = date.getCurrentDate() | |||
local dayNow = tonumber(curr:match('^(%d+),')) or 0 | |||
local year = tonumber(curr:match(', (%d+) PSSC')) or 0 | |||
local dm = mapDays() | |||
local idx = dm[day] | |||
if not idx then return "'''No round mapped for that day.'''" end | |||
local | local matches = getRoundMatches(idx) | ||
local | local matchIdx | ||
for | for k, m in ipairs(matches) do | ||
if | if m[1].code == home and m[2].code == away then matchIdx = k; break end | ||
end | end | ||
if | if not matchIdx then return "'''Match not found on that day with that home/away.'''" end | ||
end | |||
local | local m = matches[matchIdx] | ||
local | local mon, dom = getMonthDay(day) | ||
local dateLabel= dom .. " " .. mon .. " " .. year .. " PSSC" | |||
local | local out = { | ||
'{| class="wikitable"', | |||
'|+ Match Statistics — ' .. dateLabel .. ((day == dayNow) and '' or ' (fixture; stats available day-of)'), | |||
'! Statistic !! ' .. _teamWithLogo(m[1]) .. ' !! ' .. _teamWithLogo(m[2]) | |||
} | |||
if day == dayNow then | |||
local | local resList = simulateDayResults(day) | ||
local | local r = resList[matchIdx] | ||
local seed = day*1000 + matchIdx + string.byte(home,1) + string.byte(away,1) | |||
local S = computeMatchStats(m[1], m[2], r.hg, r.ag, seed) | |||
local H, A = S[home], S[away] | |||
local function hi(hv, av, suffix) | |||
hv = tonumber(hv) or 0 | |||
av = tonumber(av) or 0 | |||
suffix = suffix or "" | |||
local h = tostring(hv) .. suffix | |||
local a = tostring(av) .. suffix | |||
if hv > av then h = '<span style="background-color:#ccffcc;">'..h..'</span>' | |||
elseif av > hv then a = '<span style="background-color:#ccffcc;">'..a..'</span>' end | |||
return h, a | |||
end | |||
local | local hF,aF = hi(H.final, A.final) | ||
local | local hG,aG = hi(H.goals, A.goals) | ||
local hP,aP = hi(H.poss_points, A.poss_points) | |||
local hSON,aSON = hi(H.shots_on, A.shots_on) | |||
local hSOF,aSOF = hi(H.shots_off, A.shots_off) | |||
local hFO,aFO = hi(H.fouls, A.fouls) | |||
local hFK,aFK = hi(H.fks, A.fks) | |||
local hTH,aTH = hi(H.throws, A.throws) | |||
local hGK,aGK = hi(H.gk, A.gk) | |||
local hOOB,aOOB = hi(H.oob, A.oob) | |||
local hPOS,aPOS = hi(H.poss_pct, A.poss_pct, '%') | |||
out[#out+1] = '|-'; out[#out+1] = '| Final Score || ' .. hF .. ' || ' .. aF | |||
out[#out+1] = '|-'; out[#out+1] = '| Goals (Pillar Strikes) || ' .. hG .. ' || ' .. aG | |||
out[#out+1] = '|-'; out[#out+1] = '| Possession Points || ' .. hP .. ' || ' .. aP | |||
out[#out+1] = '|-'; out[#out+1] = '| Shots on Target || ' .. hSON .. ' || ' .. aSON | |||
out[#out+1] = '|-'; out[#out+1] = '| Shots off Target || ' .. hSOF .. ' || ' .. aSOF | |||
out[#out+1] = '|-'; out[#out+1] = '| Fouls || ' .. hFO .. ' || ' .. aFO | |||
out[#out+1] = '|-'; out[#out+1] = '| Free Kicks Awarded || ' .. hFK .. ' || ' .. aFK | |||
out[#out+1] = '|-'; out[#out+1] = '| Throw-Ins || ' .. hTH .. ' || ' .. aTH | |||
out[#out+1] = '|-'; out[#out+1] = '| Goal Kicks || ' .. hGK .. ' || ' .. aGK | |||
out[#out+1] = '|-'; out[#out+1] = '| Out of Bounds || ' .. hOOB .. ' || ' .. aOOB | |||
out[#out+1] = '|-'; out[#out+1] = '| Ball Possession || ' .. hPOS .. ' || ' .. aPOS | |||
else | |||
local function blank(row) out[#out+1] = '|-'; out[#out+1] = row end | |||
blank('| Final Score || — || —') | |||
blank('| Goals (Pillar Strikes) || — || —') | |||
blank('| Possession Points || — || —') | |||
blank('| Shots on Target || — || —') | |||
blank('| Shots off Target || — || —') | |||
blank('| Fouls || — || —') | |||
blank('| Free Kicks Awarded || — || —') | |||
blank('| Throw-Ins || — || —') | |||
blank('| Goal Kicks || — || —') | |||
blank('| Out of Bounds || — || —') | |||
blank('| Ball Possession || — || —') | |||
end | end | ||
return | out[#out+1] = '|}' | ||
return table.concat(out, '\n') | |||
end | end | ||
------------------------------------------------------------------------ | |||
-- 10) ROSTERS + TRANSFERS + PLAYER STATS (embedded) | |||
------------------------------------------------------------------------ | |||
local DEFAULT_SEASON = 52 | |||
local ROSTER_SIZE = 12 | |||
local TRANSFER_MD_START = 20 | |||
local TRANSFER_MD_END = 30 | |||
local POSITIONS = { | |||
"Striker","Striker", | |||
"Playmaker","Playmaker","Playmaker", | |||
"Defender","Defender","Defender", | |||
"Keeper", | |||
"Utility","Utility","Utility" | |||
} | |||
return | -- deterministic PRNG (does NOT touch math.randomseed) | ||
local function _seedFromString(s) | |||
local h = 0 | |||
for i=1,#s do h = (h*31 + string.byte(s,i)) % 2147483647 end | |||
return (h == 0) and 1 or h | |||
end | end | ||
local function _rngNext(st) | |||
st = (1103515245 * st + 12345) % 2147483647 | |||
return st, st / 2147483647 | |||
local function | |||
return | |||
end | end | ||
local function | local function _pick(st, arr) | ||
if not arr or #arr == 0 then return st, nil end | |||
local r; st, r = _rngNext(st) | |||
local | local idx = math.floor(r * #arr) + 1 | ||
local | return st, arr[idx] | ||
end | end | ||
local function _normKey(s) | |||
if not s or s == "" then return nil end | |||
s = mw.ustring.lower(s) | |||
local function | s = mw.ustring.gsub(s, "%s+", "_") | ||
s = mw.ustring.gsub(s, "[^%w_]", "") | |||
return s | |||
return | |||
end | end | ||
local | -- Wide-variation name generator | ||
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",""}, | |||
local | lastA = {"Yıldır","Dem","Kay","Arsl","Çel","Ayd","Gün","Korkm","Sarı","Taş","Karad","Özt","Ak","Boz","Şah"}, | ||
lastB = {"im","ir","a","i","u","ü","o","ö",""}, | |||
lastC = {"maz","er","kan","soy","taş","kaya","han","gül","lı","ci","oğlu",""} | |||
}, | |||
normark = { | |||
firstA = {"Ei","Ha","Le","Bj","Si","As","In","To","Ka","Sk","Ry","Ul","Va","Sa","Jo","Th"}, | |||
firstB = {"rik","kon","if","orn","grid","trid","ga","rsten","ri","ald","var","lva","ren","mund","nar","or"}, | |||
firstC = {"","r","d","n","k","s",""}, | |||
lastA = {"Fjell","Ravn","Skog","Sund","Vinter","Knut","Thors","Haldor","Eirik","Bjorn","Isen","Storm","Sol","Hav","Ulv"}, | |||
lastB = {"sen","son","sson","vik","lund","by","heim","holt","dal","gard","berg","mark"}, | |||
lastC = {"","","",""} | |||
}, | |||
imperial = { | |||
firstA = {"Mar","Jul","Cas","Oct","Luc","Tib","Sab","Aurel","Flav","Dec","Val","Cor","Dom","Sev","Faust","Cris"}, | |||
firstB = {"cus","ia","sian","avia","ian","er","ina","ius","ia","imus","eri","vin","itian","eran","inus","pus"}, | |||
firstC = {"","us","a","","us","a","us","a","us","a"}, | |||
lastA = {"Valer","Marcell","Aquil","Sever","Corvin","Drus","Faustin","Liv","Domit","Crisp","Aurel","Cass","Octav","Lucan","Tiber","Flav"}, | |||
lastB = {"ius","us","a","an","inus","ian","ensis","orum","atus","aris"}, | |||
lastC = {"","","",""} | |||
} | |||
} | |||
local NAME_STYLE_KEYS = {"bassarid","haifan","normark","imperial"} | |||
local function _cap(s) | |||
if not s or s == "" then return s end | |||
return mw.ustring.upper(mw.ustring.sub(s,1,1)) .. mw.ustring.sub(s,2) | |||
end | |||
local function _maybeDiacritics(st, s) | |||
local r; st, r = _rngNext(st) | |||
if r < 0.08 then | |||
s = mw.ustring.gsub(s, "a", "ä", 1) | |||
elseif r < 0.16 then | |||
s = mw.ustring.gsub(s, "i", "ï", 1) | |||
elseif r < 0.24 then | |||
s = mw.ustring.gsub(s, "o", "ö", 1) | |||
elseif r < 0.32 then | |||
s = mw.ustring.gsub(s, "u", "ü", 1) | |||
end | |||
return st, s | |||
end | |||
local function _buildNameFrom(style, st, isLast) | |||
local | local t = NAME_STYLES[style] or NAME_STYLES.bassarid | ||
local | 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 | end | ||
local name = _cap((a or "") .. (b or "") .. (c or "")) | |||
local r; st, r = _rngNext(st) | |||
local | if r < 0.05 and #name >= 6 then | ||
local cut = math.floor(#name/2) | |||
name = mw.ustring.sub(name,1,cut) .. "-" .. mw.ustring.sub(name,cut+1) | |||
elseif r < 0.08 and #name >= 6 then | |||
local cut = math.floor(#name/2) | |||
name = mw.ustring.sub(name,1,cut) .. "’" .. mw.ustring.sub(name,cut+1) | |||
end | end | ||
st, name = _maybeDiacritics(st, name) | |||
return st, name | |||
end | |||
return | 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 key = _normKey(teamCode) or teamCode | |||
local st = _seedFromString(tostring(season) .. "|" .. key .. "|ROSTER") | |||
local | local style = _chooseStyleForTeam(teamCode, season) | ||
local | |||
local | |||
local | local roster, used = {}, {} | ||
local | 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 | 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 | |||
for | local function _buildBaseRosters(season) | ||
local rosters = {} | |||
for _,t in ipairs(teams) do rosters[t.code] = _genBaseRoster(t.code, season) end | |||
return rosters | |||
end | |||
local function _indexById(roster) | |||
local m = {} | |||
for i,p in ipairs(roster) do m[p.id] = i end | |||
return m | |||
end | |||
local function _findSwapCandidate(st, roster, wantPos) | |||
local candidates = {} | |||
for _,p in ipairs(roster) do if p.pos == wantPos then candidates[#candidates+1] = p end end | |||
if #candidates == 0 then candidates = roster end | |||
return _pick(st, candidates) | |||
end | |||
local function _genTransferDeals(season) | |||
local st = _seedFromString(tostring(season) .. "|TRANSFERWINDOW|MD" .. TRANSFER_MD_START .. "-" .. TRANSFER_MD_END) | |||
local deals, teamCodes = {}, {} | |||
for _,t in ipairs(teams) do teamCodes[#teamCodes+1] = t.code end | |||
for md = TRANSFER_MD_START, TRANSFER_MD_END do | |||
local r; st, r = _rngNext(st) | |||
local dealCount = 1 + math.floor(r * 3) | |||
for _=1, dealCount do | |||
local A,B | |||
st, A = _pick(st, teamCodes) | |||
repeat st, B = _pick(st, teamCodes) until B ~= A | |||
local baseA = _genBaseRoster(A, season) | |||
local baseB = _genBaseRoster(B, season) | |||
local pA; st, pA = _pick(st, baseA) | |||
if pA and pA.pos == "Keeper" then | |||
local rr; st, rr = _rngNext(st) | |||
if rr < 0.75 then st, pA = _pick(st, baseA) end | |||
end | |||
local pB; st, pB = _findSwapCandidate(st, baseB, (pA and pA.pos) or "Utility") | |||
if A and B and pA and pB then | |||
deals[#deals+1] = { md=md, A=A, B=B, A_id=pA.id, B_id=pB.id, pos=pA.pos } | |||
end | |||
end | |||
end | |||
return deals | |||
end | |||
local | local function _applyTransfersUpTo(baseRosters, deals, upToMd) | ||
local rosters = {} | |||
for code, r in pairs(baseRosters) do | |||
rosters[code] = {} | |||
for i,p in ipairs(r) do rosters[code][i] = { id=p.id, name=p.name, pos=p.pos, no=p.no, notes=p.notes } end | |||
end | |||
for _,d in ipairs(deals) do | |||
) | if d.md <= upToMd then | ||
local RA, RB = rosters[d.A], rosters[d.B] | |||
if RA and RB then | |||
local ia = _indexById(RA)[d.A_id] | |||
local ib = _indexById(RB)[d.B_id] | |||
if ia and ib then | |||
local tmp = RA[ia] | |||
RA[ia] = RB[ib] | |||
RB[ib] = tmp | |||
end | |||
end | |||
end | |||
end | end | ||
return | return rosters | ||
end | end | ||
local function _alloc(st, roster, posWeights) | |||
local pool = {} | |||
for _,p in ipairs(roster) do | |||
local w = posWeights[p.pos] or 1 | |||
function | for _=1,w do pool[#pool+1] = p end | ||
local | |||
for _, | |||
local | |||
end | end | ||
return | return _pick(st, pool) | ||
end | 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 | |||
function | |||
local | local function _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster) | ||
local salt = day*1000 + matchIndex + string.byte(homeTeam.code,1) + string.byte(awayTeam.code,1) | |||
local hg, ag = simScoreFor(homeTeam, awayTeam, salt) | |||
local S = computeMatchStats(homeTeam, awayTeam, hg, ag, salt) | |||
local H = S[homeTeam.code] or {} | |||
local A = S[awayTeam.code] or {} | |||
local stats = { home = H, away = A } | |||
local | local st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code) | ||
local events = { home={}, away={} } | |||
for _,p in ipairs(homeRoster) do _ensure(events.home, p.id).apps = 1 end | |||
for _,p in ipairs(awayRoster) do _ensure(events.away, p.id).apps = 1 end | |||
end | |||
local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 } | |||
local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 } | |||
local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 } | |||
for _=1, (stats.home.goals or 0) do | |||
local pz; st, pz = _alloc(st, homeRoster, W_GOAL) | |||
_ensure(events.home, pz.id).goals = _ensure(events.home, pz.id).goals + 1 | |||
local r; st, r = _rngNext(st) | |||
if r < 0.70 then | |||
local a; st, a = _alloc(st, homeRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0}) | |||
if a and a.id ~= pz.id then _ensure(events.home, a.id).assists = _ensure(events.home, a.id).assists + 1 end | |||
end | |||
end | end | ||
local | for _=1, (stats.away.goals or 0) do | ||
local pz; st, pz = _alloc(st, awayRoster, W_GOAL) | |||
_ensure(events.away, pz.id).goals = _ensure(events.away, pz.id).goals + 1 | |||
local r; st, r = _rngNext(st) | |||
if r < 0.70 then | |||
local a; st, a = _alloc(st, awayRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0}) | |||
if a and a.id ~= pz.id then _ensure(events.away, a.id).assists = _ensure(events.away, a.id).assists + 1 end | |||
end | |||
end | |||
local | for _=1, (stats.home.poss_points or 0) do | ||
local | local pz; st, pz = _alloc(st, homeRoster, W_POSS) | ||
_ensure(events.home, pz.id).poss = _ensure(events.home, pz.id).poss + 1 | |||
end | |||
for _=1, (stats.away.poss_points or 0) do | |||
local pz; st, pz = _alloc(st, awayRoster, W_POSS) | |||
_ensure(events.away, pz.id).poss = _ensure(events.away, pz.id).poss + 1 | |||
end | |||
local | local function distributeShots(side, roster, on, off) | ||
for _=1, (on or 0) do | |||
local pz; st, pz = _alloc(st, roster, {Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0}) | |||
_ensure(side, pz.id).son = _ensure(side, pz.id).son + 1 | |||
end | |||
for _=1, (off or 0) do | |||
local pz; st, pz = _alloc(st, roster, {Striker=4, Playmaker=3, Utility=2, Defender=1, Keeper=0}) | |||
_ensure(side, pz.id).sof = _ensure(side, pz.id).sof + 1 | |||
end | |||
end | |||
distributeShots(events.home, homeRoster, stats.home.shots_on or 0, stats.home.shots_off or 0) | |||
distributeShots(events.away, awayRoster, stats.away.shots_on or 0, stats.away.shots_off or 0) | |||
for _, | for _=1, (stats.home.fouls or 0) do | ||
local | local pz; st, pz = _alloc(st, homeRoster, W_FOUL) | ||
_ensure(events.home, pz.id).fouls = _ensure(events.home, pz.id).fouls + 1 | |||
end | |||
for _=1, (stats.away.fouls or 0) do | |||
local pz; st, pz = _alloc(st, awayRoster, W_FOUL) | |||
_ensure(events.away, pz.id).fouls = _ensure(events.away, pz.id).fouls + 1 | |||
end | end | ||
return events | |||
end | |||
local function _computePlayerSeasonTotals(season, upToMd) | |||
local dm = mapDays() | |||
local inv = invertDayMap(dm) | |||
local deals = _genTransferDeals(season) | |||
local base = _buildBaseRosters(season) | |||
local totals = {} | |||
local | |||
local | |||
local | local function ensurePlayer(pid, name, pos) | ||
if not totals[pid] then | |||
totals[pid] = { id=pid, name=name or pid, pos=pos or "—", team="—", | |||
return | apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0 } | ||
end | |||
return totals[pid] | |||
end | end | ||
for md=1, math.min(upToMd, TOTAL_ROUNDS) do | |||
local day = inv[md] | |||
if day then | |||
local rosters = _applyTransfersUpTo(base, deals, md) | |||
local fixtures = getRoundMatches(md) | |||
for matchIndex, m in ipairs(fixtures) do | |||
local h, a = m[1], m[2] | |||
local hr, ar = rosters[h.code] or {}, rosters[a.code] or {} | |||
local ev = _matchPlayerEvents(season, md, day, matchIndex, h, a, hr, ar) | |||
for pid, stt in pairs(ev.home) do | |||
local name, pos = pid, "—" | |||
for _,pp in ipairs(hr) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end | |||
local row = ensurePlayer(pid, name, pos) | |||
row.team = h.code | |||
row.apps = row.apps + (stt.apps or 0) | |||
row.goals = row.goals + (stt.goals or 0) | |||
row.assists = row.assists + (stt.assists or 0) | |||
row.poss = row.poss + (stt.poss or 0) | |||
row.son = row.son + (stt.son or 0) | |||
row.sof = row.sof + (stt.sof or 0) | |||
row.fouls = row.fouls + (stt.fouls or 0) | |||
end | |||
for pid, stt in pairs(ev.away) do | |||
local name, pos = pid, "—" | |||
for _,pp in ipairs(ar) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end | |||
local row = ensurePlayer(pid, name, pos) | |||
row.team = a.code | |||
row.apps = row.apps + (stt.apps or 0) | |||
row.goals = row.goals + (stt.goals or 0) | |||
row.assists = row.assists + (stt.assists or 0) | |||
row.poss = row.poss + (stt.poss or 0) | |||
row.son = row.son + (stt.son or 0) | |||
row.sof = row.sof + (stt.sof or 0) | |||
row.fouls = row.fouls + (stt.fouls or 0) | |||
end | |||
end | |||
end | |||
end | end | ||
return totals, deals, base | |||
return | |||
end | end | ||
function p.renderRoster(frame) | |||
local args = frame.args or {} | |||
local team = args.team or args[1] | |||
if not team or not TEAM_BY_CODE[team] then | |||
local | return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''" | ||
local | |||
end | end | ||
local | local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON | ||
local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday() | |||
local | local deals = _genTransferDeals(season) | ||
local base = _buildBaseRosters(season) | |||
local rosters = _applyTransfersUpTo(base, deals, upToMd) | |||
local out = {} | local r = rosters[team] or {} | ||
for | |||
local out = { | |||
'{| class="wikitable sortable" style="width:100%; font-size:90%;"', | |||
end | '! # !! Player !! Position !! ID !! Notes' | ||
} | |||
for _,pl in ipairs(r) do | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> || %s', | |||
pl.no or 0, pl.name or '—', pl.pos or '—', pl.id or '—', pl.notes or '—' | |||
) | |||
end | |||
out[#out+1] = '|}' | |||
return (TEAM_BY_CODE[team].logo or "") .. | |||
" '''" .. team .. "''' (Roster as of Matchday " .. tostring(upToMd) .. ")\n" .. | |||
table.concat(out, "\n") | |||
end | end | ||
function p.renderAllRosters(frame) | |||
local | local args = frame.args or {} | ||
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON | |||
local | local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday() | ||
for | |||
local out = {} | |||
for _,t in ipairs(teams) do | |||
local title = (t.logo or "") .. " <b>" .. t.code .. "</b>" | |||
out[#out+1] = | |||
'<div class="mw-collapsible mw-collapsed" data-expandtext="Show roster" data-collapsetext="Hide roster" style="margin:0.6em 0;">' .. | |||
'<div>' .. title .. '</div>' .. | |||
'<div class="mw-collapsible-content">' .. | |||
p.renderRoster({ args = { team = t.code, season = season, md = upToMd } }) .. | |||
'</div>' .. | |||
'</div>' | |||
end | end | ||
table. | return table.concat(out, "\n") | ||
end | end | ||
function p.renderTransferLog(frame) | |||
local | local args = frame.args or {} | ||
for _, | local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON | ||
if | local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday() | ||
local deals = _genTransferDeals(season) | |||
local out = { | |||
'{| class="wikitable sortable" style="width:100%; font-size:90%;"', | |||
'! Matchday !! From !! To !! Swap (pos)' | |||
} | |||
for _,d in ipairs(deals) do | |||
if d.md <= upToMd then | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> ⇄ <code>%s</code> (%s)', | |||
d.md, _teamWithLogo(d.A), _teamWithLogo(d.B), d.A_id, d.B_id, d.pos or '—' | |||
) | |||
end | end | ||
end | end | ||
out[#out+1] = '|}' | |||
return "'''Transfer window (Matchdays " .. TRANSFER_MD_START .. "–" .. TRANSFER_MD_END .. | |||
") — season " .. season .. " (through Matchday " .. tostring(upToMd) .. ")'''\n" .. | |||
table.concat(out, "\n") | |||
end | end | ||
function p.renderPlayerStats(frame) | |||
local args = frame.args or {} | |||
local | local team = args.team or args[1] | ||
if | if not team or not TEAM_BY_CODE[team] then | ||
return " ( | return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''" | ||
end | end | ||
local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON | |||
local | local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday() | ||
local | local totals, deals, base = _computePlayerSeasonTotals(season, upToMd) | ||
local rostersNow = _applyTransfersUpTo(base, deals, upToMd) | |||
local | local rosterNow = rostersNow[team] or {} | ||
local | local out = { | ||
'{| class="wikitable sortable" style="width:100%; font-size:90%;"', | |||
'! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls' | |||
} | |||
for _,pl in ipairs(rosterNow) do | |||
for | local r = totals[pl.id] | ||
local | out[#out+1] = '|-' | ||
out[#out+1] = string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d', | |||
pl.name, pl.pos, | |||
r and r.apps or 0, | |||
r and r.goals or 0, | |||
r and r.assists or 0, | |||
r and r.poss or 0, | |||
r and r.son or 0, | |||
r and r.sof or 0, | |||
r and r.fouls or 0 | |||
) | |||
end | |||
out[#out+1] = '|}' | |||
return (TEAM_BY_CODE[team].logo or "") .. | |||
" '''" .. team .. "''' — Player totals through Matchday " .. tostring(upToMd) .. "\n" .. | |||
table.concat(out, "\n") | |||
end | |||
function p.renderLeaders(frame) | |||
local args = frame.args or {} | |||
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON | |||
local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday() | |||
local stat = (args.stat or args[2] or "goals") | |||
local ok = { goals=true, assists=true, poss=true, son=true, sof=true, fouls=true, apps=true } | |||
if not ok[stat] then | |||
return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''" | |||
end | end | ||
local totals = (_computePlayerSeasonTotals(season, upToMd)) | |||
end | 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 "") | |||
return | end) | ||
end | |||
local | local out = { | ||
'{| class="wikitable sortable" style="width:100%; font-size:90%;"', | |||
'! Rank !! Player !! Team (current) !! Pos !! ' .. stat | |||
} | |||
local | for i=1, math.min(20, #list) do | ||
local r = list[i] | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| %d || %s || %s || %s || %d', | |||
i, r.name or "—", _teamWithLogo(r.team or "—"), r.pos or "—", r[stat] or 0 | |||
) | |||
end | |||
out[#out+1] = '|}' | |||
return "'''League leaders (" .. stat .. ") — season " .. season .. | |||
" through Matchday " .. tostring(upToMd) .. "'''\n" .. | |||
table.concat(out, "\n") | |||
end | |||
------------------------------------------------------------------------ | |||
-- 11) MATCH REPORTS (Match of the Week + Match Report) + DERBY TAG | |||
local | ------------------------------------------------------------------------ | ||
local | local function _fmtTime(seconds) | ||
local m = math.floor(seconds / 60) | |||
local s = seconds % 60 | |||
return string.format("%02d:%02d", m, s) | |||
end | |||
local function _abbr(teamName) | |||
local | local letters = {} | ||
for w in mw.ustring.gmatch(teamName or "", "%S+") do | |||
letters[#letters+1] = mw.ustring.upper(mw.ustring.sub(w, 1, 1)) | |||
end | |||
local a = table.concat(letters, "") | |||
if #a >= 2 then return mw.ustring.sub(a, 1, 2) end | |||
return (a ~= "" and a) or "XX" | |||
end | |||
local | local function _randTime(st, startSec, endSec) | ||
local r; st, r = _rngNext(st) | |||
local span = math.max(1, endSec - startSec - 2) | |||
local sec = startSec + 1 + math.floor(r * span) | |||
return st, sec | |||
end | |||
local function _genTimes(st, n, firstStart, firstEnd, secondStart, secondEnd) | |||
local times = {} | |||
if n <= 0 then return st, times end | |||
local split = math.ceil(n / 2) | |||
for i=1, n do | |||
if i <= split then | |||
st, times[i] = _randTime(st, firstStart, firstEnd) | |||
else | |||
local | st, times[i] = _randTime(st, secondStart, secondEnd) | ||
end | |||
end | end | ||
table.sort(times) | |||
return st, times | |||
end | |||
local | local function _timesList(times, maxn) | ||
if not times or #times == 0 then return "" end | |||
maxn = maxn or 3 | |||
local out = {} | |||
for i = 1, math.min(maxn, #times) do out[#out+1] = _fmtTime(times[i]) end | |||
if #times > maxn then out[#out+1] = "…" end | |||
return " (" .. table.concat(out, ", ") .. ")" | |||
end | |||
local | local function _pickPlayer(st, roster, posA, posB, posC) | ||
local | local candidates = {} | ||
for _,p in ipairs(roster or {}) do | |||
if p.pos == posA or p.pos == posB or p.pos == posC then | |||
candidates[#candidates+1] = p | |||
end | |||
end | |||
if #candidates == 0 then candidates = roster end | |||
local pl; st, pl = _pick(st, candidates) | |||
return st, pl | |||
end | |||
local function _rivalryTag(homeCode, awayCode) | |||
local dh, da = DIV_OF[homeCode], DIV_OF[awayCode] | |||
if dh and da and dh == da then | |||
return " (Divisional derby — " .. dh .. ")" | |||
end | |||
return "" | |||
end | |||
local function _appendToBoldTitle(title, suffix) | |||
if not suffix or suffix == "" then return title end | |||
if not title then return suffix end | |||
if mw.ustring.match(title, "'''%s*$") then | |||
return mw.ustring.gsub(title, "'''%s*$", suffix .. "'''", 1) | |||
end | end | ||
return title .. suffix | |||
end | |||
local function _pickMatchIndexForDay(season, md, pickMode) | |||
pickMode = pickMode or "best" | |||
local fixtures = getRoundMatches(md) | |||
if not fixtures or #fixtures == 0 then return nil, "No fixtures found for this matchday." end | |||
local inv = invertDayMap(mapDays()) | |||
local day = inv[md] or (START + md) | |||
local bestI, bestScore = 1, -1e18 | |||
for i, m in ipairs(fixtures) do | |||
local | local home, away = m[1], m[2] | ||
for | local salt = day * 1000 + i + string.byte(home.code,1) + string.byte(away.code,1) | ||
local hs, as = simScoreFor(home, away, salt) | |||
local S = computeMatchStats(home, away, hs, as, salt) | |||
local H = S[home.code] or {} | |||
local A = S[away.code] or {} | |||
local diff = math.abs(hs - as) | |||
local total = hs + as | |||
local chaos = (tonumber(H.fouls) or 0) + (tonumber(A.fouls) or 0) | |||
+ (tonumber(H.shots_on) or 0) + (tonumber(A.shots_on) or 0) | |||
+ (tonumber(H.shots_off) or 0) + (tonumber(A.shots_off) or 0) | |||
local q | |||
if pickMode == "close" then | |||
q = (50 - diff * 10) + total + chaos * 0.25 | |||
elseif pickMode == "high" then | |||
q = total * 12 - diff * 2 + chaos * 0.20 | |||
elseif pickMode == "chaos" then | |||
q = chaos * 10 + total * 2 - diff | |||
else | |||
q = (20 - diff * 6) + total * 8 + chaos * 0.35 | |||
end | |||
if q > bestScore then bestScore, bestI = q, i end | |||
end | end | ||
return bestI | |||
end | |||
local function _renderMatchReport(season, md, matchIndex, titleOverride) | |||
if | local fixtures = getRoundMatches(md) | ||
if not fixtures or not fixtures[matchIndex] then | |||
return "'''No such match (md=" .. tostring(md) .. ", match=" .. tostring(matchIndex) .. ").'''" | |||
end | end | ||
local inv = invertDayMap(mapDays()) | |||
local day = inv[md] or (START + md) | |||
local | |||
local | local home, away = fixtures[matchIndex][1], fixtures[matchIndex][2] | ||
local | local derby = _rivalryTag(home.code, away.code) | ||
local salt = day * 1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1) | |||
local hs, as = simScoreFor(home, away, salt) | |||
local | local S = computeMatchStats(home, away, hs, as, salt) | ||
local H = S[home.code] or {} | |||
local A = S[away.code] or {} | |||
local deals = _genTransferDeals(season) | |||
local base = _buildBaseRosters(season) | |||
local | local rosters = _applyTransfersUpTo(base, deals, md) | ||
local | local homeRoster = rosters[home.code] or {} | ||
local | local awayRoster = rosters[away.code] or {} | ||
local | |||
local | local st = _seedFromString(tostring(season).."|REPORT|MD"..md.."|M"..matchIndex.."|"..home.code.."|"..away.code) | ||
local FIRST_START, FIRST_END = 0, 35*60 | |||
local SECOND_START, SECOND_END = 35*60, 70*60 | |||
local homeTag, awayTag = _abbr(home.code), _abbr(away.code) | |||
local | |||
local homePP, awayPP, homeG, awayG | |||
st, homePP = _genTimes(st, tonumber(H.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END) | |||
st, awayPP = _genTimes(st, tonumber(A.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END) | |||
st, homeG = _genTimes(st, tonumber(H.goals) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END) | |||
st, awayG = _genTimes(st, tonumber(A.goals) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END) | |||
local homeSON, awaySON, homeFOUL, awayFOUL, homeOOB, awayOOB | |||
st, homeSON = _genTimes(st, tonumber(H.shots_on) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END) | |||
st, awaySON = _genTimes(st, tonumber(A.shots_on) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END) | |||
st, homeFOUL = _genTimes(st, tonumber(H.fouls) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END) | |||
st, awayFOUL = _genTimes(st, tonumber(A.fouls) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END) | |||
st, homeOOB = _genTimes(st, tonumber(H.oob) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END) | |||
st, awayOOB = _genTimes(st, tonumber(A.oob) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END) | |||
local | local scoreEvents = {} | ||
local function queueScore(sec, which, kind) scoreEvents[#scoreEvents+1] = { t=sec, which=which, kind=kind } end | |||
for _,sec in ipairs(homePP) do queueScore(sec, "H", "PP") end | |||
for _,sec in ipairs(awayPP) do queueScore(sec, "A", "PP") end | |||
for _,sec in ipairs(homeG) do queueScore(sec, "H", "G") end | |||
for _,sec in ipairs(awayG) do queueScore(sec, "A", "G") end | |||
table.sort(scoreEvents, function(a,b) return a.t < b.t end) | |||
local | local events = {} | ||
local function addEvent(sec, text) events[#events+1] = { t=sec, text=text } end | |||
local hRun, aRun = 0, 0 | |||
local hHalf, aHalf = 0, 0 | |||
for _,e in ipairs(scoreEvents) do | |||
local add = (e.kind == "G") and 3 or 1 | |||
for _, | if e.which == "H" then hRun = hRun + add else aRun = aRun + add end | ||
if e.t < 35*60 then hHalf, aHalf = hRun, aRun end | |||
local tag = (e.which=="H") and homeTag or awayTag | |||
local roster = (e.which=="H") and homeRoster or awayRoster | |||
local scoreLine = string.format("%s %d–%d %s", _teamWithLogo(home), hRun, aRun, _teamWithLogo(away)) | |||
if e.kind == "G" then | |||
local pz; st, pz = _pickPlayer(st, roster, "Striker", "Utility", "Playmaker") | |||
addEvent(e.t, string.format("%s – Goal (Pillar Strike) (%s): %s converts a direct pillar strike. (%s)", | |||
_fmtTime(e.t), tag, (pz and pz.name or "a forward"), scoreLine)) | |||
else | |||
local pp; st, pp = _pickPlayer(st, roster, "Playmaker", "Utility", "Striker") | |||
addEvent(e.t, string.format("%s – Possession Play (%s): %s completes a 10-second controlled hold in the scoring zone. (%s)", | |||
_fmtTime(e.t), tag, (pp and pp.name or "a midfielder"), scoreLine)) | |||
end | |||
end | end | ||
local | local function addHighlights(times, roster, tag, label, posA, posB, posC, template) | ||
local cap = math.min(2, #times) | |||
for i=1, cap do | |||
local sec = times[i] | |||
local px; st, px = _pickPlayer(st, roster, posA, posB, posC) | |||
addEvent(sec, string.format("%s – %s (%s): " .. template, | |||
_fmtTime(sec), label, tag, (px and px.name or "a player"))) | |||
end | |||
end | end | ||
addHighlights(homeFOUL, homeRoster, homeTag, "Foul", "Defender", "Utility", "Playmaker", | |||
"%s clips a runner during a shielded carry; restart taken quickly.") | |||
addHighlights(awayFOUL, awayRoster, awayTag, "Foul", "Defender", "Utility", "Playmaker", | |||
"%s commits a tactical pull in transition; shape preserved on the restart.") | |||
addHighlights(homeSON, homeRoster, homeTag, "Shot on Target", "Striker", "Utility", "Playmaker", | |||
"%s forces a controlled save off a pillar-side angle; no strike awarded.") | |||
addHighlights(awaySON, awayRoster, awayTag, "Shot on Target", "Striker", "Utility", "Playmaker", | |||
"%s drives a low effort that glances the casing; play continues.") | |||
addHighlights(homeOOB, homeRoster, homeTag, "Out of Bounds", "Utility", "Defender", "Playmaker", | |||
"%s overhits a diagonal switch; possession turns over at the boundary.") | |||
addHighlights(awayOOB, awayRoster, awayTag, "Out of Bounds", "Utility", "Defender", "Playmaker", | |||
"%s sends a pressured clearance long; reset follows.") | |||
table.sort(events, function(a,b) return a.t < b.t end) | |||
local | local firstHalf, secondHalf = {}, {} | ||
if | for _,e in ipairs(events) do | ||
if e.t < 35*60 then firstHalf[#firstHalf+1] = e.text else secondHalf[#secondHalf+1] = e.text end | |||
end | end | ||
local | local diff = math.abs(hs - as) | ||
local mood = (diff <= 1) and "a tight, tactical contest" or "a high-variance clash" | |||
local summary = string.format( | |||
"Matchday %d featured %s defined by controlled zone entries and disciplined restarts. %s edged %s %d–%d, with decisive moments coming from timed possession holds and selective pillar pressure rather than sustained long-range attempts.", | |||
md, mood, _teamWithLogo(home), _teamWithLogo(away), hs, as | |||
) | |||
local | local baseTitle = titleOverride or string.format("'''Match of the Week — %d PSSC (Matchday %d)'''", season, md) | ||
local title = _appendToBoldTitle(baseTitle, derby) | |||
local out = {} | |||
out[#out+1] = title | |||
out[#out+1] = "" | |||
out[#out+1] = "=== Match Summary ===" | |||
out[#out+1] = summary | |||
out[#out+1] = "" | |||
out[#out+1] = "=== Key Events by Time ===" | |||
out[#out+1] = "" | |||
out[#out+1] = "==== First Half (0:00–35:00) ====" | |||
if #firstHalf == 0 then | |||
out[#out+1] = "—" | |||
else | |||
for _,ln in ipairs(firstHalf) do out[#out+1] = "* " .. ln end | |||
end | end | ||
out[#out+1] = "" | |||
out[#out+1] = string.format("35:00 – Halftime: %s %d–%d %s.", _teamWithLogo(home), hHalf, aHalf, _teamWithLogo(away)) | |||
out[#out+1] = "" | |||
out[#out+1] = "==== Second Half (35:00–70:00) ====" | |||
if #secondHalf == 0 then | |||
out[#out+1] = "—" | |||
else | |||
for _,ln in ipairs(secondHalf) do out[#out+1] = "* " .. ln end | |||
end | end | ||
out[#out+1] = "" | |||
out[#out+1] = string.format("70:00 – Final Whistle: %s %d–%d %s.", _teamWithLogo(home), hs, as, _teamWithLogo(away)) | |||
out[#out+1] = "" | |||
out[#out+1] = "=== Match Stats ===" | |||
out[#out+1] = '{| class="wikitable" style="width:100%; font-size:90%;"' | |||
out[#out+1] = string.format('! Statistic !! %s !! %s', _teamWithLogo(home), _teamWithLogo(away)) | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| Final Score || %d || %d', hs, as) | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| Goals (Pillar Strikes) || %d%s || %d%s', | |||
tonumber(H.goals) or 0, _timesList(homeG, 3), | |||
tonumber(A.goals) or 0, _timesList(awayG, 3) | |||
) | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| Possession Points || %d%s || %d%s', | |||
tonumber(H.poss_points) or 0, _timesList(homePP, 4), | |||
tonumber(A.poss_points) or 0, _timesList(awayPP, 4) | |||
) | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| Shots on Target || %d || %d', tonumber(H.shots_on) or 0, tonumber(A.shots_on) or 0) | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| Shots off Target || %d || %d', tonumber(H.shots_off) or 0, tonumber(A.shots_off) or 0) | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| Fouls || %d || %d', tonumber(H.fouls) or 0, tonumber(A.fouls) or 0) | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| Free Kicks Awarded || %d || %d', tonumber(H.fks) or 0, tonumber(A.fks) or 0) | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| Out of Bounds || %d || %d', tonumber(H.oob) or 0, tonumber(A.oob) or 0) | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| Throw-Ins || %d || %d', tonumber(H.throws) or 0, tonumber(A.throws) or 0) | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| Goal Kicks || %d || %d', tonumber(H.gk) or 0, tonumber(A.gk) or 0) | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| Ball Possession || %d%% || %d%%', tonumber(H.poss_pct) or 0, tonumber(A.poss_pct) or 0) | |||
out[#out+1] = '|}' | |||
return table.concat(out, "\n") | |||
end | |||
function p.renderMatchOfWeek(frame) | |||
local args = frame.args or {} | |||
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON | |||
local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday() | |||
local pick = args.pick or "best" | |||
if md < 1 then return "'''No matchday has been played yet.'''" | |||
end | |||
local fixtures = getRoundMatches(md) | |||
if not fixtures or #fixtures == 0 then return "'''No fixtures found for matchday " .. tostring(md) .. ".'''" end | |||
local idx, err = _pickMatchIndexForDay(season, md, pick) | |||
if not idx then return "'''" .. (err or "Unable to select match.") .. "'''" end | |||
return _renderMatchReport(season, md, idx) | |||
end | |||
function p.renderChaosOfWeek(frame) | |||
local args = frame.args or {} | |||
args.pick = args.pick or "chaos" | |||
args.md = nil | |||
return p.renderMatchOfWeek({ args = args }) | |||
end | |||
function p.renderMatchReport(frame) | |||
local args = frame.args or {} | |||
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON | |||
local md = tonumber(args.md or args.matchday or args[2] or "") or _currentPlayedMatchday() | |||
local matchIndex = tonumber(args.match or args[3] or "1") or 1 | |||
local titleOverride = string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''", season, md, matchIndex) | |||
return _renderMatchReport(season, md, matchIndex, titleOverride) | |||
end | |||
------------------------------------------------------------------------ | |||
-- 12) WEEKLY AWARDS + MATCHDAY CAPSULE | |||
------------------------------------------------------------------------ | |||
function p.renderWeeklyAwards(frame) | |||
local args = frame.args or {} | |||
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON | |||
local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday() | |||
if md < 1 then return "'''No matchday has been played yet.'''" end | |||
local inv = invertDayMap(mapDays()) | |||
local day = inv[md] or (START + md) | |||
local deals = _genTransferDeals(season) | |||
local base = _buildBaseRosters(season) | |||
local rosters = _applyTransfersUpTo(base, deals, md) | |||
local function clamp(x, lo, hi) | |||
if x < lo then return lo end | |||
if x > hi then return hi end | |||
return x | |||
end | |||
local function scorePlayer(line, pos, oppShotsOn, oppGoals) | |||
local goals = line.goals or 0 | |||
local assists = line.assists or 0 | |||
local poss = line.poss or 0 | |||
local son = line.son or 0 | |||
local sof = line.sof or 0 | |||
local fouls = line.fouls or 0 | |||
local rating = 6 | |||
+ goals*1.25 | |||
+ assists*0.85 | |||
+ poss*0.05 | |||
+ son*0.15 | |||
- sof*0.08 | |||
- fouls*0.16 | |||
if pos == "Keeper" then | |||
local saves = math.max(0, (oppShotsOn or 0) - (oppGoals or 0)) | |||
rating = 6 + saves*0.25 - fouls*0.10 | |||
if (oppGoals or 0) == 0 then rating = rating + 1.0 end | |||
end | end | ||
return clamp(rating, 0, 10) | |||
end | |||
local best = { overall=nil, striker=nil, playmaker=nil, defender=nil, keeper=nil } | |||
local function consider(entry, bucket) | |||
if not best[bucket] or entry.rating > best[bucket].rating then best[bucket] = entry end | |||
end | end | ||
local | local fixtures = getRoundMatches(md) | ||
for matchIndex, m in ipairs(fixtures) do | |||
local home, away = m[1], m[2] | |||
local hr = rosters[home.code] or {} | |||
local ar = rosters[away.code] or {} | |||
local salt = day*1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1) | |||
local hs, as = simScoreFor(home, away, salt) | |||
local S = computeMatchStats(home, away, hs, as, salt) | |||
local H = S[home.code] or {} | |||
local A = S[away.code] or {} | |||
local st = _seedFromString(tostring(season).."|AWARDS|MD"..md.."|D"..day.."|M"..matchIndex.."|"..home.code.."|"..away.code) | |||
local linesH, linesA = {}, {} | |||
local function ensure(map, pid) | |||
if not map[pid] then map[pid] = {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0} end | |||
return map[pid] | |||
end | end | ||
row("Player of the Week", best.overall) | local function pickWeighted(stt, roster, weights) | ||
row("Striker of the Week", best.striker) | local pool = {} | ||
row("Playmaker of the Week", best.playmaker) | for _,p in ipairs(roster or {}) do | ||
row("Defender of the Week", best.defender) | local w = weights[p.pos] or 1 | ||
row("Keeper of the Week", best.keeper) | for _=1,w do pool[#pool+1] = p end | ||
end | |||
return _pick(stt, pool) | |||
end | |||
local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 } | |||
local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 } | |||
local W_SHOT = { Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0 } | |||
local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 } | |||
for _=1, tonumber(H.goals) or 0 do | |||
local pl; st, pl = pickWeighted(st, hr, W_GOAL) | |||
ensure(linesH, pl.id).goals = ensure(linesH, pl.id).goals + 1 | |||
local r; st, r = _rngNext(st) | |||
if r < 0.70 then | |||
local a; st, a = pickWeighted(st, hr, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0}) | |||
if a and a.id ~= pl.id then ensure(linesH, a.id).assists = ensure(linesH, a.id).assists + 1 end | |||
end | |||
end | |||
for _=1, tonumber(A.goals) or 0 do | |||
local pl; st, pl = pickWeighted(st, ar, W_GOAL) | |||
ensure(linesA, pl.id).goals = ensure(linesA, pl.id).goals + 1 | |||
local r; st, r = _rngNext(st) | |||
if r < 0.70 then | |||
local a; st, a = pickWeighted(st, ar, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0}) | |||
if a and a.id ~= pl.id then ensure(linesA, a.id).assists = ensure(linesA, a.id).assists + 1 end | |||
end | |||
end | |||
for _=1, tonumber(H.poss_points) or 0 do | |||
local pl; st, pl = pickWeighted(st, hr, W_POSS) | |||
ensure(linesH, pl.id).poss = ensure(linesH, pl.id).poss + 1 | |||
end | |||
for _=1, tonumber(A.poss_points) or 0 do | |||
local pl; st, pl = pickWeighted(st, ar, W_POSS) | |||
ensure(linesA, pl.id).poss = ensure(linesA, pl.id).poss + 1 | |||
end | |||
for _=1, tonumber(H.shots_on) or 0 do | |||
local pl; st, pl = pickWeighted(st, hr, W_SHOT) | |||
ensure(linesH, pl.id).son = ensure(linesH, pl.id).son + 1 | |||
end | |||
for _=1, tonumber(H.shots_off) or 0 do | |||
local pl; st, pl = pickWeighted(st, hr, W_SHOT) | |||
ensure(linesH, pl.id).sof = ensure(linesH, pl.id).sof + 1 | |||
end | |||
for _=1, tonumber(A.shots_on) or 0 do | |||
local pl; st, pl = pickWeighted(st, ar, W_SHOT) | |||
ensure(linesA, pl.id).son = ensure(linesA, pl.id).son + 1 | |||
end | |||
for _=1, tonumber(A.shots_off) or 0 do | |||
local pl; st, pl = pickWeighted(st, ar, W_SHOT) | |||
ensure(linesA, pl.id).sof = ensure(linesA, pl.id).sof + 1 | |||
end | |||
for _=1, tonumber(H.fouls) or 0 do | |||
local pl; st, pl = pickWeighted(st, hr, W_FOUL) | |||
ensure(linesH, pl.id).fouls = ensure(linesH, pl.id).fouls + 1 | |||
end | |||
for _=1, tonumber(A.fouls) or 0 do | |||
local pl; st, pl = pickWeighted(st, ar, W_FOUL) | |||
ensure(linesA, pl.id).fouls = ensure(linesA, pl.id).fouls + 1 | |||
end | |||
local function evalTeam(teamObj, roster, lines, oppStats) | |||
local oppShotsOn = tonumber(oppStats.shots_on) or 0 | |||
local oppGoals = tonumber(oppStats.goals) or 0 | |||
for _,pp in ipairs(roster or {}) do | |||
local line = lines[pp.id] or {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0} | |||
local rating = scorePlayer(line, pp.pos, oppShotsOn, oppGoals) | |||
local saves, clean = 0, false | |||
if pp.pos == "Keeper" then | |||
saves = math.max(0, oppShotsOn - oppGoals) | |||
clean = (oppGoals == 0) | |||
end | |||
local entry = { | |||
rating=rating, name=pp.name or pp.id, pos=pp.pos or "—", | |||
team=teamObj.code, goals=line.goals or 0, assists=line.assists or 0, | |||
poss=line.poss or 0, son=line.son or 0, sof=line.sof or 0, fouls=line.fouls or 0, | |||
saves=saves, clean=clean | |||
} | |||
consider(entry, "overall") | |||
if pp.pos == "Striker" then consider(entry, "striker") end | |||
if pp.pos == "Playmaker" then consider(entry, "playmaker") end | |||
if pp.pos == "Defender" then consider(entry, "defender") end | |||
if pp.pos == "Keeper" then consider(entry, "keeper") end | |||
end | |||
end | |||
evalTeam(home, hr, linesH, A) | |||
evalTeam(away, ar, linesA, H) | |||
end | |||
local out = {} | |||
out[#out+1] = "=== Weekly Awards (Matchday " .. tostring(md) .. ") ===" | |||
out[#out+1] = '{| class="wikitable sortable" style="width:100%; font-size:90%;"' | |||
out[#out+1] = "! Award !! Winner !! Team !! Pos !! G !! A !! Poss !! S-on !! S-off !! Fouls !! Saves/CS !! Rating" | |||
local function row(label, e) | |||
if not e then return end | |||
local sc = "—" | |||
if e.pos == "Keeper" then sc = tostring(e.saves or 0) .. ((e.clean and " / CS") or "") end | |||
out[#out+1] = "|-" | |||
out[#out+1] = string.format("| %s || %s || %s || %s || %d || %d || %d || %d || %d || %d || %s || %.2f", | |||
label, e.name or "—", _teamWithLogo(e.team or "—"), e.pos or "—", | |||
e.goals or 0, e.assists or 0, e.poss or 0, e.son or 0, e.sof or 0, e.fouls or 0, sc, e.rating or 0 | |||
) | |||
end | |||
row("Player of the Week", best.overall) | |||
row("Striker of the Week", best.striker) | |||
row("Playmaker of the Week", best.playmaker) | |||
row("Defender of the Week", best.defender) | |||
row("Keeper of the Week", best.keeper) | |||
out[#out+1] = "|}" | out[#out+1] = "|}" | ||
return table.concat(out, "\n") | |||
end | |||
function p.renderMatchdayCapsule(frame) | |||
local args = frame.args or {} | |||
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON | |||
local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday() | |||
local pick = args.pick or "chaos" | |||
if md < 1 then return "'''No matchday has been played yet.'''" | |||
end | |||
local inv = invertDayMap(mapDays()) | |||
local day = inv[md] | |||
if not day then return "'''Unable to resolve PSSC day for matchday " .. tostring(md) .. ".'''" | |||
end | |||
local results = simulateDayResults(day) | |||
local out = {} | |||
out[#out+1] = "== Matchday " .. tostring(md) .. " Capsule ==" | |||
out[#out+1] = "" | |||
out[#out+1] = p.renderMatchOfWeek({ args = { season = season, md = md, pick = pick } }) | |||
out[#out+1] = "" | |||
out[#out+1] = "=== Full Results ===" | |||
for _, r in ipairs(results or {}) do | |||
out[#out+1] = string.format("* %s '''%d''' %s vs %s '''%d''' %s", | |||
(r.home and r.home.logo) or "", | |||
tonumber(r.hg) or 0, | |||
(r.home and (r.home.fullName or r.home.code)) or "Home", | |||
(r.away and r.away.logo) or "", | |||
tonumber(r.ag) or 0, | |||
(r.away and (r.away.fullName or r.away.code)) or "Away" | |||
) | |||
end | |||
out[#out+1] = "" | |||
out[#out+1] = p.renderWeeklyAwards({ args = { season = season, md = md } }) | |||
return table.concat(out, "\n") | return table.concat(out, "\n") | ||
end | end | ||
Revision as of 17:44, 15 December 2025
Documentation for this module may be created at Module:PillSeasonSchedule/doc
-- Module:PillSeasonSchedule
local p = {}
local date = require('Module:BassaridianCalendar')
------------------------------------------------------------------------
-- 0) CONFIG / CALENDAR
------------------------------------------------------------------------
local START, END = 62, 183 -- season window within the PSSC year
local DAYS_IN_YEAR = 183
local function getMonthDay(d)
if d <= 61 then
return "Atosiel", d
elseif d <= 122 then
return "Thalassiel", d - 61
else
return "Opsitheiel", d - 122
end
end
local function md2day(m,d) return (m-1)*61 + d end
local function absDay(y,d) return y*DAYS_IN_YEAR + d end
------------------------------------------------------------------------
-- 1) TEAMS
------------------------------------------------------------------------
local teams = {
{ code="Allegro Symphonara", fullName="Allegro Symphonara", logo="[[File:AllegrosymphonaraB.png|20px]]", championships=4 },
{ code="Ashguard Pyralis", fullName="Ashguard Pyralis", logo="[[File:AshguardpyralisB.png|20px]]", championships=1 },
{ code="Aurelia Auric", fullName="Aurelia Auric", logo="[[File:AureliaAuricB.png|20px]]", championships=2 },
{ code="Brightpath Aureum", fullName="Brightpath Aureum", logo="[[File:BrightpathaureumB.png|20px]]", championships=2 },
{ code="Celestial Sancta Lunaris", fullName="Celestial Sancta Lunaris", logo="[[File:CelestialsanctaB.png|20px]]", championships=1 },
{ code="Delphica Windborne", fullName="Delphica Windborne", logo="[[File:DelphicawindborneB.png|20px]]", championships=1 },
{ code="Emberstone Vaeringheim", fullName="Emberstone Vaeringheim", logo="[[File:EmberstonevaeringheimB.png|20px]]", championships=3 },
{ code="Flameborne Erythros", fullName="Flameborne Erythros", logo="[[File:FlameborneerythrosB.png|20px]]", championships=1 },
{ code="Forgeward Nexa", fullName="Forgeward Nexa", logo="[[File:ForgewardnexaB.png|20px]]", championships=0 },
{ code="Hearthkeeper Koinon", fullName="Hearthkeeper Koinon", logo="[[File:HearthkeeperkoinonB.png|20px]]", championships=0 },
{ code="Ironbough Sylvapolis", fullName="Ironbough Sylvapolis", logo="[[File:IronboughSylvapolisB.png|20px]]", championships=1 },
{ code="Redcliff Citadel", fullName="Redcliff Citadel", logo="[[File:RedcliffCitadel.png|20px]]", championships=1 },
{ code="Riftwarden Acheron", fullName="Riftwarden Acheron", logo="[[File:RiftwardenAcheronB.png|20px]]", championships=3 },
{ code="Saluria Skylight", fullName="Saluria Skylight", logo="[[File:SaluriaSkylightB.png|20px]]", championships=0 },
{ code="Sandveil Somniumpolis", fullName="Sandveil Somniumpolis", logo="[[File:SandVeilSomniumB.png|20px]]", championships=1 },
{ code="Serena Tidesong", fullName="Serena Tidesong", logo="[[File:Serena TidesongB.png|20px]]", championships=2 },
{ code="Steppe Luminaria", fullName="Steppe Luminaria", logo="[[File:Steppe LuminariaB.png|20px]]", championships=4 },
{ code="Strider Myrene", fullName="Strider Myrene", logo="[[File:StriderMyreneB.png|20px]]", championships=1 },
{ code="Vaeringheim Pillar", fullName="Vaeringheim Pillar", logo="[[File:VaeringheimPillarB.png|20px]]", championships=5 },
{ code="Imperial Delphica", fullName="Imperial Delphica", logo="[[File:ImperialDelphicaB.png|20px]]", championships=1 },
{ code="Ascendant Aetherium", fullName="Ascendant Aetherium", logo="[[File:AscendantAetheriumB.png|20px]]", championships=2 },
{ code="Sufriya Stormwake", fullName="Sufriya Stormwake", logo="[[File:SufriyaStormwake.png|20px]]", championships=1 },
{ code="Pillarion Club Suncliff", fullName="Pillarion Club Suncliff", logo="[[File:PCSuncliff.png|20px]]", championships=0 },
{ code="Jogi Regiment", fullName="Jogi Regiment", logo="[[File:JogiRegiment.png|20px]]", championships=0 },
{ code="Vine Fleet Mylecia", fullName="Vine Fleet Mylecia", logo="[[File:VineFleetMylecia.png|20px]]", championships=0 },
{ code="Port of Blore Heath", fullName="Port of Blore Heath", logo="[[File:PortBloreHeath.png|20px]]", championships=0 },
{ code="Free State Abeis", fullName="Free State Abeis", logo="[[File:FreeStateAbeis.png|20px]]", championships=0 },
{ code="Jezeraah City", fullName="Jezeraah City", logo="[[File:JezeraahCity.png|20px]]", championships=0 },
{ code="Ourid Pegasi", fullName="Ourid Pegasi", logo="[[File:OuridPegasiLogo.png|20px]]", championships=0 },
{ code="Pillarion Club Caspazani", fullName="Pillarion Club Caspazani", logo="[[File:PCCaspazani.png|20px]]", championships=0 },
{ code="Amberwatch Slevik", fullName="Amberwatch Slevik", logo="[[File:AmberwatchSlevik.png|20px]]", championships=0 },
{ code="Fanghorn Rein", fullName="Fanghorn Rein", logo="[[File:FanghornRein.png|20px]]", championships=0 },
}
local TEAM_BY_CODE = {}
for _,t in ipairs(teams) do TEAM_BY_CODE[t.code] = t end
local function _asTeam(x)
if type(x) == "table" then return x end
return TEAM_BY_CODE[x] or { code=tostring(x), fullName=tostring(x), logo="" }
end
local function _teamWithLogo(x, bold)
local t = _asTeam(x)
local name = t.fullName or t.code or tostring(x)
if bold then name = "'''" .. name .. "'''" end
local logo = t.logo or ""
if logo ~= "" and logo ~= "—" then
return logo .. " " .. name
end
return name
end
------------------------------------------------------------------------
-- 2) DIVISIONS (4 x 8)
------------------------------------------------------------------------
local DIVISIONS = {
["Morovian Division"] = {
"Emberstone Vaeringheim","Vaeringheim Pillar","Imperial Delphica","Delphica Windborne",
"Allegro Symphonara","Steppe Luminaria","Jezeraah City","Saluria Skylight"
},
["Southern Strait Division"] = {
"Sufriya Stormwake","Vine Fleet Mylecia","Port of Blore Heath","Jogi Regiment",
"Free State Abeis","Sandveil Somniumpolis","Brightpath Aureum","Flameborne Erythros"
},
["Western Highlands Division"] = {
"Ashguard Pyralis","Aurelia Auric","Celestial Sancta Lunaris","Forgeward Nexa",
"Hearthkeeper Koinon","Ironbough Sylvapolis","Riftwarden Acheron","Serena Tidesong"
},
["Normarkian Division"] = {
"Redcliff Citadel","Strider Myrene","Ascendant Aetherium","Pillarion Club Suncliff",
"Ourid Pegasi","Pillarion Club Caspazani","Amberwatch Slevik","Fanghorn Rein"
}
}
local DIV_OF = {}
for div, list in pairs(DIVISIONS) do
for _, code in ipairs(list) do DIV_OF[code] = div end
end
------------------------------------------------------------------------
-- 3) SCHEDULE LOCK + BALANCED FUTURE SCHEDULE
-- • SCHED_LOCKED preserves history (your existing generator)
-- • SCHED_BALANCED ensures equal games going forward (38 full rounds)
-- • mapDays() locks past days and remaps future days to balanced rounds
------------------------------------------------------------------------
-- ====== Existing schedule generator (history) ======
local function homeFirst(codeA, codeB)
local sA, sB = 0, 0
for i=1,#codeA do sA = (sA + string.byte(codeA,i)) % 9973 end
for i=1,#codeB do sB = (sB + string.byte(codeB,i)) % 9973 end
return sA > sB
end
local function buildPairings_greedy()
local intra, inter = {}, {}
for _, list in pairs(DIVISIONS) do
for i=1,#list-1 do
for j=i+1,#list do
local A, B = TEAM_BY_CODE[list[i]], TEAM_BY_CODE[list[j]]
intra[#intra+1] = {home=A, away=B}
intra[#intra+1] = {home=B, away=A}
end
end
end
local allCodes = {}
for _,t in ipairs(teams) do allCodes[#allCodes+1] = t.code end
table.sort(allCodes)
for ai=1,#allCodes-1 do
for bi=ai+1,#allCodes do
local ca, cb = allCodes[ai], allCodes[bi]
if DIV_OF[ca] ~= DIV_OF[cb] then
local a, b = TEAM_BY_CODE[ca], TEAM_BY_CODE[cb]
local hFirst = homeFirst(ca, cb)
inter[#inter+1] = {home = hFirst and a or b, away = hFirst and b or a}
end
end
end
return intra, inter
end
local function packRounds_greedy(matches)
local rounds, used = {}, {}
local function canPlace(r, m)
return not (used[r] and (used[r][m.home.code] or used[r][m.away.code]))
end
for _, m in ipairs(matches) do
local placed = false
for r=1,#rounds do
if canPlace(r, m) then
rounds[r][#rounds[r]+1] = { m.home, m.away }
used[r] = used[r] or {}
used[r][m.home.code] = true
used[r][m.away.code] = true
placed = true
break
end
end
if not placed then
rounds[#rounds+1] = { { m.home, m.away } }
used[#rounds] = { [m.home.code]=true, [m.away.code]=true }
end
end
return rounds
end
local SCHED_LOCKED = (function()
local intra, inter = buildPairings_greedy()
local function chunk(arr, size)
local out = {}
for i=1,#arr,size do
local b = {}
for j=i, math.min(i+size-1, #arr) do b[#b+1] = arr[j] end
out[#out+1] = b
end
return out
end
local intraBlocks = chunk(intra, 64)
local interBlocks = chunk(inter, 128)
local merged, ia, ib = {}, 1, 1
while ia <= #intraBlocks or ib <= #interBlocks do
if ia <= #intraBlocks then
for _,m in ipairs(intraBlocks[ia]) do merged[#merged+1] = m end
ia = ia + 1
end
if ib <= #interBlocks then
for _,m in ipairs(interBlocks[ib]) do merged[#merged+1] = m end
ib = ib + 1
end
end
return packRounds_greedy(merged)
end)()
-- ====== Balanced schedule (38 full rounds, no byes) ======
local DIV_ORDER = {
"Morovian Division",
"Southern Strait Division",
"Western Highlands Division",
"Normarkian Division"
}
local function _copy(arr)
local t = {}
for i=1,#arr do t[i] = arr[i] end
return t
end
local function roundRobin8(teamCodes)
local n = #teamCodes
if n ~= 8 then error("roundRobin8 expects exactly 8 teams") end
local arr = _copy(teamCodes)
local rounds = {}
for r=1,7 do
local matches = {}
for i=1,4 do
local a = TEAM_BY_CODE[arr[i]]
local b = TEAM_BY_CODE[arr[n+1-i]]
local home, away
if (r+i) % 2 == 0 then home, away = a, b else home, away = b, a end
matches[#matches+1] = { home, away }
end
rounds[#rounds+1] = matches
local last = arr[n]
for i=n,3,-1 do arr[i] = arr[i-1] end
arr[2] = last
end
return rounds
end
local function swapHA(rounds)
local out = {}
for r=1,#rounds do
out[r] = {}
for i=1,#rounds[r] do
local m = rounds[r][i]
out[r][i] = { m[2], m[1] }
end
end
return out
end
local function interPairRounds(divA_codes, divB_codes, blockIndex)
local rounds = {}
for t=0,7 do
local matches = {}
for i=1,8 do
local a = TEAM_BY_CODE[divA_codes[i]]
local b = TEAM_BY_CODE[divB_codes[((i+t-1) % 8) + 1]]
local home, away
if ((blockIndex + t) % 2 == 0) then home, away = a, b else home, away = b, a end
matches[#matches+1] = { home, away }
end
rounds[#rounds+1] = matches
end
return rounds
end
local SCHED_BALANCED = (function()
local rounds = {}
-- Intra: 14 rounds (4 matches per division per round = 16 total)
local intraByDiv = {}
for _, divName in ipairs(DIV_ORDER) do
local rr7 = roundRobin8(DIVISIONS[divName])
local rr14 = {}
for i=1,7 do rr14[i] = rr7[i] end
local rr7s = swapHA(rr7)
for i=1,7 do rr14[7+i] = rr7s[i] end
intraByDiv[divName] = rr14
end
for r=1,14 do
local full = {}
for _, divName in ipairs(DIV_ORDER) do
for _, m in ipairs(intraByDiv[divName][r]) do full[#full+1] = m end
end
rounds[#rounds+1] = full
end
-- Inter: 24 rounds = 3 blocks × 8 rounds
local A = DIVISIONS[DIV_ORDER[1]]
local B = DIVISIONS[DIV_ORDER[2]]
local C = DIVISIONS[DIV_ORDER[3]]
local D = DIVISIONS[DIV_ORDER[4]]
local blocks = {
{ {A,B}, {C,D} },
{ {A,C}, {B,D} },
{ {A,D}, {B,C} },
}
for blockIndex, pairset in ipairs(blocks) do
local p1 = interPairRounds(pairset[1][1], pairset[1][2], blockIndex)
local p2 = interPairRounds(pairset[2][1], pairset[2][2], blockIndex)
for t=1,8 do
local full = {}
for _,m in ipairs(p1[t]) do full[#full+1] = m end
for _,m in ipairs(p2[t]) do full[#full+1] = m end
rounds[#rounds+1] = full
end
end
if #rounds ~= 38 then error("Balanced schedule should have 38 rounds, got "..tostring(#rounds)) end
for i=1,#rounds do
if #rounds[i] ~= 16 then error("Balanced round "..i.." has "..tostring(#rounds[i]).." matches") end
end
return rounds
end)()
local TOTAL_ROUNDS = 38
-- Day map builder for a schedule length
local function mapDaysFor(schedule)
local totalDays = END - START + 1
local totalRnds = #schedule
local iv = math.floor(totalDays / totalRnds)
local ex = totalDays % totalRnds
local m, d = {}, START
for i=1,totalRnds do
m[d] = i
d = d + iv + (ex > 0 and 1 or 0)
if ex > 0 then ex = ex - 1 end
end
return m
end
local DM_LOCKED = mapDaysFor(SCHED_LOCKED)
-- Find last played matchday under locked mapping (history boundary)
local function lastPlayedLocked()
local curr = date.getCurrentDate()
local today = tonumber(curr:match("^(%d+),")) or 0
local lastDay, lastMd = nil, 0
for d=START, math.min(today, END) do
local md = DM_LOCKED[d]
if md and md > lastMd then
lastMd = md
lastDay = d
end
end
return lastDay, lastMd
end
-- Composite mapDays: locked in the past, balanced in the future
local function mapDays()
local pivotDay, pivotMd = lastPlayedLocked()
if not pivotDay or pivotMd == 0 then
return mapDaysFor(SCHED_BALANCED)
end
local dm = {}
for d,md in pairs(DM_LOCKED) do
if d <= pivotDay then dm[d] = md end
end
local futureStartDay = pivotDay + 1
local remainingDays = math.max(0, END - futureStartDay + 1)
local remainingRnds = math.max(0, TOTAL_ROUNDS - pivotMd)
if remainingDays == 0 or remainingRnds == 0 then
return dm
end
local iv = math.floor(remainingDays / remainingRnds)
local ex = remainingDays % remainingRnds
local day = futureStartDay
for i=1,remainingRnds do
local mdx = pivotMd + i
if mdx > TOTAL_ROUNDS then break end
dm[day] = mdx
day = day + iv + (ex > 0 and 1 or 0)
if ex > 0 then ex = ex - 1 end
if day > END then break end
end
return dm
end
local function invertDayMap(dm)
local inv = {}
for day, md in pairs(dm) do inv[md] = day end
return inv
end
local function _currentPlayedMatchday()
local curr = date.getCurrentDate()
local today = tonumber(curr:match("^(%d+),")) or 0
local dm = mapDays()
local lastMd = 0
for d=START, math.min(today, END) do
if dm[d] and dm[d] > lastMd then lastMd = dm[d] end
end
return lastMd
end
local function schedForRound(roundIdx)
local _, pivotMd = lastPlayedLocked()
if roundIdx <= pivotMd then return SCHED_LOCKED end
return SCHED_BALANCED
end
local function getRoundMatches(roundIdx)
local sched = schedForRound(roundIdx)
return sched[roundIdx] or {}
end
------------------------------------------------------------------------
-- 4) MATCH SIMULATION (deterministic by day & pairing)
------------------------------------------------------------------------
local function simScoreFor(h, a, seed)
math.randomseed(seed)
local p = math.random()
local hg, ag
if p < 0.1 then
hg = math.random(0, 12); ag = hg
else
local wh, wa = (h.championships or 0) + 1, (a.championships or 0) + 1
local rem = (p - 0.1) / 0.9
local homeWin= rem < (wh / (wh + wa))
local winG = math.random(1, 12)
local loseG = math.random(0, math.min(winG - 1, 12))
if homeWin then hg, ag = winG, loseG else hg, ag = loseG, winG end
end
return hg, ag
end
local function simulateDayResults(day)
local dm = mapDays()
local idx = dm[day]
if not idx then return {} end
local matches = getRoundMatches(idx)
local out = {}
for k, m in ipairs(matches) do
local h, a = m[1], m[2]
local salt = day*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
local hg, ag = simScoreFor(h, a, salt)
out[#out+1] = { home=h, away=a, hg=hg, ag=ag }
end
return out
end
------------------------------------------------------------------------
-- 5) TODAY / NEXT MATCHDAY RENDER
------------------------------------------------------------------------
local function renderRound(curDay, year)
local dm = mapDays()
local target = curDay
local upcoming= false
if not dm[target] then
for d = curDay + 1, END do
if dm[d] then target = d; upcoming = true; break end
end
end
if not dm[target] then
return "'''No more matches this season.'''"
end
local prefix = upcoming and "'''Next matchday:''' " or ""
local mon, dom = getMonthDay(target)
local label = dom .. " " .. mon .. " " .. year .. " PSSC"
local rows = {}
local idx = dm[target]
local matches = getRoundMatches(idx)
if not upcoming then
for k, m in ipairs(matches) do
local h, a = m[1], m[2]
local salt = target*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
local hg, ag = simScoreFor(h, a, salt)
local hc = (hg > ag and "green") or (hg < ag and "red") or "yellow"
local ac = (ag > hg and "green") or (ag < hg and "red") or "yellow"
local hs = string.format("<span style='color:%s;'>'''%d'''</span>", hc, hg)
local as = string.format("<span style='color:%s;'>'''%d'''</span>", ac, ag)
rows[#rows+1] =
string.format("%s %s %s vs %s %s %s",
h.logo, hs, h.fullName,
a.logo, as, a.fullName
)
end
else
for _, m in ipairs(matches) do
local h, a = m[1], m[2]
rows[#rows+1] =
string.format("%s %s vs %s %s",
h.logo, h.fullName,
a.logo, a.fullName
)
end
end
return string.format("|-\n| %s%d || %s || %s", prefix, target, label, table.concat(rows, "<br>"))
end
function p.renderSchedule(frame)
local curr = date.getCurrentDate()
local day = tonumber(curr:match('^(%d+),')) or 0
local year = tonumber(curr:match(', (%d+) PSSC')) or 0
return table.concat({
'{| class="wikitable sortable"',
'! Day !! Date !! Matches',
renderRound(day, year),
'|}'
}, "\n")
end
------------------------------------------------------------------------
-- 6) DIVISION STANDINGS
------------------------------------------------------------------------
function p.renderStandings(frame)
local curr = date.getCurrentDate()
local today = tonumber(curr:match("^(%d+),")) or 0
local last = math.min(today, END)
local recs = {}
for _, t in ipairs(teams) do
recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0 }
end
local dm = mapDays()
for d = START, last do
local idx = dm[d]
if idx then
local matches = getRoundMatches(idx)
for k, m in ipairs(matches) do
local h, a = m[1], m[2]
local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
local hg, ag = simScoreFor(h, a, salt)
local H, A = recs[h.code], recs[a.code]
H.PF, H.PA = H.PF + hg, H.PA + ag
A.PF, A.PA = A.PF + ag, A.PA + hg
if hg > ag then
H.W, H.Pts = H.W + 1, H.Pts + 3
A.L = A.L + 1
elseif hg < ag then
A.W, A.Pts = A.W + 1, A.Pts + 3
H.L = H.L + 1
else
H.D, H.Pts = H.D + 1, H.Pts + 1
A.D, A.Pts = A.D + 1, A.Pts + 1
end
end
end
end
local buckets = {}
for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
for code, r in pairs(recs) do
r.PD = r.PF - r.PA
local div = DIV_OF[code] or "—"
buckets[div] = buckets[div] or {}
table.insert(buckets[div], r)
end
local function cmp(a,b)
if a.Pts ~= b.Pts then return a.Pts > b.Pts end
if a.PD ~= b.PD then return a.PD > b.PD end
if a.W ~= b.W then return a.W > b.W end
return a.team.fullName < b.team.fullName
end
for div, list in pairs(buckets) do table.sort(list, cmp) end
local order = {
"Morovian Division","Southern Strait Division",
"Western Highlands Division","Normarkian Division"
}
local out = {}
for _, div in ipairs(order) do
local list = buckets[div] or {}
out[#out+1] = "== " .. div .. " Standings =="
out[#out+1] = '{| class="wikitable sortable"'
out[#out+1] = "! Pos !! Team !! W !! D !! L !! Pts !! PF !! PA !! PD"
for i, rec in ipairs(list) do
local style = ""
if i <= 2 then style = ' style="background-color:#ccffcc;"'
elseif i <= 4 then style = ' style="background-color:#cce5ff;"'
elseif i <= 6 then style = ' style="background-color:#F1EB9C;"'
else style = ' style="background-color:#ffcccc;"'
end
out[#out+1] = "|-" .. style
out[#out+1] =
"| " .. i
.. " || " .. rec.team.logo .. " " .. rec.team.fullName
.. " || " .. rec.W
.. " || " .. rec.D
.. " || " .. rec.L
.. " || " .. rec.Pts
.. " || " .. rec.PF
.. " || " .. rec.PA
.. " || " .. rec.PD
end
out[#out+1] = "|}"
out[#out+1] = ""
end
return table.concat(out, "\n")
end
------------------------------------------------------------------------
-- 7) PREVIOUS RESULTS (last 5 matchdays)
------------------------------------------------------------------------
function p.renderPreviousResults(frame)
local curr = date.getCurrentDate()
local today = tonumber(curr:match("^(%d+),")) or 0
local year = tonumber(curr:match(", (%d+) PSSC")) or 0
local dm = mapDays()
local played = {}
for d = today - 1, START, -1 do
if dm[d] then played[#played+1] = d end
if #played == 5 then break end
end
local out = {
'{| class="wikitable sortable"',
'! Date !! Home !! Score !! Away'
}
for _, d in ipairs(played) do
local mon, dom = getMonthDay(d)
local dateLabel = dom .. " " .. mon .. " " .. year .. " PSSC"
local idx = dm[d]
local matches = getRoundMatches(idx)
for k, m in ipairs(matches) do
local h, a = m[1], m[2]
local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
local hg, ag = simScoreFor(h, a, salt)
local hs = "'''" .. hg .. "-" .. ag .. "'''"
out[#out+1] = "|-"
out[#out+1] =
"| " .. dateLabel
.. " || " .. h.logo .. " " .. h.fullName
.. " || " .. hs
.. " || " .. a.logo .. " " .. a.fullName
end
end
out[#out+1] = "|}"
return table.concat(out, "\n")
end
------------------------------------------------------------------------
-- 8) PLAYOFFS (12 teams seeded from divisions)
------------------------------------------------------------------------
local PO_YEAR = 52
local PO_FIRST = md2day(1,4)
local PO_IV = 3
local function tb_cmp(a,b)
if a.Pts ~= b.Pts then return a.Pts > b.Pts end
if a.PD ~= b.PD then return a.PD > b.PD end
if a.W ~= b.W then return a.W > b.W end
return a.team.fullName < b.team.fullName
end
local function computeRecordsUpTo(dayEnd)
local recs = {}
for _, t in ipairs(teams) do
recs[t.code] = { team=t, W=0, D=0, L=0, Pts=0, PF=0, PA=0, PD=0, Div=DIV_OF[t.code] }
end
local dm = mapDays()
for d = START, math.min(dayEnd, END) do
local idx = dm[d]
if idx then
local matches = getRoundMatches(idx)
for k, m in ipairs(matches) do
local h, a = m[1], m[2]
local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
local hg, ag = simScoreFor(h, a, salt)
local H, A = recs[h.code], recs[a.code]
H.PF, H.PA = H.PF + hg, H.PA + ag
A.PF, A.PA = A.PF + ag, A.PA + hg
if hg > ag then
H.W, H.Pts = H.W + 1, H.Pts + 3
A.L = A.L + 1
elseif hg < ag then
A.W, A.Pts = A.W + 1, A.Pts + 3
H.L = H.L + 1
else
H.D, H.Pts = H.D + 1, H.Pts + 1
A.D, A.Pts = A.D + 1, A.Pts + 1
end
end
end
end
for _, r in pairs(recs) do r.PD = r.PF - r.PA end
return recs
end
local function getSeeds12()
local curr = date.getCurrentDate()
local today = tonumber(curr:match("^(%d+),")) or END
local recs = computeRecordsUpTo(today)
local buckets = {}
for div,_ in pairs(DIVISIONS) do buckets[div] = {} end
for _, r in pairs(recs) do
buckets[r.Div] = buckets[r.Div] or {}
table.insert(buckets[r.Div], r)
end
for div, list in pairs(buckets) do table.sort(list, tb_cmp) end
local winners, runners = {}, {}
for _, div in ipairs(DIV_ORDER) do
local list = buckets[div] or {}
if list[1] then table.insert(winners, list[1]) end
if list[2] then table.insert(runners, list[2]) end
end
table.sort(winners, tb_cmp)
table.sort(runners, tb_cmp)
local chosen = {}
for _,r in ipairs(winners) do chosen[r.team.code] = true end
for _,r in ipairs(runners) do chosen[r.team.code] = true end
local allList = {}
for _, r in pairs(recs) do table.insert(allList, r) end
table.sort(allList, tb_cmp)
local wild = {}
for _,r in ipairs(allList) do
if not chosen[r.team.code] then
table.insert(wild, r)
if #wild == 4 then break end
end
end
local seeds = {}
for i=1,#winners do seeds[#seeds+1] = winners[i].team end
for i=1,#runners do seeds[#seeds+1] = runners[i].team end
for i=1,#wild do seeds[#seeds+1] = wild[i].team end
return seeds
end
local function playScore(A,B,salt)
math.randomseed(salt)
local p = math.random()
if p < 0.10 then local g=math.random(0,12); return g,g end
local wa,wb = (A.championships or 0)+1,(B.championships or 0)+1
local home = (p-0.10)/0.90 < wa/(wa+wb)
local hi = math.random(1,12)
local lo = math.random(0, math.min(hi-1,12))
return home and hi or lo, home and lo or hi
end
local function mkRow(tag, absDate, H, A, salt, absNow)
local hs, win = '—','—'
if absNow >= absDate and H.fullName~='TBD' and A.fullName~='TBD' then
local g1,g2 = playScore(H,A,salt)
hs = string.format("'''%d-%d'''", g1, g2)
win = (g1>g2 and H or A).logo .. ' ' .. (g1>g2 and H or A).fullName
end
return string.format("|-\n| %s || %s %s || %s || %s %s || %s",
tag, H.logo,H.fullName, hs, A.logo,A.fullName, win)
end
function p.renderPlayoffSchedule(frame)
local seeds = getSeeds12()
local out = {
'{| class="wikitable sortable"',
'! Round !! Date !! Match-up'
}
local function dateCell(day)
local mon,dom = getMonthDay(day)
return dom .. ' ' .. mon .. ' ' .. PO_YEAR .. ' PSSC'
end
local function add(tag, day, H, A)
out[#out+1] = '|-'
out[#out+1] = string.format('| %s || %s || %s %s vs %s %s',
tag, dateCell(day), H.logo,H.fullName, A.logo,A.fullName)
end
local p1, p2 = seeds[9], seeds[12]
local p3, p4 = seeds[10], seeds[11]
add('Play-in-1', PO_FIRST, p1, p2)
add('Play-in-2', PO_FIRST, p3, p4)
local banners = {
{ 'Round-of-8', 1 },
{ 'Round-of-4', 2 },
{ 'Semi-finals (Seeds 1–2 BYE)', 3 },
{ 'Final', 4 }
}
for _,b in ipairs(banners) do
local d = PO_FIRST + PO_IV * b[2]
out[#out+1] = '|-'
out[#out+1] = '| colspan="3" | ' .. "'''" .. b[1] .. ":'''" .. ' ' .. dateCell(d)
end
out[#out+1] = '|}'
return table.concat(out, '\n')
end
function p.renderPlayoffResults(frame)
local seeds = getSeeds12()
local now = date.getCurrentDate()
local dNow = tonumber(now:match('^(%d+),')) or 0
local yNow = tonumber(now:match(', (%d+) PSSC')) or 0
local absNow= absDay(yNow,dNow)
local rows = {
'{| class="wikitable sortable"',
'! Round !! Home !! Score !! Away !! Winner'
}
local absPI = absDay(PO_YEAR, PO_FIRST)
local PI = { { seeds[9], seeds[12] }, { seeds[10], seeds[11] } }
local W_PI = {}
for i,pair in ipairs(PI) do
local H,A = pair[1], pair[2]
if absNow >= absPI then
local g1,g2 = playScore(H,A,absPI*100+i)
W_PI[i] = (g1>g2) and H or A
end
rows[#rows+1] = mkRow('PI-'..i, absPI, H, A, absPI*100+i, absNow)
end
local absR8 = absPI + PO_IV
local pool = { seeds[3],seeds[4],seeds[5],seeds[6],seeds[7],seeds[8],
W_PI[1] or {logo='—',fullName='TBD'}, W_PI[2] or {logo='—',fullName='TBD'} }
local R8 = { {pool[3], pool[6]}, {pool[4], pool[5]}, {pool[1], pool[8]}, {pool[2], pool[7]} }
local W_R8 = {}
for i,pair in ipairs(R8) do
local H,A = pair[1], pair[2]
if absNow >= absR8 and A.fullName~='TBD' then
local g1,g2 = playScore(H,A,absR8*100+i)
W_R8[i] = (g1>g2) and H or A
end
rows[#rows+1] = mkRow('R-8-'..i, absR8, H, A, absR8*100+i, absNow)
end
local absR4 = absR8 + PO_IV
local R4, W_R4 = {}, {}
if #W_R8 == 4 then
local idxByCode = {}
for i,t in ipairs(seeds) do idxByCode[t.code] = i end
table.sort(W_R8, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end)
R4 = { { W_R8[1], W_R8[4] }, { W_R8[2], W_R8[3] } }
end
for i=1,2 do
local H = R4[i] and R4[i][1] or {logo='—',fullName='TBD'}
local A = R4[i] and R4[i][2] or {logo='—',fullName='TBD'}
if absNow >= absR4 and R4[i] then
local g1,g2 = playScore(H,A,absR4*100+i)
W_R4[i] = (g1>g2) and H or A
end
rows[#rows+1] = mkRow('R-4-'..i, absR4, H, A, absR4*100+i, absNow)
end
local absSF = absR4 + PO_IV
local SF = {}
if #W_R4 == 2 then
local idxByCode = {}
for i,t in ipairs(seeds) do idxByCode[t.code] = i end
table.sort(W_R4, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end)
SF = { { seeds[1], W_R4[2] }, { seeds[2], W_R4[1] } }
else
SF = { { seeds[1], {logo='—',fullName='TBD'} }, { seeds[2], {logo='—',fullName='TBD'} } }
end
local W_SF = {}
for i=1,2 do
local H,A = SF[i][1], SF[i][2]
if absNow >= absSF and A.fullName~='TBD' then
local g1,g2 = playScore(H,A,absSF*100+i)
W_SF[i] = (g1>g2) and H or A
end
rows[#rows+1] = mkRow('SF-'..i, absSF, H, A, absSF*100+i, absNow)
end
local absF = absSF + PO_IV
local FH = W_SF[1] or {logo='—',fullName='TBD'}
local FA = W_SF[2] or {logo='—',fullName='TBD'}
rows[#rows+1] = mkRow('Final', absF, FH, FA, absF*100+1, absNow)
rows[#rows+1] = '|}'
return table.concat(rows, '\n')
end
------------------------------------------------------------------------
-- 9) PER-MATCH STATS
------------------------------------------------------------------------
local function computeMatchStats(h, a, hg, ag, seed)
local function splitScore(total, salt)
local maxG = math.floor(total / 3)
if maxG == 0 then return 0, total end
math.randomseed(salt)
local g = maxG
if maxG >= 1 and math.random() < 0.40 then g = maxG - 1 end
local p = total - 3*g
return g, p
end
local h_goals, h_poss = splitScore(hg, seed + 101)
local a_goals, a_poss = splitScore(ag, seed + 102)
local function rrange(s, lo, hi) math.randomseed(s); return math.random(lo, hi) end
local h_sh_on = (h_goals > 0) and rrange(seed+201, h_goals, h_goals+2) or rrange(seed+201, 0, 2)
local a_sh_on = (a_goals > 0) and rrange(seed+202, a_goals, a_goals+2) or rrange(seed+202, 0, 2)
local h_sh_off = rrange(seed+203, 0, 3)
local a_sh_off = rrange(seed+204, 0, 3)
local h_fouls = rrange(seed+205, 0, 3)
local a_fouls = rrange(seed+206, 0, 3)
local h_fks = a_fouls
local a_fks = h_fouls
local h_throws = rrange(seed+207, 0, 3)
local a_throws = rrange(seed+208, 0, 3)
local h_oob = rrange(seed+209, 0, 2)
local a_oob = rrange(seed+210, 0, 2)
local h_gk = rrange(seed+211, 0, 2)
local a_gk = rrange(seed+212, 0, 2)
local base = 50
local swing = rrange(seed+213, 3, 6)
local h_poss_pct, a_poss_pct
if hg > ag then
h_poss_pct, a_poss_pct = base + swing, base - swing
elseif hg < ag then
h_poss_pct, a_poss_pct = base - swing, base + swing
else
local tilt = rrange(seed+214, -3, 3)
h_poss_pct, a_poss_pct = base + tilt, base - tilt
end
return {
[h.code] = {
final = hg, goals = h_goals, poss_points = h_poss,
shots_on = h_sh_on, shots_off = h_sh_off,
fouls = h_fouls, fks = h_fks, throws = h_throws,
gk = h_gk, oob = h_oob, poss_pct = h_poss_pct
},
[a.code] = {
final = ag, goals = a_goals, poss_points = a_poss,
shots_on = a_sh_on, shots_off = a_sh_off,
fouls = a_fouls, fks = a_fks, throws = a_throws,
gk = a_gk, oob = a_oob, poss_pct = a_poss_pct
}
}
end
function p.renderMatchStatsForDay(frame)
local curr = date.getCurrentDate()
local dayNow = tonumber(curr:match('^(%d+),')) or 0
local year = tonumber(curr:match(', (%d+) PSSC')) or 0
local dm = mapDays()
local target = dm[dayNow] and dayNow or nil
if not target then
for d = dayNow + 1, END do
if dm[d] then target = d; break end
end
end
if not target then return "'''No scheduled matches in window.'''" end
local idx = dm[target]
local isToday = (target == dayNow)
local mon, dom = getMonthDay(target)
local label = dom .. " " .. mon .. " " .. year .. " PSSC"
local function hiPairNum(hVal, aVal, suffix)
hVal = tonumber(hVal) or 0
aVal = tonumber(aVal) or 0
suffix = suffix or ""
local function cell(v, hi)
local txt = tostring(v) .. suffix
return hi and ('<span style="background-color:#ccffcc;">'..txt..'</span>') or txt
end
return cell(hVal, hVal>aVal), cell(aVal, aVal>hVal)
end
local out = {
'{| class="wikitable sortable"',
'|+ Match Statistics — ' .. label .. (isToday and '' or ' (fixtures; stats available day-of)'),
'! Match !! Team !! Final !! Goals (Pillar) !! Possession Pts !! Shots on !! Shots off !! Fouls !! Free Kicks !! Throw-Ins !! Goal Kicks !! OOB !! Poss %'
}
local matches = getRoundMatches(idx)
if isToday then
local results = simulateDayResults(target)
for k, r in ipairs(results) do
local seed = target*1000 + k + string.byte(r.home.code,1) + string.byte(r.away.code,1)
local S = computeMatchStats(r.home, r.away, r.hg, r.ag, seed)
local H, A = S[r.home.code], S[r.away.code]
local matchLabel = r.home.logo .. ' ' .. r.home.fullName .. ' vs ' .. r.away.logo .. ' ' .. r.away.fullName
local hF, aF = hiPairNum(H.final, A.final)
local hG, aG = hiPairNum(H.goals, A.goals)
local hP, aP = hiPairNum(H.poss_points, A.poss_points)
local hSON, aSON = hiPairNum(H.shots_on, A.shots_on)
local hSOF, aSOF = hiPairNum(H.shots_off, A.shots_off)
local hFO, aFO = hiPairNum(H.fouls, A.fouls)
local hFK, aFK = hiPairNum(H.fks, A.fks)
local hTH, aTH = hiPairNum(H.throws, A.throws)
local hGK, aGK = hiPairNum(H.gk, A.gk)
local hOOB, aOOB = hiPairNum(H.oob, A.oob)
local hPOS, aPOS = hiPairNum(H.poss_pct, A.poss_pct, '%')
out[#out+1] = '|-'
out[#out+1] =
'| rowspan="2" | ' .. matchLabel ..
' || ' .. r.home.logo .. ' ' .. r.home.fullName ..
' || ' .. hF ..
' || ' .. hG ..
' || ' .. hP ..
' || ' .. hSON ..
' || ' .. hSOF ..
' || ' .. hFO ..
' || ' .. hFK ..
' || ' .. hTH ..
' || ' .. hGK ..
' || ' .. hOOB ..
' || ' .. hPOS
out[#out+1] = '|-'
out[#out+1] =
'| ' .. r.away.logo .. ' ' .. r.away.fullName ..
' || ' .. aF ..
' || ' .. aG ..
' || ' .. aP ..
' || ' .. aSON ..
' || ' .. aSOF ..
' || ' .. aFO ..
' || ' .. aFK ..
' || ' .. aTH ..
' || ' .. aGK ..
' || ' .. aOOB ..
' || ' .. aPOS
end
else
for _, m in ipairs(matches) do
local matchLabel = m[1].logo .. ' ' .. m[1].fullName .. ' vs ' .. m[2].logo .. ' ' .. m[2].fullName
out[#out+1] = '|-'
out[#out+1] =
'| rowspan="2" | ' .. matchLabel ..
' || ' .. m[1].logo .. ' ' .. m[1].fullName ..
' || — || — || — || — || — || — || — || — || — || — || —'
out[#out+1] = '|-'
out[#out+1] =
'| ' .. m[2].logo .. ' ' .. m[2].fullName ..
' || — || — || — || — || — || — || — || — || — || — || —'
end
end
out[#out+1] = '|}'
return table.concat(out, '\n')
end
function p.renderMatchStatsSingle(frame)
local args = frame.args or {}
local day = tonumber(args.day or '') or 0
local home = args.home
local away = args.away
if day == 0 or not home or not away then
return "'''Pass day, home, and away:''' day=###, home=Team, away=Team"
end
local curr = date.getCurrentDate()
local dayNow = tonumber(curr:match('^(%d+),')) or 0
local year = tonumber(curr:match(', (%d+) PSSC')) or 0
local dm = mapDays()
local idx = dm[day]
if not idx then return "'''No round mapped for that day.'''" end
local matches = getRoundMatches(idx)
local matchIdx
for k, m in ipairs(matches) do
if m[1].code == home and m[2].code == away then matchIdx = k; break end
end
if not matchIdx then return "'''Match not found on that day with that home/away.'''" end
local m = matches[matchIdx]
local mon, dom = getMonthDay(day)
local dateLabel= dom .. " " .. mon .. " " .. year .. " PSSC"
local out = {
'{| class="wikitable"',
'|+ Match Statistics — ' .. dateLabel .. ((day == dayNow) and '' or ' (fixture; stats available day-of)'),
'! Statistic !! ' .. _teamWithLogo(m[1]) .. ' !! ' .. _teamWithLogo(m[2])
}
if day == dayNow then
local resList = simulateDayResults(day)
local r = resList[matchIdx]
local seed = day*1000 + matchIdx + string.byte(home,1) + string.byte(away,1)
local S = computeMatchStats(m[1], m[2], r.hg, r.ag, seed)
local H, A = S[home], S[away]
local function hi(hv, av, suffix)
hv = tonumber(hv) or 0
av = tonumber(av) or 0
suffix = suffix or ""
local h = tostring(hv) .. suffix
local a = tostring(av) .. suffix
if hv > av then h = '<span style="background-color:#ccffcc;">'..h..'</span>'
elseif av > hv then a = '<span style="background-color:#ccffcc;">'..a..'</span>' end
return h, a
end
local hF,aF = hi(H.final, A.final)
local hG,aG = hi(H.goals, A.goals)
local hP,aP = hi(H.poss_points, A.poss_points)
local hSON,aSON = hi(H.shots_on, A.shots_on)
local hSOF,aSOF = hi(H.shots_off, A.shots_off)
local hFO,aFO = hi(H.fouls, A.fouls)
local hFK,aFK = hi(H.fks, A.fks)
local hTH,aTH = hi(H.throws, A.throws)
local hGK,aGK = hi(H.gk, A.gk)
local hOOB,aOOB = hi(H.oob, A.oob)
local hPOS,aPOS = hi(H.poss_pct, A.poss_pct, '%')
out[#out+1] = '|-'; out[#out+1] = '| Final Score || ' .. hF .. ' || ' .. aF
out[#out+1] = '|-'; out[#out+1] = '| Goals (Pillar Strikes) || ' .. hG .. ' || ' .. aG
out[#out+1] = '|-'; out[#out+1] = '| Possession Points || ' .. hP .. ' || ' .. aP
out[#out+1] = '|-'; out[#out+1] = '| Shots on Target || ' .. hSON .. ' || ' .. aSON
out[#out+1] = '|-'; out[#out+1] = '| Shots off Target || ' .. hSOF .. ' || ' .. aSOF
out[#out+1] = '|-'; out[#out+1] = '| Fouls || ' .. hFO .. ' || ' .. aFO
out[#out+1] = '|-'; out[#out+1] = '| Free Kicks Awarded || ' .. hFK .. ' || ' .. aFK
out[#out+1] = '|-'; out[#out+1] = '| Throw-Ins || ' .. hTH .. ' || ' .. aTH
out[#out+1] = '|-'; out[#out+1] = '| Goal Kicks || ' .. hGK .. ' || ' .. aGK
out[#out+1] = '|-'; out[#out+1] = '| Out of Bounds || ' .. hOOB .. ' || ' .. aOOB
out[#out+1] = '|-'; out[#out+1] = '| Ball Possession || ' .. hPOS .. ' || ' .. aPOS
else
local function blank(row) out[#out+1] = '|-'; out[#out+1] = row end
blank('| Final Score || — || —')
blank('| Goals (Pillar Strikes) || — || —')
blank('| Possession Points || — || —')
blank('| Shots on Target || — || —')
blank('| Shots off Target || — || —')
blank('| Fouls || — || —')
blank('| Free Kicks Awarded || — || —')
blank('| Throw-Ins || — || —')
blank('| Goal Kicks || — || —')
blank('| Out of Bounds || — || —')
blank('| Ball Possession || — || —')
end
out[#out+1] = '|}'
return table.concat(out, '\n')
end
------------------------------------------------------------------------
-- 10) ROSTERS + TRANSFERS + PLAYER STATS (embedded)
------------------------------------------------------------------------
local DEFAULT_SEASON = 52
local ROSTER_SIZE = 12
local TRANSFER_MD_START = 20
local TRANSFER_MD_END = 30
local POSITIONS = {
"Striker","Striker",
"Playmaker","Playmaker","Playmaker",
"Defender","Defender","Defender",
"Keeper",
"Utility","Utility","Utility"
}
-- deterministic PRNG (does NOT touch math.randomseed)
local function _seedFromString(s)
local h = 0
for i=1,#s do h = (h*31 + string.byte(s,i)) % 2147483647 end
return (h == 0) and 1 or h
end
local function _rngNext(st)
st = (1103515245 * st + 12345) % 2147483647
return st, st / 2147483647
end
local function _pick(st, arr)
if not arr or #arr == 0 then return st, nil end
local r; st, r = _rngNext(st)
local idx = math.floor(r * #arr) + 1
return st, arr[idx]
end
local function _normKey(s)
if not s or s == "" then return nil end
s = mw.ustring.lower(s)
s = mw.ustring.gsub(s, "%s+", "_")
s = mw.ustring.gsub(s, "[^%w_]", "")
return s
end
-- Wide-variation name generator
local NAME_STYLES = {
bassarid = {
firstA = {"Ka","Tha","Py","De","Ar","Lo","Ni","Sa","Eo","Ae","Ky","My","Ga","Do","Sy","Va"},
firstB = {"li","the","ra","me","ri","phi","no","lo","re","so","te","xa","ro","na","di","mos"},
firstC = {"thros","rios","nax","dros","lion","menos","kar","thes","sos","lios","zian","phoros","kris","dor","thes","ron"},
lastA = {"Amin","Delph","Kalli","Vareng","Caspaz","Myren","Aurel","Koin","Therm","Skyr","Erythr","Loth","Chrys","Silen","Nexa","Morov"},
lastB = {"a","i","o","e","u","y","ae","io","ou","ea"},
lastC = {"dis","kos","tron","ides","aris","eas","ion","oros","anos","inos","elis","akis","eth","on","eus","yr"}
},
haifan = {
firstA = {"Se","Or","Ke","Ha","Yu","Az","Le","Mi","De","Sa","Fa","Ri","Ta","Na","Su","Ba"},
firstB = {"lim","han","mal","kan","suf","iz","yla","na","rya","fi","ru","hir","mir","zir","yif","sar"},
firstC = {"","oğlu","an","ir","em","et","in","a","e","u",""},
lastA = {"Yıldır","Dem","Kay","Arsl","Çel","Ayd","Gün","Korkm","Sarı","Taş","Karad","Özt","Ak","Boz","Şah"},
lastB = {"im","ir","a","i","u","ü","o","ö",""},
lastC = {"maz","er","kan","soy","taş","kaya","han","gül","lı","ci","oğlu",""}
},
normark = {
firstA = {"Ei","Ha","Le","Bj","Si","As","In","To","Ka","Sk","Ry","Ul","Va","Sa","Jo","Th"},
firstB = {"rik","kon","if","orn","grid","trid","ga","rsten","ri","ald","var","lva","ren","mund","nar","or"},
firstC = {"","r","d","n","k","s",""},
lastA = {"Fjell","Ravn","Skog","Sund","Vinter","Knut","Thors","Haldor","Eirik","Bjorn","Isen","Storm","Sol","Hav","Ulv"},
lastB = {"sen","son","sson","vik","lund","by","heim","holt","dal","gard","berg","mark"},
lastC = {"","","",""}
},
imperial = {
firstA = {"Mar","Jul","Cas","Oct","Luc","Tib","Sab","Aurel","Flav","Dec","Val","Cor","Dom","Sev","Faust","Cris"},
firstB = {"cus","ia","sian","avia","ian","er","ina","ius","ia","imus","eri","vin","itian","eran","inus","pus"},
firstC = {"","us","a","","us","a","us","a","us","a"},
lastA = {"Valer","Marcell","Aquil","Sever","Corvin","Drus","Faustin","Liv","Domit","Crisp","Aurel","Cass","Octav","Lucan","Tiber","Flav"},
lastB = {"ius","us","a","an","inus","ian","ensis","orum","atus","aris"},
lastC = {"","","",""}
}
}
local NAME_STYLE_KEYS = {"bassarid","haifan","normark","imperial"}
local function _cap(s)
if not s or s == "" then return s end
return mw.ustring.upper(mw.ustring.sub(s,1,1)) .. mw.ustring.sub(s,2)
end
local function _maybeDiacritics(st, s)
local r; st, r = _rngNext(st)
if r < 0.08 then
s = mw.ustring.gsub(s, "a", "ä", 1)
elseif r < 0.16 then
s = mw.ustring.gsub(s, "i", "ï", 1)
elseif r < 0.24 then
s = mw.ustring.gsub(s, "o", "ö", 1)
elseif r < 0.32 then
s = mw.ustring.gsub(s, "u", "ü", 1)
end
return st, s
end
local function _buildNameFrom(style, st, isLast)
local t = NAME_STYLES[style] or NAME_STYLES.bassarid
local a,b,c
if not isLast then
st, a = _pick(st, t.firstA); st, b = _pick(st, t.firstB); st, c = _pick(st, t.firstC)
else
st, a = _pick(st, t.lastA); st, b = _pick(st, t.lastB); st, c = _pick(st, t.lastC)
end
local name = _cap((a or "") .. (b or "") .. (c or ""))
local r; st, r = _rngNext(st)
if r < 0.05 and #name >= 6 then
local cut = math.floor(#name/2)
name = mw.ustring.sub(name,1,cut) .. "-" .. mw.ustring.sub(name,cut+1)
elseif r < 0.08 and #name >= 6 then
local cut = math.floor(#name/2)
name = mw.ustring.sub(name,1,cut) .. "’" .. mw.ustring.sub(name,cut+1)
end
st, name = _maybeDiacritics(st, name)
return st, name
end
local function _chooseStyleForTeam(teamCode, season)
local st = _seedFromString(tostring(season).."|"..tostring(teamCode).."|STYLE")
local key; st, key = _pick(st, NAME_STYLE_KEYS)
return key or "bassarid"
end
local function _genBaseRoster(teamCode, season)
local key = _normKey(teamCode) or teamCode
local st = _seedFromString(tostring(season) .. "|" .. key .. "|ROSTER")
local style = _chooseStyleForTeam(teamCode, season)
local roster, used = {}, {}
for i=1, ROSTER_SIZE do
local id = key .. "-" .. string.format("%02d", i)
local full
for _=1, 10 do
local first, last
st, first = _buildNameFrom(style, st, false)
st, last = _buildNameFrom(style, st, true)
full = first .. " " .. last
if not used[full] then used[full] = true; break end
end
roster[i] = { id=id, name=full or ("Player "..id), pos=POSITIONS[i] or "Utility", no=i }
end
roster[1].notes = "Captain"
return roster
end
local function _buildBaseRosters(season)
local rosters = {}
for _,t in ipairs(teams) do rosters[t.code] = _genBaseRoster(t.code, season) end
return rosters
end
local function _indexById(roster)
local m = {}
for i,p in ipairs(roster) do m[p.id] = i end
return m
end
local function _findSwapCandidate(st, roster, wantPos)
local candidates = {}
for _,p in ipairs(roster) do if p.pos == wantPos then candidates[#candidates+1] = p end end
if #candidates == 0 then candidates = roster end
return _pick(st, candidates)
end
local function _genTransferDeals(season)
local st = _seedFromString(tostring(season) .. "|TRANSFERWINDOW|MD" .. TRANSFER_MD_START .. "-" .. TRANSFER_MD_END)
local deals, teamCodes = {}, {}
for _,t in ipairs(teams) do teamCodes[#teamCodes+1] = t.code end
for md = TRANSFER_MD_START, TRANSFER_MD_END do
local r; st, r = _rngNext(st)
local dealCount = 1 + math.floor(r * 3)
for _=1, dealCount do
local A,B
st, A = _pick(st, teamCodes)
repeat st, B = _pick(st, teamCodes) until B ~= A
local baseA = _genBaseRoster(A, season)
local baseB = _genBaseRoster(B, season)
local pA; st, pA = _pick(st, baseA)
if pA and pA.pos == "Keeper" then
local rr; st, rr = _rngNext(st)
if rr < 0.75 then st, pA = _pick(st, baseA) end
end
local pB; st, pB = _findSwapCandidate(st, baseB, (pA and pA.pos) or "Utility")
if A and B and pA and pB then
deals[#deals+1] = { md=md, A=A, B=B, A_id=pA.id, B_id=pB.id, pos=pA.pos }
end
end
end
return deals
end
local function _applyTransfersUpTo(baseRosters, deals, upToMd)
local rosters = {}
for code, r in pairs(baseRosters) do
rosters[code] = {}
for i,p in ipairs(r) do rosters[code][i] = { id=p.id, name=p.name, pos=p.pos, no=p.no, notes=p.notes } end
end
for _,d in ipairs(deals) do
if d.md <= upToMd then
local RA, RB = rosters[d.A], rosters[d.B]
if RA and RB then
local ia = _indexById(RA)[d.A_id]
local ib = _indexById(RB)[d.B_id]
if ia and ib then
local tmp = RA[ia]
RA[ia] = RB[ib]
RB[ib] = tmp
end
end
end
end
return rosters
end
local function _alloc(st, roster, posWeights)
local pool = {}
for _,p in ipairs(roster) do
local w = posWeights[p.pos] or 1
for _=1,w do pool[#pool+1] = p end
end
return _pick(st, pool)
end
local function _ensure(map, pid)
if not map[pid] then map[pid] = { goals=0, assists=0, poss=0, son=0, sof=0, fouls=0, apps=0 } end
return map[pid]
end
local function _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster)
local salt = day*1000 + matchIndex + string.byte(homeTeam.code,1) + string.byte(awayTeam.code,1)
local hg, ag = simScoreFor(homeTeam, awayTeam, salt)
local S = computeMatchStats(homeTeam, awayTeam, hg, ag, salt)
local H = S[homeTeam.code] or {}
local A = S[awayTeam.code] or {}
local stats = { home = H, away = A }
local st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)
local events = { home={}, away={} }
for _,p in ipairs(homeRoster) do _ensure(events.home, p.id).apps = 1 end
for _,p in ipairs(awayRoster) do _ensure(events.away, p.id).apps = 1 end
local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
for _=1, (stats.home.goals or 0) do
local pz; st, pz = _alloc(st, homeRoster, W_GOAL)
_ensure(events.home, pz.id).goals = _ensure(events.home, pz.id).goals + 1
local r; st, r = _rngNext(st)
if r < 0.70 then
local a; st, a = _alloc(st, homeRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
if a and a.id ~= pz.id then _ensure(events.home, a.id).assists = _ensure(events.home, a.id).assists + 1 end
end
end
for _=1, (stats.away.goals or 0) do
local pz; st, pz = _alloc(st, awayRoster, W_GOAL)
_ensure(events.away, pz.id).goals = _ensure(events.away, pz.id).goals + 1
local r; st, r = _rngNext(st)
if r < 0.70 then
local a; st, a = _alloc(st, awayRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
if a and a.id ~= pz.id then _ensure(events.away, a.id).assists = _ensure(events.away, a.id).assists + 1 end
end
end
for _=1, (stats.home.poss_points or 0) do
local pz; st, pz = _alloc(st, homeRoster, W_POSS)
_ensure(events.home, pz.id).poss = _ensure(events.home, pz.id).poss + 1
end
for _=1, (stats.away.poss_points or 0) do
local pz; st, pz = _alloc(st, awayRoster, W_POSS)
_ensure(events.away, pz.id).poss = _ensure(events.away, pz.id).poss + 1
end
local function distributeShots(side, roster, on, off)
for _=1, (on or 0) do
local pz; st, pz = _alloc(st, roster, {Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0})
_ensure(side, pz.id).son = _ensure(side, pz.id).son + 1
end
for _=1, (off or 0) do
local pz; st, pz = _alloc(st, roster, {Striker=4, Playmaker=3, Utility=2, Defender=1, Keeper=0})
_ensure(side, pz.id).sof = _ensure(side, pz.id).sof + 1
end
end
distributeShots(events.home, homeRoster, stats.home.shots_on or 0, stats.home.shots_off or 0)
distributeShots(events.away, awayRoster, stats.away.shots_on or 0, stats.away.shots_off or 0)
for _=1, (stats.home.fouls or 0) do
local pz; st, pz = _alloc(st, homeRoster, W_FOUL)
_ensure(events.home, pz.id).fouls = _ensure(events.home, pz.id).fouls + 1
end
for _=1, (stats.away.fouls or 0) do
local pz; st, pz = _alloc(st, awayRoster, W_FOUL)
_ensure(events.away, pz.id).fouls = _ensure(events.away, pz.id).fouls + 1
end
return events
end
local function _computePlayerSeasonTotals(season, upToMd)
local dm = mapDays()
local inv = invertDayMap(dm)
local deals = _genTransferDeals(season)
local base = _buildBaseRosters(season)
local totals = {}
local function ensurePlayer(pid, name, pos)
if not totals[pid] then
totals[pid] = { id=pid, name=name or pid, pos=pos or "—", team="—",
apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0 }
end
return totals[pid]
end
for md=1, math.min(upToMd, TOTAL_ROUNDS) do
local day = inv[md]
if day then
local rosters = _applyTransfersUpTo(base, deals, md)
local fixtures = getRoundMatches(md)
for matchIndex, m in ipairs(fixtures) do
local h, a = m[1], m[2]
local hr, ar = rosters[h.code] or {}, rosters[a.code] or {}
local ev = _matchPlayerEvents(season, md, day, matchIndex, h, a, hr, ar)
for pid, stt in pairs(ev.home) do
local name, pos = pid, "—"
for _,pp in ipairs(hr) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
local row = ensurePlayer(pid, name, pos)
row.team = h.code
row.apps = row.apps + (stt.apps or 0)
row.goals = row.goals + (stt.goals or 0)
row.assists = row.assists + (stt.assists or 0)
row.poss = row.poss + (stt.poss or 0)
row.son = row.son + (stt.son or 0)
row.sof = row.sof + (stt.sof or 0)
row.fouls = row.fouls + (stt.fouls or 0)
end
for pid, stt in pairs(ev.away) do
local name, pos = pid, "—"
for _,pp in ipairs(ar) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
local row = ensurePlayer(pid, name, pos)
row.team = a.code
row.apps = row.apps + (stt.apps or 0)
row.goals = row.goals + (stt.goals or 0)
row.assists = row.assists + (stt.assists or 0)
row.poss = row.poss + (stt.poss or 0)
row.son = row.son + (stt.son or 0)
row.sof = row.sof + (stt.sof or 0)
row.fouls = row.fouls + (stt.fouls or 0)
end
end
end
end
return totals, deals, base
end
function p.renderRoster(frame)
local args = frame.args or {}
local team = args.team or args[1]
if not team or not TEAM_BY_CODE[team] then
return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''"
end
local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
local deals = _genTransferDeals(season)
local base = _buildBaseRosters(season)
local rosters = _applyTransfersUpTo(base, deals, upToMd)
local r = rosters[team] or {}
local out = {
'{| class="wikitable sortable" style="width:100%; font-size:90%;"',
'! # !! Player !! Position !! ID !! Notes'
}
for _,pl in ipairs(r) do
out[#out+1] = '|-'
out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> || %s',
pl.no or 0, pl.name or '—', pl.pos or '—', pl.id or '—', pl.notes or '—'
)
end
out[#out+1] = '|}'
return (TEAM_BY_CODE[team].logo or "") ..
" '''" .. team .. "''' (Roster as of Matchday " .. tostring(upToMd) .. ")\n" ..
table.concat(out, "\n")
end
function p.renderAllRosters(frame)
local args = frame.args or {}
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
local out = {}
for _,t in ipairs(teams) do
local title = (t.logo or "") .. " <b>" .. t.code .. "</b>"
out[#out+1] =
'<div class="mw-collapsible mw-collapsed" data-expandtext="Show roster" data-collapsetext="Hide roster" style="margin:0.6em 0;">' ..
'<div>' .. title .. '</div>' ..
'<div class="mw-collapsible-content">' ..
p.renderRoster({ args = { team = t.code, season = season, md = upToMd } }) ..
'</div>' ..
'</div>'
end
return table.concat(out, "\n")
end
function p.renderTransferLog(frame)
local args = frame.args or {}
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
local deals = _genTransferDeals(season)
local out = {
'{| class="wikitable sortable" style="width:100%; font-size:90%;"',
'! Matchday !! From !! To !! Swap (pos)'
}
for _,d in ipairs(deals) do
if d.md <= upToMd then
out[#out+1] = '|-'
out[#out+1] = string.format('| %d || %s || %s || <code>%s</code> ⇄ <code>%s</code> (%s)',
d.md, _teamWithLogo(d.A), _teamWithLogo(d.B), d.A_id, d.B_id, d.pos or '—'
)
end
end
out[#out+1] = '|}'
return "'''Transfer window (Matchdays " .. TRANSFER_MD_START .. "–" .. TRANSFER_MD_END ..
") — season " .. season .. " (through Matchday " .. tostring(upToMd) .. ")'''\n" ..
table.concat(out, "\n")
end
function p.renderPlayerStats(frame)
local args = frame.args or {}
local team = args.team or args[1]
if not team or not TEAM_BY_CODE[team] then
return "'''Unknown team. Pass team=Exact Team Name (matching teams list).'''"
end
local season = tonumber(args.season or args[2] or DEFAULT_SEASON) or DEFAULT_SEASON
local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
local totals, deals, base = _computePlayerSeasonTotals(season, upToMd)
local rostersNow = _applyTransfersUpTo(base, deals, upToMd)
local rosterNow = rostersNow[team] or {}
local out = {
'{| class="wikitable sortable" style="width:100%; font-size:90%;"',
'! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'
}
for _,pl in ipairs(rosterNow) do
local r = totals[pl.id]
out[#out+1] = '|-'
out[#out+1] = string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d',
pl.name, pl.pos,
r and r.apps or 0,
r and r.goals or 0,
r and r.assists or 0,
r and r.poss or 0,
r and r.son or 0,
r and r.sof or 0,
r and r.fouls or 0
)
end
out[#out+1] = '|}'
return (TEAM_BY_CODE[team].logo or "") ..
" '''" .. team .. "''' — Player totals through Matchday " .. tostring(upToMd) .. "\n" ..
table.concat(out, "\n")
end
function p.renderLeaders(frame)
local args = frame.args or {}
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
local upToMd = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
local stat = (args.stat or args[2] or "goals")
local ok = { goals=true, assists=true, poss=true, son=true, sof=true, fouls=true, apps=true }
if not ok[stat] then
return "'''Unknown stat. Use: goals, assists, poss, son, sof, fouls, apps'''"
end
local totals = (_computePlayerSeasonTotals(season, upToMd))
local list = {}
for _,r in pairs(totals) do list[#list+1] = r end
table.sort(list, function(a,b)
if (a[stat] or 0) ~= (b[stat] or 0) then return (a[stat] or 0) > (b[stat] or 0) end
return (a.name or "") < (b.name or "")
end)
local out = {
'{| class="wikitable sortable" style="width:100%; font-size:90%;"',
'! Rank !! Player !! Team (current) !! Pos !! ' .. stat
}
for i=1, math.min(20, #list) do
local r = list[i]
out[#out+1] = '|-'
out[#out+1] = string.format('| %d || %s || %s || %s || %d',
i, r.name or "—", _teamWithLogo(r.team or "—"), r.pos or "—", r[stat] or 0
)
end
out[#out+1] = '|}'
return "'''League leaders (" .. stat .. ") — season " .. season ..
" through Matchday " .. tostring(upToMd) .. "'''\n" ..
table.concat(out, "\n")
end
------------------------------------------------------------------------
-- 11) MATCH REPORTS (Match of the Week + Match Report) + DERBY TAG
------------------------------------------------------------------------
local function _fmtTime(seconds)
local m = math.floor(seconds / 60)
local s = seconds % 60
return string.format("%02d:%02d", m, s)
end
local function _abbr(teamName)
local letters = {}
for w in mw.ustring.gmatch(teamName or "", "%S+") do
letters[#letters+1] = mw.ustring.upper(mw.ustring.sub(w, 1, 1))
end
local a = table.concat(letters, "")
if #a >= 2 then return mw.ustring.sub(a, 1, 2) end
return (a ~= "" and a) or "XX"
end
local function _randTime(st, startSec, endSec)
local r; st, r = _rngNext(st)
local span = math.max(1, endSec - startSec - 2)
local sec = startSec + 1 + math.floor(r * span)
return st, sec
end
local function _genTimes(st, n, firstStart, firstEnd, secondStart, secondEnd)
local times = {}
if n <= 0 then return st, times end
local split = math.ceil(n / 2)
for i=1, n do
if i <= split then
st, times[i] = _randTime(st, firstStart, firstEnd)
else
st, times[i] = _randTime(st, secondStart, secondEnd)
end
end
table.sort(times)
return st, times
end
local function _timesList(times, maxn)
if not times or #times == 0 then return "" end
maxn = maxn or 3
local out = {}
for i = 1, math.min(maxn, #times) do out[#out+1] = _fmtTime(times[i]) end
if #times > maxn then out[#out+1] = "…" end
return " (" .. table.concat(out, ", ") .. ")"
end
local function _pickPlayer(st, roster, posA, posB, posC)
local candidates = {}
for _,p in ipairs(roster or {}) do
if p.pos == posA or p.pos == posB or p.pos == posC then
candidates[#candidates+1] = p
end
end
if #candidates == 0 then candidates = roster end
local pl; st, pl = _pick(st, candidates)
return st, pl
end
local function _rivalryTag(homeCode, awayCode)
local dh, da = DIV_OF[homeCode], DIV_OF[awayCode]
if dh and da and dh == da then
return " (Divisional derby — " .. dh .. ")"
end
return ""
end
local function _appendToBoldTitle(title, suffix)
if not suffix or suffix == "" then return title end
if not title then return suffix end
if mw.ustring.match(title, "'''%s*$") then
return mw.ustring.gsub(title, "'''%s*$", suffix .. "'''", 1)
end
return title .. suffix
end
local function _pickMatchIndexForDay(season, md, pickMode)
pickMode = pickMode or "best"
local fixtures = getRoundMatches(md)
if not fixtures or #fixtures == 0 then return nil, "No fixtures found for this matchday." end
local inv = invertDayMap(mapDays())
local day = inv[md] or (START + md)
local bestI, bestScore = 1, -1e18
for i, m in ipairs(fixtures) do
local home, away = m[1], m[2]
local salt = day * 1000 + i + string.byte(home.code,1) + string.byte(away.code,1)
local hs, as = simScoreFor(home, away, salt)
local S = computeMatchStats(home, away, hs, as, salt)
local H = S[home.code] or {}
local A = S[away.code] or {}
local diff = math.abs(hs - as)
local total = hs + as
local chaos = (tonumber(H.fouls) or 0) + (tonumber(A.fouls) or 0)
+ (tonumber(H.shots_on) or 0) + (tonumber(A.shots_on) or 0)
+ (tonumber(H.shots_off) or 0) + (tonumber(A.shots_off) or 0)
local q
if pickMode == "close" then
q = (50 - diff * 10) + total + chaos * 0.25
elseif pickMode == "high" then
q = total * 12 - diff * 2 + chaos * 0.20
elseif pickMode == "chaos" then
q = chaos * 10 + total * 2 - diff
else
q = (20 - diff * 6) + total * 8 + chaos * 0.35
end
if q > bestScore then bestScore, bestI = q, i end
end
return bestI
end
local function _renderMatchReport(season, md, matchIndex, titleOverride)
local fixtures = getRoundMatches(md)
if not fixtures or not fixtures[matchIndex] then
return "'''No such match (md=" .. tostring(md) .. ", match=" .. tostring(matchIndex) .. ").'''"
end
local inv = invertDayMap(mapDays())
local day = inv[md] or (START + md)
local home, away = fixtures[matchIndex][1], fixtures[matchIndex][2]
local derby = _rivalryTag(home.code, away.code)
local salt = day * 1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
local hs, as = simScoreFor(home, away, salt)
local S = computeMatchStats(home, away, hs, as, salt)
local H = S[home.code] or {}
local A = S[away.code] or {}
local deals = _genTransferDeals(season)
local base = _buildBaseRosters(season)
local rosters = _applyTransfersUpTo(base, deals, md)
local homeRoster = rosters[home.code] or {}
local awayRoster = rosters[away.code] or {}
local st = _seedFromString(tostring(season).."|REPORT|MD"..md.."|M"..matchIndex.."|"..home.code.."|"..away.code)
local FIRST_START, FIRST_END = 0, 35*60
local SECOND_START, SECOND_END = 35*60, 70*60
local homeTag, awayTag = _abbr(home.code), _abbr(away.code)
local homePP, awayPP, homeG, awayG
st, homePP = _genTimes(st, tonumber(H.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, awayPP = _genTimes(st, tonumber(A.poss_points) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, homeG = _genTimes(st, tonumber(H.goals) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, awayG = _genTimes(st, tonumber(A.goals) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
local homeSON, awaySON, homeFOUL, awayFOUL, homeOOB, awayOOB
st, homeSON = _genTimes(st, tonumber(H.shots_on) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, awaySON = _genTimes(st, tonumber(A.shots_on) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, homeFOUL = _genTimes(st, tonumber(H.fouls) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, awayFOUL = _genTimes(st, tonumber(A.fouls) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, homeOOB = _genTimes(st, tonumber(H.oob) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, awayOOB = _genTimes(st, tonumber(A.oob) or 0, FIRST_START, FIRST_END, SECOND_START, SECOND_END)
local scoreEvents = {}
local function queueScore(sec, which, kind) scoreEvents[#scoreEvents+1] = { t=sec, which=which, kind=kind } end
for _,sec in ipairs(homePP) do queueScore(sec, "H", "PP") end
for _,sec in ipairs(awayPP) do queueScore(sec, "A", "PP") end
for _,sec in ipairs(homeG) do queueScore(sec, "H", "G") end
for _,sec in ipairs(awayG) do queueScore(sec, "A", "G") end
table.sort(scoreEvents, function(a,b) return a.t < b.t end)
local events = {}
local function addEvent(sec, text) events[#events+1] = { t=sec, text=text } end
local hRun, aRun = 0, 0
local hHalf, aHalf = 0, 0
for _,e in ipairs(scoreEvents) do
local add = (e.kind == "G") and 3 or 1
if e.which == "H" then hRun = hRun + add else aRun = aRun + add end
if e.t < 35*60 then hHalf, aHalf = hRun, aRun end
local tag = (e.which=="H") and homeTag or awayTag
local roster = (e.which=="H") and homeRoster or awayRoster
local scoreLine = string.format("%s %d–%d %s", _teamWithLogo(home), hRun, aRun, _teamWithLogo(away))
if e.kind == "G" then
local pz; st, pz = _pickPlayer(st, roster, "Striker", "Utility", "Playmaker")
addEvent(e.t, string.format("%s – Goal (Pillar Strike) (%s): %s converts a direct pillar strike. (%s)",
_fmtTime(e.t), tag, (pz and pz.name or "a forward"), scoreLine))
else
local pp; st, pp = _pickPlayer(st, roster, "Playmaker", "Utility", "Striker")
addEvent(e.t, string.format("%s – Possession Play (%s): %s completes a 10-second controlled hold in the scoring zone. (%s)",
_fmtTime(e.t), tag, (pp and pp.name or "a midfielder"), scoreLine))
end
end
local function addHighlights(times, roster, tag, label, posA, posB, posC, template)
local cap = math.min(2, #times)
for i=1, cap do
local sec = times[i]
local px; st, px = _pickPlayer(st, roster, posA, posB, posC)
addEvent(sec, string.format("%s – %s (%s): " .. template,
_fmtTime(sec), label, tag, (px and px.name or "a player")))
end
end
addHighlights(homeFOUL, homeRoster, homeTag, "Foul", "Defender", "Utility", "Playmaker",
"%s clips a runner during a shielded carry; restart taken quickly.")
addHighlights(awayFOUL, awayRoster, awayTag, "Foul", "Defender", "Utility", "Playmaker",
"%s commits a tactical pull in transition; shape preserved on the restart.")
addHighlights(homeSON, homeRoster, homeTag, "Shot on Target", "Striker", "Utility", "Playmaker",
"%s forces a controlled save off a pillar-side angle; no strike awarded.")
addHighlights(awaySON, awayRoster, awayTag, "Shot on Target", "Striker", "Utility", "Playmaker",
"%s drives a low effort that glances the casing; play continues.")
addHighlights(homeOOB, homeRoster, homeTag, "Out of Bounds", "Utility", "Defender", "Playmaker",
"%s overhits a diagonal switch; possession turns over at the boundary.")
addHighlights(awayOOB, awayRoster, awayTag, "Out of Bounds", "Utility", "Defender", "Playmaker",
"%s sends a pressured clearance long; reset follows.")
table.sort(events, function(a,b) return a.t < b.t end)
local firstHalf, secondHalf = {}, {}
for _,e in ipairs(events) do
if e.t < 35*60 then firstHalf[#firstHalf+1] = e.text else secondHalf[#secondHalf+1] = e.text end
end
local diff = math.abs(hs - as)
local mood = (diff <= 1) and "a tight, tactical contest" or "a high-variance clash"
local summary = string.format(
"Matchday %d featured %s defined by controlled zone entries and disciplined restarts. %s edged %s %d–%d, with decisive moments coming from timed possession holds and selective pillar pressure rather than sustained long-range attempts.",
md, mood, _teamWithLogo(home), _teamWithLogo(away), hs, as
)
local baseTitle = titleOverride or string.format("'''Match of the Week — %d PSSC (Matchday %d)'''", season, md)
local title = _appendToBoldTitle(baseTitle, derby)
local out = {}
out[#out+1] = title
out[#out+1] = ""
out[#out+1] = "=== Match Summary ==="
out[#out+1] = summary
out[#out+1] = ""
out[#out+1] = "=== Key Events by Time ==="
out[#out+1] = ""
out[#out+1] = "==== First Half (0:00–35:00) ===="
if #firstHalf == 0 then
out[#out+1] = "—"
else
for _,ln in ipairs(firstHalf) do out[#out+1] = "* " .. ln end
end
out[#out+1] = ""
out[#out+1] = string.format("35:00 – Halftime: %s %d–%d %s.", _teamWithLogo(home), hHalf, aHalf, _teamWithLogo(away))
out[#out+1] = ""
out[#out+1] = "==== Second Half (35:00–70:00) ===="
if #secondHalf == 0 then
out[#out+1] = "—"
else
for _,ln in ipairs(secondHalf) do out[#out+1] = "* " .. ln end
end
out[#out+1] = ""
out[#out+1] = string.format("70:00 – Final Whistle: %s %d–%d %s.", _teamWithLogo(home), hs, as, _teamWithLogo(away))
out[#out+1] = ""
out[#out+1] = "=== Match Stats ==="
out[#out+1] = '{| class="wikitable" style="width:100%; font-size:90%;"'
out[#out+1] = string.format('! Statistic !! %s !! %s', _teamWithLogo(home), _teamWithLogo(away))
out[#out+1] = '|-'
out[#out+1] = string.format('| Final Score || %d || %d', hs, as)
out[#out+1] = '|-'
out[#out+1] = string.format('| Goals (Pillar Strikes) || %d%s || %d%s',
tonumber(H.goals) or 0, _timesList(homeG, 3),
tonumber(A.goals) or 0, _timesList(awayG, 3)
)
out[#out+1] = '|-'
out[#out+1] = string.format('| Possession Points || %d%s || %d%s',
tonumber(H.poss_points) or 0, _timesList(homePP, 4),
tonumber(A.poss_points) or 0, _timesList(awayPP, 4)
)
out[#out+1] = '|-'
out[#out+1] = string.format('| Shots on Target || %d || %d', tonumber(H.shots_on) or 0, tonumber(A.shots_on) or 0)
out[#out+1] = '|-'
out[#out+1] = string.format('| Shots off Target || %d || %d', tonumber(H.shots_off) or 0, tonumber(A.shots_off) or 0)
out[#out+1] = '|-'
out[#out+1] = string.format('| Fouls || %d || %d', tonumber(H.fouls) or 0, tonumber(A.fouls) or 0)
out[#out+1] = '|-'
out[#out+1] = string.format('| Free Kicks Awarded || %d || %d', tonumber(H.fks) or 0, tonumber(A.fks) or 0)
out[#out+1] = '|-'
out[#out+1] = string.format('| Out of Bounds || %d || %d', tonumber(H.oob) or 0, tonumber(A.oob) or 0)
out[#out+1] = '|-'
out[#out+1] = string.format('| Throw-Ins || %d || %d', tonumber(H.throws) or 0, tonumber(A.throws) or 0)
out[#out+1] = '|-'
out[#out+1] = string.format('| Goal Kicks || %d || %d', tonumber(H.gk) or 0, tonumber(A.gk) or 0)
out[#out+1] = '|-'
out[#out+1] = string.format('| Ball Possession || %d%% || %d%%', tonumber(H.poss_pct) or 0, tonumber(A.poss_pct) or 0)
out[#out+1] = '|}'
return table.concat(out, "\n")
end
function p.renderMatchOfWeek(frame)
local args = frame.args or {}
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
local pick = args.pick or "best"
if md < 1 then return "'''No matchday has been played yet.'''"
end
local fixtures = getRoundMatches(md)
if not fixtures or #fixtures == 0 then return "'''No fixtures found for matchday " .. tostring(md) .. ".'''" end
local idx, err = _pickMatchIndexForDay(season, md, pick)
if not idx then return "'''" .. (err or "Unable to select match.") .. "'''" end
return _renderMatchReport(season, md, idx)
end
function p.renderChaosOfWeek(frame)
local args = frame.args or {}
args.pick = args.pick or "chaos"
args.md = nil
return p.renderMatchOfWeek({ args = args })
end
function p.renderMatchReport(frame)
local args = frame.args or {}
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
local md = tonumber(args.md or args.matchday or args[2] or "") or _currentPlayedMatchday()
local matchIndex = tonumber(args.match or args[3] or "1") or 1
local titleOverride = string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''", season, md, matchIndex)
return _renderMatchReport(season, md, matchIndex, titleOverride)
end
------------------------------------------------------------------------
-- 12) WEEKLY AWARDS + MATCHDAY CAPSULE
------------------------------------------------------------------------
function p.renderWeeklyAwards(frame)
local args = frame.args or {}
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
if md < 1 then return "'''No matchday has been played yet.'''" end
local inv = invertDayMap(mapDays())
local day = inv[md] or (START + md)
local deals = _genTransferDeals(season)
local base = _buildBaseRosters(season)
local rosters = _applyTransfersUpTo(base, deals, md)
local function clamp(x, lo, hi)
if x < lo then return lo end
if x > hi then return hi end
return x
end
local function scorePlayer(line, pos, oppShotsOn, oppGoals)
local goals = line.goals or 0
local assists = line.assists or 0
local poss = line.poss or 0
local son = line.son or 0
local sof = line.sof or 0
local fouls = line.fouls or 0
local rating = 6
+ goals*1.25
+ assists*0.85
+ poss*0.05
+ son*0.15
- sof*0.08
- fouls*0.16
if pos == "Keeper" then
local saves = math.max(0, (oppShotsOn or 0) - (oppGoals or 0))
rating = 6 + saves*0.25 - fouls*0.10
if (oppGoals or 0) == 0 then rating = rating + 1.0 end
end
return clamp(rating, 0, 10)
end
local best = { overall=nil, striker=nil, playmaker=nil, defender=nil, keeper=nil }
local function consider(entry, bucket)
if not best[bucket] or entry.rating > best[bucket].rating then best[bucket] = entry end
end
local fixtures = getRoundMatches(md)
for matchIndex, m in ipairs(fixtures) do
local home, away = m[1], m[2]
local hr = rosters[home.code] or {}
local ar = rosters[away.code] or {}
local salt = day*1000 + matchIndex + string.byte(home.code,1) + string.byte(away.code,1)
local hs, as = simScoreFor(home, away, salt)
local S = computeMatchStats(home, away, hs, as, salt)
local H = S[home.code] or {}
local A = S[away.code] or {}
local st = _seedFromString(tostring(season).."|AWARDS|MD"..md.."|D"..day.."|M"..matchIndex.."|"..home.code.."|"..away.code)
local linesH, linesA = {}, {}
local function ensure(map, pid)
if not map[pid] then map[pid] = {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0} end
return map[pid]
end
local function pickWeighted(stt, roster, weights)
local pool = {}
for _,p in ipairs(roster or {}) do
local w = weights[p.pos] or 1
for _=1,w do pool[#pool+1] = p end
end
return _pick(stt, pool)
end
local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
local W_SHOT = { Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
for _=1, tonumber(H.goals) or 0 do
local pl; st, pl = pickWeighted(st, hr, W_GOAL)
ensure(linesH, pl.id).goals = ensure(linesH, pl.id).goals + 1
local r; st, r = _rngNext(st)
if r < 0.70 then
local a; st, a = pickWeighted(st, hr, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
if a and a.id ~= pl.id then ensure(linesH, a.id).assists = ensure(linesH, a.id).assists + 1 end
end
end
for _=1, tonumber(A.goals) or 0 do
local pl; st, pl = pickWeighted(st, ar, W_GOAL)
ensure(linesA, pl.id).goals = ensure(linesA, pl.id).goals + 1
local r; st, r = _rngNext(st)
if r < 0.70 then
local a; st, a = pickWeighted(st, ar, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
if a and a.id ~= pl.id then ensure(linesA, a.id).assists = ensure(linesA, a.id).assists + 1 end
end
end
for _=1, tonumber(H.poss_points) or 0 do
local pl; st, pl = pickWeighted(st, hr, W_POSS)
ensure(linesH, pl.id).poss = ensure(linesH, pl.id).poss + 1
end
for _=1, tonumber(A.poss_points) or 0 do
local pl; st, pl = pickWeighted(st, ar, W_POSS)
ensure(linesA, pl.id).poss = ensure(linesA, pl.id).poss + 1
end
for _=1, tonumber(H.shots_on) or 0 do
local pl; st, pl = pickWeighted(st, hr, W_SHOT)
ensure(linesH, pl.id).son = ensure(linesH, pl.id).son + 1
end
for _=1, tonumber(H.shots_off) or 0 do
local pl; st, pl = pickWeighted(st, hr, W_SHOT)
ensure(linesH, pl.id).sof = ensure(linesH, pl.id).sof + 1
end
for _=1, tonumber(A.shots_on) or 0 do
local pl; st, pl = pickWeighted(st, ar, W_SHOT)
ensure(linesA, pl.id).son = ensure(linesA, pl.id).son + 1
end
for _=1, tonumber(A.shots_off) or 0 do
local pl; st, pl = pickWeighted(st, ar, W_SHOT)
ensure(linesA, pl.id).sof = ensure(linesA, pl.id).sof + 1
end
for _=1, tonumber(H.fouls) or 0 do
local pl; st, pl = pickWeighted(st, hr, W_FOUL)
ensure(linesH, pl.id).fouls = ensure(linesH, pl.id).fouls + 1
end
for _=1, tonumber(A.fouls) or 0 do
local pl; st, pl = pickWeighted(st, ar, W_FOUL)
ensure(linesA, pl.id).fouls = ensure(linesA, pl.id).fouls + 1
end
local function evalTeam(teamObj, roster, lines, oppStats)
local oppShotsOn = tonumber(oppStats.shots_on) or 0
local oppGoals = tonumber(oppStats.goals) or 0
for _,pp in ipairs(roster or {}) do
local line = lines[pp.id] or {goals=0, assists=0, poss=0, son=0, sof=0, fouls=0}
local rating = scorePlayer(line, pp.pos, oppShotsOn, oppGoals)
local saves, clean = 0, false
if pp.pos == "Keeper" then
saves = math.max(0, oppShotsOn - oppGoals)
clean = (oppGoals == 0)
end
local entry = {
rating=rating, name=pp.name or pp.id, pos=pp.pos or "—",
team=teamObj.code, goals=line.goals or 0, assists=line.assists or 0,
poss=line.poss or 0, son=line.son or 0, sof=line.sof or 0, fouls=line.fouls or 0,
saves=saves, clean=clean
}
consider(entry, "overall")
if pp.pos == "Striker" then consider(entry, "striker") end
if pp.pos == "Playmaker" then consider(entry, "playmaker") end
if pp.pos == "Defender" then consider(entry, "defender") end
if pp.pos == "Keeper" then consider(entry, "keeper") end
end
end
evalTeam(home, hr, linesH, A)
evalTeam(away, ar, linesA, H)
end
local out = {}
out[#out+1] = "=== Weekly Awards (Matchday " .. tostring(md) .. ") ==="
out[#out+1] = '{| class="wikitable sortable" style="width:100%; font-size:90%;"'
out[#out+1] = "! Award !! Winner !! Team !! Pos !! G !! A !! Poss !! S-on !! S-off !! Fouls !! Saves/CS !! Rating"
local function row(label, e)
if not e then return end
local sc = "—"
if e.pos == "Keeper" then sc = tostring(e.saves or 0) .. ((e.clean and " / CS") or "") end
out[#out+1] = "|-"
out[#out+1] = string.format("| %s || %s || %s || %s || %d || %d || %d || %d || %d || %d || %s || %.2f",
label, e.name or "—", _teamWithLogo(e.team or "—"), e.pos or "—",
e.goals or 0, e.assists or 0, e.poss or 0, e.son or 0, e.sof or 0, e.fouls or 0, sc, e.rating or 0
)
end
row("Player of the Week", best.overall)
row("Striker of the Week", best.striker)
row("Playmaker of the Week", best.playmaker)
row("Defender of the Week", best.defender)
row("Keeper of the Week", best.keeper)
out[#out+1] = "|}"
return table.concat(out, "\n")
end
function p.renderMatchdayCapsule(frame)
local args = frame.args or {}
local season = tonumber(args.season or args[1] or DEFAULT_SEASON) or DEFAULT_SEASON
local md = tonumber(args.md or args.matchday or "") or _currentPlayedMatchday()
local pick = args.pick or "chaos"
if md < 1 then return "'''No matchday has been played yet.'''"
end
local inv = invertDayMap(mapDays())
local day = inv[md]
if not day then return "'''Unable to resolve PSSC day for matchday " .. tostring(md) .. ".'''"
end
local results = simulateDayResults(day)
local out = {}
out[#out+1] = "== Matchday " .. tostring(md) .. " Capsule =="
out[#out+1] = ""
out[#out+1] = p.renderMatchOfWeek({ args = { season = season, md = md, pick = pick } })
out[#out+1] = ""
out[#out+1] = "=== Full Results ==="
for _, r in ipairs(results or {}) do
out[#out+1] = string.format("* %s '''%d''' %s vs %s '''%d''' %s",
(r.home and r.home.logo) or "",
tonumber(r.hg) or 0,
(r.home and (r.home.fullName or r.home.code)) or "Home",
(r.away and r.away.logo) or "",
tonumber(r.ag) or 0,
(r.away and (r.away.fullName or r.away.code)) or "Away"
)
end
out[#out+1] = ""
out[#out+1] = p.renderWeeklyAwards({ args = { season = season, md = md } })
return table.concat(out, "\n")
end
------------------------------------------------------------------------
-- EXPORT
------------------------------------------------------------------------
return p