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 |
||
| (One intermediate revision by the same user not shown) | |||
| Line 1: | Line 1: | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- Module: BassaridiaOutbreaks | -- Module: BassaridiaOutbreaks (UPDATED) | ||
-- 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 | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
| Line 14: | Line 11: | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- Date helpers & deterministic daily seed | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
local function getCurrentDateInfo() | local function getCurrentDateInfo() | ||
| Line 20: | Line 17: | ||
local secondsInDay= 86400 | local secondsInDay= 86400 | ||
local daysPerYear = 183 | local daysPerYear = 183 | ||
local now = os.time() | local now = os.time() | ||
local totalDays = math.floor((now - startDate) / secondsInDay) | local totalDays = math.floor((now - startDate) / secondsInDay) | ||
| Line 26: | Line 22: | ||
local psscYear = math.floor(fraction) | local psscYear = math.floor(fraction) | ||
local dayOfYear = math.floor((fraction - psscYear) * daysPerYear) + 1 | local dayOfYear = math.floor((fraction - psscYear) * daysPerYear) + 1 | ||
return { psscYear = psscYear, dayOfYear = dayOfYear } | return { psscYear = psscYear, dayOfYear = dayOfYear } | ||
end | end | ||
| Line 36: | Line 31: | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- 1. | -- 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 = { | local diseases = { | ||
{name="Thalassan Rot", code="TR", w=10, r=1.20, controls="canal flush, rat-proofing, food-handler checks"}, | |||
{name="Thalassan Rot", | {name="Breath of Nephele", code="BN", w=9, r=1.12, controls="ventilation drills, mask catechism, spacing"}, | ||
{name=" | {name="Haifan Salt-Sickness", code="HS", w=6, r=1.10, controls="indoor rotations, antifungal clinics, dehumidify"}, | ||
{name="Pilgrim’s Flux", | {name="Pilgrim’s Flux", code="PF", w=9, r=1.15, controls="boil notices, cistern audit, ORS distribution"}, | ||
{name="Stygian Dust", | {name="Stygian Dust", code="SD", w=5, r=1.05, controls="respirators, mist carts, guide clinic checks"}, | ||
{name=" | {name="Iylara’s Rash", code="IR", w=5, r=1.06, controls="drying powders, swamp barrier, glove protocols"}, | ||
{name="Norsolyrian Drain", | {name="Norsolyrian Drain", code="ND", w=6, r=1.18, controls="lined ditches, praziquantel, snail abatement"}, | ||
{name="Somniumpolis Lung", | {name="Somniumpolis Lung", code="SL", w=5, r=1.07, controls="dust suppression, respirator fit-checks"}, | ||
{name=" | {name="Sea-Cow Ague", code="SCA", w=3, r=1.02, controls="livestock PPE, pasteurize, vaccination drills"}, | ||
{name="Strip Yellowing", | {name="Ezeri Blisters", code="EB", w=3, r=1.00, controls="gloves for fishers, wound care, short exclusion"}, | ||
{name="Eye of Styx", | {name="Agnian Lockjaw", code="AL", w=3, r=0.95, controls="tetanus boosters, injury clinics"}, | ||
{name="Cato’s Curse", | {name="Caspazani Madness", code="CM", w=2, r=1.00, controls="PEP, bat-avoidance patrols, night rosters"}, | ||
{name="White Pestilence", | {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 | local function biasedWeights(baseList, biasCodes) | ||
if not biasCodes or #biasCodes==0 then return baseList end | |||
local | local boosted = {} | ||
for | 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 | |||
for | |||
end | end | ||
boosted[i] = {name=d.name, code=d.code, w=w, r=d.r, controls=d.controls} | |||
end | end | ||
return | return boosted | ||
end | 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 = { | 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) | local function plainName(link) | ||
return (link:match("%s+([^%]]+)%]$")) or link | return (link:match("%s+([^%]]+)%]$")) or link | ||
| Line 141: | Line 204: | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- Severity, colors, arrows | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
local function severityFor(casesPer100k) | local function severityFor(casesPer100k) | ||
| Line 162: | Line 225: | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- 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 | local function chooseNActive(rnd, cfg) | ||
-- | if rnd() > cfg.freq then return 0 end | ||
local | local w = {0.70, 0.25, 0.05} -- 1,2,3 diseases | ||
local | if cfg.max == 1 then | ||
if | w = {1.0, 0.0, 0.0} | ||
elseif | elseif cfg.max == 2 then | ||
local s = (0.70 + 0.25) | |||
else | 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 | |||
local picked = | --------------------------------------------------------- | ||
-- 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 names, short = {}, {} | ||
local totalCases = 0 | local totalCases, totalPopBase = 0, 0 | ||
local maxSeverity, maxColor = "NONE", severityColors.NONE | |||
local maxSeverity = "NONE" | local advices = {} | ||
local advices | |||
for _,d in ipairs(picked) do | for _,d in ipairs(picked) do | ||
local popBase = 100000 -- nominal per-disease base pop | |||
local popBase = 100000 -- nominal per disease | local base = (d.r * 10) | ||
local base = (d.r * 10) | |||
local jitter = math.floor(rnd()*base*3 + 0.5) | local jitter = math.floor(rnd()*base*3 + 0.5) | ||
local cases = math.max(0, math.floor(base + jitter)) | local cases = math.max(0, math.floor(base + jitter)) | ||
totalCases | totalCases = totalCases + cases | ||
totalPopBase | totalPopBase = totalPopBase + popBase | ||
local per100k = (cases / popBase) * 100000 | local per100k = (cases / popBase) * 100000 | ||
| Line 209: | Line 305: | ||
end | end | ||
local per100k = (totalPopBase>0) and (totalCases / totalPopBase) * 100000 or 0 | local per100k = (totalPopBase>0) and (totalCases / totalPopBase) * 100000 or 0 | ||
local Rt = (#picked>0) and (1.0 + rnd()*0.8) 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 trend = (rnd()*0.6 - 0.3) | ||
local posRate = (#picked>0) and (15 + math.floor(rnd()*25)) or ( | 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 diseasesCell = (#picked>0) and table.concat(names, ", ") or "—" | ||
| Line 220: | Line 315: | ||
return { | return { | ||
cityLink | cityLink = cityLink, | ||
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) | ||
| Line 255: | Line 350: | ||
end | end | ||
local function renderFooter() | local function renderFooter() return "|}\n" end | ||
end | |||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- 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 270: | Line 364: | ||
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() return math.random() end | local function rnd() return math.random() end | ||
local row = synthCityRow(rnd, entry.city) | local row = synthCityRow(rnd, entry.city, cfg) | ||
table.insert(out, renderRowAll(row)) | table.insert(out, renderRowAll(row)) | ||
end | end | ||
| Line 282: | Line 375: | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- Public: SINGLE CITY | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
function p.outbreaksForCity(frame) | function p.outbreaksForCity(frame) | ||
| Line 289: | Line 382: | ||
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, link | local idx, link | ||
for i,entry in ipairs(cityData) do | for i,entry in ipairs(cityData) do | ||
local plain = plainName(entry.city) | local plain = plainName(entry.city) | ||
if entry.city == q or plain == q then | if entry.city == q or plain == q then idx = i; link = entry.city; break end | ||
end | end | ||
if not idx then return "Error: City '"..q.."' not found." end | if not idx then return "Error: City '"..q.."' not found." end | ||
| Line 304: | Line 394: | ||
math.randomseed(seed + idx*97) | math.randomseed(seed + idx*97) | ||
local function rnd() return math.random() end | local function rnd() return math.random() end | ||
local row = synthCityRow(rnd, link) | local row = synthCityRow(rnd, link, cfg) | ||
local out = {} | local out = {} | ||
| Line 315: | Line 405: | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
-- | -- Public: Glossary | ||
--------------------------------------------------------- | --------------------------------------------------------- | ||
function p.diseaseGlossary(frame) | function p.diseaseGlossary(frame) | ||
| Line 329: | Line 419: | ||
end | end | ||
return p | return p | ||
Latest revision as of 17:56, 21 October 2025
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