Module:BassaridianElectionSim: Difference between revisions
From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs) No edit summary |
NewZimiaGov (talk | contribs) No edit summary |
||
| (11 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 43: | Line 82: | ||
name = "Morovian Social Cooperative", | name = "Morovian Social Cooperative", | ||
short = "MSC", | short = "MSC", | ||
logo = "[[File:MSC logo.png|20px]]", | logo = "[[File:MSC logo.png.png|20px]]", | ||
base = { city = 1.10, tribe = 1.00 }, | base = { city = 1.10, tribe = 1.00 }, | ||
}, | }, | ||
| Line 85: | Line 124: | ||
name = "Civic Reform Covenant", | name = "Civic Reform Covenant", | ||
short = "CRC", | short = "CRC", | ||
logo = "[[File:CRC logo.png|20px]]", | logo = "[[File:CRC logo.png.png|20px]]", | ||
base = { city = 1.00, tribe = 0.80 }, | base = { city = 1.00, tribe = 0.80 }, | ||
}, | }, | ||
| Line 97: | Line 136: | ||
------------------------------------------------------------ | ------------------------------------------------------------ | ||
-- 2) DISTRICTS | -- 2) DISTRICTS | ||
------------------------------------------------------------ | ------------------------------------------------------------ | ||
local districts = { | local districts = { | ||
-- Major Cities in Bassaridia Vaeringheim | -- Major Cities in Bassaridia Vaeringheim | ||
{ name="[[Vaeringheim]]", type="city", seats=3, profile="port_core" }, | { key="Vaeringheim", name="[[List of cities in Bassaridia Vaeringheim#Vaeringheim|Vaeringheim]]", type="city", seats=3, profile="port_core" }, | ||
{ name="[[Luminaria]]", | { key="Luminaria", name="[[List of cities in Bassaridia Vaeringheim#Luminaria|Luminaria]]", type="city", seats=3, profile="shrine_mystic" }, | ||
{ name="[[Serena]]", | { key="Serena", name="[[List of cities in Bassaridia Vaeringheim#Serena|Serena]]", type="city", seats=3, profile="port_periphery" }, | ||
{ name="[[Pyralis]]", | { key="Pyralis", name="[[List of cities in Bassaridia Vaeringheim#Pyralis|Pyralis]]", type="city", seats=3, profile="war_frontier" }, | ||
{ name="[[Symphonara]]", | { key="Symphonara", name="[[List of cities in Bassaridia Vaeringheim#Symphonara|Symphonara]]", type="city", seats=3, profile="shrine_mystic" }, | ||
{ name="[[Aurelia]]", | { key="Aurelia", name="[[List of cities in Bassaridia Vaeringheim#Aurelia|Aurelia]]", type="city", seats=3, profile="agrarian" }, | ||
{ name="[[Somniumpolis]]", | { key="Somniumpolis", name="[[List of cities in Bassaridia Vaeringheim#Somniumpolis|Somniumpolis]]", type="city", seats=3, profile="port_periphery" }, | ||
{ name="[[Nexa]]", | { key="Nexa", name="[[List of cities in Bassaridia Vaeringheim#Nexa|Nexa]]", type="city", seats=3, profile="war_frontier" }, | ||
{ name="[[Lunalis Sancta]]", | { key="LunalisSancta", name="[[List of cities in Bassaridia Vaeringheim#Lunalis Sancta|Lunalis Sancta]]", type="city", seats=3, profile="shrine_mystic" }, | ||
{ name="[[Sylvapolis]]", | { key="Sylvapolis", name="[[List of cities in Bassaridia Vaeringheim#Sylvapolis|Sylvapolis]]", type="city", seats=3, profile="agrarian" }, | ||
-- Minor Cities in Bassaridia Vaeringheim | -- Minor Cities in Bassaridia Vaeringheim | ||
{ name="[[Saluria]]", | { key="Saluria", name="[[List of cities in Bassaridia Vaeringheim#Saluria|Saluria]]", type="city", seats=3, profile="port_periphery" }, | ||
{ name="[[Aetherium]]", | { key="Aetherium", name="[[List of cities in Bassaridia Vaeringheim#Aetherium|Aetherium]]", type="city", seats=3, profile="shrine_mystic" }, | ||
{ name="[[Ferrum Citadel]]", | { key="FerrumCitadel", name="[[List of cities in Bassaridia Vaeringheim#Ferrum Citadel|Ferrum Citadel]]", type="city", seats=3, profile="war_frontier" }, | ||
{ name="[[Acheron]]", | { key="Acheron", name="[[List of cities in Bassaridia Vaeringheim#Acheron|Acheron]]", type="city", seats=3, profile="war_frontier" }, | ||
{ name="[[Erythros]]", | { key="Erythros", name="[[List of cities in Bassaridia Vaeringheim#Erythros|Erythros]]", type="city", seats=3, profile="war_frontier" }, | ||
{ name="[[Catonis Atrium]]", | { key="CatonisAtrium", name="[[List of cities in Bassaridia Vaeringheim#Catonis Atrium|Catonis Atrium]]", type="city", seats=3, profile="agrarian" }, | ||
{ name="[[Delphica]]", | { key="Delphica", name="[[List of cities in Bassaridia Vaeringheim#Delphica|Delphica]]", type="city", seats=3, profile="port_core" }, | ||
{ name="[[Koinonía]]", | { key="Koinonia", name="[[List of cities in Bassaridia Vaeringheim#Koinonía|Koinonía]]", type="city", seats=3, profile="agrarian" }, | ||
{ name="[[Aureum]]", | { key="Aureum", name="[[List of cities in Bassaridia Vaeringheim#Aureum|Aureum]]", type="city", seats=3, profile="port_periphery" }, | ||
-- Alperkin cities | -- Alperkin cities | ||
{ name="[[The Alpazkigz]]", | { key="TheAlpazkigz", name="[[List of cities in Bassaridia Vaeringheim#The Alpazkigz|The Alpazkigz]]", type="city", seats=3, profile="alperkin_city" }, | ||
{ name="[[Odiferia]]", | { key="Odiferia", name="[[List of cities in Bassaridia Vaeringheim#Odiferia|Odiferia]]", type="city", seats=3, profile="alperkin_city" }, | ||
-- Major Cities in New South Jangsong | -- Major Cities in New South Jangsong | ||
{ name="[[Skýrophos]]", | { key="Skyrophos", name="[[List of cities in Bassaridia Vaeringheim#Skýrophos|Skýrophos]]", type="city", seats=3, profile="war_frontier" }, | ||
{ name="[[Bjornopolis]]", type="city", seats=3, profile="shrine_mystic" }, | { key="Bjornopolis", name="[[List of cities in Bassaridia Vaeringheim#Bjornopolis|Bjornopolis]]", type="city", seats=3, profile="shrine_mystic" }, | ||
{ name="[[Aegirheim]]", | { key="Aegirheim", name="[[List of cities in Bassaridia Vaeringheim#Aegirheim|Aegirheim]]", type="city", seats=3, profile="port_periphery" }, | ||
{ name="[[Norsolyra]]", | { key="Norsolyra", name="[[List of cities in Bassaridia Vaeringheim#Norsolyra|Norsolyra]]", type="city", seats=3, profile="agrarian" }, | ||
{ name="[[Thorsalon]]", | { key="Thorsalon", name="[[List of cities in Bassaridia Vaeringheim#Thorsalon|Thorsalon]]", type="city", seats=3, profile="agrarian" }, | ||
-- Minor Cities in New South Jangsong | -- Minor Cities in New South Jangsong | ||
{ name="[[Pelagia]]", | { key="Pelagia", name="[[List of cities in Bassaridia Vaeringheim#Pelagia|Pelagia]]", type="city", seats=3, profile="port_periphery" }, | ||
{ name="[[Myrene]]", | { key="Myrene", name="[[List of cities in Bassaridia Vaeringheim#Myrene|Myrene]]", type="city", seats=3, profile="agrarian" }, | ||
{ name="[[Thyrea]]", | { key="Thyrea", name="[[List of cities in Bassaridia Vaeringheim#Thyrea|Thyrea]]", type="city", seats=3, profile="agrarian" }, | ||
{ name="[[Ephyra]]", | { key="Ephyra", name="[[List of cities in Bassaridia Vaeringheim#Ephyra|Ephyra]]", type="city", seats=3, profile="agrarian" }, | ||
{ name="[[Halicarn]]", | { key="Halicarn", name="[[List of cities in Bassaridia Vaeringheim#Halicarn|Halicarn]]", type="city", seats=3, profile="port_periphery" }, | ||
-- Major Cities in Haifan Bassaridia | -- Major Cities in Haifan Bassaridia | ||
{ name="[[Keybir-Aviv]]", type="city", seats=3, profile="haifan_urban" }, | { key="KeybirAviv", name="[[List of cities in Bassaridia Vaeringheim#Keybir-Aviv|Keybir-Aviv]]", type="city", seats=3, profile="haifan_urban" }, | ||
{ name="[[Tel-Amin]]", | { key="TelAmin", name="[[List of cities in Bassaridia Vaeringheim#Tel-Amin|Tel-Amin]]", type="city", seats=3, profile="haifan_urban" }, | ||
{ name="[[Diamandis]]", | { key="Diamandis", name="[[List of cities in Bassaridia Vaeringheim#Diamandis|Diamandis]]", type="city", seats=3, profile="haifan_resort" }, | ||
{ name="[[Jogi]]", | { key="Jogi", name="[[List of cities in Bassaridia Vaeringheim#Jogi|Jogi]]", type="city", seats=3, profile="haifan_resort" }, | ||
{ name="[[Lewisburg]]", | { key="Lewisburg", name="[[List of cities in Bassaridia Vaeringheim#Lewisburg|Lewisburg]]", type="city", seats=3, profile="haifan_urban" }, | ||
-- Minor Cities in Haifan Bassaridia | -- Minor Cities in Haifan Bassaridia | ||
{ name="[[Thermosalem]]", type="city", seats=3, profile="haifan_resort" }, | { key="Thermosalem", name="[[List of cities in Bassaridia Vaeringheim#Thermosalem|Thermosalem]]", type="city", seats=3, profile="haifan_resort" }, | ||
{ name="[[Akróstadium]]", 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" }, | ||
{ name="[[Sufriya]]", | { key="Sufriya", name="[[List of cities in Bassaridia Vaeringheim#Sufriya|Sufriya]]", type="city", seats=3, profile="port_periphery" }, | ||
{ name="[[Lykopolis]]", | { key="Lykopolis", name="[[List of cities in Bassaridia Vaeringheim#Lykopolis|Lykopolis]]", type="city", seats=3, profile="war_frontier" }, | ||
-- Bassaridian Normark | -- Bassaridian Normark | ||
{ name="[[Ardclach]]", | { key="Ardclach", name="[[List of cities in Bassaridia Vaeringheim#Ardclach|Ardclach]]", type="city", seats=3, profile="normark_trade" }, | ||
{ name="[[Riddersborg]]", 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) | -- Ouriana Province (major cities) | ||
{ name="[[Bashkim]]", | { key="Bashkim", name="[[List of cities in Bassaridia Vaeringheim#Bashkim|Bashkim]]", type="city", seats=3, profile="agrarian" }, | ||
{ name="[[Ourid]]", | { key="Ourid", name="[[List of cities in Bassaridia Vaeringheim#Ourid|Ourid]]", type="city", seats=3, profile="agrarian" }, | ||
-- Alperkin tribal constituencies (non-city, 1-seat) | -- Alperkin tribal constituencies (non-city, 1-seat) | ||
{ name="Western Steppes Alperkin Tribe", type="tribe", seats=1, profile="alperkin_tribe" }, | { key="WesternSteppesTribe", name="Western Steppes Alperkin Tribe", type="tribe", seats=1, profile="alperkin_tribe" }, | ||
{ name="Northern Canyon Alperkin Tribe", type="tribe", seats=1, profile="alperkin_tribe" }, | { key="NorthernCanyonTribe", name="Northern Canyon Alperkin Tribe", type="tribe", seats=1, profile="alperkin_tribe" }, | ||
{ name="Morovian Shoreline 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 | -- 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_periphery = { | 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) | -- 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 | |||
local function partyScore(year, dIndex, pIndex, dType, | 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 party = parties[pIndex] | ||
local base = (party.base[dType] or 1.0) | local base = (party.base[dType] or 1.0) | ||
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) | ||
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) | ||
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 * | 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 | ||
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 | end | ||
| Line 294: | Line 553: | ||
------------------------------------------------------------ | ------------------------------------------------------------ | ||
-- | -- 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 = {}, {} | ||
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 | ||
return alloc, total | return alloc, total | ||
end | end | ||
local quota | local quota, assigned = total / seats, 0 | ||
for id, v in pairs(votes) do | for id, v in pairs(votes) do | ||
local exact = v / quota | local exact = v / quota | ||
| Line 325: | 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 | ||
end | end | ||
| Line 345: | 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 | |||
return pct, total | return pct, total | ||
end | end | ||
| Line 356: | 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 } | ||
end | end | ||
table.sort(list, function(a, b) | table.sort(list, function(a, b) | ||
| Line 371: | Line 613: | ||
------------------------------------------------------------ | ------------------------------------------------------------ | ||
-- | -- 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) | ||
end | end | ||
| Line 388: | 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 } | ||
end | end | ||
local function simulateNation(year) | local function simulateNation(year, sig, saltDay) | ||
local results | local results, seatTotal, voteTotal = {}, {}, {} | ||
for _, party in ipairs(parties) do | for _, party in ipairs(parties) do | ||
| Line 409: | 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 | ||
for id, v in pairs(r.votes) do voteTotal[id] = voteTotal[id] + (v or 0) end | |||
for id, v in pairs(r.votes) do | |||
end | end | ||
local pctNation, totalNation = normalizePercentages(voteTotal) | local pctNation, totalNation = normalizePercentages(voteTotal) | ||
return { districts=results, seatTotal=seatTotal, voteTotal=voteTotal, pctNation=pctNation, totalNation=totalNation } | |||
return { | |||
end | end | ||
------------------------------------------------------------ | ------------------------------------------------------------ | ||
-- | -- 8) ELECTION TIMING (BASED ON BASSARIDIAN CALENDAR) | ||
------------------------------------------------------------ | ------------------------------------------------------------ | ||
local function getCurrentDayYear() | local function getCurrentDayYear() | ||
local curr = date.getCurrentDate() | 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 441: | Line 659: | ||
end | end | ||
local function isElectionYear(year) | local function isElectionYear(year) | ||
return year >= FIRST_ELECTION_YEAR | return year >= FIRST_ELECTION_YEAR | ||
| Line 447: | Line 664: | ||
------------------------------------------------------------ | ------------------------------------------------------------ | ||
-- | -- 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) | 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 463: | Line 935: | ||
if not isElectionYear(year) then | if not isElectionYear(year) then | ||
return string.format("'''No general election in %d PSSC.'''", year) | |||
return string.format( | |||
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) | ||
end | |||
) | 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 494: | Line 962: | ||
for _, r in ipairs(nation.districts) do | for _, r in ipairs(nation.districts) do | ||
local d = r.district | local d = r.district | ||
local rows = {} | local rows = {} | ||
for _, entry in ipairs(r.sorted) do | for _, entry in ipairs(r.sorted) do | ||
| Line 501: | 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 } | ||
end | end | ||
end | end | ||
if #rows > 0 then | if #rows > 0 then | ||
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, | ||
first.party.logo or "", first.party.name, | |||
first.seats, first.pct | |||
first.party.logo or "", | |||
first.seats, | |||
) | ) | ||
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', | ||
row.party.logo or "", row.party.name, row.seats, row.pct) | |||
row.party.logo or "", | |||
end | end | ||
end | end | ||
| Line 544: | Line 993: | ||
end | end | ||
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 558: | 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] = "|}" | ||
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 | 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