Module:PillSeasonSchedule
From MicrasWiki
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 -- 61-day “months”
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
------------------------------------------------------------------------
-- 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) DIVISION-WEIGHTED SCHEDULER
-- • In-division: 7 rivals twice (H/A) -> 14 matches
-- • Cross-division: other 24 once -> 24 matches
-- = 38 total per club
------------------------------------------------------------------------
local function homeFirst(codeA, codeB)
local sA, sB = 0, 0
for i=1,#codeA do sA = (sA + string.byte(codeA,i)) % 9973 end
for i=1,#codeB do sB = (sB + string.byte(codeB,i)) % 9973 end
return sA > sB
end
local function buildPairings()
local intra, inter = {}, {}
for _, list in pairs(DIVISIONS) do
for i=1,#list-1 do
for j=i+1,#list do
local A, B = TEAM_BY_CODE[list[i]], TEAM_BY_CODE[list[j]]
table.insert(intra, {home=A, away=B})
table.insert(intra, {home=B, away=A})
end
end
end
local allCodes = {}
for _,t in ipairs(teams) do table.insert(allCodes, t.code) end
table.sort(allCodes)
for ai=1,#allCodes-1 do
for bi=ai+1,#allCodes do
local ca, cb = allCodes[ai], allCodes[bi]
if DIV_OF[ca] ~= DIV_OF[cb] then
local a, b = TEAM_BY_CODE[ca], TEAM_BY_CODE[cb]
local hFirst = homeFirst(ca, cb)
table.insert(inter, {home = hFirst and a or b, away = hFirst and b or a})
end
end
end
table.sort(intra, function(a,b)
if DIV_OF[a.home.code] ~= DIV_OF[b.home.code] then
return DIV_OF[a.home.code] < DIV_OF[b.home.code]
end
return a.home.code .. a.away.code < b.home.code .. b.away.code
end)
table.sort(inter, function(a,b)
local ka = DIV_OF[a.home.code] .. "|" .. DIV_OF[a.away.code]
local kb = DIV_OF[b.home.code] .. "|" .. DIV_OF[b.away.code]
if ka ~= kb then return ka < kb end
return a.home.code .. a.away.code < b.home.code .. b.away.code
end)
return intra, inter
end
local function packRounds(matches)
local rounds, used = {}, {}
local function canPlace(r, m)
return not (used[r] and (used[r][m.home.code] or used[r][m.away.code]))
end
for _, m in ipairs(matches) do
local placed = false
for r=1,#rounds do
if canPlace(r, m) then
rounds[r][#rounds[r]+1] = { m.home, m.away }
used[r] = used[r] or {}
used[r][m.home.code] = true
used[r][m.away.code] = true
placed = true
break
end
end
if not placed then
rounds[#rounds+1] = { { m.home, m.away } }
used[#rounds] = {}
used[#rounds][m.home.code] = true
used[#rounds][m.away.code] = true
end
end
return rounds
end
local SCHED = (function()
local intra, inter = buildPairings()
local function chunk(arr, size)
local out = {}
for i=1,#arr,size do
local b = {}
for j=i, math.min(i+size-1, #arr) do b[#b+1] = arr[j] end
out[#out+1] = b
end
return out
end
local intraBlocks = chunk(intra, 64)
local interBlocks = chunk(inter, 128)
local merged, ia, ib = {}, 1, 1
while ia <= #intraBlocks or ib <= #interBlocks do
if ia <= #intraBlocks then
for _,m in ipairs(intraBlocks[ia]) do merged[#merged+1] = m end
ia = ia + 1
end
if ib <= #interBlocks then
for _,m in ipairs(interBlocks[ib]) do merged[#merged+1] = m end
ib = ib + 1
end
end
return packRounds(merged)
end)()
local function generateSchedule() return SCHED end
local function mapDays()
local totalDays = END - START + 1
local totalRnds = #SCHED
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
------------------------------------------------------------------------
-- 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 + 1, a.championships + 1
local rem = (p - 0.1) / 0.9
local homeWin= rem < (wh / (wh + wa))
local winG = math.random(1, 12)
local loseG = math.random(0, math.min(winG - 1, 12))
if homeWin then hg, ag = winG, loseG else hg, ag = loseG, winG end
end
return hg, ag
end
local function simulateDayResults(day)
local dm = mapDays()
local idx = dm[day]
if not idx then return {} end
local out = {}
for k, m in ipairs(SCHED[idx]) do
local h, a = m[1], m[2]
local salt = day*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
local hg, ag = simScoreFor(h, a, salt)
out[#out+1] = { home=h, away=a, hg=hg, ag=ag }
end
return out
end
------------------------------------------------------------------------
-- 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 = {}
if not upcoming then
for _, r in ipairs(simulateDayResults(target)) do
local h, a, hg, ag = r.home, r.away, r.hg, r.ag
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
local idx = dm[target]
for _, m in ipairs(SCHED[idx]) do
local h, a = m[1], m[2]
rows[#rows+1] =
string.format("%s %s vs %s %s",
h.logo, h.fullName,
a.logo, a.fullName
)
end
end
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 (color-coded by divisional position)
------------------------------------------------------------------------
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
for k, m in ipairs(SCHED[idx]) do
local h, a = m[1], m[2]
local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
local hg, ag = simScoreFor(h, a, salt)
local H, A = recs[h.code], recs[a.code]
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]
for k, m in ipairs(SCHED[idx]) do
local h, a = m[1], m[2]
local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
local hg, ag = simScoreFor(h, a, salt)
local hs = "'''" .. hg .. "-" .. ag .. "'''"
out[#out+1] = "|-"
out[#out+1] =
"| " .. dateLabel
.. " || " .. h.logo .. " " .. h.fullName
.. " || " .. hs
.. " || " .. a.logo .. " " .. a.fullName
end
end
out[#out+1] = "|}"
return table.concat(out, "\n")
end
------------------------------------------------------------------------
-- 8) PLAYOFFS (12 teams seeded from divisions; unchanged seeding block)
------------------------------------------------------------------------
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
for k, m in ipairs(SCHED[idx]) do
local h, a = m[1], m[2]
local salt = d*1000 + k + string.byte(h.code,1) + string.byte(a.code,1)
local hg, ag = simScoreFor(h, a, salt)
local H, A = recs[h.code], recs[a.code]
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({
"Morovian Division","Southern Strait Division",
"Western Highlands Division","Normarkian Division"
}) 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+1,B.championships+1
local home = (p-0.10)/0.90 < wa/(wa+wb)
local hi = math.random(1,12)
local lo = math.random(0, math.min(hi-1,12))
return home and hi or lo, home and lo or hi
end
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 = {}
if #W_R8 == 4 then
local idxByCode = {}
for i,t in ipairs(seeds) do idxByCode[t.code] = i end
table.sort(W_R8, function(a,b) return idxByCode[a.code] < idxByCode[b.code] end)
R4 = { { W_R8[1], W_R8[4] }, { W_R8[2], W_R8[3] } }
end
local W_R4 = {}
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 (compute only on match day)
-- Guarantees: Final = 3*Goals + PossessionPts
-- Adds column-wise highlight of higher value (light green)
------------------------------------------------------------------------
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
-- highlight helper: returns HTML-wrapped cells for the higher value
local function hiPair(hVal, aVal)
local function cell(v, isHigh)
if isHigh then
return string.format('<span style="background-color:#ccffcc;">%s</span>', v)
else
return tostring(v)
end
end
local hHigh = (hVal > aVal)
local aHigh = (aVal > hVal)
return cell(hVal, hHigh), cell(aVal, aHigh)
end
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 out = {
'{| class="wikitable sortable"',
'|+ Match Statistics — ' .. label .. (isToday and '' or ' (fixtures; stats available day-of)'),
'! Match !! Team !! Final !! Goals (Pillar) !! Possession Pts !! Shots on !! Shots off !! Fouls !! Free Kicks !! Throw-Ins !! Goal Kicks !! OOB !! Poss %'
}
if isToday then
local results = simulateDayResults(target)
for k, r in ipairs(results) do
local seed = target*1000 + k + string.byte(r.home.code,1) + string.byte(r.away.code,1)
local S = computeMatchStats(r.home, r.away, r.hg, r.ag, seed)
local H, A = S[r.home.code], S[r.away.code]
local matchLabel = r.home.logo .. ' ' .. r.home.fullName .. ' vs ' .. r.away.logo .. ' ' .. r.away.fullName
local hF,aF = hiPair(H.final, A.final)
local hG,aG = hiPair(H.goals, A.goals)
local hP,aP = hiPair(H.poss_points, A.poss_points)
local hSON,aSON = hiPair(H.shots_on, A.shots_on)
local hSOF,aSOF = hiPair(H.shots_off, A.shots_off)
local hFO,aFO = hiPair(H.fouls, A.fouls)
local hFK,aFK = hiPair(H.fks, A.fks)
local hTH,aTH = hiPair(H.throws, A.throws)
local hGK,aGK = hiPair(H.gk, A.gk)
local hOOB,aOOB = hiPair(H.oob, A.oob)
local hPOS,aPOS = hiPair(H.poss_pct .. '%', A.poss_pct .. '%')
out[#out+1] = '|-'
out[#out+1] = string.format(
'| rowspan="2" | %s || %s %s || %s || %s || %s || %s || %s || %s || %s || %s || %s || %s || %s',
matchLabel, r.home.logo, r.home.fullName,
hF, hG, hP, hSON, hSOF, hFO, hFK, hTH, hGK, hOOB, hPOS)
out[#out+1] = '|-'
out[#out+1] = string.format(
'| %s %s || %s || %s || %s || %s || %s || %s || %s || %s || %s || %s || %s',
r.away.logo, r.away.fullName,
aF, aG, aP, aSON, aSOF, aFO, aFK, aTH, aGK, aOOB, aPOS)
end
else
for _, m in ipairs(SCHED[idx]) do
local matchLabel = m[1].logo .. ' ' .. m[1].fullName .. ' vs ' .. m[2].logo .. ' ' .. m[2].fullName
out[#out+1] = '|-'
out[#out+1] = string.format('| rowspan="2" | %s || %s %s || — || — || — || — || — || — || — || — || — || — || —',
matchLabel, m[1].logo, m[1].fullName)
out[#out+1] = '|-'
out[#out+1] = string.format('| %s %s || — || — || — || — || — || — || — || — || — || —',
m[2].logo, m[2].fullName)
end
end
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" end
local matchIdx
for k, m in ipairs(SCHED[idx]) do
if m[1].code == home and m[2].code == away then matchIdx = k; break end
end
if not matchIdx then return "'''Match not found on that day with that home/away.''' end" end
local m = SCHED[idx][matchIdx]
local mon, dom = getMonthDay(day)
local dateLabel= dom .. " " .. mon .. " " .. year .. " PSSC"
local out = {
'{| class="wikitable"',
'|+ Match Statistics — ' .. dateLabel .. ((day == dayNow) and '' or ' (fixture; stats available day-of)'),
'! Statistic !! ' .. m[1].fullName .. ' !! ' .. m[2].fullName
}
if day == dayNow then
local resList = simulateDayResults(day)
local r = resList[matchIdx]
local seed = day*1000 + matchIdx + string.byte(home,1) + string.byte(away,1)
local S = computeMatchStats(m[1], m[2], r.hg, r.ag, seed)
local H, A = S[home], S[away]
local function hi(hv, av, suffix)
local h,a = tostring(hv) .. (suffix or ""), tostring(av) .. (suffix or "")
if hv > av then h = '<span style="background-color:#ccffcc;">'..h..'</span>'
elseif av > hv then a = '<span style="background-color:#ccffcc;">'..a..'</span>' end
return h, a
end
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, deterministic)
-- Includes wide-variation Bassaridian-style name generation.
------------------------------------------------------------------------
-- Config
local ROSTER_SIZE = 12
local TRANSFER_MD_START = 20
local TRANSFER_MD_END = 30
local DEFAULT_SEASON = 52
-- Positions (edit to match in-world naming later if you want)
local POSITIONS = {
"Striker","Striker",
"Playmaker","Playmaker","Playmaker",
"Defender","Defender","Defender",
"Keeper",
"Utility","Utility","Utility"
}
-----------------------------------------------------------------------
-- Deterministic PRNG (does NOT touch math.random / randomseed)
-----------------------------------------------------------------------
local function _seedFromString(s)
local 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 (syllables + light diacritics)
-----------------------------------------------------------------------
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)
-- very light touch
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 ""))
-- rare hyphen/apostrophe
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
-----------------------------------------------------------------------
-- Base roster generation (stable per team+season)
-----------------------------------------------------------------------
local function _genBaseRoster(teamCode, season)
local key = _normKey(teamCode) or teamCode
local st = _seedFromString(tostring(season) .. "|" .. key .. "|ROSTER")
local style = _chooseStyleForTeam(teamCode, season)
local roster, used = {}, {}
for i=1, ROSTER_SIZE do
local id = key .. "-" .. string.format("%02d", i)
local full
for _=1, 10 do
local first, last
st, first = _buildNameFrom(style, st, false)
st, last = _buildNameFrom(style, st, true)
full = first .. " " .. last
if not used[full] then used[full] = true; break end
end
roster[i] = {
id = id,
name = full or ("Player " .. id),
pos = POSITIONS[i] or "Utility",
no = i
}
end
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
-----------------------------------------------------------------------
-- Transfer market (deterministic 1-for-1 swaps, MD20..MD30)
-----------------------------------------------------------------------
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) -- 1–3 per matchday
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
-----------------------------------------------------------------------
-- Matchday helpers
-----------------------------------------------------------------------
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
-----------------------------------------------------------------------
-- Allocate per-player events that SUM to computeMatchStats outputs
-----------------------------------------------------------------------
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]
local A = S[awayTeam.code]
-- ADD THIS:
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={} }
-- appearances: count everyone on roster as 1 appearance (simple)
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 }
-- goals + assists
for _=1, stats.home.goals do
local p; st, p = _alloc(st, homeRoster, W_GOAL)
_ensure(events.home, p.id).goals = _ensure(events.home, p.id).goals + 1
local r; st, r = _rngNext(st)
if r < 0.70 then
local a; st, a = _alloc(st, homeRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
if a and a.id ~= p.id then _ensure(events.home, a.id).assists = _ensure(events.home, a.id).assists + 1 end
end
end
for _=1, stats.away.goals do
local p; st, p = _alloc(st, awayRoster, W_GOAL)
_ensure(events.away, p.id).goals = _ensure(events.away, p.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 ~= p.id then _ensure(events.away, a.id).assists = _ensure(events.away, a.id).assists + 1 end
end
end
-- possession points
for _=1, stats.home.poss_points do
local p; st, p = _alloc(st, homeRoster, W_POSS)
_ensure(events.home, p.id).poss = _ensure(events.home, p.id).poss + 1
end
for _=1, stats.away.poss_points do
local p; st, p = _alloc(st, awayRoster, W_POSS)
_ensure(events.away, p.id).poss = _ensure(events.away, p.id).poss + 1
end
-- shots on/off
local function distributeShots(side, roster, on, off)
for _=1, on do
local p; st, p = _alloc(st, roster, {Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0})
_ensure(side, p.id).son = _ensure(side, p.id).son + 1
end
for _=1, off do
local p; st, p = _alloc(st, roster, {Striker=4, Playmaker=3, Utility=2, Defender=1, Keeper=0})
_ensure(side, p.id).sof = _ensure(side, p.id).sof + 1
end
end
distributeShots(events.home, homeRoster, stats.home.shots_on, stats.home.shots_off)
distributeShots(events.away, awayRoster, stats.away.shots_on, stats.away.shots_off)
-- fouls
for _=1, stats.home.fouls do
local p; st, p = _alloc(st, homeRoster, W_FOUL)
_ensure(events.home, p.id).fouls = _ensure(events.home, p.id).fouls + 1
end
for _=1, stats.away.fouls do
local p; st, p = _alloc(st, awayRoster, W_FOUL)
_ensure(events.away, p.id).fouls = _ensure(events.away, p.id).fouls + 1
end
return events
end
-----------------------------------------------------------------------
-- Season totals (recomputed on view; "auto-updates" with matchday)
-----------------------------------------------------------------------
local function _computePlayerSeasonTotals(season, upToMd)
local dm = mapDays()
local inv = _invertDayMap(dm)
local deals = _genTransferDeals(season)
local base = _buildBaseRosters(season)
local totals = {} -- pid -> totals row
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, #SCHED) do
local day = inv[md]
if day then
local rosters = _applyTransfersUpTo(base, deals, md)
for matchIndex, m in ipairs(SCHED[md]) do
local h, a = m[1], m[2]
local hr, ar = rosters[h.code], rosters[a.code]
local ev = _matchPlayerEvents(season, md, day, matchIndex, h, a, hr, ar)
-- home
for pid, st in pairs(ev.home) do
local name, pos = pid, "—"
for _,pp in ipairs(hr) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
local row = ensurePlayer(pid, name, pos)
row.team = h.code
row.apps = row.apps + (st.apps or 0)
row.goals = row.goals + (st.goals or 0)
row.assists = row.assists + (st.assists or 0)
row.poss = row.poss + (st.poss or 0)
row.son = row.son + (st.son or 0)
row.sof = row.sof + (st.sof or 0)
row.fouls = row.fouls + (st.fouls or 0)
end
-- away
for pid, st in pairs(ev.away) do
local name, pos = pid, "—"
for _,pp in ipairs(ar) do if pp.id == pid then name=pp.name; pos=pp.pos; break end end
local row = ensurePlayer(pid, name, pos)
row.team = a.code
row.apps = row.apps + (st.apps or 0)
row.goals = row.goals + (st.goals or 0)
row.assists = row.assists + (st.assists or 0)
row.poss = row.poss + (st.poss or 0)
row.son = row.son + (st.son or 0)
row.sof = row.sof + (st.sof or 0)
row.fouls = row.fouls + (st.fouls or 0)
end
end
end
end
return totals, deals, base
end
-----------------------------------------------------------------------
-- PUBLIC: Single roster
-- {{#invoke:PillSeasonSchedule|renderRoster|team=Vaeringheim Pillar|season=52}}
-----------------------------------------------------------------------
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
-----------------------------------------------------------------------
-- PUBLIC: All rosters (collapsible per team)
-- {{#invoke:PillSeasonSchedule|renderAllRosters|season=52}}
-----------------------------------------------------------------------
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
-----------------------------------------------------------------------
-- PUBLIC: Transfer log (up to current matchday unless md provided)
-- {{#invoke:PillSeasonSchedule|renderTransferLog|season=52}}
-----------------------------------------------------------------------
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, d.A, d.B, d.A_id, d.B_id, d.pos or '—'
)
end
end
out[#out+1] = '|}'
return "'''Transfer window (Matchdays " .. TRANSFER_MD_START .. "–" .. TRANSFER_MD_END ..
") — season " .. season .. " (through Matchday " .. tostring(upToMd) .. ")'''\n" ..
table.concat(out, "\n")
end
-----------------------------------------------------------------------
-- PUBLIC: Team player totals (team roster as of md, totals through md)
-- {{#invoke:PillSeasonSchedule|renderPlayerStats|team=Vaeringheim Pillar|season=52}}
-----------------------------------------------------------------------
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
-----------------------------------------------------------------------
-- PUBLIC: League leaders (top 20)
-- {{#invoke:PillSeasonSchedule|renderLeaders|stat=goals|season=52}}
-----------------------------------------------------------------------
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 "—", 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
-----------------------------------------------------------------------
-- MATCH REPORTS: Match of the Week + single match report
-- Fixes:
-- • Uses MediaWiki headings (=) (no "1.1.1" numbering)
-- • Running score uses Pillarion scoring: +3 per Goal, +1 per Possession Play
-- • Uses computeMatchStats keys: final, goals, poss_points, shots_on/off, fouls, fks, throws, gk, oob, poss_pct
-----------------------------------------------------------------------
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 _getN(t, k) return (t and tonumber(t[k])) or 0 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 _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 _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 _pickMatchIndexForDay(season, md, pickMode)
pickMode = pickMode or "best"
local fixtures = SCHED[md]
if not fixtures 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 = (_getN(H,"fouls") + _getN(A,"fouls"))
+ (_getN(H,"shots_on") + _getN(A,"shots_on"))
+ (_getN(H,"shots_off") + _getN(A,"shots_off"))
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 = SCHED[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 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 {}
-- Rosters (transfer-aware for this matchday)
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 {}
-- Times
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)
-- scoring components (GOAL = 3 points, POSS = 1 point)
local homePP, awayPP, homeG, awayG
st, homePP = _genTimes(st, _getN(H,"poss_points"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, awayPP = _genTimes(st, _getN(A,"poss_points"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, homeG = _genTimes(st, _getN(H,"goals"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, awayG = _genTimes(st, _getN(A,"goals"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
-- highlights
local homeSON, awaySON, homeFOUL, awayFOUL, homeOOB, awayOOB
st, homeSON = _genTimes(st, _getN(H,"shots_on"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, awaySON = _genTimes(st, _getN(A,"shots_on"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, homeFOUL = _genTimes(st, _getN(H,"fouls"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, awayFOUL = _genTimes(st, _getN(A,"fouls"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, homeOOB = _genTimes(st, _getN(H,"oob"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
st, awayOOB = _genTimes(st, _getN(A,"oob"), FIRST_START, FIRST_END, SECOND_START, SECOND_END)
-- Build scoring timeline (running score must add +3 for goals, +1 for possession)
local scoreEvents = {}
local function queueScore(sec, which, kind)
scoreEvents[#scoreEvents+1] = { t=sec, which=which, kind=kind } -- kind="PP" or "G"
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", home.code, hRun, aRun, away.code)
if e.kind == "G" then
local p; st, p = _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, (p and p.name or "a forward"), scoreLine))
else
local p; st, p = _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, (p and p.name or "a midfielder"), scoreLine))
end
end
-- Add a few non-scoring highlights (kept small)
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 p; st, p = _pickPlayer(st, roster, posA, posB, posC)
addEvent(sec, string.format("%s – %s (%s): " .. template,
_fmtTime(sec), label, tag, (p and p.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)
-- Split into halves
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
-- Summary
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, home.code, away.code, hs, as
)
local title = titleOverride or string.format("'''Match of the Week — %d PSSC (Matchday %d)'''", season, md)
-- Output using MediaWiki headings (no numbered list artifacts)
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.", home.code, hHalf, aHalf, away.code)
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.", home.code, hs, as, away.code)
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', home.code, away.code)
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',
_getN(H,"goals"), _timesList(homeG, 3),
_getN(A,"goals"), _timesList(awayG, 3)
)
out[#out+1] = '|-'
out[#out+1] = string.format('| Possession Points || %d%s || %d%s',
_getN(H,"poss_points"), _timesList(homePP, 4),
_getN(A,"poss_points"), _timesList(awayPP, 4)
)
out[#out+1] = '|-'
out[#out+1] = string.format('| Shots on Target || %d || %d', _getN(H,"shots_on"), _getN(A,"shots_on"))
out[#out+1] = '|-'
out[#out+1] = string.format('| Shots off Target || %d || %d', _getN(H,"shots_off"), _getN(A,"shots_off"))
out[#out+1] = '|-'
out[#out+1] = string.format('| Fouls || %d || %d', _getN(H,"fouls"), _getN(A,"fouls"))
out[#out+1] = '|-'
out[#out+1] = string.format('| Free Kicks Awarded || %d || %d', _getN(H,"fks"), _getN(A,"fks"))
out[#out+1] = '|-'
out[#out+1] = string.format('| Out of Bounds || %d || %d', _getN(H,"oob"), _getN(A,"oob"))
out[#out+1] = '|-'
out[#out+1] = string.format('| Throw-Ins || %d || %d', _getN(H,"throws"), _getN(A,"throws"))
out[#out+1] = '|-'
out[#out+1] = string.format('| Goal Kicks || %d || %d', _getN(H,"gk"), _getN(A,"gk"))
out[#out+1] = '|-'
out[#out+1] = string.format('| Ball Possession || %d%% || %d%%', _getN(H,"poss_pct"), _getN(A,"poss_pct"))
out[#out+1] = '|}'
return table.concat(out, "\n")
end
-- PUBLIC: Match of the Week (auto-pick match on a matchday)
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 "chaos"
if md < 1 then return "'''No matchday has been played yet.'''"
end
if not SCHED[md] 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
-- PUBLIC: Force a specific match report (md + match index)
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 title = string.format("'''Match Report — %d PSSC (Matchday %d, Match %d)'''", season, md, matchIndex)
return _renderMatchReport(season, md, matchIndex, title)
end
function p.renderChaosOfWeek(frame)
local args = frame.args or {}
args.pick = args.pick or "chaos"
args.md = nil -- force latest matchday
return p.renderMatchOfWeek({ args = args })
end
-- Per-match player allocation + weekly awards (deterministic; sums to match stats)
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)
-- Base rating around 6; tune weights whenever you want.
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.2
+ assists*0.8
+ poss*0.05
+ son*0.15
- sof*0.08
- fouls*0.15
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 -- clean sheet bonus
end
return _clamp(rating, 0, 10)
end
local function _allocMatchPlayerLines(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 st = _seedFromString(tostring(season).."|AWARDS|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)
local linesH, linesA = {}, {}
local function ensure(map, pid)
map[pid] = map[pid] or { goals=0, assists=0, poss=0, son=0, sof=0, fouls=0 }
return map[pid]
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 }
local function alloc(st, roster, weights)
local pool = {}
for _,p in ipairs(roster) do
local w = weights[p.pos] or 1
for _=1,w do pool[#pool+1] = p end
end
return _pick(st, pool)
end
-- Home goals (+ assists sometimes)
for _=1, _getN(H,"goals") do
local p; st, p = alloc(st, homeRoster, W_GOAL)
ensure(linesH, p.id).goals = ensure(linesH, p.id).goals + 1
local r; st, r = _rngNext(st)
if r < 0.70 then
local a; st, a = alloc(st, homeRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
if a and a.id ~= p.id then ensure(linesH, a.id).assists = ensure(linesH, a.id).assists + 1 end
end
end
-- Away goals
for _=1, _getN(A,"goals") do
local p; st, p = alloc(st, awayRoster, W_GOAL)
ensure(linesA, p.id).goals = ensure(linesA, p.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 ~= p.id then ensure(linesA, a.id).assists = ensure(linesA, a.id).assists + 1 end
end
end
-- Possession points
for _=1, _getN(H,"poss_points") do
local p; st, p = alloc(st, homeRoster, W_POSS)
ensure(linesH, p.id).poss = ensure(linesH, p.id).poss + 1
end
for _=1, _getN(A,"poss_points") do
local p; st, p = alloc(st, awayRoster, W_POSS)
ensure(linesA, p.id).poss = ensure(linesA, p.id).poss + 1
end
-- Shots on/off
for _=1, _getN(H,"shots_on") do
local p; st, p = alloc(st, homeRoster, W_SHOT)
ensure(linesH, p.id).son = ensure(linesH, p.id).son + 1
end
for _=1, _getN(H,"shots_off") do
local p; st, p = alloc(st, homeRoster, W_SHOT)
ensure(linesH, p.id).sof = ensure(linesH, p.id).sof + 1
end
for _=1, _getN(A,"shots_on") do
local p; st, p = alloc(st, awayRoster, W_SHOT)
ensure(linesA, p.id).son = ensure(linesA, p.id).son + 1
end
for _=1, _getN(A,"shots_off") do
local p; st, p = alloc(st, awayRoster, W_SHOT)
ensure(linesA, p.id).sof = ensure(linesA, p.id).sof + 1
end
-- Fouls
for _=1, _getN(H,"fouls") do
local p; st, p = alloc(st, homeRoster, W_FOUL)
ensure(linesH, p.id).fouls = ensure(linesH, p.id).fouls + 1
end
for _=1, _getN(A,"fouls") do
local p; st, p = alloc(st, awayRoster, W_FOUL)
ensure(linesA, p.id).fouls = ensure(linesA, p.id).fouls + 1
end
return linesH, linesA, H, A
end
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 not SCHED[md] then return "'''No fixtures for matchday " .. tostring(md) .. ".'''" 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 deals = _genTransferDeals(season)
local base = _buildBaseRosters(season)
local rosters = _applyTransfersUpTo(base, deals, md)
local best = {
overall=nil, striker=nil, playmaker=nil, defender=nil, keeper=nil
}
local function consider(teamObj, roster, pid, pos, line, oppShotsOn, oppGoals)
local r = _scorePlayer(line, pos, oppShotsOn, oppGoals)
local name = pid
for _,pp in ipairs(roster) do if pp.id == pid then name = pp.name; break end end
local entry = { rating=r, name=name, team=teamObj.code, logo=teamObj.logo or "", pos=pos }
local function upd(slot)
if (not best[slot]) or entry.rating > best[slot].rating then best[slot] = entry end
end
upd("overall")
if pos == "Striker" then upd("striker") end
if pos == "Playmaker" then upd("playmaker") end
if pos == "Defender" then upd("defender") end
if pos == "Keeper" then upd("keeper") end
end
for matchIndex, m in ipairs(SCHED[md]) do
local home, away = m[1], m[2]
local hr, ar = rosters[home.code] or {}, rosters[away.code] or {}
local linesH, linesA, H, A = _allocMatchPlayerLines(season, md, day, matchIndex, home, away, hr, ar)
-- evaluate home players (their keeper uses away shots/goals for saves/clean sheet)
for _,pp in ipairs(hr) do
local line = linesH[pp.id] or { goals=0,assists=0,poss=0,son=0,sof=0,fouls=0 }
consider(home, hr, pp.id, pp.pos, line, _getN(A,"shots_on"), _getN(A,"goals"))
end
-- evaluate away players
for _,pp in ipairs(ar) do
local line = linesA[pp.id] or { goals=0,assists=0,poss=0,son=0,sof=0,fouls=0 }
consider(away, ar, pp.id, pp.pos, line, _getN(H,"shots_on"), _getN(H,"goals"))
end
end
local out = {}
out[#out+1] = "=== Weekly Awards (Matchday " .. tostring(md) .. ") ==="
out[#out+1] = '{| class="wikitable" style="width:100%; font-size:90%;"'
out[#out+1] = "! Award !! Winner !! Team !! Rating"
local function row(label, e)
if not e then return end
out[#out+1] = "|-"
out[#out+1] = string.format("| %s || %s || %s %s || %.2f",
label, e.name, e.logo or "", e.team, e.rating)
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" -- default to chaos, per your request
if md < 1 or not SCHED[md] then
return "'''No matchday has been played yet (or no fixtures found).'''"
end
local inv = _invertDayMap(mapDays())
local day = inv[md]
if not day then return "'''Unable to resolve PSSC day for matchday " .. tostring(md) .. ".'''" end
-- Results list (uses same sim method as standings/schedule outputs)
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) do
out[#out+1] = string.format("* %s '''%d''' %s vs %s '''%d''' %s",
r.home.logo or "", r.hg or 0, r.home.fullName or r.home.code,
r.away.logo or "", r.ag or 0, r.away.fullName or r.away.code
)
end
out[#out+1] = ""
out[#out+1] = p.renderWeeklyAwards({ args = { season = season, md = md } })
return table.concat(out, "\n")
end
-- EXPORT
------------------------------------------------------------------------
return p