Module:WarLeagueDivisionOpsBVR: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
Created page with "-- Module:WarLeagueDivisionOpsBVR -- Daily, in-world War League divisional operations log. -- Integrates Module:BassaridianDailyEvents by parsing today’s city-event/urgency table output. :contentReference[oaicite:2]{index=2} local p = {} -------------------------------------------------------------------------------- -- (A) PSSC DATE (same logic used by Module:BassaridianDailyEvents) :contentReference[oaicite:3]{index=3} ----------------------------------------------..."
 
NewZimiaGov (talk | contribs)
No edit summary
 
(16 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:WarLeagueDivisionOpsBVR
-- Module:WarLeagueDivisionOpsBVR
-- Daily, in-world War League divisional operations log.
-- Daily, in-world War League divisional operations log.
-- Integrates Module:BassaridianDailyEvents by parsing today’s city-event/urgency table output. :contentReference[oaicite:2]{index=2}
-- 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 = {}
local p = {}


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- (A) PSSC DATE (same logic used by Module:BassaridianDailyEvents) :contentReference[oaicite:3]{index=3}
-- (A) PSSC DATE (same logic used by Module:BassaridianDailyEvents)
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
local function getPSSCDate()
local function getPSSCDate(args)
    args = args or {}
 
    local overrideYear = tonumber(args.year or args.psscyear)
    local overrideDay  = tonumber(args.day or args.dayofyear)
 
    if overrideYear and overrideDay then
        if overrideDay < 1 then overrideDay = 1 end
        if overrideDay > 183 then overrideDay = 183 end
 
        local month, dayOfMonth
        if overrideDay <= 61 then
            month = 1
            dayOfMonth = overrideDay
        elseif overrideDay <= 122 then
            month = 2
            dayOfMonth = overrideDay - 61
        else
            month = 3
            dayOfMonth = overrideDay - 122
        end
 
        local dateString = string.format("%d/%d/%d PSSC", dayOfMonth, month, overrideYear)
        return {
            text      = dateString,
            year      = overrideYear,
            dayOfYear = overrideDay,
            month    = month,
            dom      = dayOfMonth
        }
    end
 
     local startDate    = os.time({year = 1999, month = 8, day = 6})
     local startDate    = os.time({year = 1999, month = 8, day = 6})
     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)
     local yearFraction  = totalDays / daysPerYear
     local yearFraction  = totalDays / daysPerYear
Line 21: Line 61:
     local month, dayOfMonth
     local month, dayOfMonth
     if dayOfYear <= 61 then
     if dayOfYear <= 61 then
         month = 1; dayOfMonth = dayOfYear
         month = 1
        dayOfMonth = dayOfYear
     elseif dayOfYear <= 122 then
     elseif dayOfYear <= 122 then
         month = 2; dayOfMonth = dayOfYear - 61
         month = 2
        dayOfMonth = dayOfYear - 61
     else
     else
         month = 3; dayOfMonth = dayOfYear - 122
         month = 3
        dayOfMonth = dayOfYear - 122
     end
     end


Line 65: Line 108:
end
end


local function hwLink(page, anchor, label)
local function opId(di, divCode, idx)
     if anchor and anchor ~= "" then
     return string.format("WL-%03d.%03d-%s-%02d", di.year, di.dayOfYear, divCode, idx)
        return string.format("[[%s#%s|%s]]", page, anchor, label or anchor)
end
     end
 
     return string.format("[[%s|%s]]", page, label or page)
--------------------------------------------------------------------------------
-- (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,
    ["Skøda"]      = true,
    ["Hamarrfell"] = true,
    ["Sårensby"]  = true,
    ["Slevik"]    = true,
    ["Sjøsborg"]   = true,
    ["Storesund"] = true,
    ["Notranskja"] = true,
    ["Kaledonija"] = true,
    ["Krlsgorod"]  = true,
}
 
--------------------------------------------------------------------------------
-- (D.1) HAIFAN BASSARIDIA / SOUTHERN STRAIT SET
--
-- CKD is NOT locked to these cities. This set only allows CKD to generate
-- special Southern Strait / former Maritime Markets operations when CKD happens
-- to appear in a Haifan Bassaridian city or node.
--------------------------------------------------------------------------------
local HAIFAN_BASSARIDIA_CITIES = {
    ["Keybir-Aviv"] = true,
    ["Tel-Amin"] = true,
     ["Diamandis"] = true,
     ["Jogi"] = true,
    ["Lewisburg"] = true,
    ["Thermosalem"] = true,
    ["Akróstadium"] = true,
    ["Sufriya"] = true,
    ["Lykopolis"] = true,
}
 
local function isHaifanBassaridiaCity(cityName)
    return HAIFAN_BASSARIDIA_CITIES[tostring(cityName or "")] == true
end
end


local function opId(di, divCode, idx)
local SOUTHERN_STRAIT_CITY_LIST = {
     return string.format("WL-%03d.%03d-%s-%02d", di.year, di.dayOfYear, divCode, idx)
    "Keybir-Aviv",
    "Tel-Amin",
    "Diamandis",
    "Jogi",
    "Lewisburg",
    "Thermosalem",
    "Akróstadium",
    "Sufriya",
     "Lykopolis"
}
 
local SOUTHERN_STRAIT_DIVISION_CODES = {
    ["HFA"] = true, -- Haifan Bassaridia Division
    ["HAT"] = true, -- Hatch Ministry Division
    ["CKD"] = true, -- Council of Kings Division, when operating in Southern Strait nodes
}
 
local function isSouthernStraitDivision(div)
    return div and SOUTHERN_STRAIT_DIVISION_CODES[tostring(div.code or "")] == true
end
end


local function clamp(n, lo, hi)
local function isSouthernStraitOnly(args)
     if n < lo then return lo end
     args = args or {}
     if n > hi then return hi end
     local z = mw.ustring.lower(tostring(args.zone or args.scope or args.region or args.theater or ""))
     return n
     return z == "southernstrait"
        or z == "southern strait"
        or z == "southern straits"
        or z == "haifan"
        or z == "haifan bassaridia"
        or z == "former maritime markets"
end
end


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- (C) INTEGRATE DAILY EVENTS (parse Module:BassaridianDailyEvents.makeTable()) :contentReference[oaicite:4]{index=4}
-- (E) INTEGRATE DAILY EVENTS (parse Module:BassaridianDailyEvents.makeTable())
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
local function buildEventMapForToday()
local function buildEventMapForToday(di)
     local ok, daily = pcall(require, "Module:BassaridianDailyEvents")
     local ok, daily = pcall(require, "Module:BassaridianDailyEvents")
     if not ok or not daily or type(daily.makeTable) ~= "function" then
     if not ok or not daily or type(daily.makeTable) ~= "function" then
Line 91: Line 205:
     end
     end


     -- DailyEvents reseeds RNG internally for the day (fine).
     local tbl
     local tbl = daily.makeTable() -- returns wikitable text with rows: "| date || city || event || urgency" :contentReference[oaicite:5]{index=5}
    if di and di.year and di.dayOfYear then
        tbl = daily.makeTable({
            args = {
                year = tostring(di.year),
                day = tostring(di.dayOfYear)
            }
        })
     else
        tbl = daily.makeTable()
    end
 
     local eventMap = {}
     local eventMap = {}
     for line in tostring(tbl):gmatch("[^\n]+") do
     for line in tostring(tbl):gmatch("[^\n]+") do
        -- match row lines (ignore headers and separators)
         if line:match("^|%s") and not line:match("^|%-%s*$") then
         if line:match("^|%s") and not line:match("^|%-%s*$") then
            -- split by "||"
             local parts = mw.text.split(line:gsub("^|%s*", ""), "%s*||%s*")
             local parts = mw.text.split(line:gsub("^|%s*", ""), "%s*||%s*")
             if #parts >= 4 then
             if #parts >= 4 then
                local date = parts[1]
                 local city = parts[2]
                 local city = parts[2]
                 local ev  = parts[3]
                 local ev  = parts[3]
                 local urg  = parts[4]
                 local urg  = parts[4]
                -- store only first occurrence per city (DailyEvents is one row per city) :contentReference[oaicite:6]{index=6}
                 if city and city ~= "" and not eventMap[city] then
                 if city and city ~= "" and not eventMap[city] then
                     eventMap[city] = { date = date, desc = ev, urgency = urg }
                     eventMap[city] = { desc = ev, urgency = urg }
                 end
                 end
             end
             end
Line 117: Line 236:


local function urgencyScore(urg)
local function urgencyScore(urg)
    -- align with DailyEvents urgency labels :contentReference[oaicite:7]{index=7}
     if urg == "CRITICAL" then return 4 end
     if urg == "CRITICAL" then return 4 end
     if urg == "HIGH"    then return 3 end
     if urg == "HIGH"    then return 3 end
Line 123: Line 241:
     if urg == "LOW"      then return 1 end
     if urg == "LOW"      then return 1 end
     return 0
     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
end


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- (D) FORCE ORGANIZATION (as requested)
-- (F) FORCE ORGANIZATION
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
local GROUND_ECHELONS = {
local GROUND_ECHELONS = {
Line 136: Line 302:
     { name="Cohortes",      weight=1 },
     { name="Cohortes",      weight=1 },
}
}
local NAVAL_ECHELONS = {
local NAVAL_ECHELONS = {
     { name="Birlik-i Gemi (Patrol Element)", weight=5 },
     { name="Birlik-i Gemi (Patrol Element)", weight=5 },
Line 142: Line 307:
     { name="Ordu (Task Group)",              weight=1 },
     { name="Ordu (Task Group)",              weight=1 },
}
}
local AIR_ECHELONS = {
local AIR_ECHELONS = {
     { name="Zeygi (Element)",  weight=5 },
     { name="Zeygi (Element)",  weight=5 },
Line 150: Line 314:


local function pickEchelonForUrgency(echelons, uScore)
local function pickEchelonForUrgency(echelons, uScore)
    -- uScore 0..4: higher -> more weight toward larger formations
     local tuned = {}
     local tuned = {}
     for _, e in ipairs(echelons) do
     for _, e in ipairs(echelons) do
         local w = e.weight or 1
         local w = e.weight or 1
        -- bias: larger formations have naturally lower weights; boost them with urgency
         if uScore >= 3 then
         if uScore >= 3 then
             if e.name:match("Cohortes") or e.name:match("Ordu %(") or e.name:match("Moira") then
             if e.name:match("Cohortes") or e.name:match("Ordu %(") or e.name:match("Moira") then
Line 172: Line 334:


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- (E) DIVISIONS + CITY POOLS (canonical names; links go to List_of_cities sections) :contentReference[oaicite:8]{index=8}
-- (G) FLAGS (only known files; everything else auto-falls back to ITU)
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
local CITY = {
local CITY_FLAGS = {
     -- Core majors
     -- Vaeringheim investor belt
     "Vaeringheim","Luminaria","Serena","Pyralis","Symphonara","Aurelia","Somniumpolis","Nexa","Lunalis Sancta","Sylvapolis",
     ["Vaeringheim"]      = "VaeringheimFlag.png",
     -- Core minors
    ["Lunalis Sancta"]  = "LunalisSanctaFlag.png",
     "Saluria","Aetherium","Ferrum Citadel","Acheron","Erythros","Catonis Atrium","Delphica","Koinonía","Aureum",
    ["Luminaria"]        = "LuminariaFlag.png",
     -- Alperkin
    ["Serena"]          = "SerenaFlag.png",
     "The Alpazkigz","Odiferia",
    ["Symphonara"]      = "SymphonaraFlag.png",
     -- New South Jangsong
    ["Aurelia"]          = "AureliaFlag.png",
     "Skýrophos","Bjornopolis","Aegirheim","Norsolyra","Thorsalon","Pelagia","Myrene","Thyrea","Ephyra","Halicarn",
    ["Sylvapolis"]      = "SylvapolisFlag.png",
     -- Haifan Bassaridia
    ["Delphica"]        = "DelphicaFlag.png",
     "Keybir-Aviv","Tel-Amin","Diamandis","Jogi","Lewisburg","Thermosalem","Akróstadium","Sufriya","Lykopolis",
    ["Catonis Atrium"]  = "CatonisAtriumFlag.png",
     -- Normark
    ["Ferrum Citadel"]  = "FerrumCitadelFlag.png",
     "Ardclach","Riddersborg",
     -- Alpazkigz belt
     -- Ouriana
     ["Pyralis"]          = "PyralisFlag.png",
     "Bashkim","Ourid","Tonar",
    ["Nexa"]            = "NexaFlag.png",
     -- Caledonian dependencies (canonical 9)
    ["Saluria"]          = "SaluriaFlag.png",
     "Fanghorn","Eikbu","Galvø","Sårensby","Slevik","Sjøsborg","Storesund","Notranskja","Kaledonija",
    ["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 = {
local DIVISIONS = {
     { name="Vaeringheim Division",       link="[[Vaeringheim Division]]",       code="VAE", weight=5,
     { 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"} },
       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","Acheron","Erythros","Aureum","Koinonía","Saluria","Aetherium","Fanghorn","Eikbu","Galvø","Sårensby","Slevik","Sjøsborg","Storesund","Notranskja","Kaledonija"} },
     { name="Alpazkigz Division",         link="[[Alpazkigz Division]]",         code="ALP", weight=4,
     { name="Odiferia Division",         link="[[Odiferia Division]]",         code="ODF", weight=3,
       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"} },
       cities={"Odiferia","Somniumpolis","Aetherium","Ferrum Citadel","Aurelia","Symphonara"} },
     { name="Hatch Ministry Division",   link="[[Hatch Ministry Division]]",   code="HAT", weight=3,
 
     { 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"} },
       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,
 
     { 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"} },
       cities={"Skýrophos","Bjornopolis","Aegirheim","Norsolyra","Thorsalon","Pelagia","Myrene","Thyrea","Ephyra","Halicarn"} },
     { name="Haifan Bassaridia Division", link="[[Haifan Bassaridia Division]]", code="HFA", weight=3,
 
     { 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"} },
       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,
 
     { name="Bassaridian Normark Division",link="[[Bassaridian Normark Division]]",code="BNO", weight=2,
       cities={"Ardclach","Riddersborg"} },
       cities={"Ardclach","Riddersborg"} },
     { name="Ouriana Division",           link="[[Ouriana Division]]",           code="OUR", weight=2,
 
     { name="Ouriana Division",           link="[[Ouriana Division]]",           code="OUR", weight=2,
       cities={"Bashkim","Ourid","Tonar"} },
       cities={"Bashkim","Ourid","Tonar"} },
     { name="Council of Kings Division", link="[[Council of Kings Division]]", code="CKD", weight=1,
 
       cities={"Vaeringheim","Bashkim","Ourid","Tonar"} },
     { name="Council of Kings Division",   link="[[Council of Kings Division]]",   code="CKD", weight=1,
       cities={
        -- Eastern Caledonian Highlands / Caledonian corridor nodes
        "Fanghorn",
        "Eikbu",
        "Galvø",
        "Skøda",
        "Hamarrfell",
        "Sårensby",
        "Slevik",
        "Sjøsborg",
        "Storesund",
        "Notranskja",
        "Kaledonija",
        "Krlsgorod",
 
        -- Haifan Bassaridia / Southern Strait nodes
        "Keybir-Aviv",
        "Tel-Amin",
        "Diamandis",
        "Jogi",
        "Lewisburg",
        "Thermosalem",
        "Akróstadium",
        "Sufriya",
        "Lykopolis"
      } },
}
}


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- (F) OPERATION TYPES (branch-aware)
-- (I) Guardrail: if a Caledonian city ever appears elsewhere, force CKD
--------------------------------------------------------------------------------
local function forceCKDIfCaledonian(div, cityName)
    -- Caledonian cities remain exclusive to CKD.
    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
 
local function isAllowedCKDCity(cityName)
    return CALEDONIAN_CITIES[cityName] or HAIFAN_BASSARIDIA_CITIES[cityName]
end
 
--------------------------------------------------------------------------------
-- (J) OPERATION TYPES
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
local OP_GROUND = {
local OP_GROUND = {
Line 225: Line 497:
     { name="hazard response (evacuation / debris clearance)",        weight=2 },
     { name="hazard response (evacuation / debris clearance)",        weight=2 },
     { name="convoy escort (supply / detainee / artifact transfer)",  weight=2 },
     { name="convoy escort (supply / detainee / artifact transfer)",  weight=2 },
{ name="Noctic cargo custody support (clinic / ledger / crate-seal control)", weight=1 },
}
}
local OP_NAVAL = {
local OP_NAVAL = {
     { name="maritime escort / lake patrol routing",                weight=3 },
     { name="maritime escort / lake patrol routing",                weight=3 },
Line 232: Line 504:
     { name="blockade enforcement / straits compliance sweep",      weight=2 },
     { name="blockade enforcement / straits compliance sweep",      weight=2 },
     { name="harbor security (lock-gates / inner basin patrol)",    weight=2 },
     { name="harbor security (lock-gates / inner basin patrol)",    weight=2 },
{ name="Noctic Fleet cargo inspection / raw-leaf movement control", weight=1 },
}
}
local OP_AIR = {
local OP_AIR = {
     { name="ISR overwatch / route scanning (drones / AEW&C cueing)", weight=4 },
     { name="ISR overwatch / route scanning",                       weight=4 },
     { name="rapid extraction / medevac lift",                      weight=2 },
     { name="rapid extraction / medevac lift",                      weight=2 },
     { name="strike deterrence package (show of force)",            weight=1 },
     { name="strike deterrence package (show of force)",            weight=1 },
{ name="Noctic-Rabrev cargo ISR / clinic-route overwatch", weight=1 },
}
}


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- (G) SYSTEMS / PLATFORMS (Somniant + Eosphorus links)
-- (J.0.1) CKD SOUTHERN STRAIT / FORMER MARITIME MARKETS OPERATIONS
--
-- These operations are used only when CKD appears in a Haifan Bassaridian
-- city/node. They are naval-led, but include inland route, mountain approach,
-- rail/canal/road, and trade-corridor enforcement.
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
local SYSTEMS = {
 
     hwLink("Somniant_Stock_Fund_Military_Hardware", "Syrinx_Class_Armored_Infantry_Vehicle", "Syrinx Class Armored Infantry Vehicle"),
local CKD_SOUTHERN_STRAIT_NAVAL_OPS = {
     hwLink("Somniant_Stock_Fund_Military_Hardware", "Thalassa_Class_Main_Battle_Tank", "Thalassa Class Main Battle Tank"),
     { name="CKD Southern Strait naval screen (patrol craft / boarding cordon / former Maritime Markets traffic review)", weight=6, urg="MEDIUM" },
     hwLink("Somniant_Stock_Fund_Military_Hardware", "Misttalon_Class_Heavy_Bomber", "Misttalon Class Heavy Bomber"),
    { name="CKD Southern Strait VBSS operation (unlit lighter inspection / broker-runner interdiction / forged flag review)", weight=5, urg="MEDIUM" },
     hwLink("Somniant_Stock_Fund_Military_Hardware", "Ventiflor_Eye_Class_AEW%26C_Aircraft", "Ventiflor Eye Class AEW&C Aircraft"),
    { name="CKD former Maritime Markets interdiction sweep (small-craft seizure / old broker quay watch / prize evidence custody)", weight=5, urg="HIGH" },
     hwLink("Somniant_Stock_Fund_Military_Hardware", "Cetus_Class_Attack_Craft", "Cetus Class Attack Craft"),
     { name="CKD straits compliance patrol (ferry lane control / bonded cargo lighter inspection / registry-seal verification)", weight=5, urg="MEDIUM" },
     hwLink("Somniant_Stock_Fund_Military_Hardware", "Aetherium_Class_Patrol_Ship", "Aetherium Class Patrol Ship"),
    { name="CKD missionary-sea escort screen (pilgrimage pier security / shrine boat routing / hostile skiff exclusion)", weight=4, urg="MEDIUM" },
     hwLink("Somniant_Stock_Fund_Military_Hardware", "Saluria_Class_Gunboat", "Saluria Class Gunboat"),
    { name="CKD Southern Strait blockade-warning posture (selective cordon / lawful passage marking / suspicious craft shadowing)", weight=4, urg="HIGH" },
     hwLink("Eosphorus_Motor_Company", "Model_V-99_Thunderguard_Citadel_Train", "Model V-99 Thunderguard Citadel Train"),
    { name="CKD anti-smuggling boat net (canal mouth patrol / false distress lamp check / hidden cargo hold search)", weight=4, urg="MEDIUM" },
     { name="CKD prize-court transfer escort (captured lighter convoy / detained crew protection / cargo ledger custody)", weight=3, urg="MEDIUM" },
    { name="CKD coastal radar and signal-lamp discipline sweep (false beacon suppression / night-route verification)", weight=3, urg="LOW" },
}
 
local CKD_SOUTHERN_STRAIT_GROUND_OPS = {
    { name="CKD Southern Strait inland enforcement line (quay-to-road checkpoints / caravan yard screening / bonded cargo transfer control)", weight=5, urg="MEDIUM" },
     { name="CKD former Maritime Markets broker-lane sweep (warehouse cordon / ledger seizure / broker-agent detention support)", weight=5, urg="HIGH" },
    { name="CKD trade-route interdiction package (rail siding check / canal-road transfer audit / counterfeit permit seizure)", weight=4, urg="MEDIUM" },
    { name="CKD shrine-and-market security cordon (missionary movement protection / crowd lanes / anti-agitator screening)", weight=4, urg="MEDIUM" },
     { name="CKD mountain approach patrol (upland pass watch / smuggler mule-track denial / overlook post rotation)", weight=3, urg="MEDIUM" },
    { name="CKD inland caravan compliance sweep (pack-train search / invoice reconciliation / suspicious escort company review)", weight=3, urg="MEDIUM" },
    { name="CKD Southern Strait depot-security operation (freight office hardening / sealed evidence movement / witness shielding)", weight=3, urg="LOW" },
     { name="CKD road-and-canal junction lockdown drill (rapid barrier placement / detainee lane setup / cargo-stack isolation)", weight=2, urg="HIGH" },
}
 
local CKD_SOUTHERN_STRAIT_AIR_OPS = {
    { name="CKD Southern Strait ISR grid (ferry wakes / skiff clusters / concealed cove activity / broker compound watch)", weight=5, urg="MEDIUM" },
    { name="CKD maritime-air overwatch (boarding team cover / patrol-craft route scan / false-lamp triangulation)", weight=5, urg="MEDIUM" },
     { name="CKD mountain-route ISR sweep (upland mule tracks / overlook camps / hidden caravan trails)", weight=4, urg="MEDIUM" },
    { name="CKD inland trade-route UAV scan (rail yards / caravan approaches / canal-road transfer points)", weight=4, urg="LOW" },
    { name="CKD rapid extraction standby (missionary escort recovery / detained witness lift / wounded boarding-team evacuation)", weight=3, urg="HIGH" },
     { name="CKD show-of-force pass over Southern Strait broker districts (deterrence / crowd dispersal / route discipline)", weight=2, urg="HIGH" },
}
 
local CKD_SOUTHERN_STRAIT_OVERSIGHT = {
    { label="Council of Kings Southern Strait liaison", weight=4 },
    { label="Straits Convention naval compliance monitor", weight=4 },
    { label="Hatch Ministry prize-court evidence observer", weight=3 },
    { label="Temple Bank missionary-route auditor", weight=3 },
    { label="General Port registry and cargo-ledger inspector", weight=3 },
    { label="Port compliance inspector (HCE)", weight=2 },
}
 
local CKD_SOUTHERN_STRAIT_COMPLIANCE = {
    { label="Southern Strait operation logged through CKD command channel; seizure, escort, and route-control notes filed same-day", weight=4 },
    { label="Former Maritime Markets traffic review completed; suspect ledgers, seals, and witness names preserved for review", weight=4 },
    { label="Naval screen maintained; lawful passage marked while suspicious craft were shadowed or routed to inspection", weight=4 },
    { label="Inland transfer chain stabilized; quay, rail, road, and caravan handoff records reconciled", weight=3 },
    { label="Mountain and upland approach patrol completed; suspected smuggler tracks mapped for follow-up interdiction", weight=2 },
    { label="Missionary escort corridor secured; shrine movement protected without interrupting lawful trade", weight=2 },
}
}


local function maybeCKDSouthernStraitOperation(cityName, divCode, primary, wxImp)
    if divCode ~= "CKD" or not isHaifanBassaridiaCity(cityName) then
        return nil
    end
    local allowNaval = not (wxImp and wxImp.sea == "Closed")
    local pool
    if primary == "naval" and allowNaval then
        pool = CKD_SOUTHERN_STRAIT_NAVAL_OPS
    elseif primary == "air" then
        pool = CKD_SOUTHERN_STRAIT_AIR_OPS
    else
        pool = CKD_SOUTHERN_STRAIT_GROUND_OPS
    end
    if not pool or #pool == 0 then return nil end
    return pickWeighted(pool)
end
--------------------------------------------------------------------------------
-- (J.1) NOCTIC-RABREV / NOCTIC VAMPIRISM OPERATION OVERLAY
--
-- These events are intentionally occasional. They do not replace the normal
-- War League event structure unless the city, division, or event text makes
-- a Noctic support action plausible.
--------------------------------------------------------------------------------
local NOCTIC_CITY_RISK = {
    ["Somniumpolis"] = 7,
    ["Odiferia"]    = 7,
    ["Erythros"]    = 6,
    ["Acheron"]      = 5,
    ["Norsolyra"]    = 5,
    ["Skøda"]        = 5,
    ["Vaeringheim"]  = 3,
    ["Jogi"]        = 3,
    ["Aurelia"]      = 3,
    ["Nexa"]        = 2,
    ["Pyralis"]      = 2,
    ["Keybir-Aviv"]  = 2,
    ["Tel-Amin"]    = 2,
    ["Skýrophos"]    = 2,
    ["Thermosalem"]  = 1,
    ["Hamarrfell"]  = 1,
    ["Storesund"]    = 1,
}
local NOCTIC_GROUND_OPS = {
    { name="Noctic-Rabrev custody support (raw-leaf cordon / Temple Bank inspection lane)", weight=4, urg="MEDIUM" },
    { name="Noctic Vampirism outbreak support (clinic queue security / patient transfer corridor)", weight=4, urg="MEDIUM" },
    { name="counterfeit nocticine sweep support (vendor rows / tincture stalls / crate-transfer corridor)", weight=3, urg="MEDIUM" },
    { name="Noctic registry protection (ledger escort / archive lockdown / witness shielding)", weight=2, urg="MEDIUM" },
    { name="raw Noctic-Rabrev seizure support (sealed evidence movement / crowd separation)", weight=3, urg="HIGH" },
    { name="Noctic panic stabilization (rumor-control cordon / shrine-district lane marking)", weight=2, urg="MEDIUM" },
    { name="Noctic medical convoy escort (licensed tinctures / blood-substitute supplies / cult-health team)", weight=2, urg="LOW" },
}
local NOCTIC_NAVAL_OPS = {
    { name="Noctic Fleet quay patrol (sealed leaf chests / flower bales / tincture crates)", weight=4, urg="MEDIUM" },
    { name="Noctic cargo interdiction support (small-craft inspection / crate-seal verification)", weight=4, urg="MEDIUM" },
    { name="wetland canal containment for raw Noctic-Rabrev cargo movement", weight=3, urg="HIGH" },
    { name="lock-gate security for Noctic-Rabrev custody audit", weight=3, urg="MEDIUM" },
    { name="marsh-side pier inspection for counterfeit nocticine consignments", weight=2, urg="MEDIUM" },
}
local NOCTIC_AIR_OPS = {
    { name="Noctic cargo ISR overwatch (lock-gates / wetland piers / suspected crate-handoff lanes)", weight=4, urg="MEDIUM" },
    { name="Noctic outbreak route scan (clinic corridors / relief queues / patient-transfer roads)", weight=3, urg="MEDIUM" },
    { name="RF/SIGINT scan for forged Noctic inspection calls and crate-handoff coordination", weight=3, urg="MEDIUM" },
    { name="storm-window UAV support for Noctic-Rabrev custody chain enforcement", weight=2, urg="HIGH" },
}
local NOCTIC_OVERSIGHT = {
    { label="Temple Bank Noctic custody clerk", weight=4 },
    { label="Temple Alabaster nocticine inspector", weight=3 },
    { label="Guild of Golden Shadows counterfeit-tincture auditor", weight=3 },
    { label="Cult-health Noctic Vampirism liaison", weight=3 },
    { label="Hatch Ministry cargo-seal observer", weight=2 },
}
local NOCTIC_COMPLIANCE = {
    { label="Noctic custody ledger updated; suspect vials, crate seals, and vendor-payment trails preserved", weight=4 },
    { label="Raw Noctic-Rabrev leaf suspension issued; Temple Bank registration and cult-health review required", weight=4 },
    { label="Clinic and relief queues stabilized; registered Noctic patients protected from rumor-driven harassment", weight=3 },
    { label="Counterfeit nocticine products isolated; affected vendors routed to Temple Alabaster inspection", weight=3 },
    { label="Noctic cargo released only after flower cargo, tinctures, and restricted raw-leaf material were separated", weight=2 },
    { label="Evidence chain sealed; duplicate ledgers forwarded to Temple Bank and port compliance offices", weight=2 },
}
local function textHasNocticSignal(txt)
    local s = tostring(txt or ""):lower()
    if s:find("noctic", 1, true) then return true end
    if s:find("nrv", 1, true) then return true end
    if s:find("raw leaf", 1, true) then return true end
    if s:find("raw%-leaf") then return true end
    if s:find("tincture", 1, true) then return true end
    return false
end
local function nocticChanceFor(cityName, divCode, evDesc)
    local risk = NOCTIC_CITY_RISK[cityName] or 0
    local chance = risk * 0.018
    if divCode == "ODF" then chance = chance + 0.04 end
    if divCode == "HAT" then chance = chance + 0.03 end
    if divCode == "ALP" then chance = chance + 0.02 end
    if divCode == "NSJ" and (cityName == "Norsolyra" or cityName == "Skýrophos") then chance = chance + 0.02 end
    if divCode == "CKD" and cityName == "Skøda" then chance = chance + 0.04 end
    if textHasNocticSignal(evDesc) then chance = chance + 0.22 end
    if chance > 0.30 then chance = 0.30 end
    if chance < 0 then chance = 0 end
    return chance
end
local function maybeNocticOperation(cityName, divCode, evDesc, primary, wxImp)
    local chance = nocticChanceFor(cityName, divCode, evDesc)
    if math.random() >= chance then return nil end
    local allowNaval = not (wxImp and wxImp.sea == "Closed")
    local pool
    if primary == "naval" and allowNaval then
        pool = NOCTIC_NAVAL_OPS
    elseif primary == "air" then
        pool = NOCTIC_AIR_OPS
    else
        pool = NOCTIC_GROUND_OPS
    end
    if not pool or #pool == 0 then return nil end
    return pickWeighted(pool)
end
--------------------------------------------------------------------------------
-- (K) OVERSIGHT + COMPLIANCE
--------------------------------------------------------------------------------
local OVERSIGHT = {
local OVERSIGHT = {
     { label="Senate of Elders liaison",                weight=3 },
     { label="Senate of Elders liaison",                weight=3 },
Line 261: Line 717:
     { label="No observer (weather delay)",              weight=1 },
     { label="No observer (weather delay)",              weight=1 },
}
}
local COMPLIANCE = {
local COMPLIANCE = {
     { label="Somniant issue verified; serialized inventory matched; export seal: n/a (domestic deployment)", weight=6 },
     { label="Deployment verified; corridor routing approved; ledger notes filed same-day",                 weight=6 },
     { label="Somniant kit verified; minor variance corrected same-day",                                     weight=2 },
     { label="Minor variance corrected; revised routing issued with escort buffer",                         weight=2 },
     { label="Routing adjusted (+buffer); corridor reopened under escort",                                   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="Mission deferred (no-go); export-control check triggered by false manifest",                 weight=1 },
     { label="Export request refused (constitutional prohibition); equipment retained under Council custody", weight=1 },
     { label="Export request refused (constitutional prohibition); material retained under Council custody", weight=1 },
}
}


Line 276: Line 731:
         return ' style="background-color:#fff3cd;"'
         return ' style="background-color:#fff3cd;"'
     end
     end
     if c:match("minor variance") or c:match("routing adjusted") then
     if c:match("minor variance") then
         return ' style="background-color:#fff3cd;"'
         return ' style="background-color:#fff3cd;"'
     end
     end
Line 283: Line 738:
     end
     end
     if uScore >= 3 then
     if uScore >= 3 then
         return ' style="background-color:#f8d7da;"' -- treat HIGH/CRITICAL as red attention
         return ' style="background-color:#f8d7da;"'
     end
     end
     return ' style="background-color:#d4edda;"'
     return ""
end
end


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


     local args = frame and frame.args or {}
     local eventMap, hasEvents = buildEventMapForToday(di)
    local rows = tonumber(args.rows) or 36
    local showLegend = tostring(args.showlegend or "yes"):lower() ~= "no"


    -- Build daily events mapping first (this may reseed RNG internally; we'll reseed afterwards) :contentReference[oaicite:9]{index=9}
    local eventMap, hasEvents = buildEventMapForToday()
    -- Now seed our own RNG deterministically for the day
     local seed = tonumber(args.seed) or (di.year * 1000 + di.dayOfYear)
     local seed = tonumber(args.seed) or (di.year * 1000 + di.dayOfYear)
     math.randomseed(seed)
     math.randomseed(seed)


     local divFilter = tostring(args.division or ""):lower()
-- Southern Strait-only display settings.
    local divPool = {}
-- rows = number of rows to DISPLAY.
    if divFilter ~= "" then
-- scanrows = number of original War League rows to scan before filtering.
        for _, d in ipairs(DIVISIONS) do
-- This preserves original Op-IDs because the original row index is still used.
            if d.name:lower():find(divFilter, 1, true) or d.code:lower():find(divFilter, 1, true) then
local displayLimit = rows
                table.insert(divPool, d)
local scanRows = rows
            end
if southernStraitOnly then
     scanRows = tonumber(args.scanrows or args.searchrows or args.sourcerows) or math.max(rows * 6, 120)
end
local displayed = 0
 
local divFilter = tostring(args.division or ""):lower()
local divPool = {}
 
-- Important:
-- Southern Strait-only mode must NOT change the division pool.
-- It should only hide non-Southern rows after the normal War League row
-- has been generated. This preserves Op-ID, row index, operation type,
-- and all deterministic row details.
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
     end
    if #divPool == 0 then divPool = DIVISIONS end
else
    divPool = DIVISIONS
end


     local out = {}
     local out = {}
Line 326: Line 798:
         " !! Daily event trigger" ..
         " !! Daily event trigger" ..
         " !! Urgency" ..
         " !! Urgency" ..
        " !! Ops Alert" ..
        " !! Ops Impact" ..
         " !! Operation" ..
         " !! Operation" ..
         " !! Ground Forces" ..
         " !! Ground Forces" ..
         " !! Naval Forces" ..
         " !! Naval Forces" ..
         " !! Aerial Forces" ..
         " !! Aerial Forces" ..
        " !! Systems / Platforms" ..
         " !! Oversight" ..
         " !! Oversight" ..
         " !! Compliance / Notes"
         " !! Compliance / Notes"
     )
     )


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


        -- link to canonical city section
    -- Do NOT force Southern Strait cities here.
         local cityWikilink = cityLink(cityName, cityName)
    -- The row must be generated normally so Op-ID, division, operation type,
    -- and force details match the ordinary War League page.
 
-- Guardrail: Caledonian city => CKD.
-- CKD may now operate in Caledonian cities or Haifan Bassaridian Southern Strait nodes.
div = forceCKDIfCaledonian(div, cityName)
if div.code == "CKD" and not isAllowedCKDCity(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))


        -- pull today’s event (if available)
         local ev = eventMap[cityName]
         local ev = eventMap[cityName]
         local evDesc = ev and ev.desc or (hasEvents and "No reports" or "Events module unavailable")
         local evDesc = ev and ev.desc or (hasEvents and "No reports" or "Events module unavailable")
Line 348: Line 834:
         local uScore = urgencyScore(urg)
         local uScore = urgencyScore(urg)


         -- Branch selection: Hatch/Haifan more naval; otherwise ground; add air more as urgency rises
         -- WX integration (Ops Alert / Impacts)
        local branchWeighted = {
        local wxData = getWx(cityName, di)
            { name="ground", weight = 6 },
        local wxLevel, wxLabel, wxCond, wxHaz, wxImp = 0, "Green", "", "", nil
            { name="naval",  weight = (div.code == "HAT" or div.code == "HFA") and 5 or 2 },
        local opsAlertCell, opsImpactCell = "n/a", "n/a"
            { name="air",    weight = 2 + uScore },
 
        }
        if wxData and wxData.alert then
        local primary = pickWeighted(branchWeighted).name
            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.
-- CKD Southern Strait deployments are naval-led, but still include ground,
-- inland, mountain, trade-route, and air-overwatch missions.
local isCKDSouthernStraitRow = (div.code == "CKD" and isHaifanBassaridiaCity(cityName))
 
local primary = pickWeighted({
    { name="ground", weight = isCKDSouthernStraitRow and 4 or 6 },
    { name="naval",  weight = isCKDSouthernStraitRow and 8 or ((div.code == "HAT" or div.code == "HFA") and 5 or 2) },
    { name="air",    weight = isCKDSouthernStraitRow and (3 + uScore) or (2 + uScore) },
}).name
 
local op = (primary == "naval") and pickWeighted(OP_NAVAL)
      or (primary == "air")  and pickWeighted(OP_AIR)
      or pickWeighted(OP_GROUND)


        -- Choose operations by branch, but bias by urgency
-- CKD Southern Strait override:
        local op
-- If CKD appears in a Haifan Bassaridian city/node, replace the generic
        if primary == "naval" then
-- operation with a Southern Strait / former Maritime Markets operation.
            op = pickWeighted(OP_NAVAL)
local ckdSouthernStraitOp = maybeCKDSouthernStraitOperation(cityName, div.code, primary, wxImp)
         elseif primary == "air" then
if ckdSouthernStraitOp then
             op = pickWeighted(OP_AIR)
    op = { name = ckdSouthernStraitOp.name }
    if ckdSouthernStraitOp.urg then
         if urg == "N/A" then
             urg = ckdSouthernStraitOp.urg
         else
         else
             op = pickWeighted(OP_GROUND)
             urg = urgencyMax(urg, ckdSouthernStraitOp.urg)
         end
         end
        uScore = urgencyScore(urg)
    end
end


         -- Formation picks per branch
-- WX: Bias operation choice when environment forces hold/hazard posture.
         local ground = ""
-- For CKD Southern Strait rows, weather delays are still framed through the
         local naval  = ""
-- Southern Strait mission rather than reverting to generic local patrol text.
         local air   = ""
if wxLevel >= 2 and wxImp then
    if isCKDSouthernStraitRow then
         if wxImp.sea == "Closed" then
            op = { name = "CKD Southern Strait storm-delay harbor screen (patrol craft held / quay cordon / inland transfer checks continue)" }
        end
         if wxLevel >= 3 and (wxImp.ground == "Hazard" or wxImp.air == "No-Go") then
            op = { name = "CKD Southern Strait hazard response (evacuation lanes / debris clearance / missionary and cargo-route protection)" }
         end
    else
        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
end


        -- Always include a small ground presence unless the op is pure air ISR
-- Optional Noctic overlay: occasionally turns the operation into a
         if primary ~= "naval" then
-- Noctic-Rabrev / Noctic Vampirism support task when the city or event
-- makes it plausible. CKD Southern Strait rows stay Southern Strait-specific.
if not isCKDSouthernStraitRow then
    local nocticOp = maybeNocticOperation(cityName, div.code, evDesc, primary, wxImp)
    if nocticOp then
        op = { name = nocticOp.name }
        if nocticOp.urg then
            if urg == "N/A" then
                urg = nocticOp.urg
            else
                urg = urgencyMax(urg, nocticOp.urg)
            end
            uScore = urgencyScore(urg)
        end
    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)
             local gE = pickEchelonForUrgency(GROUND_ECHELONS, uScore)
             local gUnit = string.format("%s (%s-%02d)", gE, div.code, math.random(1, 24))
             ground = string.format("%s (%s-%02d)", gE, div.code, math.random(1, 24))
            ground = gUnit
         end
         end


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


        if primary == "air" or (uScore >= 2 and math.random() < 0.55) then
-- CKD Southern Strait rows get their own oversight/compliance language.
            local aE = pickEchelonForUrgency(AIR_ECHELONS, uScore)
if isCKDSouthernStraitRow then
            air = string.format("%s (%s-A%02d)", aE, div.code, math.random(1, 18))
    obs = pickWeighted(CKD_SOUTHERN_STRAIT_OVERSIGHT).label
        end
    comp = pickWeighted(CKD_SOUTHERN_STRAIT_COMPLIANCE).label
elseif textHasNocticSignal(op.name) then
    -- If the operation is Noctic-related, allow Noctic-specific oversight and
    -- compliance notes. This does not affect unrelated rows.
    obs = pickWeighted(NOCTIC_OVERSIGHT).label
    comp = pickWeighted(NOCTIC_COMPLIANCE).label
end


        -- Systems: pick 1–2; more likely to show 2 at higher urgency
-- WX: force weather-delay compliance posture if Sea Closed / Air No-Go at Orange+.
        local sys1 = pick(SYSTEMS)
-- CKD Southern Strait rows keep Southern Strait-specific delay language.
         local sys2 = (math.random() < (0.35 + (0.10 * uScore))) and pick(SYSTEMS) or nil
if wxLevel >= 2 and wxImp and (wxImp.sea == "Closed" or wxImp.air == "No-Go") then
         local systemsText = (sys2 and sys2 ~= sys1) and bulletList({sys1, sys2}) or sys1
    if isCKDSouthernStraitRow then
         obs = "Council of Kings Southern Strait weather liaison"
         comp = "Southern Strait weather delay; naval screen held under local restrictions while inland transfer checks, quay cordons, and route-security notes continue"
    else
        obs = "No observer (weather delay)"
        comp = "Weather delay; holding pattern under local command until visibility improves"
    end
end


        local obs = pickWeighted(OVERSIGHT).label
local includeRow = true
        local comp = pickWeighted(COMPLIANCE).label
if southernStraitOnly then
    includeRow = isHaifanBassaridiaCity(cityName)
end


        -- Notes include the force hierarchy identifiers and op id
local notes = bulletList({ "Op-ID: " .. opId(di, div.code, i) })
        local notes = bulletList({
local style = rowColor(obs, comp, uScore)
            "Op-ID: " .. opId(di, div.code, i),
            "Doctrine: corridor integrity and civic stability maintenance",
            "Org keys: Ground (Turmae→Contubernia→Ordinis→Manipuli→Centuriae→Cohortes); Naval (Birlik-i Gemi→Tabur-i Derya→Ordu); Air (Zeygi→Ptisis→Moira)"
        })


        local style = rowColor(obs, comp, uScore)
if includeRow then
    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 })
    ))


        table.insert(out, "|-" .. style)
    displayed = displayed + 1
        table.insert(out, string.format(
    if displayed >= displayLimit then
            "| %s || %s || %s || %s || %s || %s || %s || %s || %s || %s || %s || %s",
         break
            di.text,
            div.link,
            cityWikilink,
            evDesc,
            urg,
            op.name,
            (ground ~= "" and ground or "—"),
            (naval  ~= "" and naval  or "—"),
            (air    ~= "" and air    or "—"),
            systemsText,
            obs,
            bulletList({ comp, notes })
         ))
     end
     end
end
    end
if southernStraitOnly and displayed == 0 then
    table.insert(out, "|-")
    table.insert(out, "| colspan='13' style='text-align:center;' | No Southern Strait / Haifan Bassaridian War League rows appeared in the scanned source rows. Increase scanrows.")
end


    table.insert(out, "|}")
table.insert(out, "|}")


     if showLegend then
     if showLegend then
Line 431: Line 1,026:
         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:#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, "<span style='background:#f8d7da;padding:2px 6px;border:1px solid #f5c6cb;'>Red</span> = HIGH/CRITICAL or export-control/deferment")
         table.insert(out, "<br/><b>Force organization:</b> Ground—Turmae, Contubernia, Ordinis, Manipuli, Centuriae, Cohortes; Naval—Birlik-i Gemi, Tabur-i Derya, Ordu; Air—Zeygi, Ptisis, Moira.")
         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>")
         table.insert(out, "</div>")
     end
     end
Line 439: Line 1,034:


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- (I) OPTIONAL: show org ladders as a compact table for documentation
-- (M) OPTIONAL: show org ladders (for documentation / page footer)
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
function p.showOrganization()
function p.showOrganization()

Latest revision as of 02:05, 29 May 2026

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(args)
    args = args or {}

    local overrideYear = tonumber(args.year or args.psscyear)
    local overrideDay  = tonumber(args.day or args.dayofyear)

    if overrideYear and overrideDay then
        if overrideDay < 1 then overrideDay = 1 end
        if overrideDay > 183 then overrideDay = 183 end

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

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

    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,
    ["Skøda"]      = true,
    ["Hamarrfell"] = true,
    ["Sårensby"]   = true,
    ["Slevik"]     = true,
    ["Sjøsborg"]   = true,
    ["Storesund"]  = true,
    ["Notranskja"] = true,
    ["Kaledonija"] = true,
    ["Krlsgorod"]  = true,
}

--------------------------------------------------------------------------------
-- (D.1) HAIFAN BASSARIDIA / SOUTHERN STRAIT SET
--
-- CKD is NOT locked to these cities. This set only allows CKD to generate
-- special Southern Strait / former Maritime Markets operations when CKD happens
-- to appear in a Haifan Bassaridian city or node.
--------------------------------------------------------------------------------
local HAIFAN_BASSARIDIA_CITIES = {
    ["Keybir-Aviv"] = true,
    ["Tel-Amin"] = true,
    ["Diamandis"] = true,
    ["Jogi"] = true,
    ["Lewisburg"] = true,
    ["Thermosalem"] = true,
    ["Akróstadium"] = true,
    ["Sufriya"] = true,
    ["Lykopolis"] = true,
}

local function isHaifanBassaridiaCity(cityName)
    return HAIFAN_BASSARIDIA_CITIES[tostring(cityName or "")] == true
end

local SOUTHERN_STRAIT_CITY_LIST = {
    "Keybir-Aviv",
    "Tel-Amin",
    "Diamandis",
    "Jogi",
    "Lewisburg",
    "Thermosalem",
    "Akróstadium",
    "Sufriya",
    "Lykopolis"
}

local SOUTHERN_STRAIT_DIVISION_CODES = {
    ["HFA"] = true, -- Haifan Bassaridia Division
    ["HAT"] = true, -- Hatch Ministry Division
    ["CKD"] = true, -- Council of Kings Division, when operating in Southern Strait nodes
}

local function isSouthernStraitDivision(div)
    return div and SOUTHERN_STRAIT_DIVISION_CODES[tostring(div.code or "")] == true
end

local function isSouthernStraitOnly(args)
    args = args or {}
    local z = mw.ustring.lower(tostring(args.zone or args.scope or args.region or args.theater or ""))
    return z == "southernstrait"
        or z == "southern strait"
        or z == "southern straits"
        or z == "haifan"
        or z == "haifan bassaridia"
        or z == "former maritime markets"
end

--------------------------------------------------------------------------------
-- (E) INTEGRATE DAILY EVENTS (parse Module:BassaridianDailyEvents.makeTable())
--------------------------------------------------------------------------------
local function buildEventMapForToday(di)
    local ok, daily = pcall(require, "Module:BassaridianDailyEvents")
    if not ok or not daily or type(daily.makeTable) ~= "function" then
        return {}, false
    end

    local tbl
    if di and di.year and di.dayOfYear then
        tbl = daily.makeTable({
            args = {
                year = tostring(di.year),
                day  = tostring(di.dayOfYear)
            }
        })
    else
        tbl = daily.makeTable()
    end

    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={
        -- Eastern Caledonian Highlands / Caledonian corridor nodes
        "Fanghorn",
        "Eikbu",
        "Galvø",
        "Skøda",
        "Hamarrfell",
        "Sårensby",
        "Slevik",
        "Sjøsborg",
        "Storesund",
        "Notranskja",
        "Kaledonija",
        "Krlsgorod",

        -- Haifan Bassaridia / Southern Strait nodes
        "Keybir-Aviv",
        "Tel-Amin",
        "Diamandis",
        "Jogi",
        "Lewisburg",
        "Thermosalem",
        "Akróstadium",
        "Sufriya",
        "Lykopolis"
      } },
}

--------------------------------------------------------------------------------
-- (I) Guardrail: if a Caledonian city ever appears elsewhere, force CKD
--------------------------------------------------------------------------------
local function forceCKDIfCaledonian(div, cityName)
    -- Caledonian cities remain exclusive to CKD.
    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

local function isAllowedCKDCity(cityName)
    return CALEDONIAN_CITIES[cityName] or HAIFAN_BASSARIDIA_CITIES[cityName]
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 },
{ name="Noctic cargo custody support (clinic / ledger / crate-seal control)", weight=1 },
}
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 },
{ name="Noctic Fleet cargo inspection / raw-leaf movement control", weight=1 },
}
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 },
{ name="Noctic-Rabrev cargo ISR / clinic-route overwatch", weight=1 },
}

--------------------------------------------------------------------------------
-- (J.0.1) CKD SOUTHERN STRAIT / FORMER MARITIME MARKETS OPERATIONS
--
-- These operations are used only when CKD appears in a Haifan Bassaridian
-- city/node. They are naval-led, but include inland route, mountain approach,
-- rail/canal/road, and trade-corridor enforcement.
--------------------------------------------------------------------------------

local CKD_SOUTHERN_STRAIT_NAVAL_OPS = {
    { name="CKD Southern Strait naval screen (patrol craft / boarding cordon / former Maritime Markets traffic review)", weight=6, urg="MEDIUM" },
    { name="CKD Southern Strait VBSS operation (unlit lighter inspection / broker-runner interdiction / forged flag review)", weight=5, urg="MEDIUM" },
    { name="CKD former Maritime Markets interdiction sweep (small-craft seizure / old broker quay watch / prize evidence custody)", weight=5, urg="HIGH" },
    { name="CKD straits compliance patrol (ferry lane control / bonded cargo lighter inspection / registry-seal verification)", weight=5, urg="MEDIUM" },
    { name="CKD missionary-sea escort screen (pilgrimage pier security / shrine boat routing / hostile skiff exclusion)", weight=4, urg="MEDIUM" },
    { name="CKD Southern Strait blockade-warning posture (selective cordon / lawful passage marking / suspicious craft shadowing)", weight=4, urg="HIGH" },
    { name="CKD anti-smuggling boat net (canal mouth patrol / false distress lamp check / hidden cargo hold search)", weight=4, urg="MEDIUM" },
    { name="CKD prize-court transfer escort (captured lighter convoy / detained crew protection / cargo ledger custody)", weight=3, urg="MEDIUM" },
    { name="CKD coastal radar and signal-lamp discipline sweep (false beacon suppression / night-route verification)", weight=3, urg="LOW" },
}

local CKD_SOUTHERN_STRAIT_GROUND_OPS = {
    { name="CKD Southern Strait inland enforcement line (quay-to-road checkpoints / caravan yard screening / bonded cargo transfer control)", weight=5, urg="MEDIUM" },
    { name="CKD former Maritime Markets broker-lane sweep (warehouse cordon / ledger seizure / broker-agent detention support)", weight=5, urg="HIGH" },
    { name="CKD trade-route interdiction package (rail siding check / canal-road transfer audit / counterfeit permit seizure)", weight=4, urg="MEDIUM" },
    { name="CKD shrine-and-market security cordon (missionary movement protection / crowd lanes / anti-agitator screening)", weight=4, urg="MEDIUM" },
    { name="CKD mountain approach patrol (upland pass watch / smuggler mule-track denial / overlook post rotation)", weight=3, urg="MEDIUM" },
    { name="CKD inland caravan compliance sweep (pack-train search / invoice reconciliation / suspicious escort company review)", weight=3, urg="MEDIUM" },
    { name="CKD Southern Strait depot-security operation (freight office hardening / sealed evidence movement / witness shielding)", weight=3, urg="LOW" },
    { name="CKD road-and-canal junction lockdown drill (rapid barrier placement / detainee lane setup / cargo-stack isolation)", weight=2, urg="HIGH" },
}

local CKD_SOUTHERN_STRAIT_AIR_OPS = {
    { name="CKD Southern Strait ISR grid (ferry wakes / skiff clusters / concealed cove activity / broker compound watch)", weight=5, urg="MEDIUM" },
    { name="CKD maritime-air overwatch (boarding team cover / patrol-craft route scan / false-lamp triangulation)", weight=5, urg="MEDIUM" },
    { name="CKD mountain-route ISR sweep (upland mule tracks / overlook camps / hidden caravan trails)", weight=4, urg="MEDIUM" },
    { name="CKD inland trade-route UAV scan (rail yards / caravan approaches / canal-road transfer points)", weight=4, urg="LOW" },
    { name="CKD rapid extraction standby (missionary escort recovery / detained witness lift / wounded boarding-team evacuation)", weight=3, urg="HIGH" },
    { name="CKD show-of-force pass over Southern Strait broker districts (deterrence / crowd dispersal / route discipline)", weight=2, urg="HIGH" },
}

local CKD_SOUTHERN_STRAIT_OVERSIGHT = {
    { label="Council of Kings Southern Strait liaison", weight=4 },
    { label="Straits Convention naval compliance monitor", weight=4 },
    { label="Hatch Ministry prize-court evidence observer", weight=3 },
    { label="Temple Bank missionary-route auditor", weight=3 },
    { label="General Port registry and cargo-ledger inspector", weight=3 },
    { label="Port compliance inspector (HCE)", weight=2 },
}

local CKD_SOUTHERN_STRAIT_COMPLIANCE = {
    { label="Southern Strait operation logged through CKD command channel; seizure, escort, and route-control notes filed same-day", weight=4 },
    { label="Former Maritime Markets traffic review completed; suspect ledgers, seals, and witness names preserved for review", weight=4 },
    { label="Naval screen maintained; lawful passage marked while suspicious craft were shadowed or routed to inspection", weight=4 },
    { label="Inland transfer chain stabilized; quay, rail, road, and caravan handoff records reconciled", weight=3 },
    { label="Mountain and upland approach patrol completed; suspected smuggler tracks mapped for follow-up interdiction", weight=2 },
    { label="Missionary escort corridor secured; shrine movement protected without interrupting lawful trade", weight=2 },
}

local function maybeCKDSouthernStraitOperation(cityName, divCode, primary, wxImp)
    if divCode ~= "CKD" or not isHaifanBassaridiaCity(cityName) then
        return nil
    end

    local allowNaval = not (wxImp and wxImp.sea == "Closed")

    local pool
    if primary == "naval" and allowNaval then
        pool = CKD_SOUTHERN_STRAIT_NAVAL_OPS
    elseif primary == "air" then
        pool = CKD_SOUTHERN_STRAIT_AIR_OPS
    else
        pool = CKD_SOUTHERN_STRAIT_GROUND_OPS
    end

    if not pool or #pool == 0 then return nil end
    return pickWeighted(pool)
end

--------------------------------------------------------------------------------
-- (J.1) NOCTIC-RABREV / NOCTIC VAMPIRISM OPERATION OVERLAY
--
-- These events are intentionally occasional. They do not replace the normal
-- War League event structure unless the city, division, or event text makes
-- a Noctic support action plausible.
--------------------------------------------------------------------------------

local NOCTIC_CITY_RISK = {
    ["Somniumpolis"] = 7,
    ["Odiferia"]     = 7,
    ["Erythros"]     = 6,
    ["Acheron"]      = 5,
    ["Norsolyra"]    = 5,
    ["Skøda"]        = 5,
    ["Vaeringheim"]  = 3,
    ["Jogi"]         = 3,
    ["Aurelia"]      = 3,
    ["Nexa"]         = 2,
    ["Pyralis"]      = 2,
    ["Keybir-Aviv"]  = 2,
    ["Tel-Amin"]     = 2,
    ["Skýrophos"]    = 2,
    ["Thermosalem"]  = 1,
    ["Hamarrfell"]   = 1,
    ["Storesund"]    = 1,
}

local NOCTIC_GROUND_OPS = {
    { name="Noctic-Rabrev custody support (raw-leaf cordon / Temple Bank inspection lane)", weight=4, urg="MEDIUM" },
    { name="Noctic Vampirism outbreak support (clinic queue security / patient transfer corridor)", weight=4, urg="MEDIUM" },
    { name="counterfeit nocticine sweep support (vendor rows / tincture stalls / crate-transfer corridor)", weight=3, urg="MEDIUM" },
    { name="Noctic registry protection (ledger escort / archive lockdown / witness shielding)", weight=2, urg="MEDIUM" },
    { name="raw Noctic-Rabrev seizure support (sealed evidence movement / crowd separation)", weight=3, urg="HIGH" },
    { name="Noctic panic stabilization (rumor-control cordon / shrine-district lane marking)", weight=2, urg="MEDIUM" },
    { name="Noctic medical convoy escort (licensed tinctures / blood-substitute supplies / cult-health team)", weight=2, urg="LOW" },
}

local NOCTIC_NAVAL_OPS = {
    { name="Noctic Fleet quay patrol (sealed leaf chests / flower bales / tincture crates)", weight=4, urg="MEDIUM" },
    { name="Noctic cargo interdiction support (small-craft inspection / crate-seal verification)", weight=4, urg="MEDIUM" },
    { name="wetland canal containment for raw Noctic-Rabrev cargo movement", weight=3, urg="HIGH" },
    { name="lock-gate security for Noctic-Rabrev custody audit", weight=3, urg="MEDIUM" },
    { name="marsh-side pier inspection for counterfeit nocticine consignments", weight=2, urg="MEDIUM" },
}

local NOCTIC_AIR_OPS = {
    { name="Noctic cargo ISR overwatch (lock-gates / wetland piers / suspected crate-handoff lanes)", weight=4, urg="MEDIUM" },
    { name="Noctic outbreak route scan (clinic corridors / relief queues / patient-transfer roads)", weight=3, urg="MEDIUM" },
    { name="RF/SIGINT scan for forged Noctic inspection calls and crate-handoff coordination", weight=3, urg="MEDIUM" },
    { name="storm-window UAV support for Noctic-Rabrev custody chain enforcement", weight=2, urg="HIGH" },
}

local NOCTIC_OVERSIGHT = {
    { label="Temple Bank Noctic custody clerk", weight=4 },
    { label="Temple Alabaster nocticine inspector", weight=3 },
    { label="Guild of Golden Shadows counterfeit-tincture auditor", weight=3 },
    { label="Cult-health Noctic Vampirism liaison", weight=3 },
    { label="Hatch Ministry cargo-seal observer", weight=2 },
}

local NOCTIC_COMPLIANCE = {
    { label="Noctic custody ledger updated; suspect vials, crate seals, and vendor-payment trails preserved", weight=4 },
    { label="Raw Noctic-Rabrev leaf suspension issued; Temple Bank registration and cult-health review required", weight=4 },
    { label="Clinic and relief queues stabilized; registered Noctic patients protected from rumor-driven harassment", weight=3 },
    { label="Counterfeit nocticine products isolated; affected vendors routed to Temple Alabaster inspection", weight=3 },
    { label="Noctic cargo released only after flower cargo, tinctures, and restricted raw-leaf material were separated", weight=2 },
    { label="Evidence chain sealed; duplicate ledgers forwarded to Temple Bank and port compliance offices", weight=2 },
}

local function textHasNocticSignal(txt)
    local s = tostring(txt or ""):lower()
    if s:find("noctic", 1, true) then return true end
    if s:find("nrv", 1, true) then return true end
    if s:find("raw leaf", 1, true) then return true end
    if s:find("raw%-leaf") then return true end
    if s:find("tincture", 1, true) then return true end
    return false
end

local function nocticChanceFor(cityName, divCode, evDesc)
    local risk = NOCTIC_CITY_RISK[cityName] or 0
    local chance = risk * 0.018

    if divCode == "ODF" then chance = chance + 0.04 end
    if divCode == "HAT" then chance = chance + 0.03 end
    if divCode == "ALP" then chance = chance + 0.02 end
    if divCode == "NSJ" and (cityName == "Norsolyra" or cityName == "Skýrophos") then chance = chance + 0.02 end
    if divCode == "CKD" and cityName == "Skøda" then chance = chance + 0.04 end

    if textHasNocticSignal(evDesc) then chance = chance + 0.22 end

    if chance > 0.30 then chance = 0.30 end
    if chance < 0 then chance = 0 end
    return chance
end

local function maybeNocticOperation(cityName, divCode, evDesc, primary, wxImp)
    local chance = nocticChanceFor(cityName, divCode, evDesc)
    if math.random() >= chance then return nil end

    local allowNaval = not (wxImp and wxImp.sea == "Closed")
    local pool

    if primary == "naval" and allowNaval then
        pool = NOCTIC_NAVAL_OPS
    elseif primary == "air" then
        pool = NOCTIC_AIR_OPS
    else
        pool = NOCTIC_GROUND_OPS
    end

    if not pool or #pool == 0 then return nil end
    return pickWeighted(pool)
end

--------------------------------------------------------------------------------
-- (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 args = frame and frame.args or {}
local di = getPSSCDate(args)
local rows = tonumber(args.rows) or 36
local showLegend = tostring(args.showlegend or "yes"):lower() ~= "no"
local flagSize = tonumber(args.flagsize) or 20
local southernStraitOnly = isSouthernStraitOnly(args)
local eventMap, hasEvents = buildEventMapForToday(di)

    local eventMap, hasEvents = buildEventMapForToday(di)

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

-- Southern Strait-only display settings.
-- rows = number of rows to DISPLAY.
-- scanrows = number of original War League rows to scan before filtering.
-- This preserves original Op-IDs because the original row index is still used.
local displayLimit = rows
local scanRows = rows
if southernStraitOnly then
    scanRows = tonumber(args.scanrows or args.searchrows or args.sourcerows) or math.max(rows * 6, 120)
end
local displayed = 0

local divFilter = tostring(args.division or ""):lower()
local divPool = {}

-- Important:
-- Southern Strait-only mode must NOT change the division pool.
-- It should only hide non-Southern rows after the normal War League row
-- has been generated. This preserves Op-ID, row index, operation type,
-- and all deterministic row details.
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, scanRows do
    local div = pickWeighted(divPool)
    local cityName = pick(div.cities)

    -- Do NOT force Southern Strait cities here.
    -- The row must be generated normally so Op-ID, division, operation type,
    -- and force details match the ordinary War League page.

-- Guardrail: Caledonian city => CKD.
-- CKD may now operate in Caledonian cities or Haifan Bassaridian Southern Strait nodes.
div = forceCKDIfCaledonian(div, cityName)
if div.code == "CKD" and not isAllowedCKDCity(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.
-- CKD Southern Strait deployments are naval-led, but still include ground,
-- inland, mountain, trade-route, and air-overwatch missions.
local isCKDSouthernStraitRow = (div.code == "CKD" and isHaifanBassaridiaCity(cityName))

local primary = pickWeighted({
    { name="ground", weight = isCKDSouthernStraitRow and 4 or 6 },
    { name="naval",  weight = isCKDSouthernStraitRow and 8 or ((div.code == "HAT" or div.code == "HFA") and 5 or 2) },
    { name="air",    weight = isCKDSouthernStraitRow and (3 + uScore) or (2 + uScore) },
}).name

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

-- CKD Southern Strait override:
-- If CKD appears in a Haifan Bassaridian city/node, replace the generic
-- operation with a Southern Strait / former Maritime Markets operation.
local ckdSouthernStraitOp = maybeCKDSouthernStraitOperation(cityName, div.code, primary, wxImp)
if ckdSouthernStraitOp then
    op = { name = ckdSouthernStraitOp.name }
    if ckdSouthernStraitOp.urg then
        if urg == "N/A" then
            urg = ckdSouthernStraitOp.urg
        else
            urg = urgencyMax(urg, ckdSouthernStraitOp.urg)
        end
        uScore = urgencyScore(urg)
    end
end

-- WX: Bias operation choice when environment forces hold/hazard posture.
-- For CKD Southern Strait rows, weather delays are still framed through the
-- Southern Strait mission rather than reverting to generic local patrol text.
if wxLevel >= 2 and wxImp then
    if isCKDSouthernStraitRow then
        if wxImp.sea == "Closed" then
            op = { name = "CKD Southern Strait storm-delay harbor screen (patrol craft held / quay cordon / inland transfer checks continue)" }
        end
        if wxLevel >= 3 and (wxImp.ground == "Hazard" or wxImp.air == "No-Go") then
            op = { name = "CKD Southern Strait hazard response (evacuation lanes / debris clearance / missionary and cargo-route protection)" }
        end
    else
        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
end

-- Optional Noctic overlay: occasionally turns the operation into a
-- Noctic-Rabrev / Noctic Vampirism support task when the city or event
-- makes it plausible. CKD Southern Strait rows stay Southern Strait-specific.
if not isCKDSouthernStraitRow then
    local nocticOp = maybeNocticOperation(cityName, div.code, evDesc, primary, wxImp)
    if nocticOp then
        op = { name = nocticOp.name }
        if nocticOp.urg then
            if urg == "N/A" then
                urg = nocticOp.urg
            else
                urg = urgencyMax(urg, nocticOp.urg)
            end
            uScore = urgencyScore(urg)
        end
    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 isCKDSouthernStraitRow
    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 (isCKDSouthernStraitRow and math.random() < 0.65)
    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

-- CKD Southern Strait rows get their own oversight/compliance language.
if isCKDSouthernStraitRow then
    obs = pickWeighted(CKD_SOUTHERN_STRAIT_OVERSIGHT).label
    comp = pickWeighted(CKD_SOUTHERN_STRAIT_COMPLIANCE).label
elseif textHasNocticSignal(op.name) then
    -- If the operation is Noctic-related, allow Noctic-specific oversight and
    -- compliance notes. This does not affect unrelated rows.
    obs = pickWeighted(NOCTIC_OVERSIGHT).label
    comp = pickWeighted(NOCTIC_COMPLIANCE).label
end

-- WX: force weather-delay compliance posture if Sea Closed / Air No-Go at Orange+.
-- CKD Southern Strait rows keep Southern Strait-specific delay language.
if wxLevel >= 2 and wxImp and (wxImp.sea == "Closed" or wxImp.air == "No-Go") then
    if isCKDSouthernStraitRow then
        obs = "Council of Kings Southern Strait weather liaison"
        comp = "Southern Strait weather delay; naval screen held under local restrictions while inland transfer checks, quay cordons, and route-security notes continue"
    else
        obs = "No observer (weather delay)"
        comp = "Weather delay; holding pattern under local command until visibility improves"
    end
end

local includeRow = true
if southernStraitOnly then
    includeRow = isHaifanBassaridiaCity(cityName)
end

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

if includeRow then
    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 })
    ))

    displayed = displayed + 1
    if displayed >= displayLimit then
        break
    end
end
    end

if southernStraitOnly and displayed == 0 then
    table.insert(out, "|-")
    table.insert(out, "| colspan='13' style='text-align:center;' | No Southern Strait / Haifan Bassaridian War League rows appeared in the scanned source rows. Increase scanrows.")
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