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 monitored cities
-- outbreaksForCity(frame) -> table for one monitored city
-- diseaseGlossary(frame) -> legend
--
-- Revision: Adds Thyrsan Bloom (THB), a parasitic botanical
-- infection endemic to the Southern Strait Corridor.
-- Saruq Thyrsa and Hayr Chaskal are monitored separately
-- as Southern Strait localities, without treating other
-- nearby territories as Bassaridian dependencies.
-- Existing Noctic Vampirism weighting is preserved.
---------------------------------------------------------
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
-- freq: base chance a city has at least one reported
-- disease cluster (0..1); default 0.80.
-- max: cap on 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
---------------------------------------------------------
-- Utility helpers
---------------------------------------------------------
local function clamp(n, lo, hi)
if n < lo then return lo end
if n > hi then return hi end
return n
end
local function contains(list, value)
if not list then return false end
for _, v in ipairs(list) do
if v == value then return true end
end
return false
end
local function cityLink(anchor, label)
return string.format("[[List of cities in Bassaridia Vaeringheim#%s|%s]]", anchor, label)
end
local function plainName(link)
local piped = link:match("%[%[[^|%]]+|([^%]]+)%]%]")
if piped then return piped end
local internal = link:match("%[%[([^%]]+)%]%]")
if internal then
local afterHash = internal:match("#(.+)$")
return afterHash or internal
end
local external = link:match("%[https?://[^%s%]]+%s+([^%]]+)%]")
if external then return external end
return link
end
local cityAliases = {
["Hammarfell"] = "Hamarrfell",
["Lunalis_Sancta"] = "Lunalis Sancta",
["Ferrum_Citadel"] = "Ferrum Citadel",
["Catonis_Atrium"] = "Catonis Atrium",
["Saruq_Thyrsa"] = "Saruq Thyrsa",
["Hayr_Chaskal"] = "Hayr Chaskal"
}
local function canonicalCityName(name)
return cityAliases[name] or name
end
---------------------------------------------------------
-- Disease catalog
-- w: baseline selection weight.
-- r: reporting pressure used in case synthesis.
-- THB is an environmentally acquired parasitic condition;
-- its reproductive ecology is not equivalent to R_t.
---------------------------------------------------------
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 = "[[Public health and disease in Bassaridia Vaeringheim#Thyrsan Bloom|Thyrsan Bloom]]",
code = "THB", w = 2, r = 0.42,
controls = "skin-abrasion care, contaminated-soil precautions, irrigation-site screening, rhizostatic treatment, root-network extraction"
},
{
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"
},
{
name = "[[Noctic Vampirism]]", code = "NRV", w = 4, r = 1.08,
controls = "raw [[Noctic-Rabrev]] leaf suspension, [[Temple Bank of the Reformed Stripping Path|Temple Bank]] registration check, cult-health evaluation, milk and blood-substitute stabilization, Noctic custody audit"
}
}
local diseaseIndex = {}
for i, d in ipairs(diseases) do
diseaseIndex[d.code] = i
end
---------------------------------------------------------
-- Noctic-Rabrev / Noctic Vampirism risk tiers
-- Noctic-specific odds and outbreak-frequency bonuses.
---------------------------------------------------------
local nocticRisk = {
["Odiferia"] = 8,
["Somniumpolis"] = 8,
["Erythros"] = 7,
["Acheron"] = 6,
["Norsolyra"] = 6,
["Skøda"] = 6,
["Nexa"] = 4,
["Pyralis"] = 4,
["Vaeringheim"] = 3,
["Jogi"] = 3,
["Aurelia"] = 3
}
---------------------------------------------------------
-- Thyrsan Bloom environmental risk tiers.
-- Only the two specified Southern Strait locations have
-- high endemic risk. Outside the corridor, rare imported
-- or environmentally linked clusters remain possible.
-- No uniform countrywide frequency bonus is imposed.
---------------------------------------------------------
local thyrsanRisk = {
["Saruq Thyrsa"] = 12,
["Hayr Chaskal"] = 10
}
---------------------------------------------------------
-- Per-city disease biases
-- Standard city biases are boosted x3.
---------------------------------------------------------
local cityBias = {
-- Capital, culinary Noctic usage, ports, canals, festivals
["Vaeringheim"] = {"TR", "WP", "FB", "SY", "NRV"},
-- Noctic-Rabrev / Alperkin / Gloom Forest / Odiferae centers
["Odiferia"] = {"NRV", "IR", "EiB", "ND"},
["Somniumpolis"] = {"NRV", "SL", "EiB", "ND"},
["Erythros"] = {"NRV", "SD", "ES", "FB"},
["Acheron"] = {"NRV", "SD", "ES"},
["Norsolyra"] = {"NRV", "ND", "HCF"},
["Nexa"] = {"NRV", "IR", "SY"},
["Pyralis"] = {"NRV", "SD", "AL"},
["Skøda"] = {"NRV", "BN", "PF", "SD"},
-- Ritual, culinary, and urban Noctic usage
["Jogi"] = {"NRV", "SY", "PF"},
["Aurelia"] = {"NRV", "FB", "SY"},
-- Haifan Bassaridia
["Tel-Amin"] = {"HS", "HCF", "WP"},
["Sufriya"] = {"HCF", "WP", "SY"},
["Keybir-Aviv"] = {"WP", "SY"},
["Thorsalon"] = {"WP"},
["Skýrophos"] = {"WP"},
-- Lake, marsh, forest, and wetland disease environments
["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"},
-- Valley of Keltia
["Bashkim"] = {"PF", "OB", "SY", "FB"},
["Ourid"] = {"PF", "OB"},
-- Inland mountainous / Caledonian dependencies
["Eikbu"] = {"BN", "PF", "SD"},
["Galvø"] = {"BN", "PF"},
["Fanghorn"] = {"BN", "PF", "SD"},
["Notranskja"] = {"BN", "PF"},
["Hamarrfell"] = {"BN", "PF", "SD"},
-- Southern Strait Corridor: botanical parasite and moist soils
["Saruq Thyrsa"] = {"THB", "PF", "IR"},
["Hayr Chaskal"] = {"THB", "PF", "TR"}
}
---------------------------------------------------------
-- City / locality roster.
-- Existing city positions are retained so their daily
-- deterministic seeds are not shifted by new localities.
---------------------------------------------------------
local cityData = {
-- BVR majors/minors
{city = cityLink("Vaeringheim", "Vaeringheim")},
{city = cityLink("Luminaria", "Luminaria")},
{city = cityLink("Serena", "Serena")},
{city = cityLink("Pyralis", "Pyralis")},
{city = cityLink("Symphonara", "Symphonara")},
{city = cityLink("Aurelia", "Aurelia")},
{city = cityLink("Somniumpolis", "Somniumpolis")},
{city = cityLink("Odiferia", "Odiferia")},
{city = cityLink("Nexa", "Nexa")},
{city = cityLink("Lunalis Sancta", "Lunalis Sancta")},
{city = cityLink("Sylvapolis", "Sylvapolis")},
{city = cityLink("Saluria", "Saluria")},
{city = cityLink("Aetherium", "Aetherium")},
{city = cityLink("Ferrum Citadel", "Ferrum Citadel")},
{city = cityLink("Acheron", "Acheron")},
{city = cityLink("Erythros", "Erythros")},
{city = cityLink("Catonis Atrium", "Catonis Atrium")},
{city = cityLink("Delphica", "Delphica")},
{city = cityLink("Koinonía", "Koinonía")},
{city = cityLink("Aureum", "Aureum")},
-- New South Jangsong
{city = cityLink("Skýrophos", "Skýrophos")},
{city = cityLink("Bjornopolis", "Bjornopolis")},
{city = cityLink("Aegirheim", "Aegirheim")},
{city = cityLink("Norsolyra", "Norsolyra")},
{city = cityLink("Thorsalon", "Thorsalon")},
{city = cityLink("Pelagia", "Pelagia")},
{city = cityLink("Myrene", "Myrene")},
{city = cityLink("Thyrea", "Thyrea")},
{city = cityLink("Ephyra", "Ephyra")},
{city = cityLink("Halicarn", "Halicarn")},
-- Haifan Bassaridia
{city = cityLink("Keybir-Aviv", "Keybir-Aviv")},
{city = cityLink("Tel-Amin", "Tel-Amin")},
{city = cityLink("Diamandis", "Diamandis")},
{city = cityLink("Jogi", "Jogi")},
{city = cityLink("Lewisburg", "Lewisburg")},
{city = cityLink("Thermosalem", "Thermosalem")},
{city = cityLink("Akróstadium", "Akróstadium")},
{city = cityLink("Sufriya", "Sufriya")},
{city = cityLink("Lykopolis", "Lykopolis")},
-- Bassaridian Normark
{city = cityLink("Ardclach", "Ardclach")},
{city = cityLink("Riddersborg", "Riddersborg")},
-- Caledonian dependencies and Eastern Caledonia
{city = cityLink("Notranskja", "Notranskja")},
{city = cityLink("Slevik", "Slevik")},
{city = cityLink("Fanghorn", "Fanghorn")},
{city = cityLink("Sårensby", "Sårensby")},
{city = cityLink("Skøda", "Skøda")},
{city = cityLink("Eikbu", "Eikbu")},
{city = cityLink("Galvø", "Galvø")},
{city = cityLink("Krlsgorod", "Krlsgorod")},
{city = cityLink("Kaledonija", "Kaledonija")},
{city = cityLink("Hamarrfell", "Hamarrfell")},
-- Valley of Keltia
{city = cityLink("Ourid", "Ourid")},
{city = cityLink("Bashkim", "Bashkim")},
-- Southern Strait Corridor monitored localities
{city = "Saruq Thyrsa"},
{city = "Hayr Chaskal"}
}
---------------------------------------------------------
-- Severity, colors, and trend 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 severityRank = {
NONE = 0, LOW = 1, MEDIUM = 2, HIGH = 3, CRITICAL = 4
}
local function arrow(trend)
if trend > 0.1 then
return "▲"
elseif trend < -0.1 then
return "▼"
else
return "▬"
end
end
---------------------------------------------------------
-- Outbreak-frequency adjustment
-- Retain existing Noctic logic; THB's environmental risk
-- affects selection, not generic outbreak frequency.
---------------------------------------------------------
local function cityOutbreakFrequency(cityName, cfg)
local risk = nocticRisk[cityName] or 0
local bonus = risk * 0.02
return clamp(cfg.freq + bonus, 0, 0.98)
end
---------------------------------------------------------
-- Weighted disease picker
---------------------------------------------------------
local function boostedDiseasePool(cityName)
local biasCodes = cityBias[cityName] or {}
local risk = nocticRisk[cityName] or 0
local thyrsan = thyrsanRisk[cityName] or 0
local pool = {}
for i, d in ipairs(diseases) do
local w = d.w
if contains(biasCodes, d.code) then
w = w * 3
end
if d.code == "NRV" and risk > 0 then
w = w * (1 + risk)
end
if d.code == "THB" and thyrsan > 0 then
w = w * (1 + thyrsan)
end
pool[i] = {
name = d.name,
code = d.code,
w = w,
r = d.r,
controls = d.controls
}
end
return pool
end
local function pickNDiseasesBiased(n, rnd, cityName)
local pool = boostedDiseasePool(cityName)
local chosen = {}
local used = {}
for _ = 1, n do
local total = 0
for i, d in ipairs(pool) do
if not used[i] then
total = total + d.w
end
end
if total <= 0 then break end
local roll = rnd() * total
local acc = 0
local pick = 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
---------------------------------------------------------
local function chooseNActive(rnd, cfg, cityName)
local freq = cityOutbreakFrequency(cityName, cfg)
if rnd() > freq then
return 0
end
local w = {0.70, 0.25, 0.05}
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
---------------------------------------------------------
local function synthCityRow(rnd, cityLinkText, cfg)
local cityName = canonicalCityName(plainName(cityLinkText))
local nActive = chooseNActive(rnd, cfg, cityName)
local picked = pickNDiseasesBiased(nActive, rnd, cityName)
local names = {}
local short = {}
local advices = {}
local totalCases = 0
local totalPopBase = 0
local maxSeverity = "NONE"
local maxColor = severityColors.NONE
for _, d in ipairs(picked) do
local popBase = 100000
local base = d.r * 10
local jitter = math.floor(rnd() * base * 3 + 0.5)
local cases = math.max(0, math.floor(base + jitter))
-- Noctic cluster excess retained from original module.
if d.code == "NRV" and (nocticRisk[cityName] or 0) > 0 then
local risk = nocticRisk[cityName] or 0
cases = cases + math.floor(rnd() * risk * 2)
end
-- THB counts are new reports of local environmental
-- infection, NOT secondary cases from person-to-person
-- transmission. Lower totals suit its chronic presentation.
if d.code == "THB" then
if (thyrsanRisk[cityName] or 0) > 0 then
cases = 2 + math.floor(rnd() * 7)
else
cases = 1 + math.floor(rnd() * 3)
end
end
totalCases = totalCases + cases
totalPopBase = totalPopBase + popBase
local per100k = (cases / popBase) * 100000
local sev = severityFor(per100k)
if severityRank[sev] > severityRank[maxSeverity] 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 = 0
if totalPopBase > 0 then
per100k = (totalCases / totalPopBase) * 100000
end
-- R_t cannot represent the ecological reproductive process
-- of Thyrsan Bloom. Use N/A if it is the sole active disease.
local solelyThyrsan = (#picked == 1 and picked[1].code == "THB")
local Rt = (#picked > 0) and (1.0 + rnd() * 0.8) or (0.80 + rnd() * 0.15)
local RtDisplay = solelyThyrsan and "N/A" or string.format("%.2f", Rt)
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 = cityLinkText,
diseases = diseasesCell,
codes = codesCell,
cases24 = totalCases,
per100k = math.floor(per100k + 0.5),
Rt = RtDisplay,
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 || %s || %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 MONITORED 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 = nil
local link = nil
local qCanonical = canonicalCityName(q)
for i, entry in ipairs(cityData) do
local plain = canonicalCityName(plainName(entry.city))
if entry.city == q or plain == q or plain == qCanonical 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