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 .. '&nbsp;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