Module:BassaridianElectionSim: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
NewZimiaGov (talk | contribs)
No edit summary
 
(4 intermediate revisions by the same user not shown)
Line 2: Line 2:
-- Simulated multi-party elections for Bassaridia Vaeringheim,
-- Simulated multi-party elections for Bassaridia Vaeringheim,
-- using the same PSSC calendar structure as PillSeasonSchedule.
-- 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 p    = {}
local date = require('Module:BassaridianCalendar')
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')


------------------------------------------------------------
------------------------------------------------------------
Line 11: Line 49:
local DAYS_IN_YEAR        = 183
local DAYS_IN_YEAR        = 183
local FIRST_ELECTION_YEAR = 50      -- first election anchor (adjust as needed)
local FIRST_ELECTION_YEAR = 50      -- first election anchor (adjust as needed)
local ELECTION_DAY        = 122      -- election held on 122nd day of year
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
-- 61-day "months" matching PillSeasonSchedule logic
Line 97: 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 = {
     -- Major Cities in Bassaridia Vaeringheim
     -- Major Cities in Bassaridia Vaeringheim
Line 175: 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
    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
     Ignis    = { PRP = 1.20, WLG = 1.10 },
     Vitalis  = { AESL = 1.10, MSC = 1.10, PRP = 1.05 },  -- Sanctum Vitalis
     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 },


     Alabaster = { FIML = 1.15, CUF = 1.05 },             -- Temple Alabaster
     Eon      = { CRC  = 1.15, FIML = 1.05 },
     Ironclad  = { WLG = 1.20, CUF = 1.05 },             -- Court of the Ironclad
     Umbral    = { PRP = 1.10, MSC = 1.05, WLG = 1.05 },


     Concordia = { CRC  = 1.15, CUF = 1.05 },             -- Accord Concordia
     Verdant  = { AESL = 1.20, MSC = 1.10 },
     Stygian  = { WLG  = 1.10, PRP = 1.05 },             -- Styx cults
    Vitalis  = { AESL = 1.10, MSC = 1.10, PRP = 1.05 },
    Alabaster = { FIML = 1.15, CUF = 1.05 },
     Ironclad  = { WLG  = 1.20, CUF = 1.05 },


     Sylvan    = { AESL = 1.20, MSC = 1.05 },             -- Sylvan Fellowship
     Concordia = { CRC  = 1.15, CUF = 1.05 },
     RedMirth  = { MSC = 1.10, PRP = 1.05 },             -- Mystery of Red Mirth
     Stygian  = { WLG = 1.10, PRP = 1.05 },


     Rex      = { FIML = 1.10, CUF = 1.10 },             -- Rex Catonis
    Sylvan    = { AESL = 1.20, MSC = 1.05 },
     Delphica  = { MSC  = 1.05, PRP = 1.05, CRC = 1.05 }, -- Sanctum Delphica
    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 },             -- 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 380: Line 287:
     LunalisSancta = { "port_finance", "cult_services" },
     LunalisSancta = { "port_finance", "cult_services" },
     Sylvapolis    = { "agricoop" },
     Sylvapolis    = { "agricoop" },
 
     Saluria      = { "agricoop", "cult_services" },
     Saluria      = { "agricoop", "fishery" },
     Aetherium    = { "cult_services" },
     Aetherium    = { "cult_services" },
     FerrumCitadel = { "arms_heavy" },
     FerrumCitadel = { "arms_heavy" },
Line 393: Line 299:
     TheAlpazkigz  = { "agricoop" },
     TheAlpazkigz  = { "agricoop" },
     Odiferia      = { "agricoop" },
     Odiferia      = { "agricoop" },
     Skyrophos    = { "arms_heavy" },
     Skyrophos    = { "arms_heavy" },
     Bjornopolis  = { "cult_services" },
     Bjornopolis  = { "cult_services" },
Line 400: Line 305:
     Thorsalon    = { "agricoop", "diaspora_port" },
     Thorsalon    = { "agricoop", "diaspora_port" },


     Pelagia      = { "fishery", "diaspora_port" },
     Pelagia      = { "war_religious", "diaspora_port" },
     Myrene        = { "agricoop" },
     Myrene        = { "agricoop" },
     Thyrea        = { "agricoop" },
     Thyrea        = { "agricoop" },
Line 406: Line 311:
     Halicarn      = { "port_finance" },
     Halicarn      = { "port_finance" },


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


     Thermosalem  = { "diaspora_port", "cult_services" },
     Thermosalem  = { "diaspora_port", "cult_services" },
Line 422: Line 327:
     Bashkim      = { "agricoop" },
     Bashkim      = { "agricoop" },
     Ourid        = { "agricoop" },
     Ourid        = { "agricoop" },
    -- tribes intentionally left blank (their economy comes via alperkin_tribe profile)
}
}


------------------------------------------------------------
------------------------------------------------------------
-- 4) RANDOMNESS (DETERMINISTIC BY YEAR/DISTRICT/PARTY)
-- 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


local function partyScore(year, dIndex, pIndex, dType, profile, cityKey)
------------------------------------------------------------
-- 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 party = parties[pIndex]
     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
     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
    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
     end


Line 476: Line 553:


------------------------------------------------------------
------------------------------------------------------------
-- 5) SEAT ALLOCATION (LARGEST REMAINDER / HARE QUOTA)
-- 6) SEAT ALLOCATION (HARE QUOTA / LARGEST REMAINDER)
------------------------------------------------------------
------------------------------------------------------------
local function allocateSeats(votes, seats)
local function allocateSeats(votes, seats)
     local total = 0
     local total = 0
     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
            remainders[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 507: Line 577:
     local remaining = seats - assigned
     local remaining = seats - assigned
     table.sort(remainders, function(a, b) return a.rem > b.rem end)
     table.sort(remainders, function(a, b) return a.rem > b.rem end)
     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 527: Line 594:
         return pct, total
         return pct, total
     end
     end
 
     for id, v in pairs(votes) do pct[id] = (v / total) * 100 end
     for id, v in pairs(votes) do
        pct[id] = (v / total) * 100
    end
     return pct, total
     return pct, total
end
end
Line 538: 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 553: Line 613:


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


     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
        )
     end
     end


Line 570: 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)
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 591: Line 639:


     for i = 1, #districts do
     for i = 1, #districts do
         local r = simulateDistrict(year, i)
         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 { districts=results, seatTotal=seatTotal, voteTotal=voteTotal, pctNation=pctNation, totalNation=totalNation }
     return {
        districts   = results,
        seatTotal   = seatTotal,
        voteTotal   = voteTotal,
        pctNation   = pctNation,
        totalNation = totalNation,
    }
end
end


------------------------------------------------------------
------------------------------------------------------------
-- 7) ELECTION TIMING (BASED ON BASSARIDIAN CALENDAR)
-- 8) ELECTION TIMING (BASED ON BASSARIDIAN CALENDAR)
------------------------------------------------------------
------------------------------------------------------------
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 623: 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 629: Line 664:


------------------------------------------------------------
------------------------------------------------------------
-- 8) RENDER FUNCTIONS
-- 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)
------------------------------------------------------------
------------------------------------------------------------


-- 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 {}
     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 645: Line 935:


     if not isElectionYear(year) then
     if not isElectionYear(year) then
         return string.format(
         return string.format("'''No general election in %d PSSC.'''", year)
            "'''No general election in %d PSSC.'''",
            year
        )
     end
     end
    local archived = archivedElectionResults(year)
    if archived then 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.'''",
    end
            dom, mon, year
 
         )
    local sig = nil
    if (year == yearNow) and (day == ELECTION_DAY) then
         sig = computeSignals()
     end
     end


     local nation  = simulateNation(year)
     local nation  = simulateNation(year, sig, nil)
     local mon, dom = getMonthDay(ELECTION_DAY)
     local mon, dom = getMonthDay(ELECTION_DAY)
     local headerDate = string.format("%d %s %d PSSC", dom, mon, year)
     local headerDate = string.format("%d %s %d PSSC", dom, mon, year)
Line 670: 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 677: 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 720: 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 734: Line 1,003:
     end
     end


     local nation = simulateNation(year)
    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 = {}
     local out = {}
     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
        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
     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",
    end
            party.logo or "",
    out[#out+1] = "|}"
            party.name,
 
             seats,
    out[#out+1] = ""
             pct
    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
     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] = "|}"
     out[#out+1] = "|}"
     return table.concat(out, "\n")
     return table.concat(out, "\n")

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