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 936: | Line 936: | ||
out[#out+1] = '|}' | out[#out+1] = '|}' | ||
return table.concat(out, '\n') | return table.concat(out, '\n') | ||
end | |||
------------------------------------------------------------------------ | |||
-- 10) ROSTERS + TRANSFERS + PLAYER STATS (embedded, deterministic) | |||
------------------------------------------------------------------------ | |||
-- Config | |||
local ROSTER_SIZE = 12 | |||
local TRANSFER_MD_START = 20 | |||
local TRANSFER_MD_END = 30 | |||
local DEFAULT_SEASON = 52 | |||
-- IMPORTANT: use our own PRNG so we don't disturb math.randomseed usage elsewhere | |||
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 | |||
-- Bassaridian-ish pools (edit freely) | |||
local NAME = { | |||
first = { | |||
"Ariston","Demetrios","Eryx","Kalithros","Lothar","Nefra","Nikolaos","Pyrrhos", | |||
"Silenos","Thalios","Eosphoros","Aither","Cassian","Sabina","Selim","Leyla", | |||
"Leif","Astrid","Bjorn","Sigrid" | |||
}, | |||
last = { | |||
"Sekeri","Kallistron","Aminis","Delphikos","Varengar","Caspazani","Myrenos","Aurellian", | |||
"Koinoros","Sleviksen","Yildirim","Demir","Kaya","Arslan","Bjornsen","Ravnsson","Thorsen" | |||
} | |||
} | |||
local POSITIONS = { -- keep simple; you can lore-rename later | |||
"Striker","Striker", | |||
"Playmaker","Playmaker","Playmaker", | |||
"Defender","Defender","Defender", | |||
"Keeper", | |||
"Utility","Utility","Utility" | |||
} | |||
local function _genBaseRoster(teamCode, season) | |||
local key = _normKey(teamCode) or teamCode | |||
local st = _seedFromString(tostring(season) .. "|" .. key .. "|ROSTER") | |||
local roster = {} | |||
for i=1, ROSTER_SIZE do | |||
local fn, ln | |||
st, fn = _pick(st, NAME.first) | |||
st, ln = _pick(st, NAME.last) | |||
local id = key .. "-" .. string.format("%02d", i) | |||
roster[i] = { | |||
id = id, | |||
name = (fn or "—") .. " " .. (ln or "—"), | |||
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 engine (deterministic swaps to keep roster size constant) | |||
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) | |||
-- Produce a full list of deals for MD20..MD30. | |||
-- Each deal is a 1-for-1 swap between two clubs (same position where possible). | |||
local st = _seedFromString(tostring(season) .. "|TRANSFERWINDOW|MD" .. TRANSFER_MD_START .. "-" .. TRANSFER_MD_END) | |||
local deals = {} | |||
local teamCodes = {} | |||
for _,t in ipairs(teams) do teamCodes[#teamCodes+1] = t.code end | |||
for md = TRANSFER_MD_START, TRANSFER_MD_END do | |||
-- 1–3 deals per matchday | |||
local r; st, r = _rngNext(st) | |||
local dealCount = 1 + math.floor(r * 3) | |||
for _=1, dealCount do | |||
local a,b | |||
st, a = _pick(st, teamCodes) | |||
repeat st, b = _pick(st, teamCodes) until b ~= a | |||
-- choose a “from” player from A (not keeper too often) | |||
local baseRosterA = _genBaseRoster(a, season) | |||
local baseRosterB = _genBaseRoster(b, season) | |||
local pA | |||
st, pA = _pick(st, baseRosterA) | |||
if pA and pA.pos == "Keeper" then | |||
local rr; st, rr = _rngNext(st) | |||
if rr < 0.75 then st, pA = _pick(st, baseRosterA) end | |||
end | |||
local pB | |||
st, pB = _findSwapCandidate(st, baseRosterB, 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) | |||
-- deep-ish copy rosters as arrays of tables | |||
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 the match stats | |||
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 _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster) | |||
-- Use same salt pattern used throughout module for determinism | |||
local salt = day*1000 + matchIndex + string.byte(homeTeam.code,1) + string.byte(awayTeam.code,1) | |||
local hg, ag = simScoreFor(homeTeam, awayTeam, salt) | |||
local stats = computeMatchStats(homeTeam, awayTeam, hg, ag, salt) | |||
local events = { home={}, away={} } -- list of {pid, goals, assists, poss, son, sof, fouls} | |||
local function initMap() | |||
return { goals=0, assists=0, poss=0, son=0, sof=0, fouls=0, apps=0 } | |||
end | |||
local function ensure(m, pid) | |||
m[pid] = m[pid] or initMap() | |||
return m[pid] | |||
end | |||
-- weights by position | |||
local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 } | |||
local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 } | |||
local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 } | |||
local st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code) | |||
-- appearances (everyone on roster "available"; you can later add minutes) | |||
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 | |||
-- Goals (pillar strikes) | |||
for _=1, stats.home.goals do | |||
local pid; st, pid = _alloc(st, homeRoster, W_GOAL) | |||
ensure(events.home, pid.id).goals = ensure(events.home, pid.id).goals + 1 | |||
-- 70% assist | |||
local r; st, r = _rngNext(st) | |||
if r < 0.70 then | |||
local ap; st, ap = _alloc(st, homeRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0}) | |||
if ap.id ~= pid.id then ensure(events.home, ap.id).assists = ensure(events.home, ap.id).assists + 1 end | |||
end | |||
end | |||
for _=1, stats.away.goals do | |||
local pid; st, pid = _alloc(st, awayRoster, W_GOAL) | |||
ensure(events.away, pid.id).goals = ensure(events.away, pid.id).goals + 1 | |||
local r; st, r = _rngNext(st) | |||
if r < 0.70 then | |||
local ap; st, ap = _alloc(st, awayRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0}) | |||
if ap.id ~= pid.id then ensure(events.away, ap.id).assists = ensure(events.away, ap.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 (goals are already “on”, so distribute remaining) | |||
local function distributeShots(sideEvents, roster, on, off) | |||
-- allocate ON (including goals already counted) | |||
for _=1, on do | |||
local p; st, p = _alloc(st, roster, {Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0}) | |||
ensure(sideEvents, p.id).son = ensure(sideEvents, 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(sideEvents, p.id).sof = ensure(sideEvents, 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 | |||
local function _computePlayerSeasonTotals(season, upToMd) | |||
local dm = mapDays() | |||
local inv = _invertDayMap(dm) | |||
local deals = _genTransferDeals(season) | |||
local base = _buildBaseRosters(season) | |||
local totals = {} -- totals[playerId] = {name,pos,currentTeam, apps, goals, assists, poss, son, sof, fouls} | |||
local function ensurePlayer(pid, pmeta) | |||
totals[pid] = totals[pid] or { | |||
id=pid, name=pmeta.name, pos=pmeta.pos, team=pmeta.team, | |||
apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0 | |||
} | |||
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) | |||
-- accumulate home | |||
for _,p in ipairs(hr) do | |||
local row = ensurePlayer(p.id, {name=p.name, pos=p.pos, team=h.code}) | |||
row.team = h.code | |||
end | |||
for pid, st in pairs(ev.home) do | |||
local pmeta = { name = pid, pos="—", team=h.code } | |||
-- find display info from roster | |||
for _,pp in ipairs(hr) do if pp.id == pid then pmeta.name=pp.name; pmeta.pos=pp.pos; break end end | |||
local row = ensurePlayer(pid, pmeta) | |||
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 | |||
-- accumulate away | |||
for _,p in ipairs(ar) do | |||
local row = ensurePlayer(p.id, {name=p.name, pos=p.pos, team=a.code}) | |||
row.team = a.code | |||
end | |||
for pid, st in pairs(ev.away) do | |||
local pmeta = { name = pid, pos="—", team=a.code } | |||
for _,pp in ipairs(ar) do if pp.id == pid then pmeta.name=pp.name; pmeta.pos=pp.pos; break end end | |||
local row = ensurePlayer(pid, pmeta) | |||
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: roster table | |||
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.'''" 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 .. " '''" .. team .. "''' (roster as of Matchday " .. tostring(upToMd) .. ")\n" .. table.concat(out, "\n") | |||
end | |||
-- PUBLIC: transfer log | |||
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 .. "'''\n" .. table.concat(out, "\n") | |||
end | |||
-- PUBLIC: team player stats table | |||
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.'''" 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 = _computePlayerSeasonTotals(season, upToMd) | |||
local rows = {} | |||
for _,t in ipairs(_applyTransfersUpTo(_buildBaseRosters(season), _genTransferDeals(season), upToMd)[team] or {}) do | |||
rows[#rows+1] = t.id | |||
end | |||
local out = { | |||
'{| class="wikitable sortable" style="width:100%; font-size:90%;"', | |||
'! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls' | |||
} | |||
for _,pid in ipairs(rows) do | |||
local r = totals[pid] | |||
if r then | |||
out[#out+1] = '|-' | |||
out[#out+1] = string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d', | |||
r.name, r.pos, r.apps, r.goals, r.assists, r.poss, r.son, r.sof, r.fouls | |||
) | |||
end | |||
end | |||
out[#out+1] = '|}' | |||
return TEAM_BY_CODE[team].logo .. " '''" .. team .. "''' — Player totals through Matchday " .. tostring(upToMd) .. "\n" .. table.concat(out, "\n") | |||
end | |||
-- PUBLIC: league leaders (top 20) | |||
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 < b.name | |||
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, r.team or "—", r.pos or "—", r[stat] or 0 | |||
) | |||
end | |||
out[#out+1] = '|}' | |||
return "'''League leaders (" .. stat .. ") — season " .. season .. " through Matchday " .. tostring(upToMd) .. "'''\n" .. table.concat(out, "\n") | |||
end | end | ||
Revision as of 01:28, 12 December 2025
Documentation for this module may be created at Module:PillSeasonSchedule/doc
-- Module:PillSeasonSchedule
local p = {}
local date = require('Module:BassaridianCalendar')
------------------------------------------------------------------------
-- 0) CONFIG / CALENDAR
------------------------------------------------------------------------
local START, END = 62, 183 -- season window within the PSSC year
local DAYS_IN_YEAR = 183
local function getMonthDay(d)
if d <= 61 then
return "Atosiel", d
elseif d <= 122 then
return "Thalassiel", d - 61
else
return "Opsitheiel", d - 122
end
end
local function md2day(m,d) return (m-1)*61 + d end -- 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)
------------------------------------------------------------------------
-- Config
local ROSTER_SIZE = 12
local TRANSFER_MD_START = 20
local TRANSFER_MD_END = 30
local DEFAULT_SEASON = 52
-- IMPORTANT: use our own PRNG so we don't disturb math.randomseed usage elsewhere
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
-- Bassaridian-ish pools (edit freely)
local NAME = {
first = {
"Ariston","Demetrios","Eryx","Kalithros","Lothar","Nefra","Nikolaos","Pyrrhos",
"Silenos","Thalios","Eosphoros","Aither","Cassian","Sabina","Selim","Leyla",
"Leif","Astrid","Bjorn","Sigrid"
},
last = {
"Sekeri","Kallistron","Aminis","Delphikos","Varengar","Caspazani","Myrenos","Aurellian",
"Koinoros","Sleviksen","Yildirim","Demir","Kaya","Arslan","Bjornsen","Ravnsson","Thorsen"
}
}
local POSITIONS = { -- keep simple; you can lore-rename later
"Striker","Striker",
"Playmaker","Playmaker","Playmaker",
"Defender","Defender","Defender",
"Keeper",
"Utility","Utility","Utility"
}
local function _genBaseRoster(teamCode, season)
local key = _normKey(teamCode) or teamCode
local st = _seedFromString(tostring(season) .. "|" .. key .. "|ROSTER")
local roster = {}
for i=1, ROSTER_SIZE do
local fn, ln
st, fn = _pick(st, NAME.first)
st, ln = _pick(st, NAME.last)
local id = key .. "-" .. string.format("%02d", i)
roster[i] = {
id = id,
name = (fn or "—") .. " " .. (ln or "—"),
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 engine (deterministic swaps to keep roster size constant)
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)
-- Produce a full list of deals for MD20..MD30.
-- Each deal is a 1-for-1 swap between two clubs (same position where possible).
local st = _seedFromString(tostring(season) .. "|TRANSFERWINDOW|MD" .. TRANSFER_MD_START .. "-" .. TRANSFER_MD_END)
local deals = {}
local teamCodes = {}
for _,t in ipairs(teams) do teamCodes[#teamCodes+1] = t.code end
for md = TRANSFER_MD_START, TRANSFER_MD_END do
-- 1–3 deals per matchday
local r; st, r = _rngNext(st)
local dealCount = 1 + math.floor(r * 3)
for _=1, dealCount do
local a,b
st, a = _pick(st, teamCodes)
repeat st, b = _pick(st, teamCodes) until b ~= a
-- choose a “from” player from A (not keeper too often)
local baseRosterA = _genBaseRoster(a, season)
local baseRosterB = _genBaseRoster(b, season)
local pA
st, pA = _pick(st, baseRosterA)
if pA and pA.pos == "Keeper" then
local rr; st, rr = _rngNext(st)
if rr < 0.75 then st, pA = _pick(st, baseRosterA) end
end
local pB
st, pB = _findSwapCandidate(st, baseRosterB, 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)
-- deep-ish copy rosters as arrays of tables
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 the match stats
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 _matchPlayerEvents(season, md, day, matchIndex, homeTeam, awayTeam, homeRoster, awayRoster)
-- Use same salt pattern used throughout module for determinism
local salt = day*1000 + matchIndex + string.byte(homeTeam.code,1) + string.byte(awayTeam.code,1)
local hg, ag = simScoreFor(homeTeam, awayTeam, salt)
local stats = computeMatchStats(homeTeam, awayTeam, hg, ag, salt)
local events = { home={}, away={} } -- list of {pid, goals, assists, poss, son, sof, fouls}
local function initMap()
return { goals=0, assists=0, poss=0, son=0, sof=0, fouls=0, apps=0 }
end
local function ensure(m, pid)
m[pid] = m[pid] or initMap()
return m[pid]
end
-- weights by position
local W_GOAL = { Striker=6, Playmaker=3, Utility=2, Defender=1, Keeper=0 }
local W_POSS = { Playmaker=6, Defender=3, Utility=2, Striker=1, Keeper=1 }
local W_FOUL = { Defender=4, Utility=3, Playmaker=2, Striker=1, Keeper=1 }
local st = _seedFromString(tostring(season).."|MD"..md.."|D"..day.."|M"..matchIndex.."|"..homeTeam.code.."|"..awayTeam.code)
-- appearances (everyone on roster "available"; you can later add minutes)
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
-- Goals (pillar strikes)
for _=1, stats.home.goals do
local pid; st, pid = _alloc(st, homeRoster, W_GOAL)
ensure(events.home, pid.id).goals = ensure(events.home, pid.id).goals + 1
-- 70% assist
local r; st, r = _rngNext(st)
if r < 0.70 then
local ap; st, ap = _alloc(st, homeRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
if ap.id ~= pid.id then ensure(events.home, ap.id).assists = ensure(events.home, ap.id).assists + 1 end
end
end
for _=1, stats.away.goals do
local pid; st, pid = _alloc(st, awayRoster, W_GOAL)
ensure(events.away, pid.id).goals = ensure(events.away, pid.id).goals + 1
local r; st, r = _rngNext(st)
if r < 0.70 then
local ap; st, ap = _alloc(st, awayRoster, {Playmaker=6, Striker=3, Utility=2, Defender=1, Keeper=0})
if ap.id ~= pid.id then ensure(events.away, ap.id).assists = ensure(events.away, ap.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 (goals are already “on”, so distribute remaining)
local function distributeShots(sideEvents, roster, on, off)
-- allocate ON (including goals already counted)
for _=1, on do
local p; st, p = _alloc(st, roster, {Striker=5, Playmaker=3, Utility=2, Defender=1, Keeper=0})
ensure(sideEvents, p.id).son = ensure(sideEvents, 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(sideEvents, p.id).sof = ensure(sideEvents, 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
local function _computePlayerSeasonTotals(season, upToMd)
local dm = mapDays()
local inv = _invertDayMap(dm)
local deals = _genTransferDeals(season)
local base = _buildBaseRosters(season)
local totals = {} -- totals[playerId] = {name,pos,currentTeam, apps, goals, assists, poss, son, sof, fouls}
local function ensurePlayer(pid, pmeta)
totals[pid] = totals[pid] or {
id=pid, name=pmeta.name, pos=pmeta.pos, team=pmeta.team,
apps=0, goals=0, assists=0, poss=0, son=0, sof=0, fouls=0
}
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)
-- accumulate home
for _,p in ipairs(hr) do
local row = ensurePlayer(p.id, {name=p.name, pos=p.pos, team=h.code})
row.team = h.code
end
for pid, st in pairs(ev.home) do
local pmeta = { name = pid, pos="—", team=h.code }
-- find display info from roster
for _,pp in ipairs(hr) do if pp.id == pid then pmeta.name=pp.name; pmeta.pos=pp.pos; break end end
local row = ensurePlayer(pid, pmeta)
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
-- accumulate away
for _,p in ipairs(ar) do
local row = ensurePlayer(p.id, {name=p.name, pos=p.pos, team=a.code})
row.team = a.code
end
for pid, st in pairs(ev.away) do
local pmeta = { name = pid, pos="—", team=a.code }
for _,pp in ipairs(ar) do if pp.id == pid then pmeta.name=pp.name; pmeta.pos=pp.pos; break end end
local row = ensurePlayer(pid, pmeta)
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: roster table
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.'''" 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 .. " '''" .. team .. "''' (roster as of Matchday " .. tostring(upToMd) .. ")\n" .. table.concat(out, "\n")
end
-- PUBLIC: transfer log
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 .. "'''\n" .. table.concat(out, "\n")
end
-- PUBLIC: team player stats table
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.'''" 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 = _computePlayerSeasonTotals(season, upToMd)
local rows = {}
for _,t in ipairs(_applyTransfersUpTo(_buildBaseRosters(season), _genTransferDeals(season), upToMd)[team] or {}) do
rows[#rows+1] = t.id
end
local out = {
'{| class="wikitable sortable" style="width:100%; font-size:90%;"',
'! Player !! Pos !! Apps !! Goals !! Assists !! Poss Pts !! S-on !! S-off !! Fouls'
}
for _,pid in ipairs(rows) do
local r = totals[pid]
if r then
out[#out+1] = '|-'
out[#out+1] = string.format('| %s || %s || %d || %d || %d || %d || %d || %d || %d',
r.name, r.pos, r.apps, r.goals, r.assists, r.poss, r.son, r.sof, r.fouls
)
end
end
out[#out+1] = '|}'
return TEAM_BY_CODE[team].logo .. " '''" .. team .. "''' — Player totals through Matchday " .. tostring(upToMd) .. "\n" .. table.concat(out, "\n")
end
-- PUBLIC: league leaders (top 20)
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 < b.name
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, 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
------------------------------------------------------------------------
-- EXPORT
------------------------------------------------------------------------
return p