Module:WarLeagueDivisionOpsBVR

From MicrasWiki
Revision as of 21:15, 1 January 2026 by NewZimiaGov (talk | contribs)
Jump to navigationJump to search

Documentation for this module may be created at Module:WarLeagueDivisionOpsBVR/doc

-- Module:WarLeagueDivisionOpsBVR
-- Daily, in-world War League divisional operations log.
-- Integrates Module:BassaridianDailyEvents by parsing today’s city-event/urgency table output.
-- Includes division + city flag icons with a safe fallback to the Imperial Trade Union flag.
-- Caledonian city deployments are handled EXCLUSIVELY by the Council of Kings Division (CKD),
-- and CKD’s sole purpose is Caledonian deployments.
--
-- UPDATE (Ops Alert integration):
--  - Pulls deterministic Ops Alert + Ops Impact from Module:BassaridiaForecast (if available)
--  - Adds Ops Alert + Ops Impact columns to the War League table
--  - Weather (Orange/Red) can become the event trigger if "No reports"
--  - Weather can raise urgency floor (Orange=>MEDIUM, Red=>HIGH)
--  - Sea Closed / Air No-Go forces weather delay posture and suppresses those force lines

local p = {}

--------------------------------------------------------------------------------
-- (A) PSSC DATE (same logic used by Module:BassaridianDailyEvents)
--------------------------------------------------------------------------------
local function getPSSCDate()
    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 yearFraction  = totalDays / daysPerYear
    local psscYear      = math.floor(yearFraction)
    local dayOfYear     = math.floor((yearFraction - psscYear) * daysPerYear) + 1

    local month, dayOfMonth
    if dayOfYear <= 61 then
        month = 1; dayOfMonth = dayOfYear
    elseif dayOfYear <= 122 then
        month = 2; dayOfMonth = dayOfYear - 61
    else
        month = 3; dayOfMonth = dayOfYear - 122
    end

    local dateString = string.format("%d/%d/%d PSSC", dayOfMonth, month, psscYear)
    return {
        text      = dateString,
        year      = psscYear,
        dayOfYear = dayOfYear,
        month     = month,
        dom       = dayOfMonth
    }
end

--------------------------------------------------------------------------------
-- (B) HELPERS
--------------------------------------------------------------------------------
local function pick(t) return t[math.random(#t)] end

local function pickWeighted(list)
    local total = 0
    for _, it in ipairs(list) do total = total + (it.weight or 1) end
    local r = math.random() * total
    local acc = 0
    for _, it in ipairs(list) do
        acc = acc + (it.weight or 1)
        if r <= acc then return it end
    end
    return list[#list]
end

local function bulletList(items)
    if not items or #items == 0 then return "" end
    return "<ul><li>" .. table.concat(items, "</li><li>") .. "</li></ul>"
end

local function cityLink(cityName, anchorOverride)
    local anchor = anchorOverride or cityName
    return string.format("[[List_of_cities_in_Bassaridia_Vaeringheim#%s|%s]]", anchor, cityName)
end

local function opId(di, divCode, idx)
    return string.format("WL-%03d.%03d-%s-%02d", di.year, di.dayOfYear, divCode, idx)
end

--------------------------------------------------------------------------------
-- (C) FLAG FALLBACK (use ITU flag when missing)
--------------------------------------------------------------------------------
local DEFAULT_FLAG = "Imperial_Trade_Union_flag.png"
local function flagIcon(fileName, sizePx)
    local size = tonumber(sizePx) or 20
    local f = (fileName and fileName ~= "") and fileName or DEFAULT_FLAG
    return string.format("[[File:%s|%dpx]]", f, size)
end

--------------------------------------------------------------------------------
-- (D) CALEDONIAN SET (CKD ONLY)
--------------------------------------------------------------------------------
local CALEDONIAN_CITIES = {
    ["Fanghorn"]   = true,
    ["Eikbu"]      = true,
    ["Galvø"]      = true,
    ["Sårensby"]   = true,
    ["Slevik"]     = true,
    ["Sjøsborg"]   = true,
    ["Storesund"]  = true,
    ["Notranskja"] = true,
    ["Kaledonija"] = true,
}

--------------------------------------------------------------------------------
-- (E) INTEGRATE DAILY EVENTS (parse Module:BassaridianDailyEvents.makeTable())
--------------------------------------------------------------------------------
local function buildEventMapForToday()
    local ok, daily = pcall(require, "Module:BassaridianDailyEvents")
    if not ok or not daily or type(daily.makeTable) ~= "function" then
        return {}, false
    end
    local tbl = daily.makeTable() -- rows: "| date || city || event || urgency"
    local eventMap = {}
    for line in tostring(tbl):gmatch("[^\n]+") do
        if line:match("^|%s") and not line:match("^|%-%s*$") then
            local parts = mw.text.split(line:gsub("^|%s*", ""), "%s*||%s*")
            if #parts >= 4 then
                local city = parts[2]
                local ev   = parts[3]
                local urg  = parts[4]
                if city and city ~= "" and not eventMap[city] then
                    eventMap[city] = { desc = ev, urgency = urg }
                end
            end
        end
    end
    return eventMap, true
end

local function urgencyScore(urg)
    if urg == "CRITICAL" then return 4 end
    if urg == "HIGH"     then return 3 end
    if urg == "MEDIUM"   then return 2 end
    if urg == "LOW"      then return 1 end
    return 0
end

local function urgencyMax(a, b)
    local sa, sb = urgencyScore(a), urgencyScore(b)
    if sb > sa then return b end
    return a
end

--------------------------------------------------------------------------------
-- (WX) OPTIONAL: deterministic Ops Alert from Module:BassaridiaForecast
--------------------------------------------------------------------------------
local wxOk, wx = pcall(require, "Module:BassaridiaForecast")
if not wxOk then wx = nil end

local function wxColor(level)
    level = tonumber(level) or 0
    if level == 3 then return "#f8d7da" end
    if level == 2 then return "#ffe5cc" end
    if level == 1 then return "#fff3cd" end
    return "#d4edda"
end

local function wxBadge(level, label, cond)
    local bg = wxColor(level)
    label = tostring(label or "Green")
    cond  = tostring(cond or "")
    return string.format(
        "<span style='background:%s;padding:2px 6px;border:1px solid #ccc;border-radius:10px;white-space:nowrap;'>%s</span>%s",
        bg, label, (cond ~= "" and ("<br/><span style=\"font-size:90%;\">" .. cond .. "</span>") or "")
    )
end

local function wxImpactText(imp)
    imp = imp or {}
    return string.format("Gnd: %s; Sea: %s; Air: %s", imp.ground or "OK", imp.sea or "OK", imp.air or "OK")
end

local function getWx(cityName, di)
    if not wx or type(wx.getOpsData) ~= "function" then return nil end
    return wx.getOpsData(cityName, di.year, di.dayOfYear)
end

local function floorUrgencyFromWx(level)
    level = tonumber(level) or 0
    if level >= 3 then return "HIGH" end
    if level == 2 then return "MEDIUM" end
    if level == 1 then return "LOW" end
    return nil
end

--------------------------------------------------------------------------------
-- (F) FORCE ORGANIZATION
--------------------------------------------------------------------------------
local GROUND_ECHELONS = {
    { name="Turmae",        weight=5 },
    { name="Contubernia",   weight=5 },
    { name="Ordinis",       weight=4 },
    { name="Manipuli",      weight=3 },
    { name="Centuriae",     weight=2 },
    { name="Cohortes",      weight=1 },
}
local NAVAL_ECHELONS = {
    { name="Birlik-i Gemi (Patrol Element)", weight=5 },
    { name="Tabur-i Derya (Task Unit)",      weight=3 },
    { name="Ordu (Task Group)",              weight=1 },
}
local AIR_ECHELONS = {
    { name="Zeygi (Element)",   weight=5 },
    { name="Ptisis (Flight)",   weight=3 },
    { name="Moira (Squadron)",  weight=1 },
}

local function pickEchelonForUrgency(echelons, uScore)
    local tuned = {}
    for _, e in ipairs(echelons) do
        local w = e.weight or 1
        if uScore >= 3 then
            if e.name:match("Cohortes") or e.name:match("Ordu %(") or e.name:match("Moira") then
                w = w + 3
            elseif e.name:match("Centuriae") or e.name:match("Tabur%-i Derya") or e.name:match("Ptisis") then
                w = w + 2
            end
        elseif uScore == 2 then
            if e.name:match("Centuriae") or e.name:match("Tabur%-i Derya") or e.name:match("Ptisis") then
                w = w + 1
            end
        end
        table.insert(tuned, { name = e.name, weight = w })
    end
    return pickWeighted(tuned).name
end

--------------------------------------------------------------------------------
-- (G) FLAGS (only known files; everything else auto-falls back to ITU)
--------------------------------------------------------------------------------
local CITY_FLAGS = {
    -- Vaeringheim investor belt
    ["Vaeringheim"]      = "VaeringheimFlag.png",
    ["Lunalis Sancta"]   = "LunalisSanctaFlag.png",
    ["Luminaria"]        = "LuminariaFlag.png",
    ["Serena"]           = "SerenaFlag.png",
    ["Symphonara"]       = "SymphonaraFlag.png",
    ["Aurelia"]          = "AureliaFlag.png",
    ["Sylvapolis"]       = "SylvapolisFlag.png",
    ["Delphica"]         = "DelphicaFlag.png",
    ["Catonis Atrium"]   = "CatonisAtriumFlag.png",
    ["Ferrum Citadel"]   = "FerrumCitadelFlag.png",
    -- Alpazkigz belt
    ["Pyralis"]          = "PyralisFlag.png",
    ["Nexa"]             = "NexaFlag.png",
    ["Saluria"]          = "SaluriaFlag.png",
    ["Acheron"]          = "AcheronFlag.png",
    ["Koinonía"]         = "KoinoniaFlag.png",
    ["Aureum"]           = "AureumFlag.png",
    ["Aetherium"]        = "AetheriumFlag.png",
    ["Erythros"]         = "ErythrosFlag.png",
    -- Odiferia
    ["Somniumpolis"]     = "SomniumpolisFlag.png",
    ["Odiferia"]         = "OdiferiaFlag.png",

    -- Hafaan / Jeseri (if they exist; otherwise fallback)
    ["Hafaan"]           = "HafaanFlag.png",
    ["Jeseri"]           = "JeseriFlag.png",

    -- NSJ
    ["Skýrophos"]        = "SkyrophosFlag.png",
    ["Bjornopolis"]      = "BjornopolisFlag.png",
    ["Aegirheim"]        = "AegirheimFlag.png",
    ["Norsolyra"]        = "NorsolyraFlag.png",
    ["Thorsalon"]        = "ThorsalonFlag.png",
    ["Myrene"]           = "MyreneFlag.png",
    ["Ephyra"]           = "EphyraFlag.png",
    ["Thyrea"]           = "ThyreaFlag.png",
    ["Pelagia"]          = "PelagiaFlag.png",
    ["Halicarn"]         = "HalicarnFlag.png",

    -- Haifan
    ["Keybir-Aviv"]      = "KeybirAvivFlag.png",
    ["Tel-Amin"]         = "TelAminFlag.png",
    ["Jogi"]             = "JogiFlag.png",
    ["Lewisburg"]        = "LewisburgFlag.png",
    ["Diamandis"]        = "DiamandisFlag.png",
    ["Thermosalem"]      = "ThermosalemFlag.png",
    ["Akróstadium"]      = "AkrostadiumFlag.png",
    ["Lykopolis"]        = "LykopolisFlag.png",
    ["Sufriya"]          = "SufriyaFlag.png",

    -- Normark + Ouriana
    ["Riddersborg"]      = "RiddersborgFlag.png",
    ["Ardclach"]         = "ArdclachFlag.png",
    ["Ourid"]            = "Ouridflag.png",
    ["Bashkim"]          = "BashkimFlag.png",
    -- Tonar flag unknown -> fallback by omission
}

local DIVISION_FLAGS = {
    ["Vaeringheim Division"]         = "VaeringheimDivisionFlag.png",
    ["Alpazkigz Division"]           = "AlpazkigzDivisionFlag.png",
    ["Hatch Ministry Division"]      = "HatchMinistryFlag.png",
    ["New South Jangsong Division"]  = "NewSouthJangsongFlag.png",
    ["Haifan Bassaridia Division"]   = "HaifaDivisionFlag.png",
    ["Bassaridian Normark Division"] = "BassaridianNormarkFlagB.png",
    ["Ouriana Division"]             = "OurianaFlag.png",
    -- Odiferia Division / CKD: omit unless you have dedicated division flags; fallback applies
}

--------------------------------------------------------------------------------
-- (H) DIVISIONS + CITY POOLS
-- NOTE: Caledonian cities removed from all divisions except CKD.
-- CKD's sole purpose: Caledonian deployments.
--------------------------------------------------------------------------------
local DIVISIONS = {
    { name="Vaeringheim Division",        link="[[Vaeringheim Division]]",        code="VAE", weight=5,
      cities={"Vaeringheim","Luminaria","Serena","Symphonara","Aurelia","Somniumpolis","Nexa","Lunalis Sancta","Sylvapolis","Delphica","Catonis Atrium","Ferrum Citadel"} },

    { name="Alpazkigz Division",          link="[[Alpazkigz Division]]",          code="ALP", weight=4,
      cities={"The Alpazkigz","Pyralis","Nexa","Saluria","Acheron","Koinonía","Aureum","Aetherium","Erythros"} },

    { name="Odiferia Division",           link="[[Odiferia Division]]",           code="ODF", weight=3,
      cities={"Odiferia","Somniumpolis","Aetherium","Ferrum Citadel","Aurelia","Symphonara"} },

    { name="Hatch Ministry Division",     link="[[Hatch Ministry Division]]",     code="HAT", weight=3,
      cities={"Lewisburg","Lykopolis","Jogi","Keybir-Aviv","Tel-Amin","Diamandis","Thermosalem","Akróstadium","Sufriya"} },

    { name="New South Jangsong Division", link="[[New South Jangsong Division]]", code="NSJ", weight=3,
      cities={"Skýrophos","Bjornopolis","Aegirheim","Norsolyra","Thorsalon","Pelagia","Myrene","Thyrea","Ephyra","Halicarn"} },

    { name="Haifan Bassaridia Division",  link="[[Haifan Bassaridia Division]]",  code="HFA", weight=3,
      cities={"Keybir-Aviv","Tel-Amin","Diamandis","Jogi","Lewisburg","Thermosalem","Akróstadium","Sufriya","Lykopolis"} },

    { name="Bassaridian Normark Division",link="[[Bassaridian Normark Division]]",code="BNO", weight=2,
      cities={"Ardclach","Riddersborg"} },

    { name="Ouriana Division",            link="[[Ouriana Division]]",            code="OUR", weight=2,
      cities={"Bashkim","Ourid","Tonar"} },

    { name="Council of Kings Division",   link="[[Council of Kings Division]]",   code="CKD", weight=1,
      cities={"Fanghorn","Eikbu","Galvø","Sårensby","Slevik","Sjøsborg","Storesund","Notranskja","Kaledonija"} },
}

--------------------------------------------------------------------------------
-- (I) Guardrail: if a Caledonian city ever appears elsewhere, force CKD
--------------------------------------------------------------------------------
local function forceCKDIfCaledonian(div, cityName)
    if CALEDONIAN_CITIES[cityName] and div.code ~= "CKD" then
        for _, d in ipairs(DIVISIONS) do
            if d.code == "CKD" then
                return d
            end
        end
    end
    return div
end

--------------------------------------------------------------------------------
-- (J) OPERATION TYPES
--------------------------------------------------------------------------------
local OP_GROUND = {
    { name="corridor security (rail / canal / road checkpointing)", weight=4 },
    { name="urban stabilization (cordon / curfew enforcement)",      weight=3 },
    { name="anti-smuggling interdiction / contraband seizure",       weight=3 },
    { name="infrastructure protection (port/bridge/aqueduct)",       weight=3 },
    { name="counter-insurgency sweep (cell disruption)",             weight=2 },
    { name="hazard response (evacuation / debris clearance)",        weight=2 },
    { name="convoy escort (supply / detainee / artifact transfer)",  weight=2 },
}
local OP_NAVAL = {
    { name="maritime escort / lake patrol routing",                 weight=3 },
    { name="VBSS / interdiction / contraband boarding",             weight=3 },
    { name="blockade enforcement / straits compliance sweep",       weight=2 },
    { name="harbor security (lock-gates / inner basin patrol)",     weight=2 },
}
local OP_AIR = {
    { name="ISR overwatch / route scanning",                        weight=4 },
    { name="rapid extraction / medevac lift",                       weight=2 },
    { name="strike deterrence package (show of force)",             weight=1 },
}

--------------------------------------------------------------------------------
-- (K) OVERSIGHT + COMPLIANCE
--------------------------------------------------------------------------------
local OVERSIGHT = {
    { label="Senate of Elders liaison",                 weight=3 },
    { label="Port compliance inspector (HCE)",          weight=4 },
    { label="Temple Aprobelle social-harmony auditor",  weight=3 },
    { label="Straits Convention monitor (civilian)",    weight=3 },
    { label="No observer (weather delay)",              weight=1 },
}
local COMPLIANCE = {
    { label="Deployment verified; corridor routing approved; ledger notes filed same-day",                  weight=6 },
    { label="Minor variance corrected; revised routing issued with escort buffer",                          weight=2 },
    { label="Weather delay; holding pattern under local command until visibility improves",                weight=1 },
    { label="Mission deferred (no-go); export-control check triggered by false manifest",                  weight=1 },
    { label="Export request refused (constitutional prohibition); material retained under Council custody", weight=1 },
}

local function rowColor(obs, comp, uScore)
    local o = (obs or ""):lower()
    local c = (comp or ""):lower()
    if o:match("weather delay") then
        return ' style="background-color:#fff3cd;"'
    end
    if c:match("minor variance") then
        return ' style="background-color:#fff3cd;"'
    end
    if c:match("export request refused") or c:match("mission deferred") then
        return ' style="background-color:#f8d7da;"'
    end
    if uScore >= 3 then
        return ' style="background-color:#f8d7da;"'
    end
    return ""
end

--------------------------------------------------------------------------------
-- (L) MAIN TABLE (flags + CKD-Caledonia enforcement + Ops Alert)
--------------------------------------------------------------------------------
function p.makeTable(frame)
    local di = getPSSCDate()
    local args = frame and frame.args or {}
    local rows = tonumber(args.rows) or 36
    local showLegend = tostring(args.showlegend or "yes"):lower() ~= "no"
    local flagSize = tonumber(args.flagsize) or 20

    local eventMap, hasEvents = buildEventMapForToday()

    local seed = tonumber(args.seed) or (di.year * 1000 + di.dayOfYear)
    math.randomseed(seed)

    local divFilter = tostring(args.division or ""):lower()
    local divPool = {}
    if divFilter ~= "" then
        for _, d in ipairs(DIVISIONS) do
            if d.name:lower():find(divFilter, 1, true) or d.code:lower():find(divFilter, 1, true) then
                table.insert(divPool, d)
            end
        end
        if #divPool == 0 then divPool = DIVISIONS end
    else
        divPool = DIVISIONS
    end

    local out = {}
    table.insert(out, "{| class='wikitable sortable'")
    table.insert(out,
        "! style='white-space:nowrap;' | Date" ..
        " !! Division" ..
        " !! City / Node" ..
        " !! Daily event trigger" ..
        " !! Urgency" ..
        " !! Ops Alert" ..
        " !! Ops Impact" ..
        " !! Operation" ..
        " !! Ground Forces" ..
        " !! Naval Forces" ..
        " !! Aerial Forces" ..
        " !! Oversight" ..
        " !! Compliance / Notes"
    )

    for i = 1, rows do
        local div = pickWeighted(divPool)
        local cityName = pick(div.cities)

        -- Guardrail: Caledonian city => CKD, and CKD => Caledonian city only
        div = forceCKDIfCaledonian(div, cityName)
        if div.code == "CKD" and not CALEDONIAN_CITIES[cityName] then
            cityName = pick(div.cities)
        end

        local divFlag = flagIcon(DIVISION_FLAGS[div.name], flagSize)
        local divCell = string.format("%s %s", divFlag, div.link)

        local cityFlag = flagIcon(CITY_FLAGS[cityName], flagSize)
        local cityCell = string.format("%s %s", cityFlag, cityLink(cityName, cityName))

        local ev = eventMap[cityName]
        local evDesc = ev and ev.desc or (hasEvents and "No reports" or "Events module unavailable")
        local urg = ev and ev.urgency or "N/A"
        local uScore = urgencyScore(urg)

        -- WX integration (Ops Alert / Impacts)
        local wxData = getWx(cityName, di)
        local wxLevel, wxLabel, wxCond, wxHaz, wxImp = 0, "Green", "", "", nil
        local opsAlertCell, opsImpactCell = "n/a", "n/a"

        if wxData and wxData.alert then
            wxLevel = tonumber(wxData.alert.level) or 0
            wxLabel = wxData.alert.label or "Green"
            wxCond  = wxData.condition or ""
            wxHaz   = wxData.alert.hazard or ""
            wxImp   = wxData.impacts
            opsAlertCell  = wxBadge(wxLevel, wxLabel, wxCond)
            opsImpactCell = wxImpactText(wxImp)
        end

        -- If no event is reported, allow weather to become the event driver on Orange/Red
        if wxLevel >= 2 and (evDesc == "No reports" or evDesc == "Events module unavailable") then
            evDesc = "Weather disruption: " .. ((wxHaz ~= "" and wxHaz) or (wxCond ~= "" and wxCond) or "elevated operational risk")
        end

        -- Raise urgency floor based on Wx alert (without suppressing event-driven urgency)
        local floor = floorUrgencyFromWx(wxLevel)
        if floor then
            if urg == "N/A" then
                urg = floor
            else
                urg = urgencyMax(urg, floor)
            end
        end
        uScore = urgencyScore(urg)

        -- Primary branch selection (naval weighting up for HAT/HFA)
        local primary = pickWeighted({
            { name="ground", weight = 6 },
            { name="naval",  weight = (div.code == "HAT" or div.code == "HFA") and 5 or 2 },
            { name="air",    weight = 2 + uScore },
        }).name

        local op = (primary == "naval") and pickWeighted(OP_NAVAL)
               or (primary == "air")  and pickWeighted(OP_AIR)
               or pickWeighted(OP_GROUND)

        -- WX: Bias operation choice when environment forces hold/hazard posture
        if wxLevel >= 2 and wxImp then
            if wxImp.sea == "Closed" then
                op = { name = "harbor security (lock-gates / inner basin patrol)" }
            end
            if wxLevel >= 3 and (wxImp.ground == "Hazard" or wxImp.air == "No-Go") then
                op = { name = "hazard response (evacuation / debris clearance)" }
            end
        end

        local ground, naval, air = "—","—","—"

        -- WX: suppress air/naval allocations if outright no-go/closed
        local allowNaval = not (wxImp and wxImp.sea == "Closed")
        local allowAir   = not (wxImp and wxImp.air == "No-Go")

        if not (primary == "air" and uScore <= 1 and math.random() < 0.35) then
            local gE = pickEchelonForUrgency(GROUND_ECHELONS, uScore)
            ground = string.format("%s (%s-%02d)", gE, div.code, math.random(1, 24))
        end

        if allowNaval and (primary == "naval"
            or (div.code == "HAT" and math.random() < 0.55)
            or (uScore >= 3 and math.random() < 0.35))
        then
            local nE = pickEchelonForUrgency(NAVAL_ECHELONS, uScore)
            naval = string.format("%s (%s-N%02d)", nE, div.code, math.random(1, 12))
        end

        if allowAir and (primary == "air" or (uScore >= 2 and math.random() < 0.55)) then
            local aE = pickEchelonForUrgency(AIR_ECHELONS, uScore)
            air = string.format("%s (%s-A%02d)", aE, div.code, math.random(1, 18))
        end

        local obs = pickWeighted(OVERSIGHT).label
        local comp = pickWeighted(COMPLIANCE).label

        -- WX: force “weather delay” compliance posture if Sea Closed / Air No-Go at Orange+
        if wxLevel >= 2 and wxImp and (wxImp.sea == "Closed" or wxImp.air == "No-Go") then
            obs = "No observer (weather delay)"
            comp = "Weather delay; holding pattern under local command until visibility improves"
        end

        local notes = bulletList({ "Op-ID: " .. opId(di, div.code, i) })
        local style = rowColor(obs, comp, uScore)

        table.insert(out, "|-" .. style)
        table.insert(out, string.format(
            "| %s || %s || %s || %s || %s || %s || %s || %s || %s || %s || %s || %s || %s",
            di.text, divCell, cityCell, evDesc, urg,
            opsAlertCell, opsImpactCell,
            op.name, ground, naval, air, obs, bulletList({ comp, notes })
        ))
    end

    table.insert(out, "|}")

    if showLegend then
        table.insert(out, "\n<div style='margin-top:0.5em; font-size:90%;'>")
        table.insert(out, "<b>Legend:</b> ")
        table.insert(out, "<span style='background:#d4edda;padding:2px 6px;border:1px solid #c3e6cb;'>Green</span> = routine/compliant&nbsp; ")
        table.insert(out, "<span style='background:#fff3cd;padding:2px 6px;border:1px solid #ffeeba;'>Yellow</span> = variance/adjustment/weather&nbsp; ")
        table.insert(out, "<span style='background:#f8d7da;padding:2px 6px;border:1px solid #f5c6cb;'>Red</span> = HIGH/CRITICAL or export-control/deferment")
        table.insert(out, "<br/>Ops Alert badge reflects forecast-driven operational risk (Green/Yellow/Orange/Red); Ops Impact summarizes ground/sea/air constraints.")
        table.insert(out, "</div>")
    end

    return table.concat(out, "\n")
end

--------------------------------------------------------------------------------
-- (M) OPTIONAL: show org ladders (for documentation / page footer)
--------------------------------------------------------------------------------
function p.showOrganization()
    return table.concat({
        '{| class="wikitable" style="width:100%;"',
        '! Branch !! Smallest → Largest',
        '|-',
        '| Ground || Turmae → Contubernia → Ordinis → Manipuli → Centuriae → Cohortes',
        '|-',
        '| Naval || Birlik-i Gemi (Patrol Element) → Tabur-i Derya (Task Unit) → Ordu (Task Group)',
        '|-',
        '| Aerial || Zeygi (Element) → Ptisis (Flight) → Moira (Squadron)',
        '|}',
    }, "\n")
end

return p