Module:BassaridiaOutbreaks: Difference between revisions
From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs) Created page with "--------------------------------------------------------- -- Module: BassaridiaOutbreaks -- Daily outbreak tracker for Bassaridia Vaeringheim. -- Public functions: -- 1) outbreaksAll(frame) -> single big table for ALL cities -- 2) outbreaksForCity(frame) -> single table for a city -- 3) diseaseGlossary(frame) -> legend of disease names & controls -- -- Deterministic per-day values via math.randomseed(year*1000 + yday). -- City rows are further stabilized by..." |
NewZimiaGov (talk | contribs) No edit summary |
||
| (2 intermediate revisions by the same user not shown) | |||
| Line 2: | Line 2: | ||
-- Module: BassaridiaOutbreaks | -- Module: BassaridiaOutbreaks | ||
-- Daily outbreak tracker for Bassaridia Vaeringheim. | -- Daily outbreak tracker for Bassaridia Vaeringheim. | ||
-- | |||
-- Public functions: | -- 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. | |||
--------------------------------------------------------- | --------------------------------------------------------- | ||
| Line 14: | Line 18: | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- Date helpers & deterministic daily seed | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
local function getCurrentDateInfo() | local function getCurrentDateInfo() | ||
local startDate | local startDate = os.time({year=1999, month=8, day=6}) | ||
local secondsInDay= 86400 | local secondsInDay = 86400 | ||
local daysPerYear = 183 | 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 | |||
} | |||
return { psscYear = psscYear, dayOfYear = dayOfYear } | |||
end | end | ||
| Line 36: | Line 43: | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- 1. | -- 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 = { | 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 | end | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- 2. | -- 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) | local function severityFor(casesPer100k) | ||
if casesPer100k >= 60 then return "CRITICAL" | if casesPer100k >= 60 then | ||
elseif casesPer100k >= 25 then return "HIGH" | return "CRITICAL" | ||
elseif casesPer100k >= 10 then return "MEDIUM" | elseif casesPer100k >= 25 then | ||
elseif casesPer100k >= 1 then return "LOW" | return "HIGH" | ||
else return "NONE" end | elseif casesPer100k >= 10 then | ||
return "MEDIUM" | |||
elseif casesPer100k >= 1 then | |||
return "LOW" | |||
else | |||
return "NONE" | |||
end | |||
end | end | ||
local severityColors = { | local severityColors = { | ||
CRITICAL="#ffcccc", HIGH="#ffe0b2", MEDIUM="#fff9c4", LOW="#e0f2f1", NONE="#f2f2f2" | 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) | local function arrow(trend) | ||
if trend > 0.1 then return "▲" | if trend > 0.1 then | ||
elseif trend < -0.1 then return "▼" | return "▲" | ||
else return "▬" end | elseif trend < -0.1 then | ||
return "▼" | |||
else | |||
return "▬" | |||
end | |||
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 picked = | 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 totalCases = 0 | ||
local totalPopBase = 0 | local totalPopBase = 0 | ||
local maxSeverity = "NONE" | local maxSeverity = "NONE" | ||
local maxColor | local maxColor = severityColors.NONE | ||
local | |||
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 = totalCases + cases | ||
totalPopBase | totalPopBase = totalPopBase + popBase | ||
local per100k = (cases / popBase) * 100000 | local per100k = (cases / popBase) * 100000 | ||
local sev | local sev = severityFor(per100k) | ||
if sev | |||
if severityRank[sev] > severityRank[maxSeverity] then | |||
maxSeverity = sev | maxSeverity = sev | ||
maxColor | maxColor = severityColors[sev] | ||
end | end | ||
| Line 209: | Line 665: | ||
end | end | ||
local per100k = 0 | |||
local per100k = | if totalPopBase > 0 then | ||
per100k = (totalCases / totalPopBase) * 100000 | |||
end | |||
local diseasesCell = (#picked>0) and table.concat(names, ", ") or "—" | local Rt = (#picked > 0) and (1.0 + rnd() * 0.8) or (0.80 + rnd() * 0.15) | ||
local codesCell | local trend = (rnd() * 0.6 - 0.3) | ||
local advisory | 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 { | return { | ||
cityLink | cityLink = cityLinkText, | ||
diseases | diseases = diseasesCell, | ||
codes | codes = codesCell, | ||
cases24 | cases24 = totalCases, | ||
per100k | per100k = math.floor(per100k + 0.5), | ||
Rt | Rt = tonumber(string.format("%.2f", Rt)), | ||
trend | trend = trend, | ||
trendIcon | trendIcon = arrow(trend), | ||
positivity | positivity = posRate, | ||
severity | severity = maxSeverity, | ||
color | color = maxColor, | ||
advisory | advisory = advisory | ||
} | } | ||
end | end | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- Rendering | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
local function renderHeaderAll(pssc) | local function renderHeaderAll(pssc) | ||
local out = {} | local out = {} | ||
table.insert(out, "== Daily Outbreak Report ==\n") | 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, 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, '{| 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") | table.insert(out, "! City !! Active Diseases !! Codes !! 24h Cases !! /100k !! R<sub>t</sub> !! 7d Trend !! Positivity (%) !! Severity !! Advisory\n") | ||
return out | return out | ||
end | end | ||
| Line 250: | Line 712: | ||
return string.format( | return string.format( | ||
"|-\n| %s || %s || %s || %d || %d || %.2f || %s %.0f%% || %d || style=\"background:%s\" | %s || %s\n", | "|-\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.cityLink, | ||
row.trendIcon, math.abs(row.trend*100), row.positivity, row.color, row.severity, row.advisory | 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 | end | ||
| Line 260: | Line 732: | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- Public: ALL CITIES | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
function p.outbreaksAll(frame) | function p.outbreaksAll(frame) | ||
local cfg = getConfig(frame) | |||
local seed = getDailySeed() | local seed = getDailySeed() | ||
math.randomseed(seed) | math.randomseed(seed) | ||
| Line 269: | Line 744: | ||
local out = renderHeaderAll(d) | local out = renderHeaderAll(d) | ||
for i,entry in ipairs(cityData) do | for i, entry in ipairs(cityData) do | ||
math.randomseed(seed + i * 97) | |||
math.randomseed(seed + i*97) | local function rnd() | ||
local function rnd() return math.random() end | return math.random() | ||
local row = synthCityRow(rnd, entry.city) | end | ||
local row = synthCityRow(rnd, entry.city, cfg) | |||
table.insert(out, renderRowAll(row)) | table.insert(out, renderRowAll(row)) | ||
end | end | ||
table.insert(out, renderFooter()) | table.insert(out, renderFooter()) | ||
return table.concat(out) | return table.concat(out) | ||
end | end | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- Public: SINGLE CITY | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
function p.outbreaksForCity(frame) | function p.outbreaksForCity(frame) | ||
local q = frame.args.city | local q = frame.args.city | ||
if not q or q == "" then | if not q or q == "" then | ||
return "Error: Please specify a city. E.g. {{#invoke:BassaridiaOutbreaks|outbreaksForCity|city=Vaeringheim}}" | return "Error: Please specify a city. E.g. {{#invoke:BassaridiaOutbreaks|outbreaksForCity|city=Vaeringheim}}" | ||
end | end | ||
local cfg = getConfig(frame) | |||
local seed = getDailySeed() | 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 | |||
if entry.city == q or plain == q then | break | ||
idx = i | |||
end | end | ||
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 = {} | local out = {} | ||
table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"\n') | 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, "! 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, renderRowAll(row)) | ||
table.insert(out, renderFooter()) | table.insert(out, renderFooter()) | ||
return table.concat(out) | return table.concat(out) | ||
end | end | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- Public: Glossary | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
function p.diseaseGlossary(frame) | function p.diseaseGlossary(frame) | ||
local out = {} | local out = {} | ||
table.insert(out, "=== Disease Glossary & Standard Controls ===\n") | table.insert(out, "=== Disease Glossary & Standard Controls ===\n") | ||
table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"\n') | table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"\n') | ||
table.insert(out, "! Code !! Disease !! Typical Controls\n") | table.insert(out, "! Code !! Disease !! Typical Controls\n") | ||
for _,d in ipairs(diseases) do | |||
for _, d in ipairs(diseases) do | |||
table.insert(out, string.format("|-\n| %s || %s || %s\n", d.code, d.name, d.controls)) | table.insert(out, string.format("|-\n| %s || %s || %s\n", d.code, d.name, d.controls)) | ||
end | end | ||
table.insert(out, "|}\n") | table.insert(out, "|}\n") | ||
return table.concat(out) | return table.concat(out) | ||
end | end | ||
return p | return p | ||
Latest revision as of 04:02, 24 May 2026
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