Module:BassaridiaOutbreaks
From MicrasWiki
Documentation for this module may be created at Module:BassaridiaOutbreaks/doc
---------------------------------------------------------
-- Module: BassaridiaOutbreaks (UPDATED)
-- 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)
-- Expanded to match the National Disease Table.
---------------------------------------------------------
local diseases = {
{name="Thalassan Rot", code="TR", w=10, r=1.20, controls="canal flush, rat-proofing, food-handler checks"},
{name="Breath of Nephele", code="BN", w=9, r=1.12, controls="ventilation drills, mask catechism, spacing"},
{name="Haifan Salt-Sickness", code="HS", w=6, r=1.10, controls="indoor rotations, antifungal clinics, dehumidify"},
{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="Iylara’s Rash", code="IR", w=5, r=1.06, controls="drying powders, swamp barrier, glove protocols"},
{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="Sea-Cow Ague", code="SCA", w=3, r=1.02, controls="livestock PPE, pasteurize, vaccination drills"},
{name="Ezeri Blisters", code="EB", w=3, r=1.00, controls="gloves for fishers, wound care, short exclusion"},
{name="Agnian Lockjaw", code="AL", w=3, r=0.95, controls="tetanus boosters, injury clinics"},
{name="Caspazani Madness", code="CM", w=2, r=1.00, controls="PEP, bat-avoidance patrols, night rosters"},
{name="Festival Blush", code="FB", w=8, r=1.25, controls="ring vaccination, crowd-spacing, dorm rotation"},
{name="Strip Yellowing", code="SY", w=10, r=1.10, controls="single-use ladles, vendor relicensing, recall"},
{name="Haifan Crimson Fever", code="HCF", w=3, r=1.30, controls="larval control, dusk patrols, vector abatement"},
{name="Ouridian Bite", code="OB", w=4, r=1.10, controls="tick checks, permethrin, steppe trail advisories"},
{name="Eye of Styx", code="ES", w=4, r=1.00, controls="eye-wash stations, visor compliance"},
{name="Kalithros’ Numbness", code="KN", w=2, r=1.03, controls="solvent substitution, exhaust capture, nitrile PPE"},
{name="Cato’s Curse", code="CC", w=4, r=1.05, controls="PPE audits, metal exposure controls"},
{name="Eidolan Breath", code="EiB", w=2, r=1.22, controls="airflow baffles, rite spacing, envoy oversight"},
{name="White Pestilence", code="WP", w=2, r=1.40, controls="tiered quarantine, convoy assays, cordons"},
}
-- Fast index by code
local diseaseIndex = {}
for i,d in ipairs(diseases) do diseaseIndex[d.code] = i end
---------------------------------------------------------
-- Per-city disease biases (weights boosted x3)
-- Uses your table notes for plausible geography/activities.
---------------------------------------------------------
local cityBias = {
-- Wetlands, canals, festivals, ports, steppe, mines, etc.
["Vaeringheim"] = {"TR","WP","FB","SY"},
["Tel-Amin"] = {"HS","HCF","WP"},
["Sufriya"] = {"HCF","WP","SY"},
["Keybir-Aviv"] = {"WP","SY"},
["Thorsalon"] = {"WP"},
["Skýrophos"] = {"WP"},
["Norsolyra"] = {"ND","HCF"},
["Acheron"] = {"SD","ES"},
["Somniumpolis"] = {"SL","EiB"},
["Delphica"] = {"AL"},
["Ephyra"] = {"EB"},
["Krlsgorod"] = {"EB"},
["Kaledonija"] = {"EB"},
["Slevik"] = {"EB","WP"},
["Sårensby"] = {"WP"},
["Ardclach"] = {"WP","EB"},
["Riddersborg"] = {"WP","EB"},
["Saluria"] = {"IR","SY"},
["Sylvapolis"] = {"IR","SY"},
["Somniumpolis"] = {"SL","EiB"},
["Bashkim"] = {"PF","OB","SY","FB"},
["Ourid"] = {"PF","OB"},
-- Inland mountainous (Caledonia): pilgrim routes, dust, influenza
["Skøda"] = {"BN","PF","SD"},
["Eikbu"] = {"BN","PF","SD"},
["Galvø"] = {"BN","PF"},
["Fanghorn"] = {"BN","PF","SD"},
["Notranskja"] = {"BN","PF"},
["Hammarfell"] = {"BN","PF","SD"},
}
-- Bias helper
local function biasedWeights(baseList, biasCodes)
if not biasCodes or #biasCodes==0 then return baseList end
local boosted = {}
for i,d in ipairs(baseList) do
local w = d.w
for _,bc in ipairs(biasCodes) do
if d.code == bc then w = w * 3 end
end
boosted[i] = {name=d.name, code=d.code, w=w, r=d.r, controls=d.controls}
end
return boosted
end
---------------------------------------------------------
-- Cities (same link style as your weather module)
-- Added: Notranskja, Slevik, Fanghorn, Sårensby, Skøda,
-- Eikbu, Galvø, Krlsgorod, Kaledonija, Hammarfell, Ourid, Bashkim
---------------------------------------------------------
local cityData = {
-- BVR majors/minors
{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]"},
-- New South Jangsong
{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]"},
-- Haifan Bassaridia
{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]"},
-- Bassaridian Normark
{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]"},
-- Caledonian City-States & Eastern Caledonia (new)
{city="Notranskja"},
{city="Slevik"},
{city="Fanghorn"},
{city="Sårensby"},
{city="Skøda"},
{city="Eikbu"},
{city="Galvø"},
{city="Krlsgorod"},
{city="Kaledonija"},
{city="Hammarfell"},
-- Valley of Keltia additions
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ourid Ourid]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Bashkim Bashkim]"},
}
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
---------------------------------------------------------
-- Weighted disease picker (with optional city bias)
---------------------------------------------------------
local function pickNDiseasesBiased(n, rnd, biasCodes)
-- Build a temp list with biased weights
local pool = biasedWeights(diseases, biasCodes)
-- Sum weights
local total = 0
for _,d in ipairs(pool) do total = total + d.w end
local chosen, used = {}, {}
for _=1,n do
local roll = rnd() * total
local acc, pick = 0, nil
for i,d in ipairs(pool) do
if not used[i] then
acc = acc + d.w
if roll <= acc then pick = i; break end
end
end
if pick then
table.insert(chosen, pool[pick])
used[pick] = true
end
end
return chosen
end
---------------------------------------------------------
-- Outbreak count logic (unchanged behavior)
---------------------------------------------------------
local function chooseNActive(rnd, cfg)
if rnd() > cfg.freq then return 0 end
local w = {0.70, 0.25, 0.05} -- 1,2,3 diseases
if cfg.max == 1 then
w = {1.0, 0.0, 0.0}
elseif cfg.max == 2 then
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 cityName = plainName(cityLink)
local nActive = chooseNActive(rnd, cfg)
local picked = pickNDiseasesBiased(nActive, rnd, cityBias[cityName])
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 per-disease base pop
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