Module:BassaridianElectionSim
From MicrasWiki
Documentation for this module may be created at Module:BassaridianElectionSim/doc
-- Module:BassaridianElectionSim
-- Simulated multi-party elections for Bassaridia Vaeringheim,
-- using the same PSSC calendar structure as PillSeasonSchedule.
local p = {}
local date = require('Module:BassaridianCalendar')
------------------------------------------------------------
-- 0) CONFIG / CALENDAR
------------------------------------------------------------
local DAYS_IN_YEAR = 183
local FIRST_ELECTION_YEAR = 50 -- first election anchor (adjust as needed)
local ELECTION_DAY = 122 -- election held on 122nd day of year
-- 61-day "months" matching PillSeasonSchedule logic
local function getMonthDay(d)
if d <= 61 then
return "Atosiel", d
elseif d <= 122 then
return "Thalassiel", d - 61
else
return "Opsitheiel", d - 122
end
end
local function absDay(y, d)
return y * DAYS_IN_YEAR + d
end
------------------------------------------------------------
-- 1) PARTIES
------------------------------------------------------------
local parties = {
{
id = "CUF",
name = "Councilist Unity Front",
short = "CUF",
logo = "[[File:CUF logo.png|20px]]",
base = { city = 1.30, tribe = 0.90 },
},
{
id = "MSC",
name = "Morovian Social Cooperative",
short = "MSC",
logo = "[[File:MSC logo.png.png|20px]]",
base = { city = 1.10, tribe = 1.00 },
},
{
id = "PRP",
name = "Path of Renewal Party",
short = "PRP",
logo = "[[File:PRP logo.png|20px]]",
base = { city = 1.00, tribe = 1.10 },
},
{
id = "WLG",
name = "War League Guardians Bloc",
short = "WLG",
logo = "[[File:WLG logo.png|20px]]",
base = { city = 0.95, tribe = 1.30 },
},
{
id = "AESL",
name = "Alperkin Earth & Sky League",
short = "AESL",
logo = "[[File:AESL logo.png|20px]]",
base = { city = 0.60, tribe = 1.50 },
},
{
id = "DBM",
name = "Diaspora Bridges Movement",
short = "DBM",
logo = "[[File:DBM logo.png|20px]]",
base = { city = 0.90, tribe = 0.50 },
},
{
id = "FIML",
name = "Free Investors & Merchants League",
short = "FIML",
logo = "[[File:FIML logo.png|20px]]",
base = { city = 1.10, tribe = 0.40 },
},
{
id = "CRC",
name = "Civic Reform Covenant",
short = "CRC",
logo = "[[File:CRC logo.png.png|20px]]",
base = { city = 1.00, tribe = 0.80 },
},
}
local PARTY_BY_ID = {}
for _, pty in ipairs(parties) do
PARTY_BY_ID[pty.id] = pty
end
------------------------------------------------------------
-- 2) DISTRICTS
-- 3-seat city districts for ALL non-dependency cities
-- + 1-seat Alperkin tribal constituencies.
-- Each district also has a "profile" for political flavor.
------------------------------------------------------------
-- Profiles used:
-- • port_core – General Port / capital / big trade
-- • port_periphery – coastal, trade-exposed but secondary
-- • shrine_mystic – temple / cult / mystic center
-- • war_frontier – militarized, campaign-adjacent
-- • agrarian – farming, river valleys
-- • normark_trade – Normarkian/Bassarid northern trade
-- • alperkin_city – mixed Alperkin/urban strongholds
-- • haifan_resort – Haifan resort/port cities
-- • haifan_urban – dense Haifan urban/political centers
-- • alperkin_tribe – pure tribal constituencies (non-city)
local districts = {
-- Major Cities in Bassaridia Vaeringheim
{ name="[[List of cities in Bassaridia Vaeringheim#Vaeringheim|Vaeringheim]]", type="city", seats=3, profile="port_core" },
{ name="[[List of cities in Bassaridia Vaeringheim#Luminaria|Luminaria]]", type="city", seats=3, profile="shrine_mystic" },
{ name="[[List of cities in Bassaridia Vaeringheim#Serena|Serena]]", type="city", seats=3, profile="port_periphery" },
{ name="[[List of cities in Bassaridia Vaeringheim#Pyralis|Pyralis]]", type="city", seats=3, profile="war_frontier" },
{ name="[[List of cities in Bassaridia Vaeringheim#Symphonara|Symphonara]]", type="city", seats=3, profile="shrine_mystic" },
{ name="[[List of cities in Bassaridia Vaeringheim#Aurelia|Aurelia]]", type="city", seats=3, profile="agrarian" },
{ name="[[List of cities in Bassaridia Vaeringheim#Somniumpolis|Somniumpolis]]", type="city", seats=3, profile="port_periphery" },
{ name="[[List of cities in Bassaridia Vaeringheim#Nexa|Nexa]]", type="city", seats=3, profile="war_frontier" },
{ name="[[List of cities in Bassaridia Vaeringheim#Lunalis Sancta|Lunalis Sancta]]", type="city", seats=3, profile="shrine_mystic" },
{ name="[[List of cities in Bassaridia Vaeringheim#Sylvapolis|Sylvapolis]]", type="city", seats=3, profile="agrarian" },
-- Minor Cities in Bassaridia Vaeringheim
{ name="[[List of cities in Bassaridia Vaeringheim#Saluria|Saluria]]", type="city", seats=3, profile="port_periphery" },
{ name="[[List of cities in Bassaridia Vaeringheim#Aetherium|Aetherium]]", type="city", seats=3, profile="shrine_mystic" },
{ name="[[List of cities in Bassaridia Vaeringheim#Ferrum Citadel|Ferrum Citadel]]", type="city", seats=3, profile="war_frontier" },
{ name="[[List of cities in Bassaridia Vaeringheim#Acheron|Acheron]]", type="city", seats=3, profile="war_frontier" },
{ name="[[List of cities in Bassaridia Vaeringheim#Erythros|Erythros]]", type="city", seats=3, profile="war_frontier" },
{ name="[[List of cities in Bassaridia Vaeringheim#Catonis Atrium|Catonis Atrium]]", type="city", seats=3, profile="agrarian" },
{ name="[[List of cities in Bassaridia Vaeringheim#Delphica|Delphica]]", type="city", seats=3, profile="port_core" },
{ name="[[List of cities in Bassaridia Vaeringheim#Koinonía|Koinonía]]", type="city", seats=3, profile="agrarian" },
{ name="[[List of cities in Bassaridia Vaeringheim#Aureum|Aureum]]", type="city", seats=3, profile="port_periphery" },
-- Alperkin cities
{ name="[[List of cities in Bassaridia Vaeringheim#The Alpazkigz|The Alpazkigz]]", type="city", seats=3, profile="alperkin_city" },
{ name="[[List of cities in Bassaridia Vaeringheim#Odiferia|Odiferia]]", type="city", seats=3, profile="alperkin_city" },
-- Major Cities in New South Jangsong
{ name="[[List of cities in Bassaridia Vaeringheim#Skýrophos|Skýrophos]]", type="city", seats=3, profile="war_frontier" },
{ name="[[List of cities in Bassaridia Vaeringheim#Bjornopolis|Bjornopolis]]", type="city", seats=3, profile="shrine_mystic" },
{ name="[[List of cities in Bassaridia Vaeringheim#Aegirheim|Aegirheim]]", type="city", seats=3, profile="port_periphery" },
{ name="[[List of cities in Bassaridia Vaeringheim#Norsolyra|Norsolyra]]", type="city", seats=3, profile="agrarian" },
{ name="[[List of cities in Bassaridia Vaeringheim#Thorsalon|Thorsalon]]", type="city", seats=3, profile="agrarian" },
-- Minor Cities in New South Jangsong
{ name="[[List of cities in Bassaridia Vaeringheim#Pelagia|Pelagia]]", type="city", seats=3, profile="port_periphery" },
{ name="[[List of cities in Bassaridia Vaeringheim#Myrene|Myrene]]", type="city", seats=3, profile="agrarian" },
{ name="[[List of cities in Bassaridia Vaeringheim#Thyrea|Thyrea]]", type="city", seats=3, profile="agrarian" },
{ name="[[List of cities in Bassaridia Vaeringheim#Ephyra|Ephyra]]", type="city", seats=3, profile="agrarian" },
{ name="[[List of cities in Bassaridia Vaeringheim#Halicarn|Halicarn]]", type="city", seats=3, profile="port_periphery" },
-- Major Cities in Haifan Bassaridia
{ name="[[List of cities in Bassaridia Vaeringheim#Keybir-Aviv|Keybir-Aviv]]", type="city", seats=3, profile="haifan_urban" },
{ name="[[List of cities in Bassaridia Vaeringheim#Tel-Amin|Tel-Amin]]", type="city", seats=3, profile="haifan_urban" },
{ name="[[List of cities in Bassaridia Vaeringheim#Diamandis|Diamandis]]", type="city", seats=3, profile="haifan_resort" },
{ name="[[List of cities in Bassaridia Vaeringheim#Jogi|Jogi]]", type="city", seats=3, profile="haifan_resort" },
{ name="[[List of cities in Bassaridia Vaeringheim#Lewisburg|Lewisburg]]", type="city", seats=3, profile="haifan_urban" },
-- Minor Cities in Haifan Bassaridia
{ name="[[List of cities in Bassaridia Vaeringheim#Thermosalem|Thermosalem]]", type="city", seats=3, profile="haifan_resort" },
{ name="[[List of cities in Bassaridia Vaeringheim#Akróstadium|Akróstadium]]", type="city", seats=3, profile="haifan_resort" },
{ name="[[List of cities in Bassaridia Vaeringheim#Sufriya|Sufriya]]", type="city", seats=3, profile="port_periphery" },
{ name="[[List of cities in Bassaridia Vaeringheim#Lykopolis|Lykopolis]]", type="city", seats=3, profile="war_frontier" },
-- Bassaridian Normark
{ name="[[List of cities in Bassaridia Vaeringheim#Ardclach|Ardclach]]", type="city", seats=3, profile="normark_trade" },
{ name="[[List of cities in Bassaridia Vaeringheim#Riddersborg|Riddersborg]]", type="city", seats=3, profile="normark_trade" },
-- Ouriana Province (major cities)
{ name="[[List of cities in Bassaridia Vaeringheim#Bashkim|Bashkim]]", type="city", seats=3, profile="agrarian" },
{ name="[[List of cities in Bassaridia Vaeringheim#Ourid|Ourid]]", type="city", seats=3, profile="agrarian" },
-- Alperkin tribal constituencies (non-city, 1-seat)
{ name="Western Steppes Alperkin Tribe", type="tribe", seats=1, profile="alperkin_tribe" },
{ name="Northern Canyon Alperkin Tribe", type="tribe", seats=1, profile="alperkin_tribe" },
{ name="Morovian Shoreline Alperkin Tribe", type="tribe", seats=1, profile="alperkin_tribe" },
}
------------------------------------------------------------
-- 3) PROFILE-BASED BIAS
-- + cult-based influence (Reformed Stripping Path)
------------------------------------------------------------
-- Economic / geographic profile bias (as before)
local profileBias = {
-- Core Port / capital
port_core = {
CUF = 1.15,
FIML = 1.20,
CRC = 1.05,
DBM = 1.05,
},
-- Secondary/coastal trade cities
port_periphery = {
CUF = 1.05,
MSC = 1.05,
FIML = 1.10,
DBM = 1.05,
},
-- Shrine-heavy / mystic centers
shrine_mystic = {
PRP = 1.20,
CRC = 1.10,
CUF = 1.05,
},
-- War frontier / militarized zones
war_frontier = {
WLG = 1.20,
CUF = 1.05,
MSC = 1.00,
},
-- Agrarian / rural valleys
agrarian = {
MSC = 1.15,
PRP = 1.05,
CUF = 0.95,
FIML = 0.95,
},
-- Normark/Bassarid northern trade
normark_trade = {
CUF = 1.10,
CRC = 1.10,
FIML = 1.05,
},
-- Alperkin cities
alperkin_city = {
AESL = 1.30,
MSC = 1.05,
PRP = 1.05,
},
-- Haifan resort/port cities
haifan_resort = {
MSC = 1.10,
PRP = 1.05,
CUF = 1.05,
DBM = 1.10,
},
-- Haifan dense urban centers
haifan_urban = {
MSC = 1.10,
CUF = 1.10,
CRC = 1.05,
DBM = 1.10,
},
-- Pure tribal constituencies
alperkin_tribe = {
AESL = 1.40,
WLG = 1.10,
MSC = 1.05,
PRP = 1.05,
},
}
-- Cult tags (keys) correspond roughly to:
-- Aurora -> Order of Aurora Mystica (Eos)
-- Azure -> Azure Sentinel Sect (Thalassa)
-- Harmony -> Harmony Sanctum (Micras)
-- Ignis -> Ignis Aeternum (Pyros)
-- Celestial -> Celestial Harmony Sect (Indigo)
-- Golden -> Guild of Golden Shadows (Chrysos)
-- Reverie -> Reverie Nebulous (Nephele)
-- Eon -> Eon Fellowship (Glinos)
-- Umbral -> Order of the Umbral Oracle (Noctis)
-- Verdant -> Mystery of the Verdant Embrace (Opsithe)
-- Vitalis -> Sanctum Vitalis (Plateau)
-- Alabaster -> Temple Alabaster (Ivory)
-- Ironclad -> Court of the Ironclad (Fenrir)
-- Concordia -> Accord Concordia (Lukedu)
-- Stygian -> Styx cults
-- Sylvan -> Sylvan Fellowship (Faun)
-- RedMirth -> Mystery of Red Mirth (Silenus)
-- Rex -> Rex Catonis (Cato)
-- Delphica -> Sanctum Delphica (Sisera)
-- Amicitia -> Ordo Amicitia (Erasmus)
-- Illuminata -> Temple Illuminata (Nikolaj)
-- Conclave -> Conclave Illuminara (Tarsica)
-- How each cult tends to lean toward parties
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 },
}
-- For each district profile, which cults are especially influential
-- (used when no more specific cult list is provided)
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" },
}
------------------------------------------------------------
-- 4) RANDOMNESS (DETERMINISTIC BY YEAR/DISTRICT/PARTY)
------------------------------------------------------------
local function partyScore(year, dIndex, pIndex, dType, profile)
local party = parties[pIndex]
local base = (party.base[dType] or 1.0)
-- deterministic seed: year + district index + party index
local seed = year * 100000 + dIndex * 1000 + pIndex
math.randomseed(seed)
-- jitter in roughly [-0.20, +0.20]
local jitter = (math.random() - 0.5) * 0.4
local score = base * (1 + jitter)
-- apply profile-based bias if defined
if profile then
local pb = profileBias[profile]
if pb and pb[party.id] then
score = score * pb[party.id]
end
end
-- apply cult-based influence for the profile
local cults = profile and profileCults[profile] or nil
if cults then
for _, cid in ipairs(cults) do
local cf = cultInfluence[cid]
if cf and cf[party.id] then
score = score * cf[party.id]
end
end
end
if score < 0 then score = 0 end
return score
end
------------------------------------------------------------
-- 5) SEAT ALLOCATION (LARGEST REMAINDER / HARE QUOTA)
------------------------------------------------------------
local function allocateSeats(votes, seats)
local total = 0
for _, v in pairs(votes) do total = total + v end
local alloc = {}
local remainders = {}
if total <= 0 or seats <= 0 then
for id,_ in pairs(votes) do
alloc[id] = 0
remainders[id] = 0
end
return alloc, total
end
local quota = total / seats
local assigned = 0
for id, v in pairs(votes) do
local exact = v / quota
local base = math.floor(exact)
alloc[id] = base
assigned = assigned + base
remainders[#remainders+1] = { id = id, rem = exact - base }
end
local remaining = seats - assigned
table.sort(remainders, function(a, b) return a.rem > b.rem end)
for i = 1, remaining do
local r = remainders[i]
if r then
alloc[r.id] = (alloc[r.id] or 0) + 1
end
end
return alloc, total
end
local function normalizePercentages(votes)
local total = 0
for _, v in pairs(votes) do total = total + v end
local pct = {}
if total <= 0 then
for id,_ in pairs(votes) do pct[id] = 0 end
return pct, total
end
for id, v in pairs(votes) do
pct[id] = (v / total) * 100
end
return pct, total
end
local function sortedPartyList(votes, seatsAlloc)
local list = {}
for _, party in ipairs(parties) do
local id = party.id
list[#list+1] = {
party = party,
votes = votes[id] or 0,
seats = seatsAlloc[id] or 0,
}
end
table.sort(list, function(a, b)
if a.seats ~= b.seats then return a.seats > b.seats end
if a.votes ~= b.votes then return a.votes > b.votes end
return a.party.name < b.party.name
end)
return list
end
------------------------------------------------------------
-- 6) CORE SIMULATION
------------------------------------------------------------
local function simulateDistrict(year, dIndex)
local d = districts[dIndex]
local votes = {}
for pIndex, party in ipairs(parties) do
votes[party.id] = partyScore(
year, dIndex, pIndex, d.type, d.profile
)
end
local seatsAlloc, totalVotes = allocateSeats(votes, d.seats)
local pct, _ = normalizePercentages(votes)
local sorted = sortedPartyList(votes, seatsAlloc)
return {
district = d,
votes = votes,
percentages = pct,
seats = seatsAlloc,
totalVotes = totalVotes,
sorted = sorted,
}
end
local function simulateNation(year)
local results = {}
local seatTotal = {}
local voteTotal = {}
for _, party in ipairs(parties) do
seatTotal[party.id] = 0
voteTotal[party.id] = 0
end
for i = 1, #districts do
local r = simulateDistrict(year, i)
results[i] = r
for id, s in pairs(r.seats) do
seatTotal[id] = seatTotal[id] + (s or 0)
end
for id, v in pairs(r.votes) do
voteTotal[id] = voteTotal[id] + (v or 0)
end
end
local pctNation, totalNation = normalizePercentages(voteTotal)
return {
districts = results,
seatTotal = seatTotal,
voteTotal = voteTotal,
pctNation = pctNation,
totalNation = totalNation,
}
end
------------------------------------------------------------
-- 7) ELECTION TIMING (BASED ON BASSARIDIAN CALENDAR)
------------------------------------------------------------
local function getCurrentDayYear()
local curr = date.getCurrentDate() -- e.g. "67, 52 PSSC"
local day = tonumber(curr:match("^(%d+),")) or 0
local year = tonumber(curr:match(", (%d+) PSSC")) or 0
return day, year
end
-- Elections every year from FIRST_ELECTION_YEAR onward
local function isElectionYear(year)
return year >= FIRST_ELECTION_YEAR
end
------------------------------------------------------------
-- 8) RENDER FUNCTIONS
------------------------------------------------------------
-- District-by-district results
-- Usage:
-- {{#invoke:BassaridianElectionSim|renderElectionResults}}
-- {{#invoke:BassaridianElectionSim|renderElectionResults|year=52|day=183}}
function p.renderElectionResults(frame)
local args = frame.args or {}
local dayNow, yearNow = getCurrentDayYear()
local year = tonumber(args.year or "") or yearNow
local day = tonumber(args.day or "") or dayNow
local showZero = (args.show_zero and args.show_zero ~= "0") or false
if not isElectionYear(year) then
return string.format(
"'''No general election in %d PSSC.'''",
year
)
end
if day < ELECTION_DAY then
local mon, dom = getMonthDay(ELECTION_DAY)
return string.format(
"'''General election scheduled for %d %s %d PSSC.'''",
dom, mon, year
)
end
local nation = simulateNation(year)
local mon, dom = getMonthDay(ELECTION_DAY)
local headerDate = string.format("%d %s %d PSSC", dom, mon, year)
local out = {}
out[#out+1] = '{| class="wikitable sortable"'
out[#out+1] = '|+ General Election Results — ' .. headerDate
out[#out+1] = '! District !! Seats !! Party !! Seats Won !! Vote %'
for _, r in ipairs(nation.districts) do
local d = r.district
-- Build the list of party rows we actually want to render
local rows = {}
for _, entry in ipairs(r.sorted) do
local seatsWon = r.seats[entry.party.id] or 0
local votePct = r.percentages[entry.party.id] or 0
if seatsWon > 0 or showZero then
rows[#rows+1] = {
party = entry.party,
seats = seatsWon,
pct = votePct,
}
end
end
if #rows > 0 then
-- First row for this district, with rowspans on District + Seats
local first = rows[1]
out[#out+1] = "|-"
out[#out+1] = string.format(
'| rowspan="%d" | %s || rowspan="%d" | %d || %s %s || %d || %.1f',
#rows,
d.name,
#rows,
d.seats,
first.party.logo or "",
first.party.name,
first.seats,
first.pct
)
-- Subsequent rows: Party, Seats Won, Vote %
for i = 2, #rows do
local row = rows[i]
out[#out+1] = "|-"
out[#out+1] = string.format(
'| %s %s || %d || %.1f',
row.party.logo or "",
row.party.name,
row.seats,
row.pct
)
end
end
end
out[#out+1] = "|}"
return table.concat(out, "\n")
end
-- National summary: total seats + national vote share
-- Usage:
-- {{#invoke:BassaridianElectionSim|renderNationalSummary}}
function p.renderNationalSummary(frame)
local args = frame.args or {}
local dayNow, yearNow = getCurrentDayYear()
local year = tonumber(args.year or "") or yearNow
local day = tonumber(args.day or "") or dayNow
if not isElectionYear(year) or day < ELECTION_DAY then
return "'''National summary available only once the general election has been held.'''"
end
local nation = simulateNation(year)
local out = {}
out[#out+1] = '{| class="wikitable sortable"'
out[#out+1] = '! Party !! Seats (total) !! National Vote %'
for _, party in ipairs(parties) do
local seats = nation.seatTotal[party.id] or 0
local pct = nation.pctNation[party.id] or 0
out[#out+1] = "|-"
out[#out+1] = string.format(
"| %s %s || %d || %.1f",
party.logo or "",
party.name,
seats,
pct
)
end
out[#out+1] = "|}"
return table.concat(out, "\n")
end
------------------------------------------------------------
-- EXPORT
------------------------------------------------------------
return p