Module:BassaridiaOutbreaks

From MicrasWiki
Revision as of 19:10, 28 September 2025 by NewZimiaGov (talk | contribs)
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
--
---------------------------------------------------------

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)
---------------------------------------------------------
local diseases = {
  {name="Thalassan Rot",        code="TR",  w=10, r=1.20, controls="canal flush, rat-proofing, food-handler checks"},
  {name="Festival Blush",       code="FB",  w=8,  r=1.25, controls="ring vaccination, crowd-spacing, dorm rotation"},
  {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="Haifan Salt-Sickness", code="HS",  w=6,  r=1.10, controls="indoor rotations, antifungal clinics, dehumidify"},
  {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="Haifan Crimson Fever", code="HCF", w=3,  r=1.30, controls="larval control, dusk patrols, vector abatement"},
  {name="Strip Yellowing",      code="SY",  w=10, r=1.10, controls="single-use ladles, vendor relicensing, recall"},
  {name="Eye of Styx",          code="ES",  w=4,  r=1.00, controls="eye-wash stations, visor compliance"},
  {name="Cato’s Curse",         code="CC",  w=4,  r=1.05, controls="PPE audits, solvent exposure controls"},
  {name="White Pestilence",     code="WP",  w=2,  r=1.40, controls="tiered quarantine, convoy assays, cordons"},
  {name="Breath of Nephele",    code="BN",  w=9,  r=1.12, controls="ventilation drills, mask catechism, spacing"},
  {name="Agnian Lockjaw",       code="AL",  w=3,  r=0.95, controls="tetanus boosters, injury clinics"},
}

local function pickNDiseases(n, rnd)
    local total = 0
    for _,d in ipairs(diseases) do total = total + d.w end
    local chosen, used = {}, {}
    local tries = 0
    while #chosen < n and tries < 50 do
        tries = tries + 1
        local roll = rnd() * total
        local acc = 0
        for i,d in ipairs(diseases) do
            acc = acc + d.w
            if roll <= acc then
                if not used[i] then
                    table.insert(chosen, d); used[i] = true
                end
                break
            end
        end
    end
    return chosen
end

---------------------------------------------------------
-- Cities (same link formatting as your weather module)
---------------------------------------------------------
local cityData = {
  {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]"},
  {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]"},
  {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]"},
  {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]"},
}

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

---------------------------------------------------------
-- Outbreak count logic (REDUCED ACTIVITY)
-- 1) With probability = cfg.freq, a city has an outbreak.
-- 2) If outbreak happens, choose 1–3 diseases with
--    weights heavily favoring single-disease rows.
--    Default max=2 to further reduce noise.
---------------------------------------------------------
local function chooseNActive(rnd, cfg)
    -- No outbreak in this city today?
    if rnd() > cfg.freq then return 0 end

    -- Base weights for 1,2,3 diseases
    local w = {0.70, 0.25, 0.05}

    -- Respect max cap
    local max = cfg.max
    if max == 1 then
        w = {1.0, 0.0, 0.0}
    elseif max == 2 then
        -- renormalize 1 & 2
        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 nActive = chooseNActive(rnd, cfg)
    local picked  = pickNDiseases(nActive, rnd)

    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
        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