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 746: | Line 746: | ||
rows[#rows+1] = '|}' | rows[#rows+1] = '|}' | ||
return table.concat(rows, '\n') | return table.concat(rows, '\n') | ||
end | |||
-- X) PER-MATCH STATS (deterministic from the same seeds as scoring) | |||
------------------------------------------------------------------------ | |||
-- helper: bounded random with bias | |||
local function rrange(seed, lo, hi) | |||
math.randomseed(seed) | |||
return math.random(lo, hi) | |||
end | |||
-- Fabricate a consistent, rule-respecting stat line from final score. | |||
-- We split total points into: | |||
-- • possession points (0..score) | |||
-- • pillar-strike “goals” (score - possession) | |||
-- We also derive shots, fouls, FKs, throws, GK, OOB and possession %. | |||
local function computeMatchStats(h, a, hg, ag, seed) | |||
-- split points: lean ~60% possession points on average, but deterministic | |||
local function splitPoints(score, salt) | |||
math.randomseed(salt) | |||
local maxPos = score | |||
if maxPos <= 0 then return 0, 0 end | |||
local target = math.floor(0.6 * maxPos + 0.5) | |||
local jitter = math.random(-1, 1) | |||
local poss = math.max(0, math.min(maxPos, target + jitter)) | |||
local goals = score - poss | |||
return poss, goals | |||
end | |||
local h_poss, h_goals = splitPoints(hg, seed + 101) | |||
local a_poss, a_goals = splitPoints(ag, seed + 102) | |||
-- shots on target ~= pillar attempts that actually contacted pillar casing/face | |||
-- ensure >= goals (pillar strikes imply an on-target attempt) | |||
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) | |||
-- shots off target | |||
local h_sh_off = rrange(seed+203, 0, 3) | |||
local a_sh_off = rrange(seed+204, 0, 3) | |||
-- fouls / free kicks (mirror): each foul by X → FK awarded to opponent | |||
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 | |||
-- throw-ins & out-of-bounds | |||
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) | |||
-- goal-kicks: tie one side’s goal-kicks to opponent’s overhits | |||
local h_gk = rrange(seed+211, 0, 2) | |||
local a_gk = rrange(seed+212, 0, 2) | |||
-- possession %: modest edge to the winner or even if draw | |||
local base = 50 | |||
local swing = rrange(seed+213, 3, 6) -- 3–6% swing | |||
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 structured stats | |||
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 | |||
-- RENDER: all matches on the current/next scheduled matchday | |||
function p.renderMatchStatsForDay(frame) | |||
local curr = date.getCurrentDate() | |||
local day = tonumber(curr:match('^(%d+),')) or 0 | |||
local year = tonumber(curr:match(', (%d+) PSSC')) or 0 | |||
local dm = mapDays() | |||
local target = day | |||
if not dm[target] then | |||
for d = day + 1, END do if dm[d] then target = d; break end end | |||
end | |||
local idx = dm[target] | |||
if not idx then return "'''No scheduled matches in window.'''" end | |||
local mon, dom = getMonthDay(target) | |||
local label = dom .. " " .. mon .. " " .. year .. " PSSC" | |||
local out = { | |||
'{| class="wikitable sortable"', | |||
'|+ Match Statistics — ' .. label, | |||
'! Match !! Team !! Final !! Goals (Pillar) !! Possession Pts !! Shots on !! Shots off !! Fouls !! Free Kicks !! Throw-Ins !! Goal Kicks !! OOB !! Poss %' | |||
} | |||
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 = S[r.home.code] | |||
local A = S[r.away.code] | |||
local matchLabel = r.home.logo .. ' ' .. r.home.fullName .. ' vs ' .. r.away.logo .. ' ' .. r.away.fullName | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| rowspan="2" | %s || %s %s || %d || %d || %d || %d || %d || %d || %d || %d || %d || %d%%', | |||
matchLabel, r.home.logo, r.home.fullName, H.final, H.goals, H.poss_points, H.shots_on, H.shots_off, H.fouls, H.fks, H.throws, H.gk, H.oob, H.poss_pct) | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| %s %s || %d || %d || %d || %d || %d || %d || %d || %d || %d || %d%%', | |||
r.away.logo, r.away.fullName, A.final, A.goals, A.poss_points, A.shots_on, A.shots_off, A.fouls, A.fks, A.throws, A.gk, A.oob, A.poss_pct) | |||
end | |||
out[#out+1] = '|}' | |||
return table.concat(out, '\n') | |||
end | |||
-- RENDER: a single match stats table for a specific day/home/away | |||
-- Usage: {{#invoke:PillSeasonSchedule|renderMatchStatsSingle|day=120|home=Steppe Luminaria|away=Emberstone Vaeringheim}} | |||
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 dm = mapDays() | |||
local idx = dm[day] | |||
if not idx then return "'''No round mapped for that day.''' end" end | |||
-- find the match | |||
local found | |||
for k, m in ipairs(SCHED[idx]) do | |||
if m[1].code == home and m[2].code == away then | |||
local results = simulateDayResults(day)[k] | |||
local seed = day*1000 + k + string.byte(home,1) + string.byte(away,1) | |||
local S = computeMatchStats(m[1], m[2], results.hg, results.ag, seed) | |||
local H = S[home] | |||
local A = S[away] | |||
local mon, dom = getMonthDay(day) | |||
local dateLabel= dom .. " " .. mon | |||
local out = { | |||
'{| class="wikitable"', | |||
'|+ Match Statistics — ' .. dateLabel .. ' ' .. (date.getCurrentDate():match(', (%d+) PSSC') or '') .. ' PSSC', | |||
'! Statistic !! ' .. m[1].fullName .. ' !! ' .. m[2].fullName, | |||
'|-', | |||
'| Final Score || ' .. H.final .. ' || ' .. A.final, | |||
'|-', | |||
'| Goals (Pillar Strikes) || ' .. H.goals .. ' || ' .. A.goals, | |||
'|-', | |||
'| Possession Points || ' .. H.poss_points .. ' || ' .. A.poss_points, | |||
'|-', | |||
'| Shots on Target || ' .. H.shots_on .. ' || ' .. A.shots_on, | |||
'|-', | |||
'| Shots off Target || ' .. H.shots_off .. ' || ' .. A.shots_off, | |||
'|-', | |||
'| Fouls || ' .. H.fouls .. ' || ' .. A.fouls, | |||
'|-', | |||
'| Free Kicks Awarded || ' .. H.fks .. ' || ' .. A.fks, | |||
'|-', | |||
'| Throw-Ins || ' .. H.throws .. ' || ' .. A.throws, | |||
'|-', | |||
'| Goal Kicks || ' .. H.gk .. ' || ' .. A.gk, | |||
'|-', | |||
'| Out of Bounds || ' .. H.oob .. ' || ' .. A.oob, | |||
'|-', | |||
'| Ball Possession || ' .. H.poss_pct .. '% || ' .. A.poss_pct .. '%', | |||
'|}' | |||
} | |||
found = table.concat(out, '\n') | |||
break | |||
end | |||
end | |||
return found or "'''Match not found on that day with that home/away.'''" | |||
end | end | ||
Revision as of 01:05, 20 October 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 -- 61-day “months”
local function absDay(y,d) return y*DAYS_IN_YEAR + d end
------------------------------------------------------------------------
-- 1) TEAMS (keep your logos/titles/championships exactly)
------------------------------------------------------------------------
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 (your final four quads of eight)
------------------------------------------------------------------------
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: each of 7 rivals twice (H/A) -> 14 matches
-- • Cross-division: the other 24 once -> 24 matches
-- = 38 total per club
------------------------------------------------------------------------
local function homeFirst(codeA, codeB)
-- deterministic “hash” to pick a home side for cross-division fixtures
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 = {}, {}
-- in-division double round robin (H/A)
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
-- cross-division single round robin
local allCodes = {}
for _,t in ipairs(teams) do table.insert(allCodes, t.code) end
table.sort(allCodes) -- stable order
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
-- sort (deterministic grouping for nicer pacing)
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)
-- greedy packer so no club appears twice in a round
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
-- Build the master schedule once (deterministic)
local SCHED = (function()
local intra, inter = buildPairings()
-- simple interleave: one intra block, one inter block, repeat
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
-- block sizes chosen to avoid very long runs of a single type
local intraBlocks = chunk(intra, 64)
local interBlocks = chunk(inter, 128)
local merged = {}
local 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
-- Map rounds to PSSC dates across the season window
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)
-- init records
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
-- accumulate results up to 'last'
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
-- bucket by division
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
-- render four separate, color-coded tables
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;"' -- light green
elseif i <= 4 then style = ' style="background-color:#cce5ff;"' -- light blue
elseif i <= 6 then style = ' style="background-color:#F1EB9C;"' -- light yellow
else style = ' style="background-color:#ffcccc;"' -- light red (7–8)
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] = "" -- spacer
end
return table.concat(out, "\n")
end
------------------------------------------------------------------------
-- 7) PREVIOUS RESULTS (last 5 matchdays that actually exist)
------------------------------------------------------------------------
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)
-- Auto-bids: Top 2 per division (8)
-- Wildcards: Next 4 best overall (4)
-- Seeding: 1–4 winners, 5–8 runners-up, 9–12 wildcards
------------------------------------------------------------------------
local PO_YEAR = 52 -- post-season year
local PO_FIRST = md2day(1,4) -- 4 Atosiel 52 PSSC (Play-in day)
local PO_IV = 3 -- days between rounds
-- shared tiebreaker
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
-- recompute FULL final table from played season window
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
-- finalize PD
for _, r in pairs(recs) do r.PD = r.PF - r.PA end
return recs
end
-- build divisional tables, pick seeds
local function getSeeds12()
local curr = date.getCurrentDate()
local today = tonumber(curr:match("^(%d+),")) or END
local recs = computeRecordsUpTo(today)
-- bucket by division
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
-- winners (1–4) and runners-up (5–8)
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)
-- wildcards (next best 4 overall not already in winners/runners)
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
-- build seed array of TEAM OBJECTS (not record rows)
local seeds = {}
for i=1,#winners do seeds[#seeds+1] = winners[i].team end -- 1–4
for i=1,#runners do seeds[#seeds+1] = runners[i].team end -- 5–8
for i=1,#wild do seeds[#seeds+1] = wild[i].team end -- 9–12
return seeds
end
-- single–match score generator (mild title bias, 0–12 goals)
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
-- row helper
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
-- Playoff schedule (labels only)
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
-- Play-in: 9 v 12, 10 v 11
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)
-- Banners (dates only; actual pairings resolved in renderPlayoffResults)
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
-- Playoff results (auto-resolve with re-seeding by numeric seed)
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'
}
-- Play-in
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
-- Round-of-8: seeds 3–8 + PI winners, re-seeded (lowest vs highest)
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'} }
-- keep pool in seed order (already ordered in seeds list 3..8 then PI winners)
local R8 = { {pool[3], pool[6]}, {pool[4], pool[5]}, {pool[1], pool[8]}, {pool[2], pool[7]} }
-- (above pairs mimic a high-vs-low reseed pattern while staying deterministic)
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
-- Round-of-4: re-seed winners
local absR4 = absR8 + PO_IV
local R4 = {}
if #W_R8 == 4 then
-- reconstruct seed order: find numeric seeds for R8 winners
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
-- Semi-finals: Seeds 1–2 enter vs R4 winners (re-seeded)
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
-- Final
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
-- X) PER-MATCH STATS (deterministic from the same seeds as scoring)
------------------------------------------------------------------------
-- helper: bounded random with bias
local function rrange(seed, lo, hi)
math.randomseed(seed)
return math.random(lo, hi)
end
-- Fabricate a consistent, rule-respecting stat line from final score.
-- We split total points into:
-- • possession points (0..score)
-- • pillar-strike “goals” (score - possession)
-- We also derive shots, fouls, FKs, throws, GK, OOB and possession %.
local function computeMatchStats(h, a, hg, ag, seed)
-- split points: lean ~60% possession points on average, but deterministic
local function splitPoints(score, salt)
math.randomseed(salt)
local maxPos = score
if maxPos <= 0 then return 0, 0 end
local target = math.floor(0.6 * maxPos + 0.5)
local jitter = math.random(-1, 1)
local poss = math.max(0, math.min(maxPos, target + jitter))
local goals = score - poss
return poss, goals
end
local h_poss, h_goals = splitPoints(hg, seed + 101)
local a_poss, a_goals = splitPoints(ag, seed + 102)
-- shots on target ~= pillar attempts that actually contacted pillar casing/face
-- ensure >= goals (pillar strikes imply an on-target attempt)
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)
-- shots off target
local h_sh_off = rrange(seed+203, 0, 3)
local a_sh_off = rrange(seed+204, 0, 3)
-- fouls / free kicks (mirror): each foul by X → FK awarded to opponent
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
-- throw-ins & out-of-bounds
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)
-- goal-kicks: tie one side’s goal-kicks to opponent’s overhits
local h_gk = rrange(seed+211, 0, 2)
local a_gk = rrange(seed+212, 0, 2)
-- possession %: modest edge to the winner or even if draw
local base = 50
local swing = rrange(seed+213, 3, 6) -- 3–6% swing
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 structured stats
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
-- RENDER: all matches on the current/next scheduled matchday
function p.renderMatchStatsForDay(frame)
local curr = date.getCurrentDate()
local day = tonumber(curr:match('^(%d+),')) or 0
local year = tonumber(curr:match(', (%d+) PSSC')) or 0
local dm = mapDays()
local target = day
if not dm[target] then
for d = day + 1, END do if dm[d] then target = d; break end end
end
local idx = dm[target]
if not idx then return "'''No scheduled matches in window.'''" end
local mon, dom = getMonthDay(target)
local label = dom .. " " .. mon .. " " .. year .. " PSSC"
local out = {
'{| class="wikitable sortable"',
'|+ Match Statistics — ' .. label,
'! Match !! Team !! Final !! Goals (Pillar) !! Possession Pts !! Shots on !! Shots off !! Fouls !! Free Kicks !! Throw-Ins !! Goal Kicks !! OOB !! Poss %'
}
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 = S[r.home.code]
local A = S[r.away.code]
local matchLabel = r.home.logo .. ' ' .. r.home.fullName .. ' vs ' .. r.away.logo .. ' ' .. r.away.fullName
out[#out+1] = '|-'
out[#out+1] = string.format('| rowspan="2" | %s || %s %s || %d || %d || %d || %d || %d || %d || %d || %d || %d || %d%%',
matchLabel, r.home.logo, r.home.fullName, H.final, H.goals, H.poss_points, H.shots_on, H.shots_off, H.fouls, H.fks, H.throws, H.gk, H.oob, H.poss_pct)
out[#out+1] = '|-'
out[#out+1] = string.format('| %s %s || %d || %d || %d || %d || %d || %d || %d || %d || %d || %d%%',
r.away.logo, r.away.fullName, A.final, A.goals, A.poss_points, A.shots_on, A.shots_off, A.fouls, A.fks, A.throws, A.gk, A.oob, A.poss_pct)
end
out[#out+1] = '|}'
return table.concat(out, '\n')
end
-- RENDER: a single match stats table for a specific day/home/away
-- Usage: {{#invoke:PillSeasonSchedule|renderMatchStatsSingle|day=120|home=Steppe Luminaria|away=Emberstone Vaeringheim}}
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 dm = mapDays()
local idx = dm[day]
if not idx then return "'''No round mapped for that day.''' end" end
-- find the match
local found
for k, m in ipairs(SCHED[idx]) do
if m[1].code == home and m[2].code == away then
local results = simulateDayResults(day)[k]
local seed = day*1000 + k + string.byte(home,1) + string.byte(away,1)
local S = computeMatchStats(m[1], m[2], results.hg, results.ag, seed)
local H = S[home]
local A = S[away]
local mon, dom = getMonthDay(day)
local dateLabel= dom .. " " .. mon
local out = {
'{| class="wikitable"',
'|+ Match Statistics — ' .. dateLabel .. ' ' .. (date.getCurrentDate():match(', (%d+) PSSC') or '') .. ' PSSC',
'! Statistic !! ' .. m[1].fullName .. ' !! ' .. m[2].fullName,
'|-',
'| Final Score || ' .. H.final .. ' || ' .. A.final,
'|-',
'| Goals (Pillar Strikes) || ' .. H.goals .. ' || ' .. A.goals,
'|-',
'| Possession Points || ' .. H.poss_points .. ' || ' .. A.poss_points,
'|-',
'| Shots on Target || ' .. H.shots_on .. ' || ' .. A.shots_on,
'|-',
'| Shots off Target || ' .. H.shots_off .. ' || ' .. A.shots_off,
'|-',
'| Fouls || ' .. H.fouls .. ' || ' .. A.fouls,
'|-',
'| Free Kicks Awarded || ' .. H.fks .. ' || ' .. A.fks,
'|-',
'| Throw-Ins || ' .. H.throws .. ' || ' .. A.throws,
'|-',
'| Goal Kicks || ' .. H.gk .. ' || ' .. A.gk,
'|-',
'| Out of Bounds || ' .. H.oob .. ' || ' .. A.oob,
'|-',
'| Ball Possession || ' .. H.poss_pct .. '% || ' .. A.poss_pct .. '%',
'|}'
}
found = table.concat(out, '\n')
break
end
end
return found or "'''Match not found on that day with that home/away.'''"
end
------------------------------------------------------------------------
-- EXPORT
------------------------------------------------------------------------
return p