Module:BassaridianElectionSim: Difference between revisions
From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs) No edit summary |
NewZimiaGov (talk | contribs) No edit summary |
||
| Line 10: | Line 10: | ||
------------------------------------------------------------ | ------------------------------------------------------------ | ||
local DAYS_IN_YEAR = 183 | local DAYS_IN_YEAR = 183 | ||
local FIRST_ELECTION_YEAR = | local FIRST_ELECTION_YEAR = 51 -- first election anchor (adjust as needed) | ||
local CYCLE_YEARS = 1 -- Council of Representatives term length | local CYCLE_YEARS = 1 -- Council of Representatives term length | ||
local ELECTION_DAY = 122 -- election held on 122nd day of year | local ELECTION_DAY = 122 -- election held on 122nd day of year | ||
Revision as of 17:56, 6 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.
local p = {}
local date = require('Module:BassaridianCalendar')
------------------------------------------------------------
-- 0) CONFIG / CALENDAR
------------------------------------------------------------
local DAYS_IN_YEAR = 183
local FIRST_ELECTION_YEAR = 51 -- first election anchor (adjust as needed)
local CYCLE_YEARS = 1 -- Council of Representatives term length
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|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|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="[[Vaeringheim]]", type="city", seats=3, profile="port_core" },
{ name="[[Luminaria]]", type="city", seats=3, profile="shrine_mystic" },
{ name="[[Serena]]", type="city", seats=3, profile="port_periphery" },
{ name="[[Pyralis]]", type="city", seats=3, profile="war_frontier" },
{ name="[[Symphonara]]", type="city", seats=3, profile="shrine_mystic" },
{ name="[[Aurelia]]", type="city", seats=3, profile="agrarian" },
{ name="[[Somniumpolis]]", type="city", seats=3, profile="port_periphery" },
{ name="[[Nexa]]", type="city", seats=3, profile="war_frontier" },
{ name="[[Lunalis Sancta]]", type="city", seats=3, profile="shrine_mystic" },
{ name="[[Sylvapolis]]", type="city", seats=3, profile="agrarian" },
-- Minor Cities in Bassaridia Vaeringheim
{ name="[[Saluria]]", type="city", seats=3, profile="port_periphery" },
{ name="[[Aetherium]]", type="city", seats=3, profile="shrine_mystic" },
{ name="[[Ferrum Citadel]]", type="city", seats=3, profile="war_frontier" },
{ name="[[Acheron]]", type="city", seats=3, profile="war_frontier" },
{ name="[[Erythros]]", type="city", seats=3, profile="war_frontier" },
{ name="[[Catonis Atrium]]", type="city", seats=3, profile="agrarian" },
{ name="[[Delphica]]", type="city", seats=3, profile="port_core" },
{ name="[[Koinonía]]", type="city", seats=3, profile="agrarian" },
{ name="[[Aureum]]", type="city", seats=3, profile="port_periphery" },
-- Alperkin cities
{ name="[[The Alpazkigz]]", type="city", seats=3, profile="alperkin_city" },
{ name="[[Odiferia]]", type="city", seats=3, profile="alperkin_city" },
-- Major Cities in New South Jangsong
{ name="[[Skýrophos]]", type="city", seats=3, profile="war_frontier" },
{ name="[[Bjornopolis]]", type="city", seats=3, profile="shrine_mystic" },
{ name="[[Aegirheim]]", type="city", seats=3, profile="port_periphery" },
{ name="[[Norsolyra]]", type="city", seats=3, profile="agrarian" },
{ name="[[Thorsalon]]", type="city", seats=3, profile="agrarian" },
-- Minor Cities in New South Jangsong
{ name="[[Pelagia]]", type="city", seats=3, profile="port_periphery" },
{ name="[[Myrene]]", type="city", seats=3, profile="agrarian" },
{ name="[[Thyrea]]", type="city", seats=3, profile="agrarian" },
{ name="[[Ephyra]]", type="city", seats=3, profile="agrarian" },
{ name="[[Halicarn]]", type="city", seats=3, profile="port_periphery" },
-- Major Cities in Haifan Bassaridia
{ name="[[Keybir-Aviv]]", type="city", seats=3, profile="haifan_urban" },
{ name="[[Tel-Amin]]", type="city", seats=3, profile="haifan_urban" },
{ name="[[Diamandis]]", type="city", seats=3, profile="haifan_resort" },
{ name="[[Jogi]]", type="city", seats=3, profile="haifan_resort" },
{ name="[[Lewisburg]]", type="city", seats=3, profile="haifan_urban" },
-- Minor Cities in Haifan Bassaridia
{ name="[[Thermosalem]]", type="city", seats=3, profile="haifan_resort" },
{ name="[[Akróstadium]]", type="city", seats=3, profile="haifan_resort" },
{ name="[[Sufriya]]", type="city", seats=3, profile="port_periphery" },
{ name="[[Lykopolis]]", type="city", seats=3, profile="war_frontier" },
-- Bassaridian Normark
{ name="[[Ardclach]]", type="city", seats=3, profile="normark_trade" },
{ name="[[Riddersborg]]", type="city", seats=3, profile="normark_trade" },
-- Ouriana Province (major cities)
{ name="[[Bashkim]]", type="city", seats=3, profile="agrarian" },
{ name="[[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
------------------------------------------------------------
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,
},
}
------------------------------------------------------------
-- 4) RANDOMNESS (DETERMINISTIC BY YEAR/DISTRICT/PARTY)
------------------------------------------------------------
local function partyScore(year, dIndex, pIndex, dType, dName, 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
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.name, 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
local function isElectionYear(year)
if year < FIRST_ELECTION_YEAR then return false end
local diff = year - FIRST_ELECTION_YEAR
return (diff % CYCLE_YEARS) == 0
end
------------------------------------------------------------
-- 8) RENDER FUNCTIONS
------------------------------------------------------------
-- District-by-district results
-- Usage:
-- {{#invoke:BassaridianElectionSim|renderElectionResults}}
-- {{#invoke:BassaridianElectionSim|renderElectionResults|year=52|day=130}}
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
if not isElectionYear(year) then
local last
if year < FIRST_ELECTION_YEAR then
last = "none yet"
else
local diff = (year - FIRST_ELECTION_YEAR) % CYCLE_YEARS
last = tostring(year - diff) .. " PSSC"
end
return string.format(
"'''No general election in %d PSSC. Last election: %s.'''",
year, last
)
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
local dRow = true
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 (args.show_zero and args.show_zero ~= "0") then
out[#out+1] = "|-"
if dRow then
out[#out+1] = string.format(
"| %s || %d || %s %s || %d || %.1f",
d.name,
d.seats,
entry.party.logo or "",
entry.party.name,
seatsWon,
votePct
)
dRow = false
else
out[#out+1] = string.format(
"|| || %s %s || %d || %.1f",
entry.party.logo or "",
entry.party.name,
seatsWon,
votePct
)
end
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