Module:BassaridianElectionSim: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
NewZimiaGov (talk | contribs)
No edit summary
 
(2 intermediate revisions by the same user not shown)
Line 11: Line 11:
--  • Adds preliminary polling window starting day 118 through day 121 (refreshes daily).
--  • Adds preliminary polling window starting day 118 through day 121 (refreshes daily).
--  • Adds optional “archive” hooks so past election results can be hard-locked.
--  • Adds optional “archive” hooks so past election results can be hard-locked.
--  • NEW (Dec 2025 seat chart update):
--      - Adds hemicycle / seat-arc SVG chart + auto legend (party colors + party logos once in key).
--      - Uses mw.html for SVG construction (more likely to render than raw string SVG).
--      - Fills seats from CENTER outward (symmetry) rather than left-to-right.
--      - Works for preliminary polling, election results, and historical results.
--      - Historical results are NEVER changed: if an archive entry exists for that year, it is used.
--        For historical seat charts, the module looks for archive.seatTotals[year] (recommended).
--        If missing, it will NOT simulate a historical chart; it will instruct you to add seatTotals.


local p    = {}
local p    = {}
Line 28: Line 36:
--    electionResults = { [51] = "wikitext table..." , ... },
--    electionResults = { [51] = "wikitext table..." , ... },
--    nationalSummary = { [51] = "wikitext table..." , ... },
--    nationalSummary = { [51] = "wikitext table..." , ... },
--    -- NEW (recommended for historical seat charts):
--    seatTotals      = { [51] = { CUF=..., MSC=..., ... }, ... },
--    -- Optional: if you ever want to freeze polls too:
--    pollSeatTotals  = { [51] = { CUF=..., ... }, ... },
--  }
--  }
------------------------------------------------------------
------------------------------------------------------------
Line 124: Line 136:
------------------------------------------------------------
------------------------------------------------------------
-- 2) DISTRICTS
-- 2) DISTRICTS
--    3-seat city districts for ALL non-dependency cities
--    + 1-seat Alperkin tribal constituencies.
--    Each district also has:
--      • name  – wikilinked display name
--      • key    – plain key for lookups (companies etc.)
--      • profile– geographic / political profile
------------------------------------------------------------
------------------------------------------------------------
local districts = {
local districts = {
Line 201: Line 207:


------------------------------------------------------------
------------------------------------------------------------
-- 3) PROFILE-BASED BIAS
-- 3) PROFILE-BASED BIAS + cult + econ influence
--    + cult-based influence (Reformed Stripping Path)
--    + company/economic influence (Port-linked firms)
------------------------------------------------------------
------------------------------------------------------------
-- Geographical / structural profile bias
local profileBias = {
local profileBias = {
    -- Core Port / capital
     port_core = { CUF=1.15, FIML=1.20, CRC=1.05, DBM=1.05 },
     port_core = {
     port_periphery = { CUF=1.05, MSC=1.05, FIML=1.10, DBM=1.05 },
        CUF = 1.15,
     shrine_mystic = { PRP=1.20, CRC=1.10, CUF=1.05 },
        FIML = 1.20,
     war_frontier = { WLG=1.20, CUF=1.05, MSC=1.00 },
        CRC = 1.05,
     agrarian = { MSC=1.15, PRP=1.05, CUF=0.95, FIML=0.95 },
        DBM = 1.05,
     normark_trade = { CUF=1.10, CRC=1.10, FIML=1.05 },
    },
     alperkin_city = { AESL=1.30, MSC=1.05, PRP=1.05 },
 
     haifan_resort = { MSC=1.10, PRP=1.05, CUF=1.05, DBM=1.10 },
    -- Secondary/coastal trade cities
     haifan_urban = { MSC=1.10, CUF=1.10, CRC=1.05, DBM=1.10 },
     port_periphery = {
     alperkin_tribe = { AESL=1.40, WLG=1.10, MSC=1.05, PRP=1.05 },
        CUF = 1.05,
        MSC = 1.05,
        FIML = 1.10,
        DBM = 1.05,
    },
 
    -- Shrine-heavy / mystic centers
     shrine_mystic = {
        PRP = 1.20,
        CRC = 1.10,
        CUF = 1.05,
    },
 
    -- War frontier / militarized zones
     war_frontier = {
        WLG = 1.20,
        CUF = 1.05,
        MSC = 1.00,
    },
 
    -- Agrarian / rural valleys
     agrarian = {
        MSC = 1.15,
        PRP = 1.05,
        CUF = 0.95,
        FIML = 0.95,
    },
 
    -- Normark/Bassarid northern trade
     normark_trade = {
        CUF = 1.10,
        CRC = 1.10,
        FIML = 1.05,
    },
 
    -- Alperkin cities
     alperkin_city = {
        AESL = 1.30,
        MSC = 1.05,
        PRP = 1.05,
    },
 
    -- Haifan resort/port cities
     haifan_resort = {
        MSC = 1.10,
        PRP = 1.05,
        CUF = 1.05,
        DBM = 1.10,
    },
 
    -- Haifan dense urban centers
     haifan_urban = {
        MSC = 1.10,
        CUF = 1.10,
        CRC = 1.05,
        DBM = 1.10,
    },
 
    -- Pure tribal constituencies
     alperkin_tribe = {
        AESL = 1.40,
        WLG = 1.10,
        MSC = 1.05,
        PRP = 1.05,
    },
}
}


------------------------------------------------------------
-- Cult influence
------------------------------------------------------------
local cultInfluence = {
local cultInfluence = {
     Aurora    = { PRP = 1.20, CRC = 1.05, CUF = 1.05 },   -- Order of Aurora Mystica
     Aurora    = { PRP = 1.20, CRC = 1.05, CUF = 1.05 },
     Azure    = { CUF = 1.15, WLG = 1.10, FIML = 1.05 }, -- Azure Sentinel Sect
     Azure    = { CUF = 1.15, WLG = 1.10, FIML = 1.05 },
     Harmony  = { CUF = 1.10, CRC = 1.10, PRP = 1.05 },   -- Harmony Sanctum
     Harmony  = { CUF = 1.10, CRC = 1.10, PRP = 1.05 },


     Ignis    = { PRP = 1.20, WLG = 1.10 },               -- Ignis Aeternum
     Ignis    = { PRP = 1.20, WLG = 1.10 },
     Celestial = { PRP = 1.15, DBM = 1.05, CRC = 1.05 },   -- Celestial Harmony Sect
     Celestial = { PRP = 1.15, DBM = 1.05, CRC = 1.05 },
     Golden    = { FIML = 1.20, CUF = 1.10, WLG = 1.05 }, -- Guild of Golden Shadows
     Golden    = { FIML = 1.20, CUF = 1.10, WLG = 1.05 },
     Reverie  = { MSC  = 1.15, DBM = 1.10 },             -- Reverie Nebulous
     Reverie  = { MSC  = 1.15, DBM = 1.10 },


     Eon      = { CRC  = 1.15, FIML = 1.05 },             -- Eon Fellowship
     Eon      = { CRC  = 1.15, FIML = 1.05 },
     Umbral    = { PRP  = 1.10, MSC = 1.05, WLG = 1.05 }, -- Order of the Umbral Oracle
     Umbral    = { PRP  = 1.10, MSC = 1.05, WLG = 1.05 },


     Verdant  = { AESL = 1.20, MSC = 1.10 },             -- Verdant Embrace
     Verdant  = { AESL = 1.20, MSC = 1.10 },
     Vitalis  = { AESL = 1.10, MSC = 1.10, PRP = 1.05 }, -- Sanctum Vitalis
     Vitalis  = { AESL = 1.10, MSC = 1.10, PRP = 1.05 },
     Alabaster = { FIML = 1.15, CUF = 1.05 },             -- Temple Alabaster
     Alabaster = { FIML = 1.15, CUF = 1.05 },
     Ironclad  = { WLG  = 1.20, CUF = 1.05 },             -- Court of the Ironclad
     Ironclad  = { WLG  = 1.20, CUF = 1.05 },


     Concordia = { CRC  = 1.15, CUF = 1.05 },             -- Accord Concordia
     Concordia = { CRC  = 1.15, CUF = 1.05 },
     Stygian  = { WLG  = 1.10, PRP = 1.05 },             -- Styx cults
     Stygian  = { WLG  = 1.10, PRP = 1.05 },


     Sylvan    = { AESL = 1.20, MSC = 1.05 },             -- Sylvan Fellowship
     Sylvan    = { AESL = 1.20, MSC = 1.05 },
     RedMirth  = { MSC  = 1.10, PRP = 1.05 },             -- Mystery of Red Mirth
     RedMirth  = { MSC  = 1.10, PRP = 1.05 },
     Rex      = { FIML = 1.10, CUF = 1.10 },             -- Rex Catonis
     Rex      = { FIML = 1.10, CUF = 1.10 },
     Delphica  = { MSC  = 1.05, PRP = 1.05, CRC = 1.05 }, -- Sanctum Delphica
     Delphica  = { MSC  = 1.05, PRP = 1.05, CRC = 1.05 },


     Amicitia  = { MSC  = 1.10, CRC = 1.10 },             -- Ordo Amicitia
     Amicitia  = { MSC  = 1.10, CRC = 1.10 },
     Illuminata= { DBM  = 1.15, CRC = 1.05 },             -- Temple Illuminata
     Illuminata= { DBM  = 1.15, CRC = 1.05 },
     Conclave  = { PRP  = 1.10, DBM = 1.10, CUF = 1.05 }, -- Conclave Illuminara
     Conclave  = { PRP  = 1.10, DBM = 1.10, CUF = 1.05 },
}
}


-- which cult constellations dominate each profile
local profileCults = {
local profileCults = {
     port_core      = { "Azure", "Golden", "Harmony" },
     port_core      = { "Azure", "Golden", "Harmony" },
     port_periphery = { "Azure", "Reverie" },
     port_periphery = { "Azure", "Reverie" },
     shrine_mystic  = { "Aurora", "Celestial", "Harmony" },
     shrine_mystic  = { "Aurora", "Celestial", "Harmony" },
     war_frontier  = { "Ironclad", "Stygian" },
     war_frontier  = { "Ironclad", "Stygian" },
     agrarian      = { "Verdant", "Sylvan", "Vitalis" },
     agrarian      = { "Verdant", "Sylvan", "Vitalis" },
     normark_trade  = { "Golden", "Eon" },
     normark_trade  = { "Golden", "Eon" },
     alperkin_city  = { "Sylvan", "Verdant" },
     alperkin_city  = { "Sylvan", "Verdant" },
     haifan_resort  = { "Conclave", "Reverie" },
     haifan_resort  = { "Conclave", "Reverie" },
     haifan_urban  = { "Conclave", "Harmony" },
     haifan_urban  = { "Conclave", "Harmony" },
     alperkin_tribe = { "Sylvan", "Verdant" },
     alperkin_tribe = { "Sylvan", "Verdant" },
}
}


------------------------------------------------------------
-- Economic / company influence
-- Tags are informed by the General Port company table:
--  • port_finance    – Temple Bank, Temple University of Delphica, Eosphorus, etc.
--  • agricoop        – Gladeseed, Plains of Jogi, Ouriana Lowlands, Blood Vineyards…
--  • arms_heavy      – Somniant Stock Fund, Baratar Corp, Thesean Iron, heavy industry
--  • fishery        – Suncliff Fisheries, Caledonj Bay, coastal fleets
--  • diaspora_port  – ports & corridors strongly tied to diaspora and foreign investors
--  • cult_services  – cities where cultic/temple “service economy” is heavy
--  • war_religious  – shrines closely tied to security campaigns
------------------------------------------------------------
local econInfluence = {
local econInfluence = {
     port_finance = {
     port_finance = { FIML=1.25, CUF=1.10, CRC=1.05, DBM=1.05 },
        FIML = 1.25,
     agricoop = { MSC=1.20, AESL=1.05, PRP=1.05 },
        CUF = 1.10,
     arms_heavy = { WLG=1.20, CUF=1.05, FIML=1.05 },
        CRC = 1.05,
     fishery = { MSC=1.10, DBM=1.05 },
        DBM = 1.05,
     diaspora_port = { DBM=1.20, CUF=1.05, FIML=1.05 },
    },
     cult_services = { PRP=1.10, CRC=1.10 },
     agricoop = {
     war_religious = { WLG=1.15, PRP=1.05, CUF=1.05 },
        MSC = 1.20,
        AESL = 1.05,
        PRP = 1.05,
    },
     arms_heavy = {
        WLG = 1.20,
        CUF = 1.05,
        FIML = 1.05,
    },
     fishery = {
        MSC = 1.10,
        DBM = 1.05,
    },
     diaspora_port = {
        DBM = 1.20,
        CUF = 1.05,
        FIML = 1.05,
    },
     cult_services = {
        PRP = 1.10,
        CRC = 1.10,
    },
     war_religious = {
        WLG = 1.15,
        PRP = 1.05,
        CUF = 1.05,
    },
}
}


-- Which econ tags apply to which city (by key)
local econTagsByCity = {
local econTagsByCity = {
     Vaeringheim  = { "port_finance", "diaspora_port" },
     Vaeringheim  = { "port_finance", "diaspora_port" },
Line 439: Line 327:
     Bashkim      = { "agricoop" },
     Bashkim      = { "agricoop" },
     Ourid        = { "agricoop" },
     Ourid        = { "agricoop" },
    -- tribes intentionally left blank (their economy comes via alperkin_tribe profile)
}
}


Line 454: Line 340:
end
end


-- Replace common diacritics so city names from DailyEvents can map to election district keys.
local function deaccent(s)
local function deaccent(s)
     if not s then return "" end
     if not s then return "" end
Line 478: Line 363:
end
end


-- Parse a one-line row like:
-- | 57/2/52 PSSC || Vaeringheim || Some event || HIGH
local function parse4colRow(line)
local function parse4colRow(line)
     local dateTxt, city, desc, urg = line:match("^|%s*(.-)%s*||%s*(.-)%s*||%s*(.-)%s*||%s*(.-)%s*$")
     local dateTxt, city, desc, urg = line:match("^|%s*(.-)%s*||%s*(.-)%s*||%s*(.-)%s*||%s*(.-)%s*$")
Line 521: Line 404:
     end
     end


    -- Civic events (PSSC-day seeded)
     if dailyOK and dailyEvents and dailyEvents.makeTable then
     if dailyOK and dailyEvents and dailyEvents.makeTable then
         local txt = dailyEvents.makeTable()
         local txt = dailyEvents.makeTable()
         for line in mw.text.gsplit(txt or "", "\n", true) do
         for line in mw.text.gsplit(txt or "", "\n", true) do
            -- data rows start with "| " then date, then " || "
             if line:match("^|%s*%d") and line:find("||", 1, true) then
             if line:match("^|%s*%d") and line:find("||", 1, true) then
                 local _, city, _, urg = parse4colRow(line)
                 local _, city, _, urg = parse4colRow(line)
Line 535: Line 416:
     end
     end


    -- Religious events (PSSC-day seeded, and it already sanitizes CRITICAL/Eidolan to calm)
     if religOK and religious and religious.makeTable then
     if religOK and religious and religious.makeTable then
         local txt = religious.makeTable()
         local txt = religious.makeTable()
Line 548: Line 428:
     end
     end


    -- Market activity (real-world day seeded). We compute avg % change across all companies.
     if marketOK and market and market.generateTable then
     if marketOK and market and market.generateTable then
         local txt = market.generateTable({ args = {} })
         local txt = market.generateTable({ args = {} })
         for line in mw.text.gsplit(txt or "", "\n", true) do
         for line in mw.text.gsplit(txt or "", "\n", true) do
            -- data lines begin with "| " and contain "||"
             if line:match("^|%s*%d") and line:find("||", 1, true) then
             if line:match("^|%s*%d") and line:find("||", 1, true) then
                -- pick the last percent in the row (Change % column)
                 local pct = line:match("||%s*([%+%-]?%d+%.?%d*)%%")
                 local pct = line:match("||%s*([%+%-]?%d+%.?%d*)%%")
                 if pct then
                 if pct then
Line 569: Line 446:
     end
     end


    -- National indices (tuneable)
     local civicStress =
     local civicStress =
         (sig.meta.civic.CRITICAL or 0) * 1.0 +
         (sig.meta.civic.CRITICAL or 0) * 1.0 +
Line 585: Line 461:
     local marketMood = clamp((sig.meta.market.avgPct or 0) / 3.0, -1, 1)
     local marketMood = clamp((sig.meta.market.avgPct or 0) / 3.0, -1, 1)


    -- NATIONAL PARTY MULTIPLIERS (simple defaults; tweak freely)
    -- Market mood
     if marketMood >= 0 then
     if marketMood >= 0 then
         sig.partyMult.FIML = sig.partyMult.FIML * (1 + 0.18 * marketMood)
         sig.partyMult.FIML = sig.partyMult.FIML * (1 + 0.18 * marketMood)
Line 602: Line 476:
     end
     end


    -- Civic stress (security / stability bump)
     sig.partyMult.WLG = sig.partyMult.WLG * (1 + 0.22 * civicStress)
     sig.partyMult.WLG = sig.partyMult.WLG * (1 + 0.22 * civicStress)
     sig.partyMult.CUF = sig.partyMult.CUF * (1 + 0.12 * civicStress)
     sig.partyMult.CUF = sig.partyMult.CUF * (1 + 0.12 * civicStress)
     sig.partyMult.CRC = sig.partyMult.CRC * (1 + 0.05 * civicStress)
     sig.partyMult.CRC = sig.partyMult.CRC * (1 + 0.05 * civicStress)


    -- Religious stress (temple politics / renewal bump)
     sig.partyMult.PRP = sig.partyMult.PRP * (1 + 0.20 * religStress)
     sig.partyMult.PRP = sig.partyMult.PRP * (1 + 0.20 * religStress)
     sig.partyMult.CRC = sig.partyMult.CRC * (1 + 0.06 * religStress)
     sig.partyMult.CRC = sig.partyMult.CRC * (1 + 0.06 * religStress)
Line 614: Line 486:
end
end


-- City-local bump: districts react to their own line-items in the daily tables
local function cityLocalFactor(sig, cityKey, partyId)
local function cityLocalFactor(sig, cityKey, partyId)
     if not sig or not cityKey then return 1.0 end
     if not sig or not cityKey then return 1.0 end
Line 621: Line 492:


     local f = 1.0
     local f = 1.0
    -- Local HIGH/CRITICAL civic trouble -> security/stability bump
     if (c.civicMax or 0) >= 3 then
     if (c.civicMax or 0) >= 3 then
         if partyId == "WLG" then f = f * 1.12 end
         if partyId == "WLG" then f = f * 1.12 end
Line 627: Line 497:
         if partyId == "CRC" then f = f * 1.03 end
         if partyId == "CRC" then f = f * 1.03 end
     end
     end
    -- Local religious salience -> PRP bump
     if (c.religMax or 0) >= 2 then
     if (c.religMax or 0) >= 2 then
         if partyId == "PRP" then f = f * 1.08 end
         if partyId == "PRP" then f = f * 1.08 end
Line 643: Line 512:
     local base  = (party.base[dType] or 1.0)
     local base  = (party.base[dType] or 1.0)


    -- deterministic seed: year + district index + party index
     local seed = year * 100000 + dIndex * 1000 + pIndex
     local seed = year * 100000 + dIndex * 1000 + pIndex
 
     if saltDay then seed = seed + (saltDay * 10000000) end
    -- polling-only: include day salt so polls move day-to-day deterministically
     if saltDay then
        seed = seed + (saltDay * 10000000)
    end
 
     math.randomseed(seed)
     math.randomseed(seed)


    -- jitter in roughly [-0.20, +0.20]
     local jitter = (math.random() - 0.5) * 0.4
     local jitter = (math.random() - 0.5) * 0.4
     local score  = base * (1 + jitter)
     local score  = base * (1 + jitter)


    -- (a) profile-based bias
     if profile then
     if profile then
         local pb = profileBias[profile]
         local pb = profileBias[profile]
         if pb and pb[party.id] then
         if pb and pb[party.id] then score = score * pb[party.id] end
            score = score * pb[party.id]
        end
     end
     end


    -- (b) cult-based influence for this profile
     local cults = profile and profileCults[profile] or nil
     local cults = profile and profileCults[profile] or nil
     if cults then
     if cults then
         for _, cid in ipairs(cults) do
         for _, cid in ipairs(cults) do
             local cf = cultInfluence[cid]
             local cf = cultInfluence[cid]
             if cf and cf[party.id] then
             if cf and cf[party.id] then score = score * cf[party.id] end
                score = score * cf[party.id]
            end
         end
         end
     end
     end


    -- (c) company / economic influence for this city (if any)
     local tags = cityKey and econTagsByCity[cityKey] or nil
     local tags = cityKey and econTagsByCity[cityKey] or nil
     if tags then
     if tags then
         for _, tag in ipairs(tags) do
         for _, tag in ipairs(tags) do
             local ef = econInfluence[tag]
             local ef = econInfluence[tag]
             if ef and ef[party.id] then
             if ef and ef[party.id] then score = score * ef[party.id] end
                score = score * ef[party.id]
            end
         end
         end
     end
     end


    -- (d) DAILY NATIONAL MOOD
     if sig and sig.partyMult and sig.partyMult[party.id] then
     if sig and sig.partyMult and sig.partyMult[party.id] then
         score = score * sig.partyMult[party.id]
         score = score * sig.partyMult[party.id]
     end
     end


    -- (e) DAILY LOCAL CITY MOOD
     if sig and cityKey then
     if sig and cityKey then
         score = score * cityLocalFactor(sig, cityKey, party.id)
         score = score * cityLocalFactor(sig, cityKey, party.id)
Line 708: Line 559:
     for _, v in pairs(votes) do total = total + v end
     for _, v in pairs(votes) do total = total + v end


     local alloc     = {}
     local alloc, remainders = {}, {}
    local remainders = {}


     if total <= 0 or seats <= 0 then
     if total <= 0 or seats <= 0 then
         for id,_ in pairs(votes) do
         for id,_ in pairs(votes) do alloc[id] = 0 end
            alloc[id]     = 0
        end
         return alloc, total
         return alloc, total
     end
     end


     local quota   = total / seats
     local quota, assigned = total / seats, 0
    local assigned = 0
     for id, v in pairs(votes) do
     for id, v in pairs(votes) do
         local exact = v / quota
         local exact = v / quota
Line 732: Line 579:
     for i = 1, remaining do
     for i = 1, remaining do
         local r = remainders[i]
         local r = remainders[i]
         if r then
         if r then alloc[r.id] = (alloc[r.id] or 0) + 1 end
            alloc[r.id] = (alloc[r.id] or 0) + 1
        end
     end
     end


Line 749: Line 594:
         return pct, total
         return pct, total
     end
     end
     for id, v in pairs(votes) do
     for id, v in pairs(votes) do pct[id] = (v / total) * 100 end
        pct[id] = (v / total) * 100
    end
     return pct, total
     return pct, total
end
end
Line 759: Line 602:
     for _, party in ipairs(parties) do
     for _, party in ipairs(parties) do
         local id = party.id
         local id = party.id
         list[#list+1] = {
         list[#list+1] = { party=party, votes=votes[id] or 0, seats=seatsAlloc[id] or 0 }
            party = party,
            votes = votes[id] or 0,
            seats = seatsAlloc[id] or 0,
        }
     end
     end
     table.sort(list, function(a, b)
     table.sort(list, function(a, b)
Line 781: Line 620:


     for pIndex, party in ipairs(parties) do
     for pIndex, party in ipairs(parties) do
         votes[party.id] = partyScore(
         votes[party.id] = partyScore(year, dIndex, pIndex, d.type, d.profile, d.key, sig, saltDay)
            year, dIndex, pIndex, d.type, d.profile, d.key, sig, saltDay
        )
     end
     end


Line 790: Line 627:
     local sorted                = sortedPartyList(votes, seatsAlloc)
     local sorted                = sortedPartyList(votes, seatsAlloc)


     return {
     return { district=d, votes=votes, percentages=pct, seats=seatsAlloc, totalVotes=totalVotes, sorted=sorted }
        district   = d,
        votes       = votes,
        percentages = pct,
        seats       = seatsAlloc,
        totalVotes = totalVotes,
        sorted     = sorted,
    }
end
end


local function simulateNation(year, sig, saltDay)
local function simulateNation(year, sig, saltDay)
     local results   = {}
     local results, seatTotal, voteTotal = {}, {}, {}
    local seatTotal = {}
    local voteTotal = {}


     for _, party in ipairs(parties) do
     for _, party in ipairs(parties) do
Line 813: Line 641:
         local r = simulateDistrict(year, i, sig, saltDay)
         local r = simulateDistrict(year, i, sig, saltDay)
         results[i] = r
         results[i] = r
         for id, s in pairs(r.seats) do
         for id, s in pairs(r.seats) do seatTotal[id] = seatTotal[id] + (s or 0) end
            seatTotal[id] = seatTotal[id] + (s or 0)
         for id, v in pairs(r.votes) do voteTotal[id] = voteTotal[id] + (v or 0) end
        end
         for id, v in pairs(r.votes) do
            voteTotal[id] = voteTotal[id] + (v or 0)
        end
     end
     end


     local pctNation, totalNation = normalizePercentages(voteTotal)
     local pctNation, totalNation = normalizePercentages(voteTotal)
     return {
     return { districts=results, seatTotal=seatTotal, voteTotal=voteTotal, pctNation=pctNation, totalNation=totalNation }
        districts   = results,
        seatTotal   = seatTotal,
        voteTotal   = voteTotal,
        pctNation   = pctNation,
        totalNation = totalNation,
    }
end
end


Line 835: Line 653:
------------------------------------------------------------
------------------------------------------------------------
local function getCurrentDayYear()
local function getCurrentDayYear()
     local curr = date.getCurrentDate() -- e.g. "67, 52 PSSC"
     local curr = date.getCurrentDate()
     local day  = tonumber(curr:match("^(%d+),")) or 0
     local day  = tonumber(curr:match("^(%d+),")) or 0
     local year = tonumber(curr:match(", (%d+) PSSC")) or 0
     local year = tonumber(curr:match(", (%d+) PSSC")) or 0
Line 841: Line 659:
end
end


-- Elections every year from FIRST_ELECTION_YEAR onward
local function isElectionYear(year)
local function isElectionYear(year)
     return year >= FIRST_ELECTION_YEAR
     return year >= FIRST_ELECTION_YEAR
Line 859: Line 676:
     if archiveOK and type(archive) == "table" and type(archive.nationalSummary) == "table" then
     if archiveOK and type(archive) == "table" and type(archive.nationalSummary) == "table" then
         return archive.nationalSummary[year]
         return archive.nationalSummary[year]
    end
    return nil
end
local function archivedSeatTotals(year)
    if archiveOK and type(archive) == "table" and type(archive.seatTotals) == "table" then
        return archive.seatTotals[year]
    end
    return nil
end
local function archivedPollSeatTotals(year)
    if archiveOK and type(archive) == "table" and type(archive.pollSeatTotals) == "table" then
        return archive.pollSeatTotals[year]
     end
     end
     return nil
     return nil
Line 864: Line 695:


------------------------------------------------------------
------------------------------------------------------------
-- 10) RENDER FUNCTIONS
-- 10) SEAT CHART (HEMICYCLE SVG) + LEGEND (party colors + logos in key)
------------------------------------------------------------
 
local DEFAULT_COLORS = {
    CUF  = "#2455ff",
    MSC  = "#e63b2e",
    PRP  = "#2aa85b",
    WLG  = "#6b2bbf",
    AESL = "#caa200",
    DBM  = "#00a7b5",
    FIML = "#ff7a00",
    CRC  = "#888888",
}
 
local function _trim(s) return (mw.text and mw.text.trim) and mw.text.trim(s or "") or (s or "") end
 
local function _parseCsv(s)
    local out = {}
    s = s or ""
    for token in mw.ustring.gmatch(s, "[^,%s]+") do out[#out+1] = token end
    return out
end
 
local function _parseColorMap(s)
    local cmap = {}
    for k, v in pairs(DEFAULT_COLORS) do cmap[k] = v end
    s = _trim(s)
    if s == "" then return cmap end
    for pair in mw.ustring.gmatch(s, "[^,]+") do
        local pid, hex = pair:match("^%s*([^:]+)%s*:%s*(#[0-9a-fA-F]+)%s*$")
        if pid and hex then cmap[_trim(pid)] = hex end
    end
    return cmap
end
 
local function _sumTotalSeats()
    local total = 0
    for _, d in ipairs(districts) do total = total + (d.seats or 0) end
    return total
end
 
-- Generate seat points in semicircle rows.
-- We keep the geometric distribution, but we later "fill from center outward".
local function _buildHemicyclePoints(totalSeats, width, height, padding, rows)
    width  = width or 520
    height  = height or 260
    padding = padding or 10
    rows    = rows or 10
 
    local cx = width / 2
    local cy = height - padding
    local outerR = math.min(width / 2 - padding, height - padding)
 
    local dot = math.max(4, math.floor(width / 70))
    local gap = math.max(2, math.floor(dot * 0.55))
    local step = dot + gap
 
    local innerR = math.max(step * 2, outerR - rows * step)
 
    local pts = {}
    for r = 0, rows - 1 do
        local rr = outerR - r * step
        if rr <= innerR then break end
        local arcLen = math.pi * rr
        local n = math.max(1, math.floor(arcLen / step))
        for i = 0, n - 1 do
            local t = math.pi - (i + 0.5) * (math.pi / n) -- pi(left) -> 0(right)
            local x = cx + rr * math.cos(t)
            local y = cy - rr * math.sin(t)
            pts[#pts+1] = { x=x, y=y, t=t, r=rr }
        end
    end
 
    -- Stable ordering: center first (t near pi/2), then expand outward,
    -- and within similar "center distance" prefer outer rows first (bigger radius)
    table.sort(pts, function(a, b)
        local da = math.abs(a.t - (math.pi / 2))
        local db = math.abs(b.t - (math.pi / 2))
        if da ~= db then return da < db end
        if a.r ~= b.r then return a.r > b.r end
        return a.t < b.t
    end)
 
    local out = {}
    for i = 1, math.min(totalSeats, #pts) do out[i] = pts[i] end
    return out, dot
end
 
-- Returns party IDs in a "center outward" order:
-- center, then alternating left/right, then further out.
-- This is used to assign which party occupies which seat-point in the sorted pts list.
local function _centerOutPartyFill(seatTotals, partyOrder)
    local linear = {}
    for _, pid in ipairs(partyOrder) do
        local n = seatTotals[pid] or 0
        for i=1,n do linear[#linear+1] = pid end
    end
    if #linear == 0 then return linear end
 
    local out = {}
    local mid = math.ceil(#linear / 2)
    out[#out+1] = linear[mid]
    local step = 1
    while (#out < #linear) do
        local left = mid - step
        local right = mid + step
        if left >= 1 then out[#out+1] = linear[left] end
        if #out >= #linear then break end
        if right <= #linear then out[#out+1] = linear[right] end
        step = step + 1
    end
    return out
end
 
-- SVG builder via mw.html (more likely to render than raw <svg> string concatenation)
local function _hemicycleSvg(seatTotals, totalSeats, args)
    local width  = tonumber(args.width  or "") or 520
    local height  = tonumber(args.height or "") or 260
    local padding = tonumber(args.padding or "") or 10
    local rows    = tonumber(args.rows  or "") or 10
 
    local partyOrder = _parseCsv(args.party_order or "CUF,MSC,PRP,WLG,AESL,DBM,FIML,CRC")
    local colors = _parseColorMap(args.colors)
 
    local pts, dot = _buildHemicyclePoints(totalSeats, width, height, padding, rows)
 
    -- Ensure parties omitted from party_order are appended so they still render.
    local seen = {}
    for _, pid in ipairs(partyOrder) do seen[pid] = true end
    for _, pty in ipairs(parties) do
        if not seen[pty.id] then partyOrder[#partyOrder+1] = pty.id end
    end
 
    -- Build fill list, then reorder it to be center-outward for nicer balance.
    local fill = _centerOutPartyFill(seatTotals, partyOrder)
 
    local stroke = args.stroke or "#111111"
    local strokeW = tonumber(args.stroke_w or "") or 0.6
 
    local svg = mw.html.create('svg')
        :attr('xmlns', 'http://www.w3.org/2000/svg')
        :attr('width', tostring(width))
        :attr('height', tostring(height))
        :attr('viewBox', string.format('0 0 %d %d', width, height))
        :attr('role', 'img')
        :attr('aria-label', 'Election seat chart')
 
    -- optional background (default: none/transparent)
    local bg = args.bg
    if bg and bg ~= "" and bg ~= "none" and bg ~= "#00000000" then
        svg:tag('rect')
            :attr('x','0'):attr('y','0')
            :attr('width', tostring(width))
            :attr('height', tostring(height))
            :attr('fill', bg)
            :done()
    end
 
    for i, pt in ipairs(pts) do
        local pid = fill[i] or partyOrder[#partyOrder] or parties[1].id
        local color = colors[pid] or "#888888"
        svg:tag('circle')
            :attr('cx', string.format('%.2f', pt.x))
            :attr('cy', string.format('%.2f', pt.y))
            :attr('r',  string.format('%.2f', dot/2))
            :attr('fill', color)
            :attr('stroke', stroke)
            :attr('stroke-width', string.format('%.2f', strokeW))
            :done()
    end
 
    return tostring(svg)
end
 
local function _legendTable(seatTotals, totalSeats, args)
    local colors = _parseColorMap(args.colors)
    local partyOrder = _parseCsv(args.party_order or "CUF,MSC,PRP,WLG,AESL,DBM,FIML,CRC")
 
    local ordered, seen = {}, {}
    for _, pid in ipairs(partyOrder) do
        local pty = PARTY_BY_ID[pid]
        if pty then ordered[#ordered+1] = pty; seen[pid] = true end
    end
    for _, pty in ipairs(parties) do
        if not seen[pty.id] then ordered[#ordered+1] = pty end
    end
 
    if args.legend_sort ~= "order" then
        table.sort(ordered, function(a, b)
            return (seatTotals[a.id] or 0) > (seatTotals[b.id] or 0)
        end)
    end
 
    local out = {}
    out[#out+1] = '{| class="wikitable sortable" style="width:100%; text-align:left;"'
    out[#out+1] = '! Party !! Seats'
    for _, pty in ipairs(ordered) do
        local seats = seatTotals[pty.id] or 0
        local swatch = string.format(
            '<span style="display:inline-block;width:14px;height:14px;background:%s;border:1px solid #222;vertical-align:middle;margin-right:6px;"></span>',
            colors[pty.id] or "#888888"
        )
        out[#out+1] = "|-"
        out[#out+1] = string.format("| %s%s %s || %d", swatch, (pty.logo or ""), pty.name, seats)
    end
    out[#out+1] = "|-"
    out[#out+1] = string.format('| colspan="1" style="text-align:right;" | \'\'Total\'\' || %d', totalSeats)
    out[#out+1] = "|}"
    return table.concat(out, "\n")
end
 
local function _renderSeatChartBlock(title, seatTotals, args)
    local totalSeats = _sumTotalSeats()
    local out = {}
    if title and title ~= "" then out[#out+1] = string.format("'''%s'''", title) end
    out[#out+1] = _hemicycleSvg(seatTotals, totalSeats, args)
    out[#out+1] = ""
    out[#out+1] = _legendTable(seatTotals, totalSeats, args)
    return table.concat(out, "\n")
end
 
------------------------------------------------------------
-- 11) PRELIMINARY POLLING WINDOW HELPERS
------------------------------------------------------------
local function isPollingWindow(day)
    return (day >= POLL_START_DAY) and (day < ELECTION_DAY)
end
 
------------------------------------------------------------
-- 12) RENDER FUNCTIONS (EXISTING)
------------------------------------------------------------
------------------------------------------------------------


-- District-by-district results
-- Usage:
-- {{#invoke:BassaridianElectionSim|renderElectionResults}}
-- {{#invoke:BassaridianElectionSim|renderElectionResults|year=52|day=183}}
function p.renderElectionResults(frame)
function p.renderElectionResults(frame)
     local args = frame.args or {}
     local args = frame.args or {}
Line 882: Line 938:
     end
     end


    -- If archived, always return the locked result (prevents any future sim-tuning from changing history)
     local archived = archivedElectionResults(year)
     local archived = archivedElectionResults(year)
     if archived then
     if archived then return archived end
        return archived
    end


     if day < ELECTION_DAY then
     if day < ELECTION_DAY then
         local mon, dom = getMonthDay(ELECTION_DAY)
         local mon, dom = getMonthDay(ELECTION_DAY)
         return string.format(
         return string.format("'''General election scheduled for %d %s %d PSSC.'''", dom, mon, year)
            "'''General election scheduled for %d %s %d PSSC.'''",
            dom, mon, year
        )
     end
     end


    -- IMPORTANT:
    -- Only apply daily signals on the *actual election day* for the current year.
    -- Otherwise results after election day could drift daily due to market module reseeding.
     local sig = nil
     local sig = nil
     if (year == yearNow) and (day == ELECTION_DAY) then
     if (year == yearNow) and (day == ELECTION_DAY) then
Line 915: Line 962:
     for _, r in ipairs(nation.districts) do
     for _, r in ipairs(nation.districts) do
         local d = r.district
         local d = r.district
        -- Build the list of party rows we actually want to render
         local rows = {}
         local rows = {}
         for _, entry in ipairs(r.sorted) do
         for _, entry in ipairs(r.sorted) do
Line 922: Line 967:
             local votePct  = r.percentages[entry.party.id] or 0
             local votePct  = r.percentages[entry.party.id] or 0
             if seatsWon > 0 or showZero then
             if seatsWon > 0 or showZero then
                 rows[#rows+1] = {
                 rows[#rows+1] = { party=entry.party, seats=seatsWon, pct=votePct }
                    party = entry.party,
                    seats = seatsWon,
                    pct   = votePct,
                }
             end
             end
         end
         end


         if #rows > 0 then
         if #rows > 0 then
            -- First row for this district, with rowspans on District + Seats
             local first = rows[1]
             local first = rows[1]
             out[#out+1] = "|-"
             out[#out+1] = "|-"
             out[#out+1] = string.format(
             out[#out+1] = string.format(
                 '| rowspan="%d" | %s || rowspan="%d" | %d || %s %s || %d || %.1f',
                 '| rowspan="%d" | %s || rowspan="%d" | %d || %s %s || %d || %.1f',
                 #rows,
                 #rows, d.name, #rows, d.seats,
                d.name,
                 first.party.logo or "", first.party.name,
                #rows,
                 first.seats, first.pct
                d.seats,
                 first.party.logo or "",
                first.party.name,
                 first.seats,
                first.pct
             )
             )
            -- Subsequent rows: Party, Seats Won, Vote %
             for i = 2, #rows do
             for i = 2, #rows do
                 local row = rows[i]
                 local row = rows[i]
                 out[#out+1] = "|-"
                 out[#out+1] = "|-"
                 out[#out+1] = string.format(
                 out[#out+1] = string.format('| %s %s || %d || %.1f',
                    '| %s %s || %d || %.1f',
                     row.party.logo or "", row.party.name, row.seats, row.pct)
                     row.party.logo or "",
                    row.party.name,
                    row.seats,
                    row.pct
                )
             end
             end
         end
         end
Line 964: Line 993:
end
end


-- National summary: total seats + national vote share
-- Usage:
-- {{#invoke:BassaridianElectionSim|renderNationalSummary}}
function p.renderNationalSummary(frame)
function p.renderNationalSummary(frame)
     local args = frame.args or {}
     local args = frame.args or {}
     local dayNow, yearNow = getCurrentDayYear()
     local dayNow, yearNow = getCurrentDayYear()
     local year = tonumber(args.year or "") or yearNow
     local year = tonumber(args.year or "") or yearNow
Line 978: Line 1,003:
     end
     end


    -- Archive hook
     local archived = archivedNationalSummary(year)
     local archived = archivedNationalSummary(year)
     if archived then
     if archived then return archived end
        return archived
    end


     local sig = nil
     local sig = nil
     if (year == yearNow) and (day == ELECTION_DAY) then
     if (year == yearNow) and (day == ELECTION_DAY) then sig = computeSignals() end
        sig = computeSignals()
    end


     local nation = simulateNation(year, sig, nil)
     local nation = simulateNation(year, sig, nil)
Line 994: Line 1,014:
     out[#out+1] = '{| class="wikitable sortable"'
     out[#out+1] = '{| class="wikitable sortable"'
     out[#out+1] = '! Party !! Seats (total) !! National Vote %'
     out[#out+1] = '! Party !! Seats (total) !! National Vote %'
     for _, party in ipairs(parties) do
     for _, party in ipairs(parties) do
         local seats = nation.seatTotal[party.id] or 0
         local seats = nation.seatTotal[party.id] or 0
         local pct  = nation.pctNation[party.id] or 0
         local pct  = nation.pctNation[party.id] or 0
         out[#out+1] = "|-"
         out[#out+1] = "|-"
         out[#out+1] = string.format(
         out[#out+1] = string.format("| %s %s || %d || %.1f", party.logo or "", party.name, seats, pct)
            "| %s %s || %d || %.1f",
            party.logo or "",
            party.name,
            seats,
            pct
        )
     end
     end
     out[#out+1] = "|}"
     out[#out+1] = "|}"
     return table.concat(out, "\n")
     return table.concat(out, "\n")
end
end


------------------------------------------------------------
-- 11) PRELIMINARY POLLING (DAILY-REFRESH)
-- Starts at day 118 (57 Thalassiel) and refreshes daily until election day.
--
-- Usage:
-- {{#invoke:BassaridianElectionSim|renderPreliminaryPolling}}
-- {{#invoke:BassaridianElectionSim|renderPreliminaryPolling|year=52|day=118}}
------------------------------------------------------------
function p.renderPreliminaryPolling(frame)
function p.renderPreliminaryPolling(frame)
     local args = frame.args or {}
     local args = frame.args or {}
     local dayNow, yearNow = getCurrentDayYear()
     local dayNow, yearNow = getCurrentDayYear()
     local year = tonumber(args.year or "") or yearNow
     local year = tonumber(args.year or "") or yearNow
     local day  = tonumber(args.day  or "") or dayNow
     local day  = tonumber(args.day  or "") or dayNow
Line 1,042: Line 1,045:
     if day >= ELECTION_DAY then
     if day >= ELECTION_DAY then
         local em, ed = getMonthDay(ELECTION_DAY)
         local em, ed = getMonthDay(ELECTION_DAY)
         return string.format(
         return string.format("'''Polling is closed. General election day is %d %s %d PSSC.'''", ed, em, year)
            "'''Polling is closed. General election day is %d %s %d PSSC.'''",
            ed, em, year
        )
     end
     end


    -- Polls are supposed to refresh daily, so:
    --  • we DO apply computeSignals() during the window, and
    --  • we DO salt the RNG by day so there is some movement even on quiet days.
     local sig = computeSignals()
     local sig = computeSignals()
     local nation = simulateNation(year, sig, day)
     local nation = simulateNation(year, sig, day)
Line 1,068: Line 1,065:
     out[#out+1] = "|}"
     out[#out+1] = "|}"


    -- Compact “inputs snapshot” so you can sanity-check daily updates
     out[#out+1] = ""
     out[#out+1] = ""
     out[#out+1] = '{| class="wikitable" style="text-align:center; width:60%;"'
     out[#out+1] = '{| class="wikitable" style="text-align:center; width:60%;"'
Line 1,081: Line 1,077:


     return table.concat(out, "\n")
     return table.concat(out, "\n")
end
function p.renderPreliminaryPollingDistricts(frame)
    local args = frame.args or {}
    local dayNow, yearNow = getCurrentDayYear()
    local year = tonumber(args.year or "") or yearNow
    local day  = tonumber(args.day  or "") or dayNow
    local showZero = (args.show_zero and args.show_zero ~= "0") or false
    if not isElectionYear(year) then
        return string.format("'''No general election in %d PSSC.'''", year)
    end
    if day < POLL_START_DAY then
        local pm, pd = getMonthDay(POLL_START_DAY)
        local em, ed = getMonthDay(ELECTION_DAY)
        return string.format(
            "'''Preliminary polling begins on %d %s %d PSSC (day %d) and updates daily until %d %s.'''",
            pd, pm, year, POLL_START_DAY, ed, em
        )
    end
    if day >= ELECTION_DAY then
        local em, ed = getMonthDay(ELECTION_DAY)
        return string.format("'''Polling is closed. General election day is %d %s %d PSSC.'''", ed, em, year)
    end
    local sig = computeSignals()
    local nation = simulateNation(year, sig, day)
    local mon, dom = getMonthDay(day)
    local headerDate = string.format("%d %s %d PSSC", dom, mon, year)
    local out = {}
    out[#out+1] = '{| class="wikitable sortable"'
    out[#out+1] = '|+ Preliminary polling (district-by-district) — ' .. headerDate
    out[#out+1] = '! District !! Seats !! Party !! Projected Seats !! Poll %'
    for _, r in ipairs(nation.districts) do
        local d = r.district
        local rows = {}
        for _, entry in ipairs(r.sorted) do
            local seatsWon = r.seats[entry.party.id] or 0
            local votePct  = r.percentages[entry.party.id] or 0
            if seatsWon > 0 or showZero then
                rows[#rows+1] = { party=entry.party, seats=seatsWon, pct=votePct }
            end
        end
        if #rows > 0 then
            local first = rows[1]
            out[#out+1] = "|-"
            out[#out+1] = string.format(
                '| rowspan="%d" | %s || rowspan="%d" | %d || %s %s || %d || %.1f',
                #rows, d.name, #rows, d.seats,
                first.party.logo or "", first.party.name,
                first.seats, first.pct
            )
            for i = 2, #rows do
                local row = rows[i]
                out[#out+1] = "|-"
                out[#out+1] = string.format('| %s %s || %d || %.1f',
                    row.party.logo or "", row.party.name, row.seats, row.pct)
            end
        end
    end
    out[#out+1] = "|}"
    return table.concat(out, "\n")
end
------------------------------------------------------------
-- 13) NEW: SEAT CHART RENDERERS (HEMICYCLE + KEY)
-- Same historical-lock rules as before.
------------------------------------------------------------
local function _needArchivedSeatTotalsMsg(year)
    return string.format(
        "'''Seat chart unavailable for %d PSSC because historical results are locked.'''<br/>" ..
        "Add <code>seatTotals[%d]</code> to <code>Module:BassaridianElectionArchive</code> to render the historical hemicycle.",
        year, year
    )
end
function p.renderSeatChartFinal(frame)
    local args = frame.args or {}
    local dayNow, yearNow = getCurrentDayYear()
    local year = tonumber(args.year or "") or yearNow
    local day  = tonumber(args.day  or "") or dayNow
    if not isElectionYear(year) or day < ELECTION_DAY then
        return "'''Seat chart available only once the general election has been held.'''"
    end
    if year ~= yearNow then
        local st = archivedSeatTotals(year)
        if not st then return _needArchivedSeatTotalsMsg(year) end
        local mon, dom = getMonthDay(ELECTION_DAY)
        return _renderSeatChartBlock(string.format("Final seat distribution — %d %s %d PSSC", dom, mon, year), st, args)
    end
    local archivedST = archivedSeatTotals(year)
    if archivedST then
        local mon, dom = getMonthDay(ELECTION_DAY)
        return _renderSeatChartBlock(string.format("Final seat distribution — %d %s %d PSSC", dom, mon, year), archivedST, args)
    end
    local sig = nil
    if day == ELECTION_DAY then sig = computeSignals() end
    local nation = simulateNation(year, sig, nil)
    local mon, dom = getMonthDay(ELECTION_DAY)
    return _renderSeatChartBlock(string.format("Final seat distribution — %d %s %d PSSC", dom, mon, year), nation.seatTotal or {}, args)
end
function p.renderSeatChartPolling(frame)
    local args = frame.args or {}
    local dayNow, yearNow = getCurrentDayYear()
    local year = tonumber(args.year or "") or yearNow
    local day  = tonumber(args.day  or "") or dayNow
    if not isElectionYear(year) then
        return string.format("'''No general election in %d PSSC.'''", year)
    end
    if day < POLL_START_DAY then
        local pm, pd = getMonthDay(POLL_START_DAY)
        local em, ed = getMonthDay(ELECTION_DAY)
        return string.format(
            "'''Preliminary polling begins on %d %s %d PSSC (day %d) and updates daily until %d %s.'''",
            pd, pm, year, POLL_START_DAY, ed, em
        )
    end
    if day >= ELECTION_DAY then
        return "'''Polling is closed. Use renderSeatChartFinal for final results.'''"
    end
    if year ~= yearNow then
        local pst = archivedPollSeatTotals(year)
        if not pst then
            return string.format(
                "'''Polling seat chart unavailable for %d PSSC because historical results are locked.'''<br/>" ..
                "If you want a frozen historical polling chart, add <code>pollSeatTotals[%d]</code> to the archive.",
                year, year
            )
        end
        local mon, dom = getMonthDay(day)
        return _renderSeatChartBlock(string.format("Preliminary polling seat projection — %d %s %d PSSC", dom, mon, year), pst, args)
    end
    local sig = computeSignals()
    local nation = simulateNation(year, sig, day)
    local mon, dom = getMonthDay(day)
    return _renderSeatChartBlock(string.format("Preliminary polling seat projection — %d %s %d PSSC", dom, mon, year), nation.seatTotal or {}, args)
end
function p.renderSeatChart(frame)
    local args = frame.args or {}
    local dayNow, yearNow = getCurrentDayYear()
    local year = tonumber(args.year or "") or yearNow
    local day  = tonumber(args.day  or "") or dayNow
    if not isElectionYear(year) then
        return string.format("'''No general election in %d PSSC.'''", year)
    end
    if day >= ELECTION_DAY then
        return p.renderSeatChartFinal(frame)
    end
    if day >= POLL_START_DAY then
        return p.renderSeatChartPolling(frame)
    end
    local pm, pd = getMonthDay(POLL_START_DAY)
    local em, ed = getMonthDay(ELECTION_DAY)
    return string.format(
        "'''Seat chart appears during preliminary polling (%d %s) and after the election (%d %s).'''",
        pd, pm, ed, em
    )
end
end


------------------------------------------------------------
------------------------------------------------------------
-- 12) DEBUG (OPTIONAL): SEE WHAT THE SIGNALS LOOK LIKE TODAY
-- 14) DEBUG
-- Usage: {{#invoke:BassaridianElectionSim|debugDailySignals}}
------------------------------------------------------------
------------------------------------------------------------
function p.debugDailySignals(frame)
function p.debugDailySignals(frame)

Latest revision as of 16:45, 22 December 2025

Documentation for this module may be created at Module:BassaridianElectionSim/doc

-- Module:BassaridianElectionSim
-- Simulated multi-party elections for Bassaridia Vaeringheim,
-- using the same PSSC calendar structure as PillSeasonSchedule.
--
-- UPDATE (Dec 2025):
--  • Election-day results can incorporate “today’s” signals from:
--      - Module:BassaridianDailyEvents
--      - Module:ReligiousEvents
--      - Module:BassaridMarketActivity
--    but ONLY on election day for the current year (prevents post-election drift).
--  • Adds preliminary polling window starting day 118 through day 121 (refreshes daily).
--  • Adds optional “archive” hooks so past election results can be hard-locked.
--  • NEW (Dec 2025 seat chart update):
--      - Adds hemicycle / seat-arc SVG chart + auto legend (party colors + party logos once in key).
--      - Uses mw.html for SVG construction (more likely to render than raw string SVG).
--      - Fills seats from CENTER outward (symmetry) rather than left-to-right.
--      - Works for preliminary polling, election results, and historical results.
--      - Historical results are NEVER changed: if an archive entry exists for that year, it is used.
--        For historical seat charts, the module looks for archive.seatTotals[year] (recommended).
--        If missing, it will NOT simulate a historical chart; it will instruct you to add seatTotals.

local p    = {}
local date = require('Module:BassaridianCalendar')

------------------------------------------------------------
-- OPTIONAL: DAILY SIGNAL MODULES (pcall so this module never hard-errors)
------------------------------------------------------------
local dailyOK, dailyEvents = pcall(require, 'Module:BassaridianDailyEvents')
local religOK, religious   = pcall(require, 'Module:ReligiousEvents')
local marketOK, market     = pcall(require, 'Module:BassaridMarketActivity')

------------------------------------------------------------
-- OPTIONAL: ARCHIVE MODULE HOOK (lets you hard-freeze past results)
-- If you create Module:BassaridianElectionArchive, expose:
--   return {
--     electionResults = { [51] = "wikitext table..." , ... },
--     nationalSummary = { [51] = "wikitext table..." , ... },
--     -- NEW (recommended for historical seat charts):
--     seatTotals      = { [51] = { CUF=..., MSC=..., ... }, ... },
--     -- Optional: if you ever want to freeze polls too:
--     pollSeatTotals  = { [51] = { CUF=..., ... }, ... },
--   }
------------------------------------------------------------
local archiveOK, archive = pcall(require, 'Module:BassaridianElectionArchive')

------------------------------------------------------------
-- 0) CONFIG / CALENDAR
------------------------------------------------------------
local DAYS_IN_YEAR        = 183
local FIRST_ELECTION_YEAR = 50       -- first election anchor (adjust as needed)
local ELECTION_DAY        = 122      -- election held on 122nd day of year (61 Thalassiel)
local POLL_START_DAY      = 118      -- preliminary polling begins (day-of-year), refreshes daily until ELECTION_DAY-1

-- 61-day "months" matching PillSeasonSchedule logic
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 absDay(y, d)
    return y * DAYS_IN_YEAR + d
end

------------------------------------------------------------
-- 1) PARTIES
------------------------------------------------------------
local parties = {
    {
        id    = "CUF",
        name  = "Councilist Unity Front",
        short = "CUF",
        logo  = "[[File:CUF logo.png|20px]]",
        base  = { city = 1.30, tribe = 0.90 },
    },
    {
        id    = "MSC",
        name  = "Morovian Social Cooperative",
        short = "MSC",
        logo  = "[[File:MSC logo.png.png|20px]]",
        base  = { city = 1.10, tribe = 1.00 },
    },
    {
        id    = "PRP",
        name  = "Path of Renewal Party",
        short = "PRP",
        logo  = "[[File:PRP logo.png|20px]]",
        base  = { city = 1.00, tribe = 1.10 },
    },
    {
        id    = "WLG",
        name  = "War League Guardians Bloc",
        short = "WLG",
        logo  = "[[File:WLG logo.png|20px]]",
        base  = { city = 0.95, tribe = 1.30 },
    },
    {
        id    = "AESL",
        name  = "Alperkin Earth & Sky League",
        short = "AESL",
        logo  = "[[File:AESL logo.png|20px]]",
        base  = { city = 0.60, tribe = 1.50 },
    },
    {
        id    = "DBM",
        name  = "Diaspora Bridges Movement",
        short = "DBM",
        logo  = "[[File:DBM logo.png|20px]]",
        base  = { city = 0.90, tribe = 0.50 },
    },
    {
        id    = "FIML",
        name  = "Free Investors & Merchants League",
        short = "FIML",
        logo  = "[[File:FIML logo.png|20px]]",
        base  = { city = 1.10, tribe = 0.40 },
    },
    {
        id    = "CRC",
        name  = "Civic Reform Covenant",
        short = "CRC",
        logo  = "[[File:CRC logo.png.png|20px]]",
        base  = { city = 1.00, tribe = 0.80 },
    },
}

local PARTY_BY_ID = {}
for _, pty in ipairs(parties) do
    PARTY_BY_ID[pty.id] = pty
end

------------------------------------------------------------
-- 2) DISTRICTS
------------------------------------------------------------
local districts = {
    -- Major Cities in Bassaridia Vaeringheim
    { key="Vaeringheim",   name="[[List of cities in Bassaridia Vaeringheim#Vaeringheim|Vaeringheim]]",       type="city", seats=3, profile="port_core" },
    { key="Luminaria",     name="[[List of cities in Bassaridia Vaeringheim#Luminaria|Luminaria]]",           type="city", seats=3, profile="shrine_mystic" },
    { key="Serena",        name="[[List of cities in Bassaridia Vaeringheim#Serena|Serena]]",                 type="city", seats=3, profile="port_periphery" },
    { key="Pyralis",       name="[[List of cities in Bassaridia Vaeringheim#Pyralis|Pyralis]]",               type="city", seats=3, profile="war_frontier" },
    { key="Symphonara",    name="[[List of cities in Bassaridia Vaeringheim#Symphonara|Symphonara]]",         type="city", seats=3, profile="shrine_mystic" },
    { key="Aurelia",       name="[[List of cities in Bassaridia Vaeringheim#Aurelia|Aurelia]]",               type="city", seats=3, profile="agrarian" },
    { key="Somniumpolis",  name="[[List of cities in Bassaridia Vaeringheim#Somniumpolis|Somniumpolis]]",     type="city", seats=3, profile="port_periphery" },
    { key="Nexa",          name="[[List of cities in Bassaridia Vaeringheim#Nexa|Nexa]]",                     type="city", seats=3, profile="war_frontier" },
    { key="LunalisSancta", name="[[List of cities in Bassaridia Vaeringheim#Lunalis Sancta|Lunalis Sancta]]", type="city", seats=3, profile="shrine_mystic" },
    { key="Sylvapolis",    name="[[List of cities in Bassaridia Vaeringheim#Sylvapolis|Sylvapolis]]",         type="city", seats=3, profile="agrarian" },

    -- Minor Cities in Bassaridia Vaeringheim
    { key="Saluria",       name="[[List of cities in Bassaridia Vaeringheim#Saluria|Saluria]]",               type="city", seats=3, profile="port_periphery" },
    { key="Aetherium",     name="[[List of cities in Bassaridia Vaeringheim#Aetherium|Aetherium]]",           type="city", seats=3, profile="shrine_mystic" },
    { key="FerrumCitadel", name="[[List of cities in Bassaridia Vaeringheim#Ferrum Citadel|Ferrum Citadel]]", type="city", seats=3, profile="war_frontier" },
    { key="Acheron",       name="[[List of cities in Bassaridia Vaeringheim#Acheron|Acheron]]",               type="city", seats=3, profile="war_frontier" },
    { key="Erythros",      name="[[List of cities in Bassaridia Vaeringheim#Erythros|Erythros]]",             type="city", seats=3, profile="war_frontier" },
    { key="CatonisAtrium", name="[[List of cities in Bassaridia Vaeringheim#Catonis Atrium|Catonis Atrium]]", type="city", seats=3, profile="agrarian" },
    { key="Delphica",      name="[[List of cities in Bassaridia Vaeringheim#Delphica|Delphica]]",             type="city", seats=3, profile="port_core" },
    { key="Koinonia",      name="[[List of cities in Bassaridia Vaeringheim#Koinonía|Koinonía]]",             type="city", seats=3, profile="agrarian" },
    { key="Aureum",        name="[[List of cities in Bassaridia Vaeringheim#Aureum|Aureum]]",                 type="city", seats=3, profile="port_periphery" },

    -- Alperkin cities
    { key="TheAlpazkigz",  name="[[List of cities in Bassaridia Vaeringheim#The Alpazkigz|The Alpazkigz]]",   type="city", seats=3, profile="alperkin_city" },
    { key="Odiferia",      name="[[List of cities in Bassaridia Vaeringheim#Odiferia|Odiferia]]",             type="city", seats=3, profile="alperkin_city" },

    -- Major Cities in New South Jangsong
    { key="Skyrophos",     name="[[List of cities in Bassaridia Vaeringheim#Skýrophos|Skýrophos]]",           type="city", seats=3, profile="war_frontier" },
    { key="Bjornopolis",   name="[[List of cities in Bassaridia Vaeringheim#Bjornopolis|Bjornopolis]]",       type="city", seats=3, profile="shrine_mystic" },
    { key="Aegirheim",     name="[[List of cities in Bassaridia Vaeringheim#Aegirheim|Aegirheim]]",           type="city", seats=3, profile="port_periphery" },
    { key="Norsolyra",     name="[[List of cities in Bassaridia Vaeringheim#Norsolyra|Norsolyra]]",           type="city", seats=3, profile="agrarian" },
    { key="Thorsalon",     name="[[List of cities in Bassaridia Vaeringheim#Thorsalon|Thorsalon]]",           type="city", seats=3, profile="agrarian" },

    -- Minor Cities in New South Jangsong
    { key="Pelagia",       name="[[List of cities in Bassaridia Vaeringheim#Pelagia|Pelagia]]",               type="city", seats=3, profile="port_periphery" },
    { key="Myrene",        name="[[List of cities in Bassaridia Vaeringheim#Myrene|Myrene]]",                 type="city", seats=3, profile="agrarian" },
    { key="Thyrea",        name="[[List of cities in Bassaridia Vaeringheim#Thyrea|Thyrea]]",                 type="city", seats=3, profile="agrarian" },
    { key="Ephyra",        name="[[List of cities in Bassaridia Vaeringheim#Ephyra|Ephyra]]",                 type="city", seats=3, profile="agrarian" },
    { key="Halicarn",      name="[[List of cities in Bassaridia Vaeringheim#Halicarn|Halicarn]]",             type="city", seats=3, profile="port_periphery" },

    -- Major Cities in Haifan Bassaridia
    { key="KeybirAviv",    name="[[List of cities in Bassaridia Vaeringheim#Keybir-Aviv|Keybir-Aviv]]",       type="city", seats=3, profile="haifan_urban" },
    { key="TelAmin",       name="[[List of cities in Bassaridia Vaeringheim#Tel-Amin|Tel-Amin]]",             type="city", seats=3, profile="haifan_urban" },
    { key="Diamandis",     name="[[List of cities in Bassaridia Vaeringheim#Diamandis|Diamandis]]",           type="city", seats=3, profile="haifan_resort" },
    { key="Jogi",          name="[[List of cities in Bassaridia Vaeringheim#Jogi|Jogi]]",                     type="city", seats=3, profile="haifan_resort" },
    { key="Lewisburg",     name="[[List of cities in Bassaridia Vaeringheim#Lewisburg|Lewisburg]]",           type="city", seats=3, profile="haifan_urban" },

    -- Minor Cities in Haifan Bassaridia
    { key="Thermosalem",   name="[[List of cities in Bassaridia Vaeringheim#Thermosalem|Thermosalem]]",       type="city", seats=3, profile="haifan_resort" },
    { key="Akrostadium",   name="[[List of cities in Bassaridia Vaeringheim#Akróstadium|Akróstadium]]",       type="city", seats=3, profile="haifan_resort" },
    { key="Sufriya",       name="[[List of cities in Bassaridia Vaeringheim#Sufriya|Sufriya]]",               type="city", seats=3, profile="port_periphery" },
    { key="Lykopolis",     name="[[List of cities in Bassaridia Vaeringheim#Lykopolis|Lykopolis]]",           type="city", seats=3, profile="war_frontier" },

    -- Bassaridian Normark
    { key="Ardclach",      name="[[List of cities in Bassaridia Vaeringheim#Ardclach|Ardclach]]",             type="city", seats=3, profile="normark_trade" },
    { key="Riddersborg",   name="[[List of cities in Bassaridia Vaeringheim#Riddersborg|Riddersborg]]",       type="city", seats=3, profile="normark_trade" },

    -- Ouriana Province (major cities)
    { key="Bashkim",       name="[[List of cities in Bassaridia Vaeringheim#Bashkim|Bashkim]]",               type="city", seats=3, profile="agrarian" },
    { key="Ourid",         name="[[List of cities in Bassaridia Vaeringheim#Ourid|Ourid]]",                   type="city", seats=3, profile="agrarian" },

    -- Alperkin tribal constituencies (non-city, 1-seat)
    { key="WesternSteppesTribe",  name="Western Steppes Alperkin Tribe",    type="tribe", seats=1, profile="alperkin_tribe" },
    { key="NorthernCanyonTribe",  name="Northern Canyon Alperkin Tribe",    type="tribe", seats=1, profile="alperkin_tribe" },
    { key="MorovianShoreTribe",   name="Morovian Shoreline Alperkin Tribe", type="tribe", seats=1, profile="alperkin_tribe" },
}

------------------------------------------------------------
-- 3) PROFILE-BASED BIAS + cult + econ influence
------------------------------------------------------------
local profileBias = {
    port_core = { CUF=1.15, FIML=1.20, CRC=1.05, DBM=1.05 },
    port_periphery = { CUF=1.05, MSC=1.05, FIML=1.10, DBM=1.05 },
    shrine_mystic = { PRP=1.20, CRC=1.10, CUF=1.05 },
    war_frontier = { WLG=1.20, CUF=1.05, MSC=1.00 },
    agrarian = { MSC=1.15, PRP=1.05, CUF=0.95, FIML=0.95 },
    normark_trade = { CUF=1.10, CRC=1.10, FIML=1.05 },
    alperkin_city = { AESL=1.30, MSC=1.05, PRP=1.05 },
    haifan_resort = { MSC=1.10, PRP=1.05, CUF=1.05, DBM=1.10 },
    haifan_urban = { MSC=1.10, CUF=1.10, CRC=1.05, DBM=1.10 },
    alperkin_tribe = { AESL=1.40, WLG=1.10, MSC=1.05, PRP=1.05 },
}

local cultInfluence = {
    Aurora    = { PRP = 1.20, CRC = 1.05, CUF = 1.05 },
    Azure     = { CUF = 1.15, WLG = 1.10, FIML = 1.05 },
    Harmony   = { CUF = 1.10, CRC = 1.10, PRP = 1.05 },

    Ignis     = { PRP = 1.20, WLG = 1.10 },
    Celestial = { PRP = 1.15, DBM = 1.05, CRC = 1.05 },
    Golden    = { FIML = 1.20, CUF = 1.10, WLG = 1.05 },
    Reverie   = { MSC  = 1.15, DBM = 1.10 },

    Eon       = { CRC  = 1.15, FIML = 1.05 },
    Umbral    = { PRP  = 1.10, MSC = 1.05, WLG = 1.05 },

    Verdant   = { AESL = 1.20, MSC = 1.10 },
    Vitalis   = { AESL = 1.10, MSC = 1.10, PRP = 1.05 },
    Alabaster = { FIML = 1.15, CUF = 1.05 },
    Ironclad  = { WLG  = 1.20, CUF = 1.05 },

    Concordia = { CRC  = 1.15, CUF = 1.05 },
    Stygian   = { WLG  = 1.10, PRP = 1.05 },

    Sylvan    = { AESL = 1.20, MSC = 1.05 },
    RedMirth  = { MSC  = 1.10, PRP = 1.05 },
    Rex       = { FIML = 1.10, CUF = 1.10 },
    Delphica  = { MSC  = 1.05, PRP = 1.05, CRC = 1.05 },

    Amicitia  = { MSC  = 1.10, CRC = 1.10 },
    Illuminata= { DBM  = 1.15, CRC = 1.05 },
    Conclave  = { PRP  = 1.10, DBM = 1.10, CUF = 1.05 },
}

local profileCults = {
    port_core      = { "Azure", "Golden", "Harmony" },
    port_periphery = { "Azure", "Reverie" },
    shrine_mystic  = { "Aurora", "Celestial", "Harmony" },
    war_frontier   = { "Ironclad", "Stygian" },
    agrarian       = { "Verdant", "Sylvan", "Vitalis" },
    normark_trade  = { "Golden", "Eon" },
    alperkin_city  = { "Sylvan", "Verdant" },
    haifan_resort  = { "Conclave", "Reverie" },
    haifan_urban   = { "Conclave", "Harmony" },
    alperkin_tribe = { "Sylvan", "Verdant" },
}

local econInfluence = {
    port_finance = { FIML=1.25, CUF=1.10, CRC=1.05, DBM=1.05 },
    agricoop = { MSC=1.20, AESL=1.05, PRP=1.05 },
    arms_heavy = { WLG=1.20, CUF=1.05, FIML=1.05 },
    fishery = { MSC=1.10, DBM=1.05 },
    diaspora_port = { DBM=1.20, CUF=1.05, FIML=1.05 },
    cult_services = { PRP=1.10, CRC=1.10 },
    war_religious = { WLG=1.15, PRP=1.05, CUF=1.05 },
}

local econTagsByCity = {
    Vaeringheim   = { "port_finance", "diaspora_port" },
    Luminaria     = { "cult_services" },
    Serena        = { "port_finance" },
    Pyralis       = { "arms_heavy" },
    Symphonara    = { "cult_services" },
    Aurelia       = { "agricoop" },
    Somniumpolis  = { "arms_heavy", "diaspora_port" },
    Nexa          = { "arms_heavy" },
    LunalisSancta = { "port_finance", "cult_services" },
    Sylvapolis    = { "agricoop" },
    Saluria       = { "agricoop", "cult_services" },
    Aetherium     = { "cult_services" },
    FerrumCitadel = { "arms_heavy" },
    Acheron       = { "arms_heavy" },
    Erythros      = { "arms_heavy" },
    CatonisAtrium = { "agricoop" },
    Delphica      = { "port_finance", "cult_services" },
    Koinonia      = { "agricoop" },
    Aureum        = { "port_finance" },

    TheAlpazkigz  = { "agricoop" },
    Odiferia      = { "agricoop" },
    Skyrophos     = { "arms_heavy" },
    Bjornopolis   = { "cult_services" },
    Aegirheim     = { "arms_heavy", "port_finance" },
    Norsolyra     = { "agricoop" },
    Thorsalon     = { "agricoop", "diaspora_port" },

    Pelagia       = { "war_religious", "diaspora_port" },
    Myrene        = { "agricoop" },
    Thyrea        = { "agricoop" },
    Ephyra        = { "agricoop", "fishery" },
    Halicarn      = { "port_finance" },

    KeybirAviv    = { "diaspora_port" },
    TelAmin       = { "diaspora_port", "cult_services" },
    Diamandis     = { "diaspora_port" },
    Jogi          = { "agricoop" },
    Lewisburg     = { "diaspora_port" },

    Thermosalem   = { "diaspora_port", "cult_services" },
    Akrostadium   = { "diaspora_port" },
    Sufriya       = { "port_finance", "diaspora_port" },
    Lykopolis     = { "war_religious" },

    Ardclach      = { "port_finance" },
    Riddersborg   = { "port_finance" },

    Bashkim       = { "agricoop" },
    Ourid         = { "agricoop" },
}

------------------------------------------------------------
-- 4) DAILY SIGNALS (USED FOR POLLS + ELECTION DAY ONLY)
------------------------------------------------------------
local URG_W = { LOW=1, MEDIUM=2, HIGH=3, CRITICAL=4 }

local function clamp(x, lo, hi)
    if x < lo then return lo end
    if x > hi then return hi end
    return x
end

local function deaccent(s)
    if not s then return "" end
    local u = mw.ustring
    s = u.gsub(s, "[áàâäãåā]", "a")
    s = u.gsub(s, "[éèêëē]", "e")
    s = u.gsub(s, "[íìîïī]", "i")
    s = u.gsub(s, "[óòôöõō]", "o")
    s = u.gsub(s, "[úùûüū]", "u")
    s = u.gsub(s, "[ýÿ]", "y")
    s = u.gsub(s, "ç", "c")
    s = u.gsub(s, "ñ", "n")
    s = u.gsub(s, "ø", "o")
    s = u.gsub(s, "å", "a")
    return s
end

local function canonKey(s)
    local u = mw.ustring
    s = deaccent(u.lower(tostring(s or "")))
    s = u.gsub(s, "[^0-9a-z]", "")
    return s
end

local function parse4colRow(line)
    local dateTxt, city, desc, urg = line:match("^|%s*(.-)%s*||%s*(.-)%s*||%s*(.-)%s*||%s*(.-)%s*$")
    if not city or not urg then return nil end
    return dateTxt, mw.text.trim(city), (desc or ""), mw.text.trim(urg)
end

local function computeSignals()
    local sig = {
        partyMult = {},
        city = {},
        meta = {
            civic  = {LOW=0, MEDIUM=0, HIGH=0, CRITICAL=0},
            relig  = {LOW=0, MEDIUM=0, HIGH=0, CRITICAL=0},
            market = {avgPct=0, n=0, up=0, down=0},
        }
    }

    for _, party in ipairs(parties) do
        sig.partyMult[party.id] = 1.0
    end

    local function addCity(cityName, bucket, urg)
        if not urg or urg == "" or urg == "N/A" then return end
        local w = URG_W[urg] or 0
        if w <= 0 then return end

        local ck = canonKey(cityName)
        sig.city[ck] = sig.city[ck] or { civic=0, relig=0, civicMax=0, religMax=0 }
        local c = sig.city[ck]

        if bucket == "civic" then
            c.civic = c.civic + w
            if w > c.civicMax then c.civicMax = w end
            sig.meta.civic[urg] = (sig.meta.civic[urg] or 0) + 1
        else
            c.relig = c.relig + w
            if w > c.religMax then c.religMax = w end
            sig.meta.relig[urg] = (sig.meta.relig[urg] or 0) + 1
        end
    end

    if dailyOK and dailyEvents and dailyEvents.makeTable then
        local txt = dailyEvents.makeTable()
        for line in mw.text.gsplit(txt or "", "\n", true) do
            if line:match("^|%s*%d") and line:find("||", 1, true) then
                local _, city, _, urg = parse4colRow(line)
                if city and urg and urg ~= "N/A" then
                    addCity(city, "civic", urg)
                end
            end
        end
    end

    if religOK and religious and religious.makeTable then
        local txt = religious.makeTable()
        for line in mw.text.gsplit(txt or "", "\n", true) do
            if line:match("^|%s*%d") and line:find("||", 1, true) then
                local _, city, _, urg = parse4colRow(line)
                if city and urg and urg ~= "N/A" then
                    addCity(city, "relig", urg)
                end
            end
        end
    end

    if marketOK and market and market.generateTable then
        local txt = market.generateTable({ args = {} })
        for line in mw.text.gsplit(txt or "", "\n", true) do
            if line:match("^|%s*%d") and line:find("||", 1, true) then
                local pct = line:match("||%s*([%+%-]?%d+%.?%d*)%%")
                if pct then
                    local pch = tonumber(pct) or 0
                    sig.meta.market.avgPct = sig.meta.market.avgPct + pch
                    sig.meta.market.n = sig.meta.market.n + 1
                    if pch >= 0 then sig.meta.market.up = sig.meta.market.up + 1 else sig.meta.market.down = sig.meta.market.down + 1 end
                end
            end
        end
        if sig.meta.market.n > 0 then
            sig.meta.market.avgPct = sig.meta.market.avgPct / sig.meta.market.n
        end
    end

    local civicStress =
        (sig.meta.civic.CRITICAL or 0) * 1.0 +
        (sig.meta.civic.HIGH or 0)     * 0.6 +
        (sig.meta.civic.MEDIUM or 0)   * 0.25

    local religStress =
        (sig.meta.relig.CRITICAL or 0) * 1.0 +
        (sig.meta.relig.HIGH or 0)     * 0.6 +
        (sig.meta.relig.MEDIUM or 0)   * 0.25

    civicStress = clamp(civicStress / 12.0, 0, 1)
    religStress = clamp(religStress / 12.0, 0, 1)

    local marketMood = clamp((sig.meta.market.avgPct or 0) / 3.0, -1, 1)

    if marketMood >= 0 then
        sig.partyMult.FIML = sig.partyMult.FIML * (1 + 0.18 * marketMood)
        sig.partyMult.CUF  = sig.partyMult.CUF  * (1 + 0.10 * marketMood)
        sig.partyMult.CRC  = sig.partyMult.CRC  * (1 + 0.12 * marketMood)
        sig.partyMult.MSC  = sig.partyMult.MSC  * (1 - 0.05 * marketMood)
        sig.partyMult.AESL = sig.partyMult.AESL * (1 - 0.04 * marketMood)
    else
        local d = math.abs(marketMood)
        sig.partyMult.MSC  = sig.partyMult.MSC  * (1 + 0.14 * d)
        sig.partyMult.AESL = sig.partyMult.AESL * (1 + 0.10 * d)
        sig.partyMult.PRP  = sig.partyMult.PRP  * (1 + 0.06 * d)
        sig.partyMult.FIML = sig.partyMult.FIML * (1 - 0.12 * d)
        sig.partyMult.CRC  = sig.partyMult.CRC  * (1 - 0.06 * d)
    end

    sig.partyMult.WLG = sig.partyMult.WLG * (1 + 0.22 * civicStress)
    sig.partyMult.CUF = sig.partyMult.CUF * (1 + 0.12 * civicStress)
    sig.partyMult.CRC = sig.partyMult.CRC * (1 + 0.05 * civicStress)

    sig.partyMult.PRP = sig.partyMult.PRP * (1 + 0.20 * religStress)
    sig.partyMult.CRC = sig.partyMult.CRC * (1 + 0.06 * religStress)

    return sig
end

local function cityLocalFactor(sig, cityKey, partyId)
    if not sig or not cityKey then return 1.0 end
    local c = sig.city[canonKey(cityKey)]
    if not c then return 1.0 end

    local f = 1.0
    if (c.civicMax or 0) >= 3 then
        if partyId == "WLG" then f = f * 1.12 end
        if partyId == "CUF" then f = f * 1.06 end
        if partyId == "CRC" then f = f * 1.03 end
    end
    if (c.religMax or 0) >= 2 then
        if partyId == "PRP" then f = f * 1.08 end
        if partyId == "CRC" then f = f * 1.03 end
    end
    return f
end

------------------------------------------------------------
-- 5) RANDOMNESS (DETERMINISTIC BY YEAR/DISTRICT/PARTY)
--    + OPTIONAL “saltDay” (used ONLY for preliminary polling)
------------------------------------------------------------
local function partyScore(year, dIndex, pIndex, dType, profile, cityKey, sig, saltDay)
    local party = parties[pIndex]
    local base  = (party.base[dType] or 1.0)

    local seed = year * 100000 + dIndex * 1000 + pIndex
    if saltDay then seed = seed + (saltDay * 10000000) end
    math.randomseed(seed)

    local jitter = (math.random() - 0.5) * 0.4
    local score  = base * (1 + jitter)

    if profile then
        local pb = profileBias[profile]
        if pb and pb[party.id] then score = score * pb[party.id] end
    end

    local cults = profile and profileCults[profile] or nil
    if cults then
        for _, cid in ipairs(cults) do
            local cf = cultInfluence[cid]
            if cf and cf[party.id] then score = score * cf[party.id] end
        end
    end

    local tags = cityKey and econTagsByCity[cityKey] or nil
    if tags then
        for _, tag in ipairs(tags) do
            local ef = econInfluence[tag]
            if ef and ef[party.id] then score = score * ef[party.id] end
        end
    end

    if sig and sig.partyMult and sig.partyMult[party.id] then
        score = score * sig.partyMult[party.id]
    end

    if sig and cityKey then
        score = score * cityLocalFactor(sig, cityKey, party.id)
    end

    if score < 0 then score = 0 end
    return score
end

------------------------------------------------------------
-- 6) SEAT ALLOCATION (HARE QUOTA / LARGEST REMAINDER)
------------------------------------------------------------
local function allocateSeats(votes, seats)
    local total = 0
    for _, v in pairs(votes) do total = total + v end

    local alloc, remainders = {}, {}

    if total <= 0 or seats <= 0 then
        for id,_ in pairs(votes) do alloc[id] = 0 end
        return alloc, total
    end

    local quota, assigned = total / seats, 0
    for id, v in pairs(votes) do
        local exact = v / quota
        local base  = math.floor(exact)
        alloc[id]   = base
        assigned    = assigned + base
        remainders[#remainders+1] = { id = id, rem = exact - base }
    end

    local remaining = seats - assigned
    table.sort(remainders, function(a, b) return a.rem > b.rem end)
    for i = 1, remaining do
        local r = remainders[i]
        if r then alloc[r.id] = (alloc[r.id] or 0) + 1 end
    end

    return alloc, total
end

local function normalizePercentages(votes)
    local total = 0
    for _, v in pairs(votes) do total = total + v end

    local pct = {}
    if total <= 0 then
        for id,_ in pairs(votes) do pct[id] = 0 end
        return pct, total
    end
    for id, v in pairs(votes) do pct[id] = (v / total) * 100 end
    return pct, total
end

local function sortedPartyList(votes, seatsAlloc)
    local list = {}
    for _, party in ipairs(parties) do
        local id = party.id
        list[#list+1] = { party=party, votes=votes[id] or 0, seats=seatsAlloc[id] or 0 }
    end
    table.sort(list, function(a, b)
        if a.seats ~= b.seats then return a.seats > b.seats end
        if a.votes ~= b.votes then return a.votes > b.votes end
        return a.party.name < b.party.name
    end)
    return list
end

------------------------------------------------------------
-- 7) CORE SIMULATION
------------------------------------------------------------
local function simulateDistrict(year, dIndex, sig, saltDay)
    local d     = districts[dIndex]
    local votes = {}

    for pIndex, party in ipairs(parties) do
        votes[party.id] = partyScore(year, dIndex, pIndex, d.type, d.profile, d.key, sig, saltDay)
    end

    local seatsAlloc, totalVotes = allocateSeats(votes, d.seats)
    local pct, _                 = normalizePercentages(votes)
    local sorted                 = sortedPartyList(votes, seatsAlloc)

    return { district=d, votes=votes, percentages=pct, seats=seatsAlloc, totalVotes=totalVotes, sorted=sorted }
end

local function simulateNation(year, sig, saltDay)
    local results, seatTotal, voteTotal = {}, {}, {}

    for _, party in ipairs(parties) do
        seatTotal[party.id] = 0
        voteTotal[party.id] = 0
    end

    for i = 1, #districts do
        local r = simulateDistrict(year, i, sig, saltDay)
        results[i] = r
        for id, s in pairs(r.seats) do seatTotal[id] = seatTotal[id] + (s or 0) end
        for id, v in pairs(r.votes) do voteTotal[id] = voteTotal[id] + (v or 0) end
    end

    local pctNation, totalNation = normalizePercentages(voteTotal)
    return { districts=results, seatTotal=seatTotal, voteTotal=voteTotal, pctNation=pctNation, totalNation=totalNation }
end

------------------------------------------------------------
-- 8) ELECTION TIMING (BASED ON BASSARIDIAN CALENDAR)
------------------------------------------------------------
local function getCurrentDayYear()
    local curr = date.getCurrentDate()
    local day  = tonumber(curr:match("^(%d+),")) or 0
    local year = tonumber(curr:match(", (%d+) PSSC")) or 0
    return day, year
end

local function isElectionYear(year)
    return year >= FIRST_ELECTION_YEAR
end

------------------------------------------------------------
-- 9) ARCHIVE HELPERS (OPTIONAL)
------------------------------------------------------------
local function archivedElectionResults(year)
    if archiveOK and type(archive) == "table" and type(archive.electionResults) == "table" then
        return archive.electionResults[year]
    end
    return nil
end

local function archivedNationalSummary(year)
    if archiveOK and type(archive) == "table" and type(archive.nationalSummary) == "table" then
        return archive.nationalSummary[year]
    end
    return nil
end

local function archivedSeatTotals(year)
    if archiveOK and type(archive) == "table" and type(archive.seatTotals) == "table" then
        return archive.seatTotals[year]
    end
    return nil
end

local function archivedPollSeatTotals(year)
    if archiveOK and type(archive) == "table" and type(archive.pollSeatTotals) == "table" then
        return archive.pollSeatTotals[year]
    end
    return nil
end

------------------------------------------------------------
-- 10) SEAT CHART (HEMICYCLE SVG) + LEGEND (party colors + logos in key)
------------------------------------------------------------

local DEFAULT_COLORS = {
    CUF  = "#2455ff",
    MSC  = "#e63b2e",
    PRP  = "#2aa85b",
    WLG  = "#6b2bbf",
    AESL = "#caa200",
    DBM  = "#00a7b5",
    FIML = "#ff7a00",
    CRC  = "#888888",
}

local function _trim(s) return (mw.text and mw.text.trim) and mw.text.trim(s or "") or (s or "") end

local function _parseCsv(s)
    local out = {}
    s = s or ""
    for token in mw.ustring.gmatch(s, "[^,%s]+") do out[#out+1] = token end
    return out
end

local function _parseColorMap(s)
    local cmap = {}
    for k, v in pairs(DEFAULT_COLORS) do cmap[k] = v end
    s = _trim(s)
    if s == "" then return cmap end
    for pair in mw.ustring.gmatch(s, "[^,]+") do
        local pid, hex = pair:match("^%s*([^:]+)%s*:%s*(#[0-9a-fA-F]+)%s*$")
        if pid and hex then cmap[_trim(pid)] = hex end
    end
    return cmap
end

local function _sumTotalSeats()
    local total = 0
    for _, d in ipairs(districts) do total = total + (d.seats or 0) end
    return total
end

-- Generate seat points in semicircle rows.
-- We keep the geometric distribution, but we later "fill from center outward".
local function _buildHemicyclePoints(totalSeats, width, height, padding, rows)
    width   = width or 520
    height  = height or 260
    padding = padding or 10
    rows    = rows or 10

    local cx = width / 2
    local cy = height - padding
    local outerR = math.min(width / 2 - padding, height - padding)

    local dot = math.max(4, math.floor(width / 70))
    local gap = math.max(2, math.floor(dot * 0.55))
    local step = dot + gap

    local innerR = math.max(step * 2, outerR - rows * step)

    local pts = {}
    for r = 0, rows - 1 do
        local rr = outerR - r * step
        if rr <= innerR then break end
        local arcLen = math.pi * rr
        local n = math.max(1, math.floor(arcLen / step))
        for i = 0, n - 1 do
            local t = math.pi - (i + 0.5) * (math.pi / n) -- pi(left) -> 0(right)
            local x = cx + rr * math.cos(t)
            local y = cy - rr * math.sin(t)
            pts[#pts+1] = { x=x, y=y, t=t, r=rr }
        end
    end

    -- Stable ordering: center first (t near pi/2), then expand outward,
    -- and within similar "center distance" prefer outer rows first (bigger radius)
    table.sort(pts, function(a, b)
        local da = math.abs(a.t - (math.pi / 2))
        local db = math.abs(b.t - (math.pi / 2))
        if da ~= db then return da < db end
        if a.r ~= b.r then return a.r > b.r end
        return a.t < b.t
    end)

    local out = {}
    for i = 1, math.min(totalSeats, #pts) do out[i] = pts[i] end
    return out, dot
end

-- Returns party IDs in a "center outward" order:
-- center, then alternating left/right, then further out.
-- This is used to assign which party occupies which seat-point in the sorted pts list.
local function _centerOutPartyFill(seatTotals, partyOrder)
    local linear = {}
    for _, pid in ipairs(partyOrder) do
        local n = seatTotals[pid] or 0
        for i=1,n do linear[#linear+1] = pid end
    end
    if #linear == 0 then return linear end

    local out = {}
    local mid = math.ceil(#linear / 2)
    out[#out+1] = linear[mid]
    local step = 1
    while (#out < #linear) do
        local left = mid - step
        local right = mid + step
        if left >= 1 then out[#out+1] = linear[left] end
        if #out >= #linear then break end
        if right <= #linear then out[#out+1] = linear[right] end
        step = step + 1
    end
    return out
end

-- SVG builder via mw.html (more likely to render than raw <svg> string concatenation)
local function _hemicycleSvg(seatTotals, totalSeats, args)
    local width   = tonumber(args.width  or "") or 520
    local height  = tonumber(args.height or "") or 260
    local padding = tonumber(args.padding or "") or 10
    local rows    = tonumber(args.rows   or "") or 10

    local partyOrder = _parseCsv(args.party_order or "CUF,MSC,PRP,WLG,AESL,DBM,FIML,CRC")
    local colors = _parseColorMap(args.colors)

    local pts, dot = _buildHemicyclePoints(totalSeats, width, height, padding, rows)

    -- Ensure parties omitted from party_order are appended so they still render.
    local seen = {}
    for _, pid in ipairs(partyOrder) do seen[pid] = true end
    for _, pty in ipairs(parties) do
        if not seen[pty.id] then partyOrder[#partyOrder+1] = pty.id end
    end

    -- Build fill list, then reorder it to be center-outward for nicer balance.
    local fill = _centerOutPartyFill(seatTotals, partyOrder)

    local stroke = args.stroke or "#111111"
    local strokeW = tonumber(args.stroke_w or "") or 0.6

    local svg = mw.html.create('svg')
        :attr('xmlns', 'http://www.w3.org/2000/svg')
        :attr('width', tostring(width))
        :attr('height', tostring(height))
        :attr('viewBox', string.format('0 0 %d %d', width, height))
        :attr('role', 'img')
        :attr('aria-label', 'Election seat chart')

    -- optional background (default: none/transparent)
    local bg = args.bg
    if bg and bg ~= "" and bg ~= "none" and bg ~= "#00000000" then
        svg:tag('rect')
            :attr('x','0'):attr('y','0')
            :attr('width', tostring(width))
            :attr('height', tostring(height))
            :attr('fill', bg)
            :done()
    end

    for i, pt in ipairs(pts) do
        local pid = fill[i] or partyOrder[#partyOrder] or parties[1].id
        local color = colors[pid] or "#888888"
        svg:tag('circle')
            :attr('cx', string.format('%.2f', pt.x))
            :attr('cy', string.format('%.2f', pt.y))
            :attr('r',  string.format('%.2f', dot/2))
            :attr('fill', color)
            :attr('stroke', stroke)
            :attr('stroke-width', string.format('%.2f', strokeW))
            :done()
    end

    return tostring(svg)
end

local function _legendTable(seatTotals, totalSeats, args)
    local colors = _parseColorMap(args.colors)
    local partyOrder = _parseCsv(args.party_order or "CUF,MSC,PRP,WLG,AESL,DBM,FIML,CRC")

    local ordered, seen = {}, {}
    for _, pid in ipairs(partyOrder) do
        local pty = PARTY_BY_ID[pid]
        if pty then ordered[#ordered+1] = pty; seen[pid] = true end
    end
    for _, pty in ipairs(parties) do
        if not seen[pty.id] then ordered[#ordered+1] = pty end
    end

    if args.legend_sort ~= "order" then
        table.sort(ordered, function(a, b)
            return (seatTotals[a.id] or 0) > (seatTotals[b.id] or 0)
        end)
    end

    local out = {}
    out[#out+1] = '{| class="wikitable sortable" style="width:100%; text-align:left;"'
    out[#out+1] = '! Party !! Seats'
    for _, pty in ipairs(ordered) do
        local seats = seatTotals[pty.id] or 0
        local swatch = string.format(
            '<span style="display:inline-block;width:14px;height:14px;background:%s;border:1px solid #222;vertical-align:middle;margin-right:6px;"></span>',
            colors[pty.id] or "#888888"
        )
        out[#out+1] = "|-"
        out[#out+1] = string.format("| %s%s %s || %d", swatch, (pty.logo or ""), pty.name, seats)
    end
    out[#out+1] = "|-"
    out[#out+1] = string.format('| colspan="1" style="text-align:right;" | \'\'Total\'\' || %d', totalSeats)
    out[#out+1] = "|}"
    return table.concat(out, "\n")
end

local function _renderSeatChartBlock(title, seatTotals, args)
    local totalSeats = _sumTotalSeats()
    local out = {}
    if title and title ~= "" then out[#out+1] = string.format("'''%s'''", title) end
    out[#out+1] = _hemicycleSvg(seatTotals, totalSeats, args)
    out[#out+1] = ""
    out[#out+1] = _legendTable(seatTotals, totalSeats, args)
    return table.concat(out, "\n")
end

------------------------------------------------------------
-- 11) PRELIMINARY POLLING WINDOW HELPERS
------------------------------------------------------------
local function isPollingWindow(day)
    return (day >= POLL_START_DAY) and (day < ELECTION_DAY)
end

------------------------------------------------------------
-- 12) RENDER FUNCTIONS (EXISTING)
------------------------------------------------------------

function p.renderElectionResults(frame)
    local args = frame.args or {}
    local dayNow, yearNow = getCurrentDayYear()
    local year = tonumber(args.year or "") or yearNow
    local day  = tonumber(args.day  or "") or dayNow
    local showZero = (args.show_zero and args.show_zero ~= "0") or false

    if not isElectionYear(year) then
        return string.format("'''No general election in %d PSSC.'''", year)
    end

    local archived = archivedElectionResults(year)
    if archived then return archived end

    if day < ELECTION_DAY then
        local mon, dom = getMonthDay(ELECTION_DAY)
        return string.format("'''General election scheduled for %d %s %d PSSC.'''", dom, mon, year)
    end

    local sig = nil
    if (year == yearNow) and (day == ELECTION_DAY) then
        sig = computeSignals()
    end

    local nation   = simulateNation(year, sig, nil)
    local mon, dom = getMonthDay(ELECTION_DAY)
    local headerDate = string.format("%d %s %d PSSC", dom, mon, year)

    local out = {}
    out[#out+1] = '{| class="wikitable sortable"'
    out[#out+1] = '|+ General Election Results — ' .. headerDate
    out[#out+1] = '! District !! Seats !! Party !! Seats Won !! Vote %'

    for _, r in ipairs(nation.districts) do
        local d = r.district
        local rows = {}
        for _, entry in ipairs(r.sorted) do
            local seatsWon = r.seats[entry.party.id] or 0
            local votePct  = r.percentages[entry.party.id] or 0
            if seatsWon > 0 or showZero then
                rows[#rows+1] = { party=entry.party, seats=seatsWon, pct=votePct }
            end
        end

        if #rows > 0 then
            local first = rows[1]
            out[#out+1] = "|-"
            out[#out+1] = string.format(
                '| rowspan="%d" | %s || rowspan="%d" | %d || %s %s || %d || %.1f',
                #rows, d.name, #rows, d.seats,
                first.party.logo or "", first.party.name,
                first.seats, first.pct
            )
            for i = 2, #rows do
                local row = rows[i]
                out[#out+1] = "|-"
                out[#out+1] = string.format('| %s %s || %d || %.1f',
                    row.party.logo or "", row.party.name, row.seats, row.pct)
            end
        end
    end

    out[#out+1] = "|}"
    return table.concat(out, "\n")
end

function p.renderNationalSummary(frame)
    local args = frame.args or {}
    local dayNow, yearNow = getCurrentDayYear()
    local year = tonumber(args.year or "") or yearNow
    local day  = tonumber(args.day  or "") or dayNow

    if not isElectionYear(year) or day < ELECTION_DAY then
        return "'''National summary available only once the general election has been held.'''"
    end

    local archived = archivedNationalSummary(year)
    if archived then return archived end

    local sig = nil
    if (year == yearNow) and (day == ELECTION_DAY) then sig = computeSignals() end

    local nation = simulateNation(year, sig, nil)

    local out = {}
    out[#out+1] = '{| class="wikitable sortable"'
    out[#out+1] = '! Party !! Seats (total) !! National Vote %'
    for _, party in ipairs(parties) do
        local seats = nation.seatTotal[party.id] or 0
        local pct   = nation.pctNation[party.id] or 0
        out[#out+1] = "|-"
        out[#out+1] = string.format("| %s %s || %d || %.1f", party.logo or "", party.name, seats, pct)
    end
    out[#out+1] = "|}"
    return table.concat(out, "\n")
end

function p.renderPreliminaryPolling(frame)
    local args = frame.args or {}
    local dayNow, yearNow = getCurrentDayYear()
    local year = tonumber(args.year or "") or yearNow
    local day  = tonumber(args.day  or "") or dayNow

    if not isElectionYear(year) then
        return string.format("'''No general election in %d PSSC.'''", year)
    end

    if day < POLL_START_DAY then
        local pm, pd = getMonthDay(POLL_START_DAY)
        local em, ed = getMonthDay(ELECTION_DAY)
        return string.format(
            "'''Preliminary polling begins on %d %s %d PSSC (day %d) and updates daily until %d %s.'''",
            pd, pm, year, POLL_START_DAY, ed, em
        )
    end

    if day >= ELECTION_DAY then
        local em, ed = getMonthDay(ELECTION_DAY)
        return string.format("'''Polling is closed. General election day is %d %s %d PSSC.'''", ed, em, year)
    end

    local sig = computeSignals()
    local nation = simulateNation(year, sig, day)

    local mon, dom = getMonthDay(day)
    local out = {}
    out[#out+1] = string.format("'''Preliminary polling — %d %s %d PSSC (updates daily)'''", dom, mon, year)

    out[#out+1] = '{| class="wikitable sortable" style="text-align:center; width:100%;"'
    out[#out+1] = '! Party !! Projected Seats !! Poll %'
    for _, party in ipairs(parties) do
        local seats = nation.seatTotal[party.id] or 0
        local pct   = nation.pctNation[party.id] or 0
        out[#out+1] = "|-"
        out[#out+1] = string.format("| %s %s || %d || %.1f", party.logo or "", party.name, seats, pct)
    end
    out[#out+1] = "|}"

    out[#out+1] = ""
    out[#out+1] = '{| class="wikitable" style="text-align:center; width:60%;"'
    out[#out+1] = '! Daily inputs !! Value'
    out[#out+1] = "|-"
    out[#out+1] = string.format("| Market avg change %% || %.2f", sig.meta.market.avgPct or 0)
    out[#out+1] = "|-"
    out[#out+1] = string.format("| Civic HIGH+CRITICAL events || %d", (sig.meta.civic.HIGH or 0) + (sig.meta.civic.CRITICAL or 0))
    out[#out+1] = "|-"
    out[#out+1] = string.format("| Religious HIGH+CRITICAL events || %d", (sig.meta.relig.HIGH or 0) + (sig.meta.relig.CRITICAL or 0))
    out[#out+1] = "|}"

    return table.concat(out, "\n")
end

function p.renderPreliminaryPollingDistricts(frame)
    local args = frame.args or {}
    local dayNow, yearNow = getCurrentDayYear()
    local year = tonumber(args.year or "") or yearNow
    local day  = tonumber(args.day  or "") or dayNow
    local showZero = (args.show_zero and args.show_zero ~= "0") or false

    if not isElectionYear(year) then
        return string.format("'''No general election in %d PSSC.'''", year)
    end

    if day < POLL_START_DAY then
        local pm, pd = getMonthDay(POLL_START_DAY)
        local em, ed = getMonthDay(ELECTION_DAY)
        return string.format(
            "'''Preliminary polling begins on %d %s %d PSSC (day %d) and updates daily until %d %s.'''",
            pd, pm, year, POLL_START_DAY, ed, em
        )
    end

    if day >= ELECTION_DAY then
        local em, ed = getMonthDay(ELECTION_DAY)
        return string.format("'''Polling is closed. General election day is %d %s %d PSSC.'''", ed, em, year)
    end

    local sig = computeSignals()
    local nation = simulateNation(year, sig, day)

    local mon, dom = getMonthDay(day)
    local headerDate = string.format("%d %s %d PSSC", dom, mon, year)

    local out = {}
    out[#out+1] = '{| class="wikitable sortable"'
    out[#out+1] = '|+ Preliminary polling (district-by-district) — ' .. headerDate
    out[#out+1] = '! District !! Seats !! Party !! Projected Seats !! Poll %'

    for _, r in ipairs(nation.districts) do
        local d = r.district
        local rows = {}
        for _, entry in ipairs(r.sorted) do
            local seatsWon = r.seats[entry.party.id] or 0
            local votePct  = r.percentages[entry.party.id] or 0
            if seatsWon > 0 or showZero then
                rows[#rows+1] = { party=entry.party, seats=seatsWon, pct=votePct }
            end
        end

        if #rows > 0 then
            local first = rows[1]
            out[#out+1] = "|-"
            out[#out+1] = string.format(
                '| rowspan="%d" | %s || rowspan="%d" | %d || %s %s || %d || %.1f',
                #rows, d.name, #rows, d.seats,
                first.party.logo or "", first.party.name,
                first.seats, first.pct
            )
            for i = 2, #rows do
                local row = rows[i]
                out[#out+1] = "|-"
                out[#out+1] = string.format('| %s %s || %d || %.1f',
                    row.party.logo or "", row.party.name, row.seats, row.pct)
            end
        end
    end

    out[#out+1] = "|}"
    return table.concat(out, "\n")
end

------------------------------------------------------------
-- 13) NEW: SEAT CHART RENDERERS (HEMICYCLE + KEY)
-- Same historical-lock rules as before.
------------------------------------------------------------

local function _needArchivedSeatTotalsMsg(year)
    return string.format(
        "'''Seat chart unavailable for %d PSSC because historical results are locked.'''<br/>" ..
        "Add <code>seatTotals[%d]</code> to <code>Module:BassaridianElectionArchive</code> to render the historical hemicycle.",
        year, year
    )
end

function p.renderSeatChartFinal(frame)
    local args = frame.args or {}
    local dayNow, yearNow = getCurrentDayYear()
    local year = tonumber(args.year or "") or yearNow
    local day  = tonumber(args.day  or "") or dayNow

    if not isElectionYear(year) or day < ELECTION_DAY then
        return "'''Seat chart available only once the general election has been held.'''"
    end

    if year ~= yearNow then
        local st = archivedSeatTotals(year)
        if not st then return _needArchivedSeatTotalsMsg(year) end
        local mon, dom = getMonthDay(ELECTION_DAY)
        return _renderSeatChartBlock(string.format("Final seat distribution — %d %s %d PSSC", dom, mon, year), st, args)
    end

    local archivedST = archivedSeatTotals(year)
    if archivedST then
        local mon, dom = getMonthDay(ELECTION_DAY)
        return _renderSeatChartBlock(string.format("Final seat distribution — %d %s %d PSSC", dom, mon, year), archivedST, args)
    end

    local sig = nil
    if day == ELECTION_DAY then sig = computeSignals() end
    local nation = simulateNation(year, sig, nil)
    local mon, dom = getMonthDay(ELECTION_DAY)
    return _renderSeatChartBlock(string.format("Final seat distribution — %d %s %d PSSC", dom, mon, year), nation.seatTotal or {}, args)
end

function p.renderSeatChartPolling(frame)
    local args = frame.args or {}
    local dayNow, yearNow = getCurrentDayYear()
    local year = tonumber(args.year or "") or yearNow
    local day  = tonumber(args.day  or "") or dayNow

    if not isElectionYear(year) then
        return string.format("'''No general election in %d PSSC.'''", year)
    end
    if day < POLL_START_DAY then
        local pm, pd = getMonthDay(POLL_START_DAY)
        local em, ed = getMonthDay(ELECTION_DAY)
        return string.format(
            "'''Preliminary polling begins on %d %s %d PSSC (day %d) and updates daily until %d %s.'''",
            pd, pm, year, POLL_START_DAY, ed, em
        )
    end
    if day >= ELECTION_DAY then
        return "'''Polling is closed. Use renderSeatChartFinal for final results.'''"
    end

    if year ~= yearNow then
        local pst = archivedPollSeatTotals(year)
        if not pst then
            return string.format(
                "'''Polling seat chart unavailable for %d PSSC because historical results are locked.'''<br/>" ..
                "If you want a frozen historical polling chart, add <code>pollSeatTotals[%d]</code> to the archive.",
                year, year
            )
        end
        local mon, dom = getMonthDay(day)
        return _renderSeatChartBlock(string.format("Preliminary polling seat projection — %d %s %d PSSC", dom, mon, year), pst, args)
    end

    local sig = computeSignals()
    local nation = simulateNation(year, sig, day)
    local mon, dom = getMonthDay(day)
    return _renderSeatChartBlock(string.format("Preliminary polling seat projection — %d %s %d PSSC", dom, mon, year), nation.seatTotal or {}, args)
end

function p.renderSeatChart(frame)
    local args = frame.args or {}
    local dayNow, yearNow = getCurrentDayYear()
    local year = tonumber(args.year or "") or yearNow
    local day  = tonumber(args.day  or "") or dayNow

    if not isElectionYear(year) then
        return string.format("'''No general election in %d PSSC.'''", year)
    end

    if day >= ELECTION_DAY then
        return p.renderSeatChartFinal(frame)
    end
    if day >= POLL_START_DAY then
        return p.renderSeatChartPolling(frame)
    end

    local pm, pd = getMonthDay(POLL_START_DAY)
    local em, ed = getMonthDay(ELECTION_DAY)
    return string.format(
        "'''Seat chart appears during preliminary polling (%d %s) and after the election (%d %s).'''",
        pd, pm, ed, em
    )
end

------------------------------------------------------------
-- 14) DEBUG
------------------------------------------------------------
function p.debugDailySignals(frame)
    local sig = computeSignals()
    local d, y = getCurrentDayYear()

    local out = {}
    out[#out+1] = string.format("'''Signals as of day %d, %d PSSC'''", d, y)
    out[#out+1] = '{| class="wikitable"'
    out[#out+1] = '! Metric !! Value'
    out[#out+1] = "|-"
    out[#out+1] = string.format("| Market avg %% || %.2f", sig.meta.market.avgPct or 0)
    out[#out+1] = "|-"
    out[#out+1] = string.format("| Civic LOW/MED/HIGH/CRIT || %d / %d / %d / %d",
        sig.meta.civic.LOW or 0,
        sig.meta.civic.MEDIUM or 0,
        sig.meta.civic.HIGH or 0,
        sig.meta.civic.CRITICAL or 0
    )
    out[#out+1] = "|-"
    out[#out+1] = string.format("| Religious LOW/MED/HIGH/CRIT || %d / %d / %d / %d",
        sig.meta.relig.LOW or 0,
        sig.meta.relig.MEDIUM or 0,
        sig.meta.relig.HIGH or 0,
        sig.meta.relig.CRITICAL or 0
    )
    out[#out+1] = "|}"
    return table.concat(out, "\n")
end

------------------------------------------------------------
-- EXPORT
------------------------------------------------------------
return p