Module:WarLeagueOperationsPlannerBVR

From MicrasWiki
Jump to navigationJump to search

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

-- Module:WarLeagueOperationsPlannerBVR
-- Interactive/manual-input operations planner for the Bassaridian War League.
--
-- Designed as a companion to Module:WarLeagueDivisionOpsBVR.
-- The older module remains useful as the deterministic daily operations ledger;
-- this module generates ONE deliberately configured operation at a time.
--
-- Most fields accept "auto".  Editors can therefore lock only the choices they
-- care about and allow the remaining details to be generated deterministically.
--
-- Suggested wrapper:
--   Template:WarLeague Operations Planner
--
-- Main entry points:
--   {{#invoke:WarLeagueOperationsPlannerBVR|operation|...}}
--   {{#invoke:WarLeagueOperationsPlannerBVR|options}}
--   {{#invoke:WarLeagueOperationsPlannerBVR|compact|...}}

local p = {}

--------------------------------------------------------------------------------
-- A. BASIC HELPERS
--------------------------------------------------------------------------------

local function trim(v)
    if v == nil then return "" end
    return mw.text.trim(tostring(v))
end

local function lower(v)
    return mw.ustring.lower(trim(v))
end

local function norm(v)
    local s = lower(v)
    s = s:gsub("[_%-]+", " ")
    s = s:gsub("%s+", " ")
    return s
end

local function isAuto(v)
    local s = norm(v)
    return s == "" or s == "auto" or s == "automatic" or s == "random"
end

local function clamp(n, lo, hi)
    n = tonumber(n) or lo
    if n < lo then return lo end
    if n > hi then return hi end
    return n
end

local function copyArray(t)
    local out = {}
    for i, v in ipairs(t or {}) do out[i] = v end
    return out
end

local function contains(t, value)
    for _, v in ipairs(t or {}) do
        if v == value then return true end
    end
    return false
end

local function pick(t)
    if not t or #t == 0 then return nil end
    return t[math.random(#t)]
end

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

local function weightedKey(map)
    local list = {}
    for key, weight in pairs(map or {}) do
        table.insert(list, { key = key, weight = weight })
    end
    local item = pickWeighted(list)
    return item and item.key or nil
end

local function getArgs(frame)
    local args = {}
    if frame and frame.getParent then
        local parent = frame:getParent()
        if parent and parent.args then
            for k, v in pairs(parent.args) do
                if trim(v) ~= "" then args[k] = trim(v) end
            end
        end
    end
    if frame and frame.args then
        for k, v in pairs(frame.args) do
            if trim(v) ~= "" then args[k] = trim(v) end
        end
    end
    return args
end

local function safeUserText(v)
    local s = trim(v)
    if s == "" then return "" end
    if mw.text and mw.text.nowiki then return mw.text.nowiki(s) end
    return s:gsub("|", "&#124;"):gsub("<", "&lt;"):gsub(">", "&gt;")
end

local function bulletList(items)
    local clean = {}
    for _, item in ipairs(items or {}) do
        if item and trim(item) ~= "" then table.insert(clean, item) end
    end
    if #clean == 0 then return "—" end
    return "<ul><li>" .. table.concat(clean, "</li><li>") .. "</li></ul>"
end

local function joinComma(items)
    local clean = {}
    for _, item in ipairs(items or {}) do
        if item and trim(item) ~= "" then table.insert(clean, item) end
    end
    return table.concat(clean, ", ")
end

local function yes(v)
    local s = norm(v)
    return s == "yes" or s == "y" or s == "true" or s == "1" or s == "on"
end

--------------------------------------------------------------------------------
-- B. PSSC DATE
--------------------------------------------------------------------------------

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
        overrideDay = clamp(overrideDay, 1, 183)
        local month, dayOfMonth
        if overrideDay <= 61 then
            month, dayOfMonth = 1, overrideDay
        elseif overrideDay <= 122 then
            month, dayOfMonth = 2, overrideDay - 61
        else
            month, dayOfMonth = 3, overrideDay - 122
        end
        return {
            text = string.format("%d/%d/%d PSSC", dayOfMonth, month, overrideYear),
            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 psscYear = math.floor(totalDays / daysPerYear)
    local dayOfYear = (totalDays % daysPerYear) + 1
    local month, dayOfMonth
    if dayOfYear <= 61 then
        month, dayOfMonth = 1, dayOfYear
    elseif dayOfYear <= 122 then
        month, dayOfMonth = 2, dayOfYear - 61
    else
        month, dayOfMonth = 3, dayOfYear - 122
    end
    return {
        text = string.format("%d/%d/%d PSSC", dayOfMonth, month, psscYear),
        year = psscYear,
        dayOfYear = dayOfYear,
        month = month,
        dom = dayOfMonth,
    }
end

--------------------------------------------------------------------------------
-- C. DIVISIONS
--------------------------------------------------------------------------------

local DIVISIONS = {
    VAE = {
        code = "VAE", name = "Vaeringheim Division", link = "[[Vaeringheim Division]]", weight = 7,
        theatres = {"morovian_core", "general_port"},
        doctrine = "Core corridor security: port approaches, canals, industrial continuity, infrastructure protection and high-population stability.",
    },
    ALP = {
        code = "ALP", name = "Alpazkigz Division", link = "[[Alpazkigz Division]]", weight = 5,
        theatres = {"alpazkigz", "morovian_core"},
        doctrine = "Western periphery and highlands: shrine security, caravan routes, insurgent interdiction, Noctic-linked custody support and hazard response.",
    },
    ODF = {
        code = "ODF", name = "Odiferia Division", link = "[[Odiferia Division]]", weight = 4,
        theatres = {"odiferia", "morovian_core"},
        doctrine = "Southern wetlands and tribal interface: swamp corridors, lowland stability, rural enforcement support and relief movement.",
    },
    HAT = {
        code = "HAT", name = "Hatch Ministry Division", link = "[[Hatch Ministry Division]]", weight = 5,
        theatres = {"haifan", "southern_strait", "general_port"},
        doctrine = "Maritime corridor enforcement: escort corridors, VBSS, interdiction, convoy discipline, prize evidence and lawful straits enforcement.",
    },
    NSJ = {
        code = "NSJ", name = "New South Jangsong Division", link = "[[New South Jangsong Division]]", weight = 5,
        theatres = {"new_south_jangsong", "normark"},
        doctrine = "Northern straits and frontier belt: raids, weather shocks, export-security enforcement, route defence and sustained frontier operations.",
    },
    HFA = {
        code = "HFA", name = "Haifan Bassaridia Division", link = "[[Haifan Bassaridia Division]]", weight = 5,
        theatres = {"haifan", "southern_strait"},
        doctrine = "Southern straits urban and littoral integration: port-city stability, coastal route security and anti-piracy legacy zones.",
    },
    BNO = {
        code = "BNO", name = "Bassaridian Normark Division", link = "[[Bassaridian Normark Division]]", weight = 3,
        theatres = {"normark", "new_south_jangsong"},
        doctrine = "Far-northern sustainment: sparse-settlement corridor security, consolidation, weather resilience and long-range logistics.",
    },
    OUR = {
        code = "OUR", name = "Ouriana Division", link = "[[Ouriana Division]]", weight = 3,
        theatres = {"ouriana"},
        doctrine = "Central Keltian route governance: settlement security, containment, road and rail continuity and post-campaign civil stabilisation.",
    },
    CKD = {
        code = "CKD", name = "Council of Kings Division", link = "[[Council of Kings Division]]", weight = 3,
        theatres = {"eastern_caledonia", "southern_strait", "ouriana"},
        doctrine = "Executive joint tasking: dependency stabilisation, strategic corridor integration, high-risk joint operations and direct Council missions.",
    },
}

local DIVISION_ALIASES = {
    ["vae"] = "VAE", ["vaeringheim"] = "VAE", ["vaeringheim division"] = "VAE",
    ["alp"] = "ALP", ["alpazkigz"] = "ALP", ["alpazkigz division"] = "ALP",
    ["odf"] = "ODF", ["odiferia"] = "ODF", ["odiferia division"] = "ODF",
    ["hat"] = "HAT", ["hatch"] = "HAT", ["hatch ministry"] = "HAT", ["hatch ministry division"] = "HAT",
    ["nsj"] = "NSJ", ["new south jangsong"] = "NSJ", ["new south jangsong division"] = "NSJ",
    ["hfa"] = "HFA", ["haifan"] = "HFA", ["haifan bassaridia"] = "HFA", ["haifan bassaridia division"] = "HFA",
    ["bno"] = "BNO", ["bnm"] = "BNO", ["normark"] = "BNO", ["bassaridian normark"] = "BNO", ["bassaridian normark division"] = "BNO",
    ["our"] = "OUR", ["ouriana"] = "OUR", ["ouriana division"] = "OUR",
    ["ckd"] = "CKD", ["council of kings"] = "CKD", ["council of kings division"] = "CKD",
}

--------------------------------------------------------------------------------
-- D. THEATRES / NODES
--------------------------------------------------------------------------------

local THEATRES = {
    morovian_core = {
        key = "morovian_core", label = "Lake Morovia core", weight = 8,
        divisions = {VAE=8, ALP=2, ODF=2},
        nodes = {"Vaeringheim", "Luminaria", "Serena", "Symphonara", "Aurelia", "Sylvapolis", "Lunalis Sancta", "Delphica", "Catonis Atrium", "Ferrum Citadel", "Nexa"},
        tags = {urban=true, lake=true, rail=true, commercial=true},
        doctrine = "Protect throughput, canals, rail approaches, industrial continuity and densely populated civic nodes while keeping ordinary Port and municipal administration functioning.",
    },
    general_port = {
        key = "general_port", label = "General Port of Lake Morovia", weight = 4,
        divisions = {VAE=7, HAT=4, CKD=2},
        nodes = {"General Port of Lake Morovia", "Vaeringheim", "Somniumpolis"},
        tags = {urban=true, lake=true, port=true, rail=true, commercial=true, orbital=true},
        doctrine = "Maintain port-state control, registry integrity, cargo custody, strategic infrastructure and launch/logistics continuity without unnecessarily interrupting lawful trade.",
    },
    alpazkigz = {
        key = "alpazkigz", label = "Alpazkigz and western Morovian highlands", weight = 5,
        divisions = {ALP=9, VAE=1},
        nodes = {"The Alpazkigz", "Pyralis", "Saluria", "Acheron", "Koinonía", "Aureum", "Aetherium", "Erythros", "Gloom Forest of Perpetual Autumn", "Northern and Western Morovian Highlands", "Alperkin Coastal Woodlands"},
        tags = {highland=true, sacred=true, tribal=true, noctic=true, wilderness=true},
        doctrine = "Respect Alperkin sacred-space and customary constraints while securing caravan routes, settlements, clinics, Noctic custody chains and highland approaches.",
    },
    odiferia = {
        key = "odiferia", label = "Odiferia and southern wetlands", weight = 4,
        divisions = {ODF=9, ALP=2},
        nodes = {"Odiferia", "Somniumpolis", "Southern Odiferae Wetlands", "River Noctis approaches"},
        tags = {wetland=true, tribal=true, noctic=true, lake=true},
        doctrine = "Use light, distributed forces suited to swamp and canal movement; separate military security from tribal, health, temple and technical authority.",
    },
    new_south_jangsong = {
        key = "new_south_jangsong", label = "New South Jangsong", weight = 5,
        divisions = {NSJ=9, BNO=2},
        nodes = {"Skýrophos", "Bjornopolis", "Aegirheim", "Norsolyra", "Thorsalon", "Pelagia", "Myrene", "Thyrea", "Ephyra", "Halicarn"},
        tags = {frontier=true, maritime=true, rail=true, urban=true, cold=true},
        doctrine = "Hold northern-strain corridors against raids, sabotage and weather disruption while preserving export-control and civil supply routes.",
    },
    haifan = {
        key = "haifan", label = "Haifan Bassaridia", weight = 5,
        divisions = {HFA=8, HAT=5, CKD=1},
        nodes = {"Keybir-Aviv", "Tel-Amin", "Diamandis", "Jogi", "Lewisburg", "Thermosalem", "Akróstadium", "Sufriya", "Lykopolis", "Mylecia"},
        tags = {maritime=true, port=true, urban=true, strait=true, commercial=true},
        doctrine = "Secure port cities, ferries, coastal routes and anti-piracy legacy zones while maintaining lawful civilian and commercial movement.",
    },
    normark = {
        key = "normark", label = "Bassaridian Normark", weight = 3,
        divisions = {BNO=8, NSJ=3},
        nodes = {"Ardclach", "Riddersborg"},
        tags = {frontier=true, cold=true, sparse=true, maritime=true},
        doctrine = "Favor sustainment, route reliability and weather resilience over dense occupation; maintain protected links between sparse holdings.",
    },
    ouriana = {
        key = "ouriana", label = "Ouriana and central Keltian corridor", weight = 3,
        divisions = {OUR=8, CKD=3},
        nodes = {"Bashkim", "Ourid", "Tonar"},
        tags = {frontier=true, inland=true, rail=true, dependency=true},
        doctrine = "Secure post-campaign settlement corridors, contain destabilising cells and preserve the distinction between security presence and ordinary civil administration.",
    },
    eastern_caledonia = {
        key = "eastern_caledonia", label = "Eastern Caledonian Highlands", weight = 4,
        divisions = {CKD=10},
        nodes = {"Fanghorn", "Eikbu", "Galvø", "Skøda", "Hamarrfell", "Sårensby", "Slevik", "Sjøsborg", "Storesund", "Notranskja", "Kaledonija", "Krlsgorod"},
        tags = {highland=true, cold=true, rail=true, dependency=true, fjord=true},
        doctrine = "CKD-exclusive dependency and corridor tasking: protect rail/highland approaches, negotiation sites, local institutions and integration works under direct Council oversight.",
    },
    southern_strait = {
        key = "southern_strait", label = "Southern Strait corridor / Lacaran Hodos", weight = 6,
        divisions = {CKD=8, HAT=4, HFA=3},
        nodes = {"Mylecia", "Nysara Velai", "Thal Lenaia", "Selenarion", "Hayr Chaskal", "Saruq Thyrsa", "Rumi-Kefaya", "Mirovia", "Lacaran Hodos", "Lacaran Hodos Divan"},
        tags = {strait=true, corridor=true, rail=true, road=true, highland=true, commercial=true, dependency=true},
        doctrine = "Enforce the Straits Conventions through lawful passage control, registry and evidence discipline, White-Lane protection, missionary safety and route security while preserving sacred spaces, local civic authorities and customary access.",
    },
}

local THEATRE_ALIASES = {
    ["core"] = "morovian_core", ["morovia"] = "morovian_core", ["lake morovia"] = "morovian_core", ["lake morovia core"] = "morovian_core", ["morovian core"] = "morovian_core",
    ["port"] = "general_port", ["general port"] = "general_port", ["general port of lake morovia"] = "general_port",
    ["alpazkigz"] = "alpazkigz", ["alperkin"] = "alpazkigz", ["western highlands"] = "alpazkigz",
    ["odiferia"] = "odiferia", ["wetlands"] = "odiferia", ["southern wetlands"] = "odiferia",
    ["nsj"] = "new_south_jangsong", ["new south jangsong"] = "new_south_jangsong", ["jangsong"] = "new_south_jangsong",
    ["haifan"] = "haifan", ["haifan bassaridia"] = "haifan",
    ["normark"] = "normark", ["bassaridian normark"] = "normark",
    ["ouriana"] = "ouriana", ["central keltia"] = "ouriana",
    ["caledonia"] = "eastern_caledonia", ["eastern caledonia"] = "eastern_caledonia", ["eastern caledonian highlands"] = "eastern_caledonia",
    ["southern strait"] = "southern_strait", ["southern straits"] = "southern_strait", ["southernstrait"] = "southern_strait", ["lacaran hodos"] = "southern_strait", ["corridor"] = "southern_strait",
}

local NODE_TO_THEATRE = {}
for key, th in pairs(THEATRES) do
    for _, node in ipairs(th.nodes) do NODE_TO_THEATRE[norm(node)] = key end
end

local function theatreFromInput(v)
    if isAuto(v) then return nil end
    local n = norm(v)
    local key = THEATRE_ALIASES[n]
    if key then return THEATRES[key] end
    for k, th in pairs(THEATRES) do
        if norm(k) == n or norm(th.label) == n then return th end
    end
    return nil
end

local function divisionFromInput(v)
    if isAuto(v) then return nil end
    local key = DIVISION_ALIASES[norm(v)] or trim(v):upper()
    return DIVISIONS[key]
end

local function inferTheatreFromNode(node)
    local key = NODE_TO_THEATRE[norm(node)]
    return key and THEATRES[key] or nil
end

--------------------------------------------------------------------------------
-- E. MISSIONS, THREATS, POSTURES
--------------------------------------------------------------------------------

local MISSIONS = {
    corridor_security = {label="Corridor security", primary="ground", base=2, tags={corridor=true, rail=true, road=true}},
    urban_stabilization = {label="Urban stabilization", primary="ground", base=2, tags={urban=true}},
    anti_smuggling = {label="Anti-smuggling interdiction / contraband seizure", primary="joint", base=2, tags={commercial=true, strait=true, noctic=true}},
    infrastructure_protection = {label="Infrastructure protection", primary="ground", base=2, tags={rail=true, road=true, port=true, water=true}},
    counter_insurgency = {label="Counter-insurgency / cell disruption", primary="ground", base=3, tags={frontier=true, highland=true, urban=true}},
    hazard_response = {label="Hazard response / evacuation / route reopening", primary="joint", base=2, tags={wilderness=true, cold=true, wetland=true}},
    convoy_escort = {label="Convoy escort / protected transfer", primary="ground", base=1, tags={road=true, rail=true, commercial=true}},
    water_security = {label="Aqueduct / pump-house / water-relief security", primary="ground", base=2, tags={water=true, urban=true}},
    market_security = {label="Market and ledger security", primary="ground", base=2, tags={commercial=true, urban=true, port=true}},
    sacred_route = {label="Sacred-route / shrine approach protection", primary="ground", base=1, tags={sacred=true, tribal=true, corridor=true}},
    noctic_custody = {label="Noctic-Rabrev custody / clinic / ledger support", primary="joint", base=2, tags={noctic=true, wetland=true, commercial=true}},
    maritime_escort = {label="Maritime escort / lake or strait patrol", primary="naval", base=1, tags={maritime=true, lake=true, strait=true}},
    vbss = {label="VBSS / boarding / contraband inspection", primary="naval", base=2, tags={maritime=true, port=true, strait=true}},
    blockade_warning = {label="Blockade-warning / selective cordon", primary="naval", base=3, tags={strait=true, maritime=true}},
    harbor_security = {label="Harbor / lock-gate security", primary="naval", base=2, tags={port=true, lake=true, maritime=true}},
    white_lane = {label="White-Lane humanitarian / essential-goods protection", primary="joint", base=2, tags={strait=true, corridor=true, humanitarian=true}},
    missionary_escort = {label="Missionary and pilgrimage-route escort", primary="joint", base=1, tags={sacred=true, corridor=true, strait=true}},
    prize_transfer = {label="Prize-court evidence / detainee transfer escort", primary="joint", base=2, tags={strait=true, port=true, commercial=true}},
    dependency_stabilization = {label="Dependency stabilization / protected integration", primary="joint", base=2, tags={dependency=true, highland=true, frontier=true}},
    route_clearance = {label="Route clearance / engineering security", primary="ground", base=2, tags={road=true, rail=true, highland=true}},
    lacaran_hodos = {label="Lacaran Hodos rail-road protection", primary="joint", base=2, tags={corridor=true, rail=true, road=true, highland=true}},
    southern_strait_compliance = {label="Southern Strait conventions-compliance operation", primary="joint", base=2, tags={strait=true, corridor=true, commercial=true}},
    confederacy_interdiction = {label="Confederacy-linked network interdiction", primary="joint", base=3, tags={frontier=true, strait=true, corridor=true}},
    isr = {label="ISR / route scanning / signals overwatch", primary="air", base=1, tags={frontier=true, highland=true, maritime=true}},
    medevac = {label="Rapid extraction / MEDEVAC lift", primary="air", base=2, tags={humanitarian=true, wilderness=true, urban=true}},
    show_of_force = {label="Controlled show-of-force / deterrence package", primary="air", base=2, tags={frontier=true, strait=true, urban=true}},
    eidolan_containment = {label="Somniant Eidolan / anomalous containment support", primary="joint", base=4, tags={anomalous=true, noctic=true, wilderness=true}},
    training = {label="Readiness exercise / Games-of-the-Standards style field drill", primary="joint", base=1, tags={training=true, rail=true, road=true}},
}

local MISSION_ALIASES = {
    ["corridor"]="corridor_security", ["corridor security"]="corridor_security",
    ["urban"]="urban_stabilization", ["urban stabilization"]="urban_stabilization", ["urban stabilisation"]="urban_stabilization",
    ["smuggling"]="anti_smuggling", ["anti smuggling"]="anti_smuggling", ["anti-smuggling"]="anti_smuggling",
    ["infrastructure"]="infrastructure_protection", ["infrastructure protection"]="infrastructure_protection",
    ["counter insurgency"]="counter_insurgency", ["counter-insurgency"]="counter_insurgency", ["coin"]="counter_insurgency",
    ["hazard"]="hazard_response", ["hazard response"]="hazard_response", ["disaster"]="hazard_response",
    ["convoy"]="convoy_escort", ["convoy escort"]="convoy_escort",
    ["water"]="water_security", ["water security"]="water_security", ["aqueduct"]="water_security",
    ["market"]="market_security", ["market security"]="market_security", ["ledger"]="market_security",
    ["sacred"]="sacred_route", ["sacred route"]="sacred_route", ["shrine"]="sacred_route",
    ["noctic"]="noctic_custody", ["noctic custody"]="noctic_custody", ["noctic-rabrev"]="noctic_custody",
    ["maritime"]="maritime_escort", ["maritime escort"]="maritime_escort", ["lake patrol"]="maritime_escort",
    ["vbss"]="vbss", ["boarding"]="vbss",
    ["blockade"]="blockade_warning", ["blockade warning"]="blockade_warning",
    ["harbor"]="harbor_security", ["harbour"]="harbor_security", ["harbor security"]="harbor_security",
    ["white lane"]="white_lane", ["white-lane"]="white_lane", ["humanitarian"]="white_lane",
    ["missionary"]="missionary_escort", ["missionary escort"]="missionary_escort", ["pilgrimage"]="missionary_escort",
    ["prize"]="prize_transfer", ["prize court"]="prize_transfer", ["prize-court"]="prize_transfer",
    ["dependency"]="dependency_stabilization", ["dependency stabilization"]="dependency_stabilization", ["dependency stabilisation"]="dependency_stabilization",
    ["route clearance"]="route_clearance", ["engineering"]="route_clearance",
    ["lacaran hodos"]="lacaran_hodos", ["hodos"]="lacaran_hodos",
    ["southern strait"]="southern_strait_compliance", ["straits compliance"]="southern_strait_compliance", ["compliance"]="southern_strait_compliance",
    ["confederacy"]="confederacy_interdiction", ["confederacy interdiction"]="confederacy_interdiction",
    ["isr"]="isr", ["recon"]="isr", ["reconnaissance"]="isr",
    ["medevac"]="medevac", ["extraction"]="medevac",
    ["show of force"]="show_of_force", ["deterrence"]="show_of_force",
    ["eidolan"]="eidolan_containment", ["anomalous"]="eidolan_containment",
    ["training"]="training", ["exercise"]="training", ["war games"]="training",
}

local THREATS = {
    routine = {label="Routine / no specific hostile actor", score=0},
    smuggling = {label="Smuggling / cargo-identity laundering", score=1},
    piracy = {label="Pirate or irregular maritime activity", score=2},
    insurgency = {label="Insurgent / separatist cell", score=3},
    confederacy = {label="Confederacy-linked or adjacent network", score=3},
    noctic = {label="Noctic-Rabrev trafficking / counterfeit nocticine", score=2},
    vampirism = {label="Noctic Vampirism outbreak / panic", score=3},
    sabotage = {label="Infrastructure sabotage", score=3},
    forged_docs = {label="Forged manifests / false seals / courier-paper network", score=2},
    civil_unrest = {label="Civil unrest / hostile crowd pressure", score=2},
    weather = {label="Severe weather / environmental disruption", score=2},
    infrastructure = {label="Infrastructure failure / route collapse", score=2},
    eidolan = {label="Somniant Eidolan / anomalous manifestation", score=4},
}

local THREAT_ALIASES = {
    ["none"]="routine", ["routine"]="routine",
    ["smuggling"]="smuggling", ["contraband"]="smuggling",
    ["piracy"]="piracy", ["pirates"]="piracy",
    ["insurgency"]="insurgency", ["rebellion"]="insurgency", ["separatists"]="insurgency",
    ["confederacy"]="confederacy", ["dispossessed"]="confederacy",
    ["noctic"]="noctic", ["noctic trafficking"]="noctic", ["counterfeit nocticine"]="noctic",
    ["vampirism"]="vampirism", ["noctic vampirism"]="vampirism",
    ["sabotage"]="sabotage",
    ["forged docs"]="forged_docs", ["forged documents"]="forged_docs", ["false seals"]="forged_docs",
    ["civil unrest"]="civil_unrest", ["crowd"]="civil_unrest",
    ["weather"]="weather", ["storm"]="weather",
    ["infrastructure"]="infrastructure", ["infrastructure failure"]="infrastructure",
    ["eidolan"]="eidolan", ["anomaly"]="eidolan", ["anomalous"]="eidolan",
}

local POSTURES = {
    restrained = {label="Restrained", force=-1, risk=-1, desc="Minimum necessary force; escort, evidence preservation and de-escalation favored."},
    standard = {label="Standard", force=0, risk=0, desc="Normal divisional posture under standing constitutional and corridor rules."},
    assertive = {label="Assertive", force=1, risk=1, desc="Reinforced presence, broader screening and faster reserve commitment."},
    emergency = {label="Emergency", force=2, risk=2, desc="Surge posture for immediate life-safety, strategic infrastructure or national-security risk."},
}

local STAGES = {
    planning = "Planning / tasking",
    active = "Active operation",
    after_action = "After-action review",
    exercise = "Training / exercise",
}

local function missionFromInput(v)
    if isAuto(v) then return nil, nil end
    local n = norm(v)
    local key = MISSION_ALIASES[n]
    if key and MISSIONS[key] then return key, MISSIONS[key] end
    for k, m in pairs(MISSIONS) do
        if norm(k) == n or norm(m.label) == n then return k, m end
    end
    return nil, nil
end

local function threatFromInput(v)
    if isAuto(v) then return nil, nil end
    local key = THREAT_ALIASES[norm(v)]
    if key then return key, THREATS[key] end
    return nil, nil
end

--------------------------------------------------------------------------------
-- F. AUTO-SELECTION LOGIC
--------------------------------------------------------------------------------

local function chooseTheatre(div)
    local weighted = {}
    for _, key in ipairs(div and div.theatres or {}) do
        local th = THEATRES[key]
        if th then table.insert(weighted, {key=key, weight=th.weight or 1}) end
    end
    if #weighted == 0 then
        for key, th in pairs(THEATRES) do table.insert(weighted, {key=key, weight=th.weight or 1}) end
    end
    local item = pickWeighted(weighted)
    return item and THEATRES[item.key] or THEATRES.morovian_core
end

local function chooseDivision(theatre)
    local map = theatre and theatre.divisions or nil
    local key = weightedKey(map)
    if key and DIVISIONS[key] then return DIVISIONS[key] end
    local pool = {}
    for _, div in pairs(DIVISIONS) do table.insert(pool, {div=div, weight=div.weight or 1}) end
    local item = pickWeighted(pool)
    return item and item.div or DIVISIONS.VAE
end

local function missionWeightForTheatre(mission, theatre)
    local w = 1
    if not mission or not theatre then return w end
    for tag, _ in pairs(mission.tags or {}) do
        if theatre.tags and theatre.tags[tag] then w = w + 4 end
    end
    if theatre.key == "southern_strait" then
        local special = {
            southern_strait_compliance=10, lacaran_hodos=9, white_lane=7,
            missionary_escort=5, prize_transfer=5, confederacy_interdiction=6,
            route_clearance=5, dependency_stabilization=5, vbss=4,
        }
        w = w + (special[mission._key or ""] or 0)
    elseif theatre.key == "eastern_caledonia" then
        local special = {dependency_stabilization=10, route_clearance=6, corridor_security=6, hazard_response=5, isr=4}
        w = w + (special[mission._key or ""] or 0)
    elseif theatre.key == "alpazkigz" then
        local special = {sacred_route=7, noctic_custody=6, hazard_response=6, counter_insurgency=4, convoy_escort=4}
        w = w + (special[mission._key or ""] or 0)
    elseif theatre.key == "odiferia" then
        local special = {noctic_custody=8, hazard_response=6, corridor_security=5, water_security=4, medevac=4}
        w = w + (special[mission._key or ""] or 0)
    elseif theatre.key == "general_port" then
        local special = {market_security=8, infrastructure_protection=8, harbor_security=7, anti_smuggling=7, water_security=5}
        w = w + (special[mission._key or ""] or 0)
    end
    return w
end

local function chooseMission(theatre, stage)
    local list = {}
    for key, mission in pairs(MISSIONS) do
        mission._key = key
        local w = missionWeightForTheatre(mission, theatre)
        if stage == "exercise" then
            if key == "training" then w = w + 20 else w = math.max(1, math.floor(w / 3)) end
        elseif key == "training" then
            w = 1
        end
        table.insert(list, {key=key, weight=w})
    end
    local item = pickWeighted(list)
    local key = item and item.key or "corridor_security"
    return key, MISSIONS[key]
end

local function chooseThreat(theatre, mission)
    local weights = {
        routine=5, smuggling=4, piracy=2, insurgency=3, confederacy=2,
        noctic=2, vampirism=1, sabotage=2, forged_docs=3, civil_unrest=2,
        weather=2, infrastructure=2, eidolan=0.35,
    }
    if theatre then
        if theatre.key == "southern_strait" then
            weights.confederacy = 5; weights.forged_docs = 6; weights.smuggling = 6; weights.piracy = 3
        elseif theatre.key == "alpazkigz" or theatre.key == "odiferia" then
            weights.noctic = 6; weights.vampirism = 3; weights.weather = 3; weights.eidolan = 0.7
        elseif theatre.key == "general_port" then
            weights.sabotage = 5; weights.forged_docs = 5; weights.smuggling = 5
        elseif theatre.key == "new_south_jangsong" or theatre.key == "normark" then
            weights.insurgency = 5; weights.weather = 5; weights.sabotage = 4
        elseif theatre.key == "eastern_caledonia" then
            weights.weather = 5; weights.infrastructure = 4; weights.insurgency = 3
        end
    end
    if mission then
        local k = mission._key
        if k == "hazard_response" then weights.weather=8; weights.infrastructure=6 end
        if k == "noctic_custody" then weights.noctic=9; weights.vampirism=5 end
        if k == "confederacy_interdiction" then weights.confederacy=10 end
        if k == "eidolan_containment" then weights.eidolan=12 end
        if k == "vbss" or k == "anti_smuggling" then weights.smuggling=8; weights.forged_docs=5 end
    end
    local key = weightedKey(weights) or "routine"
    return key, THREATS[key]
end

--------------------------------------------------------------------------------
-- G. DAILY EVENT + FORECAST INTEGRATION
--------------------------------------------------------------------------------

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 = daily.makeTable({args={year=tostring(di.year), day=tostring(di.dayOfYear)}})
    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, ev, urg = parts[2], parts[3], 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 WX_MANUAL = {
    fair = {label="Green / Fair", level=0, condition="Fair", impacts={ground="OK", sea="OK", air="OK"}},
    fog = {label="Yellow / Fog", level=1, condition="Fog", impacts={ground="Slow", sea="OK", air="Limited"}},
    rain = {label="Yellow / Rain", level=1, condition="Rain", impacts={ground="Slow", sea="Restricted", air="Limited"}},
    storm = {label="Orange / Storm", level=2, condition="Storm", impacts={ground="Slow", sea="Restricted", air="Limited"}},
    severe_storm = {label="Red / Severe storm", level=3, condition="Severe storm", impacts={ground="Hazard", sea="Closed", air="No-Go"}},
    blizzard = {label="Red / Blizzard", level=3, condition="Blizzard", impacts={ground="Hazard", sea="Restricted", air="No-Go"}},
    flood = {label="Red / Flood", level=3, condition="Flood", impacts={ground="Hazard", sea="Restricted", air="Limited"}},
    heat = {label="Yellow / Extreme heat", level=1, condition="Extreme heat", impacts={ground="Slow", sea="OK", air="OK"}},
    ash = {label="Orange / Ash / smoke", level=2, condition="Ash / smoke", impacts={ground="Slow", sea="OK", air="Limited"}},
}

local WX_ALIASES = {
    ["fair"]="fair", ["green"]="fair", ["clear"]="fair",
    ["fog"]="fog", ["yellow"]="fog",
    ["rain"]="rain",
    ["storm"]="storm", ["orange"]="storm",
    ["severe storm"]="severe_storm", ["red"]="severe_storm",
    ["blizzard"]="blizzard", ["snow"]="blizzard",
    ["flood"]="flood", ["flooding"]="flood",
    ["heat"]="heat", ["extreme heat"]="heat",
    ["ash"]="ash", ["smoke"]="ash",
}

local function forecastFor(node, di)
    local ok, wx = pcall(require, "Module:BassaridiaForecast")
    if not ok or not wx or type(wx.getOpsData) ~= "function" then return nil end
    local data = wx.getOpsData(node, di.year, di.dayOfYear)
    if not data then return nil end
    local alert = data.alert or {}
    return {
        label = tostring(alert.label or "Green"),
        level = tonumber(alert.level) or 0,
        condition = tostring(data.condition or ""),
        hazard = tostring(alert.hazard or ""),
        impacts = data.impacts or {ground="OK", sea="OK", air="OK"},
        source = "Module:BassaridiaForecast",
    }
end

local function resolveWeather(args, node, di)
    local raw = args.weather or args.wx or "auto"
    local n = norm(raw)
    if isAuto(raw) or n == "forecast" then
        local data = forecastFor(node, di)
        if data then return data end
        return WX_MANUAL.fair
    end
    local key = WX_ALIASES[n]
    return key and WX_MANUAL[key] or WX_MANUAL.fair
end

local function weatherPenalty(wx)
    if not wx then return 0 end
    local lvl = tonumber(wx.level) or 0
    return ({[0]=0,[1]=4,[2]=9,[3]=16})[lvl] or 0
end

--------------------------------------------------------------------------------
-- H. FORCE STRUCTURE + EQUIPMENT
--------------------------------------------------------------------------------

local GROUND_ECHELONS = {"Turmae", "Contubernia", "Ordinis", "Manipuli", "Centuriae", "Cohortes"}
local NAVAL_ECHELONS = {"Birlik-i Gemi (Patrol Element)", "Tabur-i Derya (Task Unit)", "Ordu (Task Group)"}
local AIR_ECHELONS = {"Zeygi (Element)", "Ptisis (Flight)", "Moira (Squadron)"}

local FORCE_LEVEL = {
    none = -99,
    light = -1,
    standard = 0,
    reinforced = 1,
    heavy = 2,
    surge = 3,
}

local function forceOverride(v)
    if isAuto(v) then return nil end
    return FORCE_LEVEL[norm(v)]
end

local GROUND_EQUIPMENT = {
    {name="[[Bijarian Command Vehicle]]", tags={command=true, all=true}},
    {name="[[Corythia Class Dedicated Logistics and Transport Truck]]", tags={logistics=true, all=true}},
    {name="[[Abeis-Bulhanu Virelia-Class Urban Pacifier]]", tags={urban=true, crowd=true}},
    {name="[[Abeis-Bulhanu Class High-Speed Skirmisher (Hoverbike)]]", tags={road=true, frontier=true}},
    {name="[[Abeis-Bulhanu Khamsin-Class Desert Skirmisher]]", tags={road=true, frontier=true, strait=true}},
    {name="[[Abeis-Bulhanu Hrimthurs-Class Arctic Hoverbike]]", tags={cold=true, highland=true}},
    {name="[[Abeis-Ismael Class “Cathartes” Littoral Hoverbike]]", tags={strait=true, maritime=true, port=true}},
    {name="[[Quadwalker “Oble-Lisea” 4189]]", tags={wilderness=true, highland=true, patrol=true}},
    {name="[[Gargani Class Quadwalker]]", tags={wilderness=true, patrol=true, crowd=true}},
    {name="[[Model V-99 Thunderguard Citadel Train]]", tags={rail=true, command=true, custody=true}},
    {name="[[Class SZ-88 Somniant Bastion]]", tags={command=true, overwatch=true, all=true}},
    {name="[[Delphis Class Coastal Surveillance Tower]]", tags={port=true, strait=true, maritime=true}},
    {name="[[Abeis Class Advanced Air Defense System (Mobile Laser)]]", tags={airdefense=true, port=true, strategic=true}},
}

local NAVAL_EQUIPMENT = {
    {name="[[Aetherium Class Patrol Ship]]", tags={patrol=true, port=true, strait=true, lake=true}},
    {name="[[Vaeringheim Class Corvette]]", tags={escort=true, patrol=true, lake=true, strait=true}},
    {name="[[Saluria Class Gunboat]]", tags={littoral=true, patrol=true, lake=true, strait=true}},
    {name="[[Cetus Class Attack Craft]]", tags={intercept=true, patrol=true, lake=true, strait=true}},
    {name="[[Cetomagna Class Minehunter]]", tags={hazard=true, port=true, strait=true}},
    {name="[[Velkara Class Littoral Landing Ship]]", tags={lift=true, humanitarian=true, strait=true}},
    {name="[[Abeis-Empress Class Littoral Battlecruiser]]", tags={command=true, deterrence=true, strait=true}},
    {name="[[Ismael Class Privateer Commission]]", tags={privateer=true, interdiction=true, strait=true}},
}

local AIR_EQUIPMENT = {
    {name="[[Catonis Class Unmanned Aircraft (UAV)]]", tags={isr=true, all=true}},
    {name="[[Lotos Class Tactical UAV]]", tags={isr=true, all=true}},
    {name="[[Aurantius Class Multi-Role UAV]]", tags={isr=true, sigint=true, all=true}},
    {name="[[Noctiluna Class Medium Transport Helicopter]]", tags={medevac=true, lift=true, humanitarian=true}},
    {name="[[Umbraclaw Class Heavy Transport Helicopter]]", tags={lift=true, logistics=true}},
    {name="[[Fluxus Class Maritime Patrol Aircraft]]", tags={maritime=true, strait=true, lake=true, isr=true}},
    {name="[[Hostica Class Maritime Patrol Aircraft]]", tags={maritime=true, strait=true, lake=true, isr=true}},
    {name="[[Ventiflor Eye Class AEW&C Aircraft]]", tags={command=true, airspace=true, strategic=true}},
    {name="[[Thalassa Class Attack Helicopter]]", tags={deterrence=true, combat=true}},
    {name="[[Abeis-Osiríos Class CAS Gunship]]", tags={combat=true, deterrence=true}},
    {name="[[Abeis-Minerva Class Combat Littoral Strike Aircraft]]", tags={littoral=true, deterrence=true, strait=true}},
}

local function missionTags(mission, theatre)
    local tags = {}
    for k, v in pairs((mission and mission.tags) or {}) do if v then tags[k] = true end end
    for k, v in pairs((theatre and theatre.tags) or {}) do if v then tags[k] = true end end
    tags.all = true
    if mission then
        local key = mission._key
        if key == "isr" then tags.isr = true end
        if key == "medevac" or key == "hazard_response" or key == "white_lane" then tags.humanitarian = true; tags.lift = true end
        if key == "anti_smuggling" or key == "vbss" or key == "confederacy_interdiction" then tags.interdiction = true; tags.intercept = true end
        if key == "market_security" or key == "prize_transfer" or key == "noctic_custody" then tags.custody = true end
        if key == "show_of_force" or key == "blockade_warning" then tags.deterrence = true end
    end
    return tags
end

local function equipmentScore(eq, tags)
    local score = 1
    for tag, _ in pairs(eq.tags or {}) do
        if tags[tag] then score = score + 4 end
    end
    return score
end

local function chooseEquipment(pool, tags, count)
    local available = copyArray(pool)
    local chosen = {}
    count = clamp(count or 2, 0, 5)
    for _ = 1, count do
        if #available == 0 then break end
        local weighted = {}
        for idx, eq in ipairs(available) do table.insert(weighted, {idx=idx, weight=equipmentScore(eq, tags)}) end
        local item = pickWeighted(weighted)
        if not item then break end
        table.insert(chosen, available[item.idx].name)
        table.remove(available, item.idx)
    end
    return chosen
end

local function forceIndex(baseUrgency, posture, override, branchBias, maxIndex)
    if override == -99 then return 0 end
    local idx = 1 + math.floor((tonumber(baseUrgency) or 1) / 1.35)
    idx = idx + (posture.force or 0) + (branchBias or 0)
    if override ~= nil then idx = idx + override end
    return clamp(idx, 1, maxIndex)
end

local function unitId(divCode, branch, n)
    local prefix = branch == "ground" and "" or (branch == "naval" and "N" or "A")
    return string.format("%s-%s%02d", divCode, prefix, n)
end

local function resolveBranches(args, mission, theatre, div, urgency)
    local raw = norm(args.branch or args.primary or "auto")
    local primary = isAuto(raw) and (mission and mission.primary or "ground") or raw
    if primary ~= "ground" and primary ~= "naval" and primary ~= "air" and primary ~= "joint" then primary = mission and mission.primary or "ground" end

    if primary == "joint" then
        local weights = {ground=3, naval=2, air=2}
        if theatre.tags.maritime or theatre.tags.strait or theatre.tags.lake then weights.naval = 4 end
        if theatre.tags.highland or theatre.tags.frontier then weights.air = 3 end
        local lead = weightedKey(weights) or "ground"
        return "joint", lead
    end
    return primary, primary
end

local function makeForcePackage(args, div, theatre, mission, urgency, posture, wx)
    local primary, lead = resolveBranches(args, mission, theatre, div, urgency)
    local tags = missionTags(mission, theatre)
    local gOverride = forceOverride(args.ground or args.groundforce)
    local nOverride = forceOverride(args.naval or args.navalforce)
    local aOverride = forceOverride(args.air or args.airforce)

    local joint = primary == "joint"
    local wantGround = joint or primary == "ground" or (urgency >= 3 and gOverride ~= -99)
    local wantNaval = (joint and (theatre.tags.maritime or theatre.tags.strait or theatre.tags.lake or theatre.tags.port)) or primary == "naval"
    local wantAir = joint or primary == "air" or urgency >= 2

    if nOverride == -99 then wantNaval = false end
    if aOverride == -99 then wantAir = false end
    if gOverride == -99 then wantGround = false end

    if wx and wx.impacts then
        if wx.impacts.sea == "Closed" then wantNaval = false end
        if wx.impacts.air == "No-Go" then wantAir = false end
    end

    local ground, naval, air = "—", "—", "—"
    local gEq, nEq, aEq = {}, {}, {}

    if wantGround then
        local bias = lead == "ground" and 1 or 0
        local idx = forceIndex(urgency, posture, gOverride, bias, #GROUND_ECHELONS)
        ground = string.format("%s (%s)", GROUND_ECHELONS[idx], unitId(div.code, "ground", math.random(1,24)))
        gEq = chooseEquipment(GROUND_EQUIPMENT, tags, idx >= 5 and 4 or (idx >= 3 and 3 or 2))
    end
    if wantNaval then
        local bias = lead == "naval" and 1 or 0
        local idx = forceIndex(urgency, posture, nOverride, bias-1, #NAVAL_ECHELONS)
        naval = string.format("%s (%s)", NAVAL_ECHELONS[idx], unitId(div.code, "naval", math.random(1,12)))
        nEq = chooseEquipment(NAVAL_EQUIPMENT, tags, idx >= 3 and 3 or 2)
    end
    if wantAir then
        local bias = lead == "air" and 1 or 0
        local idx = forceIndex(urgency, posture, aOverride, bias-1, #AIR_ECHELONS)
        air = string.format("%s (%s)", AIR_ECHELONS[idx], unitId(div.code, "air", math.random(1,18)))
        aEq = chooseEquipment(AIR_EQUIPMENT, tags, idx >= 3 and 3 or 2)
    end

    return {
        primary = primary,
        lead = lead,
        ground = ground,
        naval = naval,
        air = air,
        groundEquipment = gEq,
        navalEquipment = nEq,
        airEquipment = aEq,
    }
end

--------------------------------------------------------------------------------
-- I. OVERSIGHT / LEGAL GUARDRAILS / SUPPORT
--------------------------------------------------------------------------------

local OVERSIGHT = {
    senate = "Senate of Elders military liaison",
    hce = "Port compliance inspector (HCE)",
    straits = "Straits Conventions civilian monitor",
    white_lane = "White-Lane Corridor Observer",
    temple = "Temple Bank / cult liaison",
    council = "Council of Kings command liaison",
    local_authority = "Municipal / dependency / customary-authority liaison",
    engineering = "Civil engineering and infrastructure authority",
    health = "Civil health / clinic liaison",
}

local function oversightFromInput(v)
    if isAuto(v) then return nil end
    local n = norm(v)
    local aliases = {
        ["senate"]="senate", ["senate of elders"]="senate",
        ["hce"]="hce", ["port"]="hce", ["port compliance"]="hce",
        ["straits"]="straits", ["straits convention"]="straits", ["conventions"]="straits",
        ["white lane"]="white_lane", ["white-lane"]="white_lane",
        ["temple"]="temple", ["temple bank"]="temple",
        ["council"]="council", ["council of kings"]="council",
        ["local"]="local_authority", ["customary"]="local_authority", ["municipal"]="local_authority",
        ["engineering"]="engineering", ["engineer"]="engineering",
        ["health"]="health", ["clinic"]="health",
    }
    local key = aliases[n]
    return key and OVERSIGHT[key] or safeUserText(v)
end

local function automaticOversight(theatre, mission, threat)
    local items = {}
    if theatre.key == "southern_strait" then
        table.insert(items, OVERSIGHT.straits)
        table.insert(items, OVERSIGHT.hce)
        if mission._key == "white_lane" or mission._key == "missionary_escort" or threat._key == "civil_unrest" then table.insert(items, OVERSIGHT.white_lane) end
        if mission._key == "missionary_escort" or mission._key == "sacred_route" then table.insert(items, OVERSIGHT.temple) end
        table.insert(items, OVERSIGHT.local_authority)
    elseif theatre.key == "eastern_caledonia" or theatre.key == "ouriana" then
        table.insert(items, OVERSIGHT.council)
        table.insert(items, OVERSIGHT.local_authority)
        table.insert(items, OVERSIGHT.senate)
    elseif mission._key == "water_security" or mission._key == "infrastructure_protection" or mission._key == "route_clearance" then
        table.insert(items, OVERSIGHT.engineering)
        table.insert(items, OVERSIGHT.senate)
    elseif mission._key == "noctic_custody" or threat._key == "vampirism" then
        table.insert(items, OVERSIGHT.temple)
        table.insert(items, OVERSIGHT.health)
    else
        table.insert(items, pick({OVERSIGHT.senate, OVERSIGHT.hce, OVERSIGHT.local_authority}))
    end
    return items
end

local function complianceRules(theatre, mission, threat, stage)
    local rules = {}
    table.insert(rules, "War League action remains subordinate to the Bassaridian constitutional chain of command and recorded legislative/executive oversight.")
    table.insert(rules, "No military equipment or controlled weapons are released for foreign export; strategic materiel remains under Bassaridian custody.")

    if theatre.key == "southern_strait" or theatre.tags.strait then
        table.insert(rules, "Neutral and lawful passage must remain distinguishable from suspect traffic; inspections require documented cause, routing and evidence custody.")
        table.insert(rules, "White-Lane humanitarian, medical and essential-goods movement is protected from avoidable interruption.")
        table.insert(rules, "General Port registry, cargo-ledger and port-state procedures remain the ordinary administrative mechanism for lawful commerce.")
        table.insert(rules, "Sacred-road segments, shrine approaches, caravan wells, ferry approaches and road-market nodes are treated as protected civic landscape unless an immediate safety requirement compels temporary restriction.")
    end
    if theatre.tags.tribal or theatre.tags.sacred then
        table.insert(rules, "Tribal, customary and sacred-space authority is preserved except where a specific immediate security necessity requires a narrowly bounded intervention.")
    end
    if theatre.tags.dependency then
        table.insert(rules, "Security presence does not by itself displace dependency or municipal civil authority; integration and public-service decisions remain civil/constitutional functions.")
    end
    if mission._key == "water_security" then
        table.insert(rules, "War League command secures the water system and relief corridors but does not assume technical management of engineering, treatment or distribution.")
    end
    if mission._key == "noctic_custody" then
        table.insert(rules, "Restricted raw-leaf material, tinctures, clinic transfers and evidence are separated and logged; health and Temple Bank functions remain distinct from military custody.")
    end
    if mission._key == "prize_transfer" or mission._key == "vbss" or mission._key == "anti_smuggling" then
        table.insert(rules, "Seized manifests, seals, cargo and witness statements are preserved for prize-court or competent civil review; field seizure does not substitute for final adjudication.")
    end
    if mission._key == "training" or stage == "exercise" then
        table.insert(rules, "Exercise activity is non-lethal and yields immediately to real emergency, civilian infrastructure, sacred-site and medical-safety requirements.")
    end
    if threat._key == "confederacy" then
        table.insert(rules, "Affiliation is not presumed from geography or irregular paperwork alone; Confederacy-linked allegations require evidence and are recorded separately from ordinary criminality.")
    end
    return rules
end

local function supportPackage(theatre, mission, threat, div)
    local s = {}
    table.insert(s, "[[General Port of Lake Morovia]] registry / logistics coordination")
    if theatre.key == "southern_strait" then
        table.insert(s, "[[Temple Bank of the Reformed Stripping Path]] missionary / civic liaison")
        if div.code ~= "HAT" then table.insert(s, "[[Hatch Ministry]] coastal intelligence / prize-evidence support") end
        table.insert(s, "Local guides, registrars, stewards and route authorities")
    elseif theatre.tags.tribal then
        table.insert(s, "Recognized clan / tribal liaison and local route knowledge")
    end
    if mission._key == "hazard_response" or mission._key == "water_security" or mission._key == "route_clearance" then
        table.insert(s, "Civil engineering / public-works element")
    end
    if mission._key == "medevac" or mission._key == "white_lane" or threat._key == "vampirism" then
        table.insert(s, "Medical / clinic coordination element")
    end
    if mission._key == "noctic_custody" or threat._key == "noctic" or threat._key == "vampirism" then
        table.insert(s, "Temple Bank Noctic custody and cult-health liaison")
    end
    if theatre.key == "eastern_caledonia" or theatre.key == "ouriana" then
        table.insert(s, "Council of Kings dependency / integration liaison")
    end
    return s
end

--------------------------------------------------------------------------------
-- J. URGENCY + OBJECTIVE + OUTCOME
--------------------------------------------------------------------------------

local URGENCY_LABEL = {[0]="N/A", [1]="LOW", [2]="MEDIUM", [3]="HIGH", [4]="CRITICAL"}

local function computeUrgency(mission, threat, posture, wx)
    local score = tonumber(mission.base) or 1
    score = math.max(score, tonumber(threat.score) or 0)
    score = score + math.max(0, posture.risk or 0)
    if wx and tonumber(wx.level) and wx.level >= 2 then score = score + 1 end
    if wx and tonumber(wx.level) and wx.level >= 3 then score = score + 1 end
    return clamp(score, 1, 4)
end

local function automaticObjective(theatre, node, mission, threat)
    local key = mission._key
    if key == "southern_strait_compliance" then
        return "Restore documented lawful passage through " .. node .. " while isolating suspect traffic, preserving White-Lane access and reconciling route, registry and cargo records."
    elseif key == "lacaran_hodos" then
        return "Keep the Lacaran Hodos rail-road spine open through " .. node .. ", protect transfer points and locally protected route features, and prevent disruption from becoming a corridor-wide closure."
    elseif key == "dependency_stabilization" then
        return "Protect civil authorities, transport links and negotiation/integration sites at " .. node .. " without converting temporary security presence into ordinary military government."
    elseif key == "white_lane" then
        return "Maintain a clearly marked humanitarian and essential-goods corridor through " .. node .. " while separating relief traffic from suspect or unregistered movements."
    elseif key == "noctic_custody" then
        return "Secure Noctic-related clinics, custody ledgers and controlled cargo at " .. node .. ", separating lawful medical or ritual movement from restricted raw-leaf or counterfeit traffic."
    elseif key == "water_security" then
        return "Protect water infrastructure, rationing/relief lanes and engineering access at " .. node .. " while leaving technical operation under competent civil authorities."
    elseif key == "hazard_response" then
        return "Reopen protected movement through " .. node .. ", support evacuation and engineering access, and prevent environmental disruption from causing a wider breakdown in provisioning or public order."
    elseif key == "confederacy_interdiction" then
        return "Disrupt evidence-supported Confederacy-linked or adjacent courier, cargo and support networks around " .. node .. " while maintaining lawful passage for uninvolved travelers and commerce."
    elseif key == "training" then
        return "Test route-holding, reconnaissance, engineering, medical and sustainment performance around " .. node .. " under non-lethal field regulations."
    end
    return mission.label .. " at " .. node .. " in response to " .. lower(threat.label) .. ", with emphasis on maintaining governable passage and documented handoff to civil institutions."
end

local OUTCOMES = {
    {min=82, label="Controlled completion", text="Primary objective achieved; route or node remains governable and the operation transitions to ordinary civil, Port or dependency follow-up."},
    {min=66, label="Objective met with variance", text="Primary objective substantially achieved, but a limited follow-up tasking remains for records, repairs, escort continuity or local reconciliation."},
    {min=48, label="Partial completion", text="Immediate risk is contained but the underlying route, evidence, infrastructure or political problem remains open and requires another operational cycle."},
    {min=30, label="Deferred / constrained", text="Weather, access, legal safeguards, force availability or local conditions prevent full execution; protected holding measures remain in place."},
    {min=0,  label="Escalation / incident remains open", text="The operation does not establish stable control of the problem; command records an escalation and requires revised tasking rather than treating the action as complete."},
}

local function outcomeOverride(v)
    if isAuto(v) then return nil end
    local n = norm(v)
    if n == "success" or n == "controlled" or n == "complete" then return OUTCOMES[1] end
    if n == "variance" or n == "mixed success" then return OUTCOMES[2] end
    if n == "partial" or n == "mixed" then return OUTCOMES[3] end
    if n == "deferred" or n == "no go" or n == "no-go" then return OUTCOMES[4] end
    if n == "escalation" or n == "failed" or n == "failure" then return OUTCOMES[5] end
    return nil
end

local function assessOutcome(args, mission, threat, posture, wx, forces, oversightCount, stage)
    local forced = outcomeOverride(args.outcome or args.resolution)
    if forced then return forced, nil end
    if stage == "planning" then return {label="Pending execution", text="No simulated result requested at the planning stage; the operation remains an approved/taskable plan."}, nil end

    local score = 68
    score = score - (threat.score or 0) * 7
    score = score - weatherPenalty(wx)
    score = score - (posture.risk or 0) * 2
    if forces.primary == "joint" then score = score + 5 end
    if forces.ground ~= "—" then score = score + 3 end
    if forces.naval ~= "—" and (mission.primary == "naval" or mission.primary == "joint") then score = score + 4 end
    if forces.air ~= "—" then score = score + 3 end
    score = score + math.min(6, (oversightCount or 1) * 2)
    if mission._key == "training" then score = score + 8 end
    score = score + math.random(-10, 10)
    score = clamp(score, 0, 100)
    for _, out in ipairs(OUTCOMES) do
        if score >= out.min then return out, score end
    end
    return OUTCOMES[#OUTCOMES], score
end

--------------------------------------------------------------------------------
-- K. SPECIAL NODE / THEATRE GUARDRAILS
--------------------------------------------------------------------------------

local SOUTHERN_STRAIT_INTERIOR = {
    [norm("Nysara Velai")]=true,
    [norm("Thal Lenaia")]=true,
    [norm("Selenarion")]=true,
    [norm("Hayr Chaskal")]=true,
    [norm("Rumi-Kefaya")]=true,
    [norm("Lacaran Hodos")]=true,
    [norm("Lacaran Hodos Divan")]=true,
}

local function applyGuardrails(div, theatre, node, warnings)
    warnings = warnings or {}
    if theatre.key == "eastern_caledonia" and div.code ~= "CKD" then
        table.insert(warnings, "Eastern Caledonian deployments are reserved to the Council of Kings Division in the inherited WarLeagueDivisionOpsBVR logic; division changed to CKD.")
        div = DIVISIONS.CKD
    end
    if theatre.key == "southern_strait" and SOUTHERN_STRAIT_INTERIOR[norm(node)] and div.code == "HAT" then
        table.insert(warnings, "Hatch Ministry forces are best treated here as coastal/intelligence/prize-evidence support rather than the principal inland formation; lead division changed to CKD.")
        div = DIVISIONS.CKD
    end
    if theatre.key == "southern_strait" and SOUTHERN_STRAIT_INTERIOR[norm(node)] and div.code == "HFA" and (node == "Nysara Velai" or node == "Lacaran Hodos") then
        table.insert(warnings, "Deep Lacaran Hodos tasking is assigned to CKD for this planner; Haifan Bassaridia Division may remain in supporting coastal or staging roles.")
        div = DIVISIONS.CKD
    end
    return div, warnings
end

--------------------------------------------------------------------------------
-- L. OPERATION ASSEMBLY
--------------------------------------------------------------------------------

local function opId(di, divCode, theatreKey)
    local th = ({
        morovian_core="MC", general_port="GP", alpazkigz="AL", odiferia="OD",
        new_south_jangsong="NJ", haifan="HF", normark="NO", ouriana="OU",
        eastern_caledonia="EC", southern_strait="SS",
    })[theatreKey] or "XX"
    return string.format("WLX-%03d.%03d-%s-%s-%03d", di.year, di.dayOfYear, divCode, th, math.random(1,999))
end

local function nodeLink(node)
    if node == "General Port of Lake Morovia" then return "[[General Port of Lake Morovia]]" end
    if node == "Lacaran Hodos" then return "[[Lacaran Hodos]]" end
    if node == "Lacaran Hodos Divan" then return "[[Lacaran Hodos Divan]]" end
    if node == "Gloom Forest of Perpetual Autumn" then return "[[Gloom Forest of Perpetual Autumn]]" end
    if node == "Southern Odiferae Wetlands" then return "[[Alperkin#Southern Odiferae Wetlands|Southern Odiferae Wetlands]]" end
    if node == "Northern and Western Morovian Highlands" then return "[[Alperkin#Northern and Western Highlands|Northern and Western Morovian Highlands]]" end
    if node == "Alperkin Coastal Woodlands" then return "[[Alperkin#Coastal Woodlands|Alperkin Coastal Woodlands]]" end
    return "[[" .. node .. "]]"
end

local function makeOperation(args)
    local di = getPSSCDate(args)
    local seed = tonumber(args.seed) or (di.year * 100000 + di.dayOfYear * 101)
    math.randomseed(seed)

    local warnings = {}
    local manualNode = not isAuto(args.node or args.city or args.location)
    local nodeInput = trim(args.node or args.city or args.location)
    local inferred = manualNode and inferTheatreFromNode(nodeInput) or nil

    local theatre = theatreFromInput(args.theatre or args.theater or args.region)
    local div = divisionFromInput(args.division)

    if not theatre and inferred then theatre = inferred end
    if not theatre and div then theatre = chooseTheatre(div) end
    if not theatre then
        -- choose a theatre first, weighted globally
        local list = {}
        for key, th in pairs(THEATRES) do table.insert(list, {key=key, weight=th.weight or 1}) end
        local item = pickWeighted(list)
        theatre = THEATRES[item and item.key or "morovian_core"]
    end

    if not div then div = chooseDivision(theatre) end

    local node = manualNode and nodeInput or pick(theatre.nodes)
    if manualNode and inferred and inferred.key ~= theatre.key then
        table.insert(warnings, "The selected node normally belongs to " .. inferred.label .. "; the manually selected theatre has been retained.")
    end

    div, warnings = applyGuardrails(div, theatre, node, warnings)

    local stageKey = norm(args.stage or "planning"):gsub(" ", "_")
    if stageKey == "after action" then stageKey = "after_action" end
    if not STAGES[stageKey] then stageKey = "planning" end

    local missionKey, mission = missionFromInput(args.mission)
    if not mission then missionKey, mission = chooseMission(theatre, stageKey) end
    mission._key = missionKey

    local threatKey, threat = threatFromInput(args.threat)
    if not threat then threatKey, threat = chooseThreat(theatre, mission) end
    threat._key = threatKey

    local postureKey = norm(args.posture or "standard")
    if not POSTURES[postureKey] then postureKey = "standard" end
    local posture = POSTURES[postureKey]

    local wx = resolveWeather(args, node, di)
    local urgency = computeUrgency(mission, threat, posture, wx)

    local eventMap, hasEvents = buildEventMapForToday(di)
    local eventDesc = "No linked daily-event report"
    local eventUrgency = nil
    if not isAuto(args.event) then
        eventDesc = safeUserText(args.event)
    else
        local ev = eventMap[node]
        if ev then
            eventDesc = ev.desc
            eventUrgency = ev.urgency
        elseif hasEvents then
            eventDesc = "No reports"
        end
    end

    local objective = isAuto(args.objective) and automaticObjective(theatre, node, mission, threat) or safeUserText(args.objective)
    if objective == "" then objective = automaticObjective(theatre, node, mission, threat) end

    local forces = makeForcePackage(args, div, theatre, mission, urgency, posture, wx)

    local manualObs = oversightFromInput(args.oversight)
    local oversight = manualObs and {manualObs} or automaticOversight(theatre, mission, threat)
    local rules = complianceRules(theatre, mission, threat, stageKey)
    local support = supportPackage(theatre, mission, threat, div)

    local outcome, score = assessOutcome(args, mission, threat, posture, wx, forces, #oversight, stageKey)
    local id = trim(args.id)
    if id == "" then id = opId(di, div.code, theatre.key) end

    local customNote = safeUserText(args.note or args.notes)

    return {
        di=di, id=id, seed=seed, div=div, theatre=theatre, node=node,
        stageKey=stageKey, stage=STAGES[stageKey], mission=mission, threat=threat,
        posture=posture, wx=wx, urgency=urgency, eventDesc=eventDesc,
        eventUrgency=eventUrgency, objective=objective, forces=forces,
        oversight=oversight, rules=rules, support=support, outcome=outcome,
        score=score, warnings=warnings, customNote=customNote,
    }
end

--------------------------------------------------------------------------------
-- M. RENDERING
--------------------------------------------------------------------------------

local function wxText(wx)
    if not wx then return "n/a" end
    local i = wx.impacts or {}
    local cond = trim(wx.condition)
    local label = trim(wx.label)
    local main = (label ~= "" and label or "Forecast")
    if cond ~= "" and not main:lower():find(cond:lower(), 1, true) then main = main .. " — " .. cond end
    return string.format("%s<br/><small>Ground: %s; Sea: %s; Air: %s</small>", main, i.ground or "OK", i.sea or "OK", i.air or "OK")
end

local function urgencyStyle(urgency)
    if urgency >= 4 then return "background:#f8d7da;" end
    if urgency >= 3 then return "background:#fce8e6;" end
    if urgency == 2 then return "background:#fff3cd;" end
    return "background:#d4edda;"
end

local function renderOperation(op, compact)
    local out = {}
    if compact then
        table.insert(out, "{| class='wikitable' style='width:100%;'")
        table.insert(out, "! Op-ID !! Date !! Division !! Theatre / Node !! Mission !! Urgency !! Forces !! Outcome")
        table.insert(out, "|-")
        local f = joinComma({op.forces.ground, op.forces.naval, op.forces.air})
        table.insert(out, string.format("| %s || %s || %s || %s — %s || %s || style='%s' | %s || %s || %s",
            op.id, op.di.text, op.div.link, op.theatre.label, nodeLink(op.node), op.mission.label,
            urgencyStyle(op.urgency), URGENCY_LABEL[op.urgency], f, op.outcome.label))
        table.insert(out, "|}")
        return table.concat(out, "\n")
    end

    table.insert(out, "<div style='border:1px solid #a2a9b1; background:#f8f9fa; padding:0.8em 1em; margin:0.5em 0 1em 0;'>")
    table.insert(out, "<div style='font-size:125%; font-weight:bold;'>Bassaridian War League Operations Planner</div>")
    table.insert(out, "<div style='font-size:90%; color:#54595d;'>Manual-input / deterministic operation dossier · Op-ID " .. op.id .. "</div>")
    table.insert(out, "</div>")

    table.insert(out, "{| class='wikitable' style='width:100%;'")
    table.insert(out, "! colspan='4' | Command and tasking")
    table.insert(out, "|-")
    table.insert(out, "! Date || " .. op.di.text .. " !! Stage || " .. op.stage)
    table.insert(out, "|-")
    table.insert(out, "! Division || " .. op.div.link .. " (" .. op.div.code .. ") !! Posture || " .. op.posture.label)
    table.insert(out, "|-")
    table.insert(out, "! Theatre || " .. op.theatre.label .. " !! Node || " .. nodeLink(op.node))
    table.insert(out, "|-")
    table.insert(out, "! Mission || " .. op.mission.label .. " !! Threat / driver || " .. op.threat.label)
    table.insert(out, "|-")
    table.insert(out, "! Urgency || style='" .. urgencyStyle(op.urgency) .. "' | '''" .. URGENCY_LABEL[op.urgency] .. "''' !! Weather / operational impact || " .. wxText(op.wx))
    table.insert(out, "|-")
    table.insert(out, "! Daily event link || colspan='3' | " .. op.eventDesc)
    table.insert(out, "|-")
    table.insert(out, "! Objective || colspan='3' | " .. op.objective)
    table.insert(out, "|}")

    table.insert(out, "{| class='wikitable' style='width:100%;'")
    table.insert(out, "! colspan='3' | Assigned forces")
    table.insert(out, "|-")
    table.insert(out, "! Ground !! Naval !! Aerial")
    table.insert(out, "|-")
    table.insert(out, "| " .. op.forces.ground .. " || " .. op.forces.naval .. " || " .. op.forces.air)
    table.insert(out, "|-")
    table.insert(out, "| " .. bulletList(op.forces.groundEquipment) .. " || " .. bulletList(op.forces.navalEquipment) .. " || " .. bulletList(op.forces.airEquipment))
    table.insert(out, "|}")

    table.insert(out, "{| class='wikitable' style='width:100%;'")
    table.insert(out, "! Oversight !! Supporting institutions / auxiliaries")
    table.insert(out, "|-")
    table.insert(out, "| " .. bulletList(op.oversight) .. " || " .. bulletList(op.support))
    table.insert(out, "|}")

    table.insert(out, "{| class='wikitable' style='width:100%;'")
    table.insert(out, "! colspan='2' | Constitutional / corridor guardrails")
    table.insert(out, "|-")
    table.insert(out, "| colspan='2' | " .. bulletList(op.rules))
    table.insert(out, "|-")
    table.insert(out, "! Divisional doctrine || " .. op.div.doctrine)
    table.insert(out, "|-")
    table.insert(out, "! Theatre doctrine || " .. op.theatre.doctrine)
    table.insert(out, "|}")

    table.insert(out, "{| class='wikitable' style='width:100%;'")
    table.insert(out, "! colspan='2' | Resolution / next state")
    table.insert(out, "|-")
    table.insert(out, "! Assessment || '''" .. op.outcome.label .. "'''" .. (op.score and (" (simulation score " .. op.score .. "/100)") or ""))
    table.insert(out, "|-")
    table.insert(out, "! Narrative || " .. op.outcome.text)
    if op.customNote ~= "" then
        table.insert(out, "|-")
        table.insert(out, "! Editor note || " .. op.customNote)
    end
    table.insert(out, "|}")

    if #op.warnings > 0 then
        table.insert(out, "<div style='border-left:4px solid #f0ad4e; padding:0.4em 0.8em; margin-top:0.6em;'><b>Planner guardrail:</b> " .. table.concat(op.warnings, " ") .. "</div>")
    end

    table.insert(out, "<div style='font-size:88%; color:#54595d; margin-top:0.5em;'>Deterministic seed: " .. tostring(op.seed) .. ". Change <code>seed=</code> to generate another version while preserving all manually selected fields.</div>")

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

--------------------------------------------------------------------------------
-- N. PUBLIC FUNCTIONS
--------------------------------------------------------------------------------

function p.operation(frame)
    local args = getArgs(frame)
    local op = makeOperation(args)
    return renderOperation(op, false)
end

function p.compact(frame)
    local args = getArgs(frame)
    local op = makeOperation(args)
    return renderOperation(op, true)
end

function p.options()
    local out = {}
    table.insert(out, "{| class='wikitable' style='width:100%;'")
    table.insert(out, "! Parameter !! Purpose !! Recommended values")
    table.insert(out, "|-")
    table.insert(out, "| division || Lead War League division || auto; VAE; ALP; ODF; HAT; NSJ; HFA; BNO; OUR; CKD")
    table.insert(out, "|-")
    table.insert(out, "| theatre || Operational theatre || auto; Lake Morovia core; General Port; Alpazkigz; Odiferia; New South Jangsong; Haifan Bassaridia; Bassaridian Normark; Ouriana; Eastern Caledonian Highlands; Southern Strait")
    table.insert(out, "|-")
    table.insert(out, "| node || Exact city / route / node || auto or any listed node, including Nysara Velai, Lacaran Hodos, Mylecia, etc.")
    table.insert(out, "|-")
    table.insert(out, "| mission || Mission type || auto; corridor security; anti-smuggling; infrastructure protection; hazard response; water security; noctic custody; VBSS; White-Lane; missionary escort; dependency stabilization; Lacaran Hodos; Southern Strait compliance; Confederacy interdiction; ISR; MEDEVAC; training")
    table.insert(out, "|-")
    table.insert(out, "| threat || Incident / opponent / driver || auto; routine; smuggling; piracy; insurgency; Confederacy; Noctic; vampirism; sabotage; forged documents; civil unrest; weather; infrastructure failure; Eidolan")
    table.insert(out, "|-")
    table.insert(out, "| posture || Command posture || restrained; standard; assertive; emergency")
    table.insert(out, "|-")
    table.insert(out, "| branch || Primary branch || auto; ground; naval; air; joint")
    table.insert(out, "|-")
    table.insert(out, "| ground / naval / air || Branch force override || auto; none; light; standard; reinforced; heavy; surge")
    table.insert(out, "|-")
    table.insert(out, "| weather || Weather source / override || auto; forecast; fair; fog; rain; storm; severe storm; blizzard; flood; heat; ash")
    table.insert(out, "|-")
    table.insert(out, "| oversight || Oversight override || auto; Senate; HCE; Straits; White-Lane; Temple Bank; Council of Kings; local; engineering; health")
    table.insert(out, "|-")
    table.insert(out, "| stage || Operation state || planning; active; after-action; exercise")
    table.insert(out, "|-")
    table.insert(out, "| outcome || Resolution override || auto; success; variance; partial; deferred; escalation")
    table.insert(out, "|-")
    table.insert(out, "| objective / event / note || Manual narrative inputs || Any text; use auto to let the module fill objective/event")
    table.insert(out, "|-")
    table.insert(out, "| year / day || PSSC date override || year = PSSC year; day = 1–183")
    table.insert(out, "|-")
    table.insert(out, "| seed || Deterministic variation || Any integer")
    table.insert(out, "|}")
    return table.concat(out, "\n")
end

return p