Module:BassaridiaOutbreaks
From MicrasWiki
Documentation for this module may be created at Module:BassaridiaOutbreaks/doc
---------------------------------------------------------
-- Module: BassaridiaOutbreaks
-- Daily outbreak tracker for Bassaridia Vaeringheim.
-- Public functions:
-- outbreaksAll(frame) -> table for ALL cities
-- outbreaksForCity(frame) -> table for a single city
-- diseaseGlossary(frame) -> legend
--
---------------------------------------------------------
local p = {}
---------------------------------------------------------
-- Date helpers & deterministic daily seed
---------------------------------------------------------
local function getCurrentDateInfo()
local startDate = os.time({year=1999, month=8, day=6})
local secondsInDay= 86400
local daysPerYear = 183
local now = os.time()
local totalDays = math.floor((now - startDate) / secondsInDay)
local fraction = totalDays / daysPerYear
local psscYear = math.floor(fraction)
local dayOfYear = math.floor((fraction - psscYear) * daysPerYear) + 1
return { psscYear = psscYear, dayOfYear = dayOfYear }
end
local function getDailySeed()
local d = os.date("*t")
return (d.year * 1000) + d.yday
end
---------------------------------------------------------
-- Config helpers (read once per invoke)
-- freq: chance any city has an outbreak (0..1), default 0.80
-- max : cap concurrent diseases per city (1..3), default 2
---------------------------------------------------------
local function getConfig(frame)
local freq = tonumber(frame and frame.args and frame.args.freq) or 0.80
if freq < 0 then freq = 0 elseif freq > 1 then freq = 1 end
local maxd = tonumber(frame and frame.args and frame.args.max) or 2
if maxd < 1 then maxd = 1 elseif maxd > 3 then maxd = 3 end
return { freq = freq, max = maxd }
end
---------------------------------------------------------
-- Disease catalog (names, weights, baseline R)
---------------------------------------------------------
local diseases = {
{name="Thalassan Rot", code="TR", w=10, r=1.20, controls="canal flush, rat-proofing, food-handler checks"},
{name="Festival Blush", code="FB", w=8, r=1.25, controls="ring vaccination, crowd-spacing, dorm rotation"},
{name="Pilgrim’s Flux", code="PF", w=9, r=1.15, controls="boil notices, cistern audit, ORS distribution"},
{name="Stygian Dust", code="SD", w=5, r=1.05, controls="respirators, mist carts, guide clinic checks"},
{name="Haifan Salt-Sickness", code="HS", w=6, r=1.10, controls="indoor rotations, antifungal clinics, dehumidify"},
{name="Norsolyrian Drain", code="ND", w=6, r=1.18, controls="lined ditches, praziquantel, snail abatement"},
{name="Somniumpolis Lung", code="SL", w=5, r=1.07, controls="dust suppression, respirator fit-checks"},
{name="Haifan Crimson Fever", code="HCF", w=3, r=1.30, controls="larval control, dusk patrols, vector abatement"},
{name="Strip Yellowing", code="SY", w=10, r=1.10, controls="single-use ladles, vendor relicensing, recall"},
{name="Eye of Styx", code="ES", w=4, r=1.00, controls="eye-wash stations, visor compliance"},
{name="Cato’s Curse", code="CC", w=4, r=1.05, controls="PPE audits, solvent exposure controls"},
{name="White Pestilence", code="WP", w=2, r=1.40, controls="tiered quarantine, convoy assays, cordons"},
{name="Breath of Nephele", code="BN", w=9, r=1.12, controls="ventilation drills, mask catechism, spacing"},
{name="Agnian Lockjaw", code="AL", w=3, r=0.95, controls="tetanus boosters, injury clinics"},
}
local function pickNDiseases(n, rnd)
local total = 0
for _,d in ipairs(diseases) do total = total + d.w end
local chosen, used = {}, {}
local tries = 0
while #chosen < n and tries < 50 do
tries = tries + 1
local roll = rnd() * total
local acc = 0
for i,d in ipairs(diseases) do
acc = acc + d.w
if roll <= acc then
if not used[i] then
table.insert(chosen, d); used[i] = true
end
break
end
end
end
return chosen
end
---------------------------------------------------------
-- Cities (same link formatting as your weather module)
---------------------------------------------------------
local cityData = {
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Vaeringheim Vaeringheim]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Luminaria Luminaria]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Serena Serena]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Pyralis Pyralis]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Symphonara Symphonara]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aurelia Aurelia]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Somniumpolis Somniumpolis]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Nexa Nexa]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Lunalis_Sancta Lunalis Sancta]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Sylvapolis Sylvapolis]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Saluria Saluria]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aetherium Aetherium]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ferrum_Citadel Ferrum Citadel]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Acheron Acheron]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Erythros Erythros]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Catonis_Atrium Catonis Atrium]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Delphica Delphica]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Koinonía Koinonía]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aureum Aureum]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Skýrophos Skýrophos]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Bjornopolis Bjornopolis]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aegirheim Aegirheim]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Norsolyra Norsolyra]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Thorsalon Thorsalon]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Pelagia Pelagia]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Myrene Myrene]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Thyrea Thyrea]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ephyra Ephyra]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Halicarn Halicarn]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Keybir-Aviv Keybir-Aviv]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Tel-Amin Tel-Amin]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Diamandis Diamandis]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Jogi Jogi]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Lewisburg Lewisburg]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Thermosalem Thermosalem]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Akróstadium Akróstadium]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Sufriya Sufriya]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Lykopolis Lykopolis]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ardclach Ardclach]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Riddersborg Riddersborg]"},
}
local function plainName(link)
return (link:match("%s+([^%]]+)%]$")) or link
end
---------------------------------------------------------
-- Severity, colors, arrows
---------------------------------------------------------
local function severityFor(casesPer100k)
if casesPer100k >= 60 then return "CRITICAL"
elseif casesPer100k >= 25 then return "HIGH"
elseif casesPer100k >= 10 then return "MEDIUM"
elseif casesPer100k >= 1 then return "LOW"
else return "NONE" end
end
local severityColors = {
CRITICAL="#ffcccc", HIGH="#ffe0b2", MEDIUM="#fff9c4", LOW="#e0f2f1", NONE="#f2f2f2"
}
local function arrow(trend)
if trend > 0.1 then return "▲"
elseif trend < -0.1 then return "▼"
else return "▬" end
end
---------------------------------------------------------
-- Outbreak count logic (REDUCED ACTIVITY)
-- 1) With probability = cfg.freq, a city has an outbreak.
-- 2) If outbreak happens, choose 1–3 diseases with
-- weights heavily favoring single-disease rows.
-- Default max=2 to further reduce noise.
---------------------------------------------------------
local function chooseNActive(rnd, cfg)
-- No outbreak in this city today?
if rnd() > cfg.freq then return 0 end
-- Base weights for 1,2,3 diseases
local w = {0.70, 0.25, 0.05}
-- Respect max cap
local max = cfg.max
if max == 1 then
w = {1.0, 0.0, 0.0}
elseif max == 2 then
-- renormalize 1 & 2
local s = (0.70 + 0.25)
w = {0.70/s, 0.25/s, 0.0}
end
local roll = rnd()
if roll <= w[1] then return 1
elseif roll <= w[1] + w[2] then return 2
else return 3 end
end
---------------------------------------------------------
-- City synthesis (deterministic per day + per city)
---------------------------------------------------------
local function synthCityRow(rnd, cityLink, cfg)
local nActive = chooseNActive(rnd, cfg)
local picked = pickNDiseases(nActive, rnd)
local names, short = {}, {}
local totalCases, totalPopBase = 0, 0
local maxSeverity, maxColor = "NONE", severityColors.NONE
local advices = {}
for _,d in ipairs(picked) do
local popBase = 100000 -- nominal
local base = (d.r * 10)
local jitter = math.floor(rnd()*base*3 + 0.5)
local cases = math.max(0, math.floor(base + jitter))
totalCases = totalCases + cases
totalPopBase = totalPopBase + popBase
local per100k = (cases / popBase) * 100000
local sev = severityFor(per100k)
if sev == "CRITICAL" or (sev=="HIGH" and maxSeverity~="CRITICAL")
or (sev=="MEDIUM" and (maxSeverity=="LOW" or maxSeverity=="NONE"))
or (sev=="LOW" and maxSeverity=="NONE") then
maxSeverity = sev
maxColor = severityColors[sev]
end
table.insert(names, d.name)
table.insert(short, d.code)
table.insert(advices, string.format("%s: %s", d.code, d.controls))
end
local per100k = (totalPopBase>0) and (totalCases / totalPopBase) * 100000 or 0
local Rt = (#picked>0) and (1.0 + rnd()*0.8) or (0.80 + rnd()*0.15)
local trend = (rnd()*0.6 - 0.3)
local posRate = (#picked>0) and (15 + math.floor(rnd()*25)) or (4 + math.floor(rnd()*4))
local diseasesCell = (#picked>0) and table.concat(names, ", ") or "—"
local codesCell = (#picked>0) and table.concat(short, "/") or "—"
local advisory = (#picked>0) and table.concat(advices, " • ") or "No active advisories"
return {
cityLink = cityLink,
diseases = diseasesCell,
codes = codesCell,
cases24 = totalCases,
per100k = math.floor(per100k + 0.5),
Rt = tonumber(string.format("%.2f", Rt)),
trend = trend,
trendIcon = arrow(trend),
positivity = posRate,
severity = maxSeverity,
color = maxColor,
advisory = advisory
}
end
---------------------------------------------------------
-- Rendering
---------------------------------------------------------
local function renderHeaderAll(pssc)
local out = {}
table.insert(out, "== Daily Outbreak Report ==\n")
table.insert(out, string.format("''(Day %d of Year %d PSSC)''\n\n", pssc.dayOfYear, pssc.psscYear))
table.insert(out, '{| class="wikitable sortable" style="width:100%; text-align:left;"\n')
table.insert(out, "! City !! Active Diseases !! Codes !! 24h Cases !! /100k !! R<sub>t</sub> !! 7d Trend !! Positivity (%) !! Severity !! Advisory\n")
return out
end
local function renderRowAll(row)
return string.format(
"|-\n| %s || %s || %s || %d || %d || %.2f || %s %.0f%% || %d || style=\"background:%s\" | %s || %s\n",
row.cityLink, row.diseases, row.codes, row.cases24, row.per100k, row.Rt,
row.trendIcon, math.abs(row.trend*100), row.positivity, row.color, row.severity, row.advisory
)
end
local function renderFooter() return "|}\n" end
---------------------------------------------------------
-- Public: ALL CITIES
---------------------------------------------------------
function p.outbreaksAll(frame)
local cfg = getConfig(frame)
local seed = getDailySeed()
math.randomseed(seed)
local d = getCurrentDateInfo()
local out = renderHeaderAll(d)
for i,entry in ipairs(cityData) do
math.randomseed(seed + i*97)
local function rnd() return math.random() end
local row = synthCityRow(rnd, entry.city, cfg)
table.insert(out, renderRowAll(row))
end
table.insert(out, renderFooter())
return table.concat(out)
end
---------------------------------------------------------
-- Public: SINGLE CITY
---------------------------------------------------------
function p.outbreaksForCity(frame)
local q = frame.args.city
if not q or q == "" then
return "Error: Please specify a city. E.g. {{#invoke:BassaridiaOutbreaks|outbreaksForCity|city=Vaeringheim}}"
end
local cfg = getConfig(frame)
local seed = getDailySeed()
local idx, link
for i,entry in ipairs(cityData) do
local plain = plainName(entry.city)
if entry.city == q or plain == q then idx = i; link = entry.city; break end
end
if not idx then return "Error: City '"..q.."' not found." end
math.randomseed(seed + idx*97)
local function rnd() return math.random() end
local row = synthCityRow(rnd, link, cfg)
local out = {}
table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"\n')
table.insert(out, "! City !! Active Diseases !! Codes !! 24h Cases !! /100k !! R<sub>t</sub> !! 7d Trend !! Positivity (%) !! Severity !! Advisory\n")
table.insert(out, renderRowAll(row))
table.insert(out, renderFooter())
return table.concat(out)
end
---------------------------------------------------------
-- Public: Glossary
---------------------------------------------------------
function p.diseaseGlossary(frame)
local out = {}
table.insert(out, "=== Disease Glossary & Standard Controls ===\n")
table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"\n')
table.insert(out, "! Code !! Disease !! Typical Controls\n")
for _,d in ipairs(diseases) do
table.insert(out, string.format("|-\n| %s || %s || %s\n", d.code, d.name, d.controls))
end
table.insert(out, "|}\n")
return table.concat(out)
end
return p