Module:BassaridiaOutbreaks

From MicrasWiki
Revision as of 17:56, 21 October 2025 by NewZimiaGov (talk | contribs)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigationJump to search

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