Module:BassaridianElectionSim

From MicrasWiki
Revision as of 18:41, 7 December 2025 by NewZimiaGov (talk | contribs)
Jump to navigationJump to search

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.

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

------------------------------------------------------------
-- 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-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
--    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 = {
    -- 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-based influence (Reformed Stripping Path)
--    + company/economic influence (Port-linked firms)
------------------------------------------------------------

-- Geographical / structural profile bias
local profileBias = {
    -- Core Port / capital
    port_core = {
        CUF  = 1.15,
        FIML = 1.20,
        CRC  = 1.05,
        DBM  = 1.05,
    },

    -- Secondary/coastal trade cities
    port_periphery = {
        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 = {
    Aurora    = { PRP = 1.20, CRC = 1.05, CUF = 1.05 },   -- Order of Aurora Mystica
    Azure     = { CUF = 1.15, WLG = 1.10, FIML = 1.05 },  -- Azure Sentinel Sect
    Harmony   = { CUF = 1.10, CRC = 1.10, PRP = 1.05 },   -- Harmony Sanctum

    Ignis     = { PRP = 1.20, WLG = 1.10 },               -- Ignis Aeternum
    Celestial = { PRP = 1.15, DBM = 1.05, CRC = 1.05 },   -- Celestial Harmony Sect

    Golden    = { FIML = 1.20, CUF = 1.10, WLG = 1.05 },  -- Guild of Golden Shadows
    Reverie   = { MSC  = 1.15, DBM = 1.10 },              -- Reverie Nebulous

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

    Verdant   = { AESL = 1.20, MSC = 1.10 },              -- Verdant Embrace
    Vitalis   = { AESL = 1.10, MSC = 1.10, PRP = 1.05 },  -- Sanctum Vitalis

    Alabaster = { FIML = 1.15, CUF = 1.05 },              -- Temple Alabaster
    Ironclad  = { WLG  = 1.20, CUF = 1.05 },              -- Court of the Ironclad

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

    Sylvan    = { AESL = 1.20, MSC = 1.05 },              -- Sylvan Fellowship
    RedMirth  = { MSC  = 1.10, PRP = 1.05 },              -- Mystery of Red Mirth

    Rex       = { FIML = 1.10, CUF = 1.10 },              -- Rex Catonis
    Delphica  = { MSC  = 1.05, PRP = 1.05, CRC = 1.05 },  -- Sanctum Delphica

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

-- which cult constellations dominate each profile
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" },
}

------------------------------------------------------------
-- 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 = {
    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,
    },
}

-- Which econ tags apply to which city (by key)
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    = { "port_finance", "diaspora_port" },
    TelAmin       = { "agricoop", "diaspora_port" },
    Diamandis     = { "diaspora_port", "cult_services" },
    Jogi          = { "arms_heavy", "agricoop", "diaspora_port" },
    Lewisburg     = { "port_finance", "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" },
    -- tribes intentionally left blank (their economy comes via alperkin_tribe profile)
}

------------------------------------------------------------
-- 4) RANDOMNESS (DETERMINISTIC BY YEAR/DISTRICT/PARTY)
------------------------------------------------------------

local function partyScore(year, dIndex, pIndex, dType, profile, cityKey)
    local party = parties[pIndex]
    local base  = (party.base[dType] or 1.0)

    -- deterministic seed: year + district index + party index
    local seed = year * 100000 + dIndex * 1000 + pIndex
    math.randomseed(seed)

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

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

    -- (b) cult-based influence for this profile
    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

    -- (c) company / economic influence for this city (if any)
    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 score < 0 then score = 0 end
    return score
end

------------------------------------------------------------
-- 5) SEAT ALLOCATION (LARGEST REMAINDER / HARE QUOTA)
------------------------------------------------------------

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

    local alloc      = {}
    local remainders = {}

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

    local quota    = total / seats
    local assigned = 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

------------------------------------------------------------
-- 6) CORE SIMULATION
------------------------------------------------------------

local function simulateDistrict(year, dIndex)
    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
        )
    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)
    local results   = {}
    local seatTotal = {}
    local 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)
        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

------------------------------------------------------------
-- 7) ELECTION TIMING (BASED ON BASSARIDIAN CALENDAR)
------------------------------------------------------------

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

-- Elections every year from FIRST_ELECTION_YEAR onward
local function isElectionYear(year)
    return year >= FIRST_ELECTION_YEAR
end

------------------------------------------------------------
-- 8) RENDER FUNCTIONS
------------------------------------------------------------

-- District-by-district results
-- Usage:
-- {{#invoke:BassaridianElectionSim|renderElectionResults}}
-- {{#invoke:BassaridianElectionSim|renderElectionResults|year=52|day=183}}
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

    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 nation   = simulateNation(year)
    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

        -- Build the list of party rows we actually want to render
        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
            -- First row for this district, with rowspans on District + Seats
            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
            )

            -- Subsequent rows: Party, Seats Won, Vote %
            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

-- National summary: total seats + national vote share
-- Usage:
-- {{#invoke:BassaridianElectionSim|renderNationalSummary}}
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 nation = simulateNation(year)

    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

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