Module:BassaridiaOutbreaks
From MicrasWiki
Jump to navigationJump to search
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
--
-- Notes:
-- This version adds Noctic Vampirism as a Noctic-Rabrev
-- linked outbreak category, with stronger weighting in
-- Noctic-Rabrev, Alperkin, Odiferaen, Gloom Forest, and
-- Skødan highland cities.
---------------------------------------------------------
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 any city has an outbreak, 0..1.
-- Default 0.80.
-- max : cap concurrent diseases per city, 1..3.
-- Default 2.
--
-- Noctic-risk cities receive an additional city-specific
-- frequency bonus below. This means the user can still
-- lower or raise the entire table with |freq=, while
-- Noctic heartland cities remain relatively more likely.
---------------------------------------------------------
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"
}
local function canonicalCityName(name)
return cityAliases[name] or name
end
---------------------------------------------------------
-- Disease catalog
--
-- w = baseline selection weight.
-- r = baseline reproductive/pressure value used in case synthesis.
--
-- Noctic Vampirism is coded NRV to keep the Noctic-Rabrev
-- association visible in the outbreak 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"
},
{
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
--
-- Higher numbers mean:
-- 1. Greater chance the city has any active outbreak.
-- 2. Greater chance that an active outbreak includes NRV.
--
-- Tier 8: major Odiferaen/Noctic-Rabrev heartland.
-- Tier 7: sacred Noctic discovery / major Alperkin Noctic center.
-- Tier 6: major established Noctic-vampiric tradition.
-- Tier 4: Gloom Forest associated settlement.
-- Tier 3: culinary/ritual experimentation or urban Noctic usage.
---------------------------------------------------------
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
}
---------------------------------------------------------
-- Per-city disease biases
--
-- Standard city biases are boosted x3.
-- NRV is included in Noctic-risk cities and then receives
-- an additional Noctic-specific multiplier in the weighting
-- function below.
---------------------------------------------------------
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"}
}
---------------------------------------------------------
-- City data
---------------------------------------------------------
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")}
}
---------------------------------------------------------
-- 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
--
-- Noctic-risk cities are slightly more likely to have
-- any active outbreak on a given day. This does not force
-- NRV every day, but it does make Noctic heartlands more
-- active in the table.
---------------------------------------------------------
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 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
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 Vampirism is treated as a cluster-prone toxicological
-- and hereditary outbreak rather than an ordinary fast contagion.
-- Its totals remain comparable to the rest of the table, but
-- it receives a slightly wider range in high-risk cities.
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
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
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 = cityLinkText,
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 = 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