Module:BassaridiaOutbreaks

From MicrasWiki
Revision as of 04:02, 24 May 2026 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
-- 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