Module:BassaridianWarGamesLeague

From MicrasWiki
Jump to navigationJump to search

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

-- Module:BassaridianWarGamesLeague
-- Deterministic Opsitheiel campaign simulator for the Games of the Standards.
--
-- The module replays, at most, sixty-one campaign days. Results are stable for
-- the same PSSC year and campaign day. The editable roster, regional rotation,
-- control nodes and scoring rules are grouped near the top of the file.

local p = {}

------------------------------------------------------------------------
-- CALENDAR AND OPTIONAL INTEGRATIONS
------------------------------------------------------------------------

local DAYS_IN_YEAR = 183
local DAYS_IN_CAMPAIGN = 61
local FIRST_CAMPAIGN_YEAR = 53
local DEFAULT_YEAR = 53

local okCalendar, Calendar = pcall(require, "Module:BassaridianCalendar")
local okForecast, Forecast = pcall(require, "Module:BassaridiaForecast")
local okUtilization, Utilization = pcall(require, "Module:WarLeagueUnitUtilizationBVR")

local function clamp(value, low, high)
    value = tonumber(value) or 0
    if value < low then return low end
    if value > high then return high end
    return value
end

local function round(value)
    return math.floor((tonumber(value) or 0) + 0.5)
end

local function trim(value)
    return tostring(value or ""):match("^%s*(.-)%s*$")
end

local function lower(value)
    local text = tostring(value or "")
    if mw and mw.ustring and mw.ustring.lower then
        return mw.ustring.lower(text)
    end
    return string.lower(text)
end

local function join(list, separator)
    return table.concat(list or {}, separator or ", ")
end

local function currentPSSC()
    local text = nil
    if okCalendar and Calendar then
        if type(Calendar.getCurrentDateNoProverb) == "function" then
            text = Calendar.getCurrentDateNoProverb()
        elseif type(Calendar.getCurrentDate) == "function" then
            text = Calendar.getCurrentDate()
        end
    end

    local year = DEFAULT_YEAR
    local dayOfYear = 1

    if text then
        dayOfYear = tonumber(tostring(text):match("^(%d+),")) or dayOfYear
        year = tonumber(tostring(text):match("(%d+)%s*PSSC")) or year
    else
        local startDate = os.time({ year = 1999, month = 8, day = 6 })
        local totalDays = math.floor((os.time() - startDate) / 86400)
        year = math.floor(totalDays / DAYS_IN_YEAR)
        dayOfYear = (totalDays % DAYS_IN_YEAR) + 1
    end

    dayOfYear = ((dayOfYear - 1) % DAYS_IN_YEAR) + 1

    local monthIndex, month, dayInMonth
    if dayOfYear <= 61 then
        monthIndex, month, dayInMonth = 1, "Atosiel", dayOfYear
    elseif dayOfYear <= 122 then
        monthIndex, month, dayInMonth = 2, "Thalassiel", dayOfYear - 61
    else
        monthIndex, month, dayInMonth = 3, "Opsitheiel", dayOfYear - 122
    end

    return {
        year = year,
        dayOfYear = dayOfYear,
        monthIndex = monthIndex,
        month = month,
        day = dayInMonth
    }
end

local function campaignArgs(frame)
    local args = (frame and frame.args) or {}
    local now = currentPSSC()
    local year = tonumber(trim(args.year or args.season)) or now.year
    local explicitDay = tonumber(trim(args.day or args.d))
    local day

    if explicitDay then
        day = clamp(explicitDay, 0, DAYS_IN_CAMPAIGN)
    elseif year < now.year then
        day = DAYS_IN_CAMPAIGN
    elseif year > now.year then
        day = 0
    elseif now.monthIndex == 3 then
        day = clamp(now.day, 1, DAYS_IN_CAMPAIGN)
    else
        day = 0
    end

    return {
        year = year,
        day = day,
        seasonKey = tostring(year) .. "|Opsitheiel",
        args = args
    }
end

local function formatDate(ctx, day)
    return tostring(day) .. "/3/" .. tostring(ctx.year) .. " [[PSSC]]"
end

local function formatSeason(ctx)
    return "Opsitheiel " .. tostring(ctx.year) .. " [[PSSC]]"
end

------------------------------------------------------------------------
-- DETERMINISTIC RANDOMNESS
------------------------------------------------------------------------

local function fallbackHash(text)
    local h = 2166136261
    text = tostring(text or "")
    for i = 1, #text do
        h = (h * 16777619 + text:byte(i)) % 4294967296
    end
    return h
end

local function hashNum(key)
    key = tostring(key or "")
    if mw and mw.hash and mw.hash.hashValue then
        local digest = mw.hash.hashValue("md5", key)
        local n = 0
        for i = 1, 8 do
            n = n * 16 + (tonumber(digest:sub(i, i), 16) or 0)
        end
        return n
    end
    return fallbackHash(key)
end

local function randKey(key)
    return (hashNum(key) % 1000000) / 1000000
end

local function randInt(key, low, high)
    low, high = tonumber(low) or 0, tonumber(high) or 0
    if high < low then low, high = high, low end
    return low + math.floor(randKey(key) * (high - low + 1))
end

local function pick(list, key)
    if not list or #list == 0 then return nil end
    local index = math.floor(randKey(key) * #list) + 1
    return list[clamp(index, 1, #list)]
end

local function shuffledCopy(list, key)
    local keyed = {}
    for i, item in ipairs(list or {}) do
        keyed[#keyed + 1] = {
            item = item,
            value = randKey(tostring(key) .. "|" .. tostring(i) .. "|" .. tostring(item.city or item.name or i))
        }
    end
    table.sort(keyed, function(a, b)
        if a.value == b.value then
            return tostring(a.item.city or a.item.name or "") < tostring(b.item.city or b.item.name or "")
        end
        return a.value < b.value
    end)
    local output = {}
    for _, row in ipairs(keyed) do output[#output + 1] = row.item end
    return output
end

------------------------------------------------------------------------
-- EDITABLE DIVISION DATA
------------------------------------------------------------------------

local DIVISIONS = {
    VAE = {
        code = "VAE", name = "Vaeringheim Division", link = "[[Vaeringheim Division]]",
        attack = 72, defense = 79, recon = 72, logistics = 82, discipline = 84, mobility = 70
    },
    ALP = {
        code = "ALP", name = "Alpazkigz Division", link = "[[Alpazkigz Division]]",
        attack = 80, defense = 69, recon = 79, logistics = 66, discipline = 75, mobility = 84
    },
    ODF = {
        code = "ODF", name = "Odiferia Division", link = "[[Odiferia Division]]",
        attack = 68, defense = 81, recon = 81, logistics = 73, discipline = 79, mobility = 65
    },
    HAT = {
        code = "HAT", name = "Hatch Ministry Division", link = "[[Hatch Ministry Division]]",
        attack = 77, defense = 73, recon = 83, logistics = 80, discipline = 81, mobility = 86
    },
    NSJ = {
        code = "NSJ", name = "New South Jangsong Division", link = "[[New South Jangsong Division]]",
        attack = 79, defense = 79, recon = 74, logistics = 73, discipline = 82, mobility = 75
    },
    HFA = {
        code = "HFA", name = "Haifan Bassaridia Division", link = "[[Haifan Bassaridia Division]]",
        attack = 73, defense = 77, recon = 71, logistics = 83, discipline = 82, mobility = 73
    },
    BNO = {
        code = "BNO", name = "Bassaridian Normark Division", link = "[[Bassaridian Normark Division]]",
        attack = 75, defense = 83, recon = 73, logistics = 69, discipline = 85, mobility = 71
    },
    OUR = {
        code = "OUR", name = "Ouriana Division", link = "[[Ouriana Division]]",
        attack = 76, defense = 71, recon = 77, logistics = 75, discipline = 79, mobility = 83
    },
    CKD = {
        code = "CKD", name = "Council of Kings Division", link = "[[Council of Kings Division]]",
        attack = 78, defense = 81, recon = 75, logistics = 71, discipline = 87, mobility = 77
    }
}

local DIVISION_ORDER = { "VAE", "ALP", "ODF", "HAT", "NSJ", "HFA", "BNO", "OUR", "CKD" }

------------------------------------------------------------------------
-- EDITABLE GAMES REGISTRY
--
-- The registry deliberately assigns each city to one parent division, even
-- where ordinary operational jurisdictions overlap. Mylecia is retained
-- because it appears on the current named city list even though the summary
-- count on that page has not always been updated consistently.
------------------------------------------------------------------------

local ROSTER = {}

local function addTeam(city, division, terrain, doctrine)
    ROSTER[#ROSTER + 1] = {
        city = city,
        division = division,
        terrain = terrain or {},
        doctrine = doctrine or "balanced field manoeuvre"
    }
end

-- Vaeringheim Division
addTeam("Vaeringheim", "VAE", { "urban", "lake", "canal", "rail" }, "capital, canal and layered urban defence")
addTeam("Luminaria", "VAE", { "urban", "rail", "highland" }, "rapid mechanised movement and luminous night signalling")
addTeam("Serena", "VAE", { "plains", "lake", "urban" }, "disciplined line defence and civic-area control")
addTeam("Symphonara", "VAE", { "rail", "lake", "urban" }, "communications coordination and rail-junction warfare")
addTeam("Lunalis Sancta", "VAE", { "forest", "night", "highland" }, "night reconnaissance and concealed approach")
addTeam("Sylvapolis", "VAE", { "forest", "plains", "river" }, "forest screens and agricultural-corridor defence")
addTeam("Delphica", "VAE", { "highland", "urban", "recon" }, "observation, prediction and counter-reconnaissance")
addTeam("Catonis Atrium", "VAE", { "urban", "road", "plains" }, "formal defensive lines and orderly route control")

-- Alpazkigz Division
addTeam("The Alpazkigz", "ALP", { "mobile", "plains", "forest" }, "mobile encampment warfare and deep raiding")
addTeam("Pyralis", "ALP", { "highland", "urban", "road" }, "aggressive shock manoeuvre and ridge assault")
addTeam("Nexa", "ALP", { "plains", "rail", "lake" }, "fast corridor switching and central reserve operations")
addTeam("Saluria", "ALP", { "lake", "wetland", "maritime" }, "amphibious interception and littoral screening")
addTeam("Acheron", "ALP", { "highland", "canyon", "road" }, "canyon infiltration and bridge seizure")
addTeam("Koinonía", "ALP", { "urban", "plains", "road" }, "cooperative infantry manoeuvre and allied relief")
addTeam("Aureum", "ALP", { "highland", "road", "urban" }, "fortified convoy protection and high-value objective defence")
addTeam("Erythros", "ALP", { "rail", "plains", "urban" }, "rapid assault, diversion and public-square control")

-- Odiferia Division
addTeam("Odiferia", "ODF", { "wetland", "forest", "river" }, "swamp concealment and shallow-water movement")
addTeam("Somniumpolis", "ODF", { "wetland", "rail", "river" }, "mist navigation, causeway defence and deception")
addTeam("Aetherium", "ODF", { "wetland", "lake", "maritime" }, "light craft reconnaissance and canal interdiction")
addTeam("Ferrum Citadel", "ODF", { "urban", "industrial", "rail" }, "fortified engineering and heavy recovery")
addTeam("Aurelia", "ODF", { "forest", "wetland", "road" }, "grove-screened defence and casualty recovery")

-- Hatch Ministry Division
addTeam("Lewisburg", "HAT", { "rail", "industrial", "road" }, "railway logistics, manifest control and rapid dispatch")
addTeam("Lykopolis", "HAT", { "rail", "road", "highland" }, "long-route interdiction and courier pursuit")
addTeam("Keybir-Aviv", "HAT", { "maritime", "lake", "urban" }, "boarding operations and port-perimeter penetration")
addTeam("Mylecia", "HAT", { "maritime", "river", "wetland" }, "shallow-water patrol and southern-corridor screening")
addTeam("Thermosalem", "HAT", { "urban", "canal", "road" }, "infrastructure protection and confined-route operations")

-- New South Jangsong Division
addTeam("Skýrophos", "NSJ", { "maritime", "highland", "urban" }, "fjord-port defence and combined littoral assault")
addTeam("Bjornopolis", "NSJ", { "forest", "highland", "road" }, "boreal patrol and fortified-road control")
addTeam("Aegirheim", "NSJ", { "maritime", "cold", "highland" }, "cold-water movement and harbor approach defence")
addTeam("Norsolyra", "NSJ", { "rail", "maritime", "cold" }, "rail-fjord transfer and northern mobile reserve")
addTeam("Thorsalon", "NSJ", { "highland", "road", "cold" }, "pass defence and hard-weather shock action")
addTeam("Pelagia", "NSJ", { "maritime", "coastal", "cold" }, "coastal scouting and small-craft insertion")
addTeam("Myrene", "NSJ", { "forest", "coastal", "recon" }, "concealed coastal reconnaissance")
addTeam("Thyrea", "NSJ", { "plains", "cold", "road" }, "salt-steppe mobility and exposed-route defence")
addTeam("Ephyra", "NSJ", { "urban", "coastal", "road" }, "harbor-district control and crowd-safe manoeuvre")
addTeam("Halicarn", "NSJ", { "maritime", "urban", "industrial" }, "dockyard security and engineering support")

-- Haifan Bassaridia Division
addTeam("Tel-Amin", "HFA", { "urban", "road", "coastal" }, "dense urban defence and southern-road control")
addTeam("Diamandis", "HFA", { "rail", "urban", "coastal" }, "rail customs control and rapid reinforcement")
addTeam("Jogi", "HFA", { "plains", "road", "coastal" }, "open-ground manoeuvre and ferry protection")
addTeam("Akróstadium", "HFA", { "urban", "plains", "road" }, "mass formation discipline and arena-district defence")
addTeam("Sufriya", "HFA", { "highland", "road", "recon" }, "hill observation and long-road screening")

-- Bassaridian Normark Division
addTeam("Ardclach", "BNO", { "cold", "maritime", "highland" }, "island defence and cold-water patrol")
addTeam("Riddersborg", "BNO", { "cold", "rail", "maritime" }, "northern railhead defence and storm-harbor control")

-- Ouriana Division
addTeam("Bashkim", "OUR", { "rail", "river", "plains" }, "rail-river logistics and central depot protection")
addTeam("Ourid", "OUR", { "highland", "road", "river" }, "ridge-route defence and shrine-corridor security")
addTeam("Tonar", "OUR", { "plains", "road", "mobile" }, "long-range steppe movement and flanking raids")

-- Council of Kings Division: Caledonian Dependencies
addTeam("Fanghorn", "CKD", { "highland", "cold", "road" }, "mountain-junction defence and pass denial")
addTeam("Eikbu", "CKD", { "rail", "highland", "cold" }, "northern rail nexus control and rapid concentration")
addTeam("Galvø", "CKD", { "maritime", "cold", "highland" }, "fjord movement and cliff-route surveillance")
addTeam("Skøda", "CKD", { "highland", "cold", "recon" }, "alpine reconnaissance and ridge infiltration")
addTeam("Hamarrfell", "CKD", { "highland", "road", "cold" }, "fortified roadblock and heavy-weather endurance")
addTeam("Notranskja", "CKD", { "cold", "rail", "plains" }, "winter rail protection and southern approach defence")
addTeam("Sårensby", "CKD", { "rail", "maritime", "cold" }, "rail-ferry coordination and northern supply control")
addTeam("Slevik", "CKD", { "maritime", "road", "cold" }, "coastal-road interception and landing denial")
addTeam("Sjøsborg", "CKD", { "maritime", "cold", "urban" }, "fjord-port security and naval observation")
addTeam("Storesund", "CKD", { "maritime", "rail", "cold" }, "strait crossing control and northern logistics")
addTeam("Kaledonija", "CKD", { "highland", "maritime", "road" }, "western expeditionary movement and coastal defence")
addTeam("Krlsgorod", "CKD", { "highland", "road", "cold" }, "border-road control and fortified withdrawal")

------------------------------------------------------------------------
-- EDITABLE HOST-REGION ROTATION AND CONTROL NODES
------------------------------------------------------------------------

local REGIONS = {
    caledonia = {
        key = "caledonia",
        name = "Eastern Caledonian Highlands",
        link = "[[Eastern Caledonian Highlands]]",
        terrain = { "highland", "cold", "rail", "maritime", "road" },
        hostCities = { "Eikbu", "Fanghorn", "Sårensby", "Notranskja", "Kaledonija" },
        description = "Alpine roads, fjords, tundra-steppe, taiga, long-distance railways and exposed highland settlements.",
        nodes = {
            { name = "Eikbu Central Junction", type = "fortified" },
            { name = "Fanghorn Pass", type = "fortified" },
            { name = "Sårensby Railhead", type = "supply" },
            { name = "Storesund Ferry Gate", type = "special" },
            { name = "Sjøsborg Quays", type = "ordinary" },
            { name = "Slevik Coastal Crossing", type = "ordinary" },
            { name = "Skøda Ridge Road", type = "fortified" },
            { name = "Hamarrfell Spur", type = "supply" },
            { name = "Notranskja Southern Approach", type = "ordinary" },
            { name = "Krlsgorod Western Gate", type = "fortified" }
        }
    },
    plains = {
        key = "plains",
        name = "Plains of Vaeringheim",
        link = "[[Plains of Vaeringheim]]",
        terrain = { "plains", "urban", "lake", "rail", "canal", "road" },
        hostCities = { "Vaeringheim", "Luminaria", "Serena", "Symphonara", "Nexa" },
        description = "Lake roads, rail approaches, canal crossings, open farmland and heavily populated civic corridors.",
        nodes = {
            { name = "General Port Outer Ring", type = "fortified" },
            { name = "Vaeringheim Canal Gates", type = "special" },
            { name = "Luminaria Rail Fork", type = "supply" },
            { name = "Serena North Road", type = "ordinary" },
            { name = "Symphonara Junction", type = "fortified" },
            { name = "Nexa Causeway", type = "ordinary" },
            { name = "Aurelia Grove Road", type = "ordinary" },
            { name = "Somniumpolis Rail Causeway", type = "supply" },
            { name = "Erythros South Line", type = "ordinary" },
            { name = "Pyralis Eastern Approach", type = "fortified" }
        }
    },
    melusine = {
        key = "melusine",
        name = "Coral Woodlands and Plains of Melusine",
        link = "[[Coral Woodlands and Plains of Melusine]]",
        terrain = { "coastal", "forest", "plains", "maritime", "road" },
        hostCities = { "Keybir-Aviv", "Tel-Amin", "Diamandis", "Jogi", "Lewisburg" },
        description = "Strait roads, coastal woodland, orchard lanes, coral cuts, ferry landings and customs yards.",
        nodes = {
            { name = "Keybir-Aviv Ferry Gate", type = "fortified" },
            { name = "Tel-Amin Customs Road", type = "ordinary" },
            { name = "Diamandis Rail Crossing", type = "supply" },
            { name = "Jogi Western Ferry", type = "special" },
            { name = "Lewisburg Dispatch Yard", type = "supply" },
            { name = "Mylecia Quays", type = "ordinary" },
            { name = "Thermosalem Aqueduct Road", type = "fortified" },
            { name = "Akróstadium Arena Approach", type = "ordinary" },
            { name = "Sufriya Hill Road", type = "fortified" },
            { name = "Lykopolis Northern Line", type = "ordinary" }
        }
    },
    odiferae = {
        key = "odiferae",
        name = "Southern Odiferae Wetlands",
        link = "[[Southern Odiferae Wetlands]]",
        terrain = { "wetland", "river", "forest", "canal", "rail" },
        hostCities = { "Odiferia", "Somniumpolis", "Aetherium", "Ferrum Citadel", "Aurelia" },
        description = "Saturated soils, fog, causeways, canals, boardwalks, red cliffs and shallow-water routes.",
        nodes = {
            { name = "Odiferia Raised Boardwalk", type = "ordinary" },
            { name = "Somniumpolis Rail Causeway", type = "fortified" },
            { name = "Aetherium Canal Lock", type = "special" },
            { name = "Ferrum Recovery Depot", type = "supply" },
            { name = "Aurelia Marsh Gate", type = "ordinary" },
            { name = "Eastern Levee Crossing", type = "fortified" },
            { name = "Dreamer Ford", type = "ordinary" },
            { name = "Red Cliff Cut", type = "fortified" },
            { name = "Noctic Orchard Road", type = "ordinary" },
            { name = "Western Sluice Yard", type = "supply" }
        }
    },
    gloom = {
        key = "gloom",
        name = "Gloom Forest of Perpetual Autumn",
        link = "[[Gloom Forest of Perpetual Autumn]]",
        terrain = { "forest", "night", "wetland", "road", "recon" },
        hostCities = { "Sylvapolis", "Lunalis Sancta", "Delphica", "Aurelia", "Somniumpolis" },
        description = "Restricted visibility, sacred groves, mist, raised roads and narrow forest corridors.",
        nodes = {
            { name = "Sylvapolis Harvest Road", type = "supply" },
            { name = "Lunalis Moon Gate", type = "fortified" },
            { name = "Delphica Watch Hill", type = "special" },
            { name = "Autumn Crown Crossroads", type = "ordinary" },
            { name = "Zoe Elm Outer Path", type = "fortified" },
            { name = "Mistwood Causeway", type = "ordinary" },
            { name = "Blackleaf Relay Post", type = "supply" },
            { name = "Hunter's Narrow", type = "ordinary" },
            { name = "Whip-Poor-Will Ridge", type = "fortified" },
            { name = "Eastern Grove Boundary", type = "ordinary" }
        }
    },
    highlands = {
        key = "highlands",
        name = "Northern and Western Morovian Highlands",
        link = "[[Northern and Western Morovian Highlands]]",
        terrain = { "highland", "canyon", "cold", "road", "rail" },
        hostCities = { "Acheron", "Pyralis", "Aureum", "Catonis Atrium", "Luminaria" },
        description = "Mountain passes, canyons, rail tunnels, exposed ridges and rapidly changing weather.",
        nodes = {
            { name = "Acheron Bridge of Rebirth", type = "special" },
            { name = "Pyralis Ridge Road", type = "fortified" },
            { name = "Aureum Convoy Gate", type = "supply" },
            { name = "Catonis Straight Pass", type = "fortified" },
            { name = "Luminaria Upper Tunnel", type = "ordinary" },
            { name = "Stygium Canyon Track", type = "ordinary" },
            { name = "Western Snow Cut", type = "fortified" },
            { name = "Highland Signal Station", type = "special" },
            { name = "North Morovian Depot", type = "supply" },
            { name = "Tunnelling Rabbit Plateau", type = "ordinary" }
        }
    },
    jangsong = {
        key = "jangsong",
        name = "New South Jangsong",
        link = "[[New South Jangsong]]",
        terrain = { "cold", "maritime", "highland", "forest", "coastal" },
        hostCities = { "Skýrophos", "Norsolyra", "Aegirheim", "Thorsalon", "Pelagia" },
        description = "Fjords, quartz cliffs, boreal forest, salt-steppe, fortified harbors and cold coastal routes.",
        nodes = {
            { name = "Skýrophos Harbor Gate", type = "fortified" },
            { name = "Norsolyra Rail-Fjord Transfer", type = "supply" },
            { name = "Aegirheim Ice Quay", type = "special" },
            { name = "Thorsalon High Pass", type = "fortified" },
            { name = "Bjornopolis Forest Road", type = "ordinary" },
            { name = "Pelagia Coastal Station", type = "ordinary" },
            { name = "Myrene Observation Cape", type = "special" },
            { name = "Thyrea Salt Road", type = "ordinary" },
            { name = "Ephyra Harbor District", type = "fortified" },
            { name = "Halicarn Dockyard Depot", type = "supply" }
        }
    },
    ouriana = {
        key = "ouriana",
        name = "Ouriana and the Valley of Keltia",
        link = "[[Ouriana|Ouriana and the Valley of Keltia]]",
        terrain = { "plains", "river", "highland", "rail", "mobile" },
        hostCities = { "Bashkim", "Ourid", "Tonar", "Lewisburg", "Somniumpolis" },
        description = "River crossings, ridge approaches, caravan roads, staging yards and exposed steppe corridors.",
        nodes = {
            { name = "Bashkim Rail Yard", type = "supply" },
            { name = "Ourid Shrine Road", type = "fortified" },
            { name = "Tonar Western Gate", type = "ordinary" },
            { name = "Valley River Crossing", type = "special" },
            { name = "Southern Caravan Fork", type = "ordinary" },
            { name = "Steppe Signal Tower", type = "special" },
            { name = "Northern Ridge Track", type = "fortified" },
            { name = "Canal-Grain Depot", type = "supply" },
            { name = "Eastern Wind Road", type = "ordinary" },
            { name = "Valley Crown Position", type = "fortified" }
        }
    },
    normark = {
        key = "normark",
        name = "Bassaridian Normark",
        link = "[[Bassaridian Normark]]",
        terrain = { "cold", "maritime", "highland", "rail", "coastal" },
        hostCities = { "Riddersborg", "Ardclach", "Sårensby", "Aegirheim", "Skýrophos" },
        description = "Subarctic ports, storm harbors, winter roads, avalanche zones and weather-dependent movement.",
        nodes = {
            { name = "Riddersborg Northern Railhead", type = "supply" },
            { name = "Riddersborg Harbor Wall", type = "fortified" },
            { name = "Ardclach Island Landing", type = "special" },
            { name = "Normark Winter Road", type = "ordinary" },
            { name = "Avalanche Cut", type = "fortified" },
            { name = "Ice Ferry Staging Point", type = "ordinary" },
            { name = "Northern Beacon", type = "special" },
            { name = "Storm-Shelter Depot", type = "supply" },
            { name = "Western Fjord Track", type = "ordinary" },
            { name = "High Snow Gate", type = "fortified" }
        }
    }
}

local REGION_ROTATION = {
    "caledonia", "plains", "melusine", "odiferae", "gloom",
    "highlands", "jangsong", "ouriana", "normark"
}

local function regionForYear(year)
    local offset = (tonumber(year) or FIRST_CAMPAIGN_YEAR) - FIRST_CAMPAIGN_YEAR
    local index = (offset % #REGION_ROTATION) + 1
    return REGIONS[REGION_ROTATION[index]]
end

------------------------------------------------------------------------
-- CAMPAIGN RULES
------------------------------------------------------------------------

local NODE_CAPTURE_POINTS = {
    ordinary = 8,
    supply = 8,
    fortified = 15,
    special = 12
}

local PHASES = {
    { first = 1, last = 6, name = "Muster of Standards", standardChance = 0.04 },
    { first = 7, last = 18, name = "Opening Manoeuvres", standardChance = 0.08 },
    { first = 19, last = 36, name = "War of Corridors", standardChance = 0.12 },
    { first = 37, last = 52, name = "Taking of Standards", standardChance = 0.20 },
    { first = 53, last = 60, name = "Closing Encirclement", standardChance = 0.24 },
    { first = 61, last = 61, name = "Day of Reckoning", standardChance = 0.28 }
}

local function phaseForDay(day)
    if tonumber(day) == 0 then
        return { name = "Pre-campaign muster", standardChance = 0 }
    end
    for _, phase in ipairs(PHASES) do
        if day >= phase.first and day <= phase.last then return phase end
    end
    return PHASES[#PHASES]
end

local function hasTag(list, wanted)
    for _, tag in ipairs(list or {}) do
        if tag == wanted then return true end
    end
    return false
end

local function terrainMatch(team, region)
    local matches = 0
    for _, tag in ipairs(team.terrain or {}) do
        if hasTag(region.terrain, tag) then matches = matches + 1 end
    end
    return matches
end

local function terrainBonus(team, region)
    return math.min(9, terrainMatch(team, region) * 3)
end

local function profileFor(entry)
    local division = DIVISIONS[entry.division]
    local function varied(field)
        local base = division[field] or 70
        local variation = randInt("profile|" .. entry.city .. "|" .. field, -6, 6)
        return clamp(base + variation, 50, 95)
    end
    return {
        attack = varied("attack"),
        defense = varied("defense"),
        recon = varied("recon"),
        logistics = varied("logistics"),
        discipline = varied("discipline"),
        mobility = varied("mobility")
    }
end

local function divisionAvailabilityFactor(code, ctx, day)
    if okUtilization and Utilization and type(Utilization.getDivisionAvailability) == "function" then
        local ok, result = pcall(Utilization.getDivisionAvailability, code, ctx.year, 122 + day)
        if ok then
            if type(result) == "number" then
                return clamp(result, 0.55, 1.05)
            elseif type(result) == "table" then
                local value = result.factor or result.availableFactor or result.availability
                if tonumber(value) then return clamp(value, 0.55, 1.05) end
            end
        end
    end
    return 1
end

local FALLBACK_WEATHER = {
    { condition = "Clear", alert = "Green", modifier = 0 },
    { condition = "Cloudy", alert = "Green", modifier = 1 },
    { condition = "Rain", alert = "Yellow", modifier = 5 },
    { condition = "Fog", alert = "Yellow", modifier = 6 },
    { condition = "Storm", alert = "Orange", modifier = 11 },
    { condition = "Snow", alert = "Yellow", modifier = 7 },
    { condition = "Blizzard", alert = "Red", modifier = 17 }
}

local function alertModifier(label)
    local text = lower(label)
    if text:find("red", 1, true) then return 17 end
    if text:find("orange", 1, true) then return 11 end
    if text:find("yellow", 1, true) then return 6 end
    return 0
end

local function impactSummary(impacts)
    if type(impacts) ~= "table" then return "Normal operating posture" end
    local parts = {}
    local order = { "ground", "sea", "air" }
    for _, key in ipairs(order) do
        local value = impacts[key]
        if value and tostring(value) ~= "" then
            parts[#parts + 1] = key:gsub("^%l", string.upper) .. ": " .. tostring(value)
        end
    end
    if #parts == 0 then return "Normal operating posture" end
    return table.concat(parts, "; ")
end

local function weatherForDay(ctx, region, day)
    local city = region.hostCities[((day - 1) % #region.hostCities) + 1]
    if okForecast and Forecast and type(Forecast.getOpsData) == "function" then
        local ok, data = pcall(Forecast.getOpsData, city, ctx.year, 122 + day)
        if ok and type(data) == "table" then
            local alert = (data.alert and (data.alert.label or data.alert.level)) or "Green"
            return {
                city = data.city or city,
                condition = data.condition or "Variable",
                alert = tostring(alert),
                modifier = alertModifier(alert),
                impacts = impactSummary(data.impacts),
                visibility = data.visKm,
                wind = data.windKmh
            }
        end
    end

    local fallback = pick(FALLBACK_WEATHER, ctx.seasonKey .. "|weather|" .. tostring(day) .. "|" .. region.key)
    return {
        city = city,
        condition = fallback.condition,
        alert = fallback.alert,
        modifier = fallback.modifier,
        impacts = "Deterministic fallback forecast"
    }
end

local function actionForTeam(ctx, team, phase, day)
    local r = randKey(ctx.seasonKey .. "|intent|" .. tostring(day) .. "|" .. team.city)
    local standardEnd = phase.standardChance
    local nodeEnd = standardEnd + 0.30
    local reconEnd = nodeEnd + 0.16
    local serviceEnd = reconEnd + 0.10
    local supplyEnd = serviceEnd + 0.10

    if r < standardEnd then return "standard" end
    if r < nodeEnd then return "node" end
    if r < reconEnd then return "recon" end
    if r < serviceEnd then return "service" end
    if r < supplyEnd then return "supply" end
    return "defend"
end

local function eventTime(ctx, day, sequence)
    local minutes = randInt(ctx.seasonKey .. "|time|" .. tostring(day) .. "|" .. tostring(sequence), 0, 13 * 60 + 59)
    local hour = 8 + math.floor(minutes / 60)
    local minute = minutes % 60
    return string.format("%02d:%02d", hour, minute)
end

------------------------------------------------------------------------
-- SIMULATION ENGINE
------------------------------------------------------------------------

local CACHE = {}

local function newState(ctx)
    local region = regionForYear(ctx.year)
    local state = {
        ctx = ctx,
        region = region,
        teams = {},
        teamByCity = {},
        nodes = {},
        eventsByDay = {},
        weatherByDay = {},
        divisionCoordination = {},
        divisionStandings = {},
        eventSequence = 0
    }

    for _, code in ipairs(DIVISION_ORDER) do
        state.divisionCoordination[code] = 0
    end

    for index, entry in ipairs(ROSTER) do
        local profile = profileFor(entry)
        local team = {
            city = entry.city,
            division = entry.division,
            terrain = entry.terrain,
            doctrine = entry.doctrine,
            attack = profile.attack,
            defense = profile.defense,
            recon = profile.recon,
            logistics = profile.logistics,
            discipline = profile.discipline,
            mobility = profile.mobility,
            baseSector = region.nodes[((index - 1) % #region.nodes) + 1].name .. " sector",
            score = 0,
            standardsTaken = 0,
            standardsLost = 0,
            recoveries = 0,
            nodesCaptured = 0,
            nodesLost = 0,
            nodeDays = 0,
            supplyDays = 0,
            reconObjectives = 0,
            servicePoints = 0,
            coordinationPoints = 0,
            penalties = 0,
            penaltyIncidents = 0,
            terrainSuccess = 0,
            immuneUntil = 0,
            intelUntil = 0,
            currentSupplyStreak = 0,
            longestSupplyStreak = 0,
            rank = 0
        }
        state.teams[#state.teams + 1] = team
        state.teamByCity[team.city] = team
    end

    for _, node in ipairs(region.nodes) do
        state.nodes[#state.nodes + 1] = {
            name = node.name,
            type = node.type,
            owner = nil,
            supplied = false,
            heldSince = nil
        }
    end

    return state
end

local function addScore(team, points)
    team.score = team.score + (tonumber(points) or 0)
end

local function logEvent(state, day, importance, kind, actor, target, location, result, points)
    state.eventSequence = state.eventSequence + 1
    local event = {
        day = day,
        time = eventTime(state.ctx, day, state.eventSequence),
        importance = importance or 1,
        kind = kind,
        actor = actor,
        target = target,
        location = location,
        result = result,
        points = points or "—",
        sequence = state.eventSequence
    }
    state.eventsByDay[day] = state.eventsByDay[day] or {}
    state.eventsByDay[day][#state.eventsByDay[day] + 1] = event
end

local function eligibleRivals(state, attacker, day)
    local rivals = {}
    for _, team in ipairs(state.teams) do
        if team.city ~= attacker.city and day > (team.immuneUntil or 0) then
            rivals[#rivals + 1] = team
        end
    end
    return rivals
end

local function processStandardRaid(state, team, day, weather, postures)
    local rivals = eligibleRivals(state, team, day)
    if #rivals == 0 then return end

    local outsideDivision = {}
    for _, rival in ipairs(rivals) do
        if rival.division ~= team.division then outsideDivision[#outsideDivision + 1] = rival end
    end
    local targetPool = (#outsideDivision > 0 and randKey(state.ctx.seasonKey .. "|betrayal|" .. day .. "|" .. team.city) < 0.88)
        and outsideDivision or rivals
    local target = pick(targetPool, state.ctx.seasonKey .. "|standardTarget|" .. day .. "|" .. team.city)
    if not target then return end

    local terrain = terrainBonus(team, state.region)
    local targetTerrain = terrainBonus(target, state.region)
    local intel = (team.intelUntil or 0) >= day and 5 or 0
    local posture = postures[target.city] == "defend" and 10 or 0
    local availability = divisionAvailabilityFactor(team.division, state.ctx, day)

    local attackPower = (
        team.attack * 0.46 + team.recon * 0.20 + team.mobility * 0.20 + team.logistics * 0.08
        + terrain + intel + randInt(state.ctx.seasonKey .. "|raidA|" .. day .. "|" .. team.city, 0, 28)
        - weather.modifier
    ) * availability

    local defensePower = (
        target.defense * 0.48 + target.discipline * 0.24 + target.logistics * 0.18
        + targetTerrain + posture
        + randInt(state.ctx.seasonKey .. "|raidD|" .. day .. "|" .. target.city .. "|" .. team.city, 0, 28)
    )

    if attackPower <= defensePower + 6 then return end

    local recoveryChance = clamp(
        0.30 + (target.defense + target.discipline - team.attack - team.recon) / 350
        + (posture > 0 and 0.12 or 0),
        0.14,
        0.64
    )

    local recovered = randKey(state.ctx.seasonKey .. "|recovery|" .. day .. "|" .. team.city .. "|" .. target.city) < recoveryChance
    if recovered then
        target.recoveries = target.recoveries + 1
        addScore(target, 35)
        logEvent(
            state, day, 8, "Standard recovery", target.city, team.city, target.baseSector,
            "Recovered its city standard before the raiders reached a marshal station.", "+35 " .. target.city
        )
        return
    end

    team.standardsTaken = team.standardsTaken + 1
    target.standardsLost = target.standardsLost + 1
    target.immuneUntil = day + 1
    addScore(team, 100)
    addScore(target, -30)
    if weather.modifier >= 11 or terrain >= 6 then team.terrainSuccess = team.terrainSuccess + 1 end

    logEvent(
        state, day, 10, "Standard captured", team.city, target.city, target.baseSector,
        "Captured and registered the " .. target.city .. " city standard.",
        "+100 " .. team.city .. "; −30 " .. target.city
    )
end

local function nodeCandidates(state, team)
    local candidates = {}
    for _, node in ipairs(state.nodes) do
        if node.owner ~= team.city then candidates[#candidates + 1] = node end
    end
    return candidates
end

local function processNodeAttack(state, team, day, weather, postures)
    local candidates = nodeCandidates(state, team)
    if #candidates == 0 then return end
    local node = pick(candidates, state.ctx.seasonKey .. "|nodeTarget|" .. day .. "|" .. team.city)
    local defender = node.owner and state.teamByCity[node.owner] or nil

    local terrain = terrainBonus(team, state.region)
    local availability = divisionAvailabilityFactor(team.division, state.ctx, day)
    local assault = (
        team.attack * 0.40 + team.mobility * 0.24 + team.recon * 0.16 + team.logistics * 0.10
        + terrain + randInt(state.ctx.seasonKey .. "|nodeA|" .. day .. "|" .. team.city .. "|" .. node.name, 0, 25)
        - weather.modifier
    ) * availability

    local resistance = 47
    if defender then
        resistance = defender.defense * 0.42 + defender.discipline * 0.20 + defender.logistics * 0.16
            + terrainBonus(defender, state.region)
            + (postures[defender.city] == "defend" and 8 or 0)
            + randInt(state.ctx.seasonKey .. "|nodeD|" .. day .. "|" .. defender.city .. "|" .. node.name, 0, 23)
    elseif node.type == "fortified" then
        resistance = resistance + 10
    elseif node.type == "special" then
        resistance = resistance + 6
    end

    if assault <= resistance then return end

    local oldOwner = node.owner
    if oldOwner and state.teamByCity[oldOwner] then
        state.teamByCity[oldOwner].nodesLost = state.teamByCity[oldOwner].nodesLost + 1
    end
    node.owner = team.city
    node.heldSince = day
    node.supplied = false
    team.nodesCaptured = team.nodesCaptured + 1
    local points = NODE_CAPTURE_POINTS[node.type] or 8
    addScore(team, points)
    if weather.modifier >= 11 or terrain >= 6 then team.terrainSuccess = team.terrainSuccess + 1 end

    local detail = oldOwner and ("Seized the node from " .. oldOwner .. ".") or "Established first control of the node."
    logEvent(
        state, day, node.type == "fortified" and 7 or 5, "Control node", team.city, oldOwner,
        node.name, detail, "+" .. tostring(points)
    )
end

local function processRecon(state, team, day, weather)
    local successChance = clamp(0.38 + team.recon / 200 + terrainBonus(team, state.region) / 100 - weather.modifier / 100, 0.35, 0.90)
    if randKey(state.ctx.seasonKey .. "|recon|" .. day .. "|" .. team.city) <= successChance then
        team.reconObjectives = team.reconObjectives + 1
        team.intelUntil = day + 3
        addScore(team, 6)
        if weather.modifier >= 11 then team.terrainSuccess = team.terrainSuccess + 1 end
        if randKey(state.ctx.seasonKey .. "|reconLog|" .. day .. "|" .. team.city) < 0.42 then
            local node = pick(state.nodes, state.ctx.seasonKey .. "|reconNode|" .. day .. "|" .. team.city)
            logEvent(
                state, day, 3, "Reconnaissance", team.city, nil, node and node.name or team.baseSector,
                "Completed a designated reconnaissance objective and opened a three-day intelligence window.", "+6"
            )
        end
    end
end

local SERVICE_TYPES = {
    "engineering bridge-and-route repair",
    "simulated casualty evacuation",
    "disabled-vehicle recovery",
    "field medical stabilisation",
    "communications relay restoration",
    "marshal-supervised civilian route clearance"
}

local function processService(state, team, day, weather)
    local points = randInt(state.ctx.seasonKey .. "|servicePoints|" .. day .. "|" .. team.city, 5, 12)
    local chance = clamp(0.48 + (team.logistics + team.discipline) / 300 - weather.modifier / 120, 0.38, 0.90)
    if randKey(state.ctx.seasonKey .. "|service|" .. day .. "|" .. team.city) <= chance then
        team.servicePoints = team.servicePoints + points
        addScore(team, points)
        local service = pick(SERVICE_TYPES, state.ctx.seasonKey .. "|serviceType|" .. day .. "|" .. team.city)
        if points >= 10 or weather.modifier >= 11 then
            logEvent(
                state, day, 4, "Field service", team.city, nil, team.baseSector,
                "Completed " .. service .. ".", "+" .. tostring(points)
            )
        end
    end
end

local function processJointOperation(state, day, weather)
    if randKey(state.ctx.seasonKey .. "|jointCheck|" .. day) > 0.42 then return end

    local eligibleCodes = {}
    for _, code in ipairs(DIVISION_ORDER) do
        local count = 0
        for _, team in ipairs(state.teams) do
            if team.division == code then count = count + 1 end
        end
        if count >= 2 then eligibleCodes[#eligibleCodes + 1] = code end
    end

    local code = pick(eligibleCodes, state.ctx.seasonKey .. "|jointDivision|" .. day)
    if not code then return end
    local pool = {}
    for _, team in ipairs(state.teams) do
        if team.division == code then pool[#pool + 1] = team end
    end
    pool = shuffledCopy(pool, state.ctx.seasonKey .. "|jointTeams|" .. day .. "|" .. code)
    local first, second = pool[1], pool[2]
    if not first or not second then return end

    local competence = (first.logistics + second.logistics + first.discipline + second.discipline) / 4
    local chance = clamp(0.38 + competence / 200 - weather.modifier / 130, 0.40, 0.88)
    if randKey(state.ctx.seasonKey .. "|jointSuccess|" .. day .. "|" .. code) > chance then return end

    first.coordinationPoints = first.coordinationPoints + 10
    second.coordinationPoints = second.coordinationPoints + 10
    addScore(first, 10)
    addScore(second, 10)
    state.divisionCoordination[code] = state.divisionCoordination[code] + 10

    local node = pick(state.nodes, state.ctx.seasonKey .. "|jointNode|" .. day .. "|" .. code)
    logEvent(
        state, day, 6, "Divisional coordination", first.city, second.city,
        node and node.name or first.baseSector,
        "Completed a verified multi-city operation under " .. DIVISIONS[code].name .. " command.",
        "+10 each; +10 divisional"
    )
end

local function adjudicateSupply(state, day, weather, postures)
    local holdings = {}
    for _, node in ipairs(state.nodes) do
        node.supplied = false
        if node.owner then
            holdings[node.owner] = holdings[node.owner] or {}
            holdings[node.owner][#holdings[node.owner] + 1] = node
        end
    end

    for _, team in ipairs(state.teams) do
        local nodes = holdings[team.city] or {}
        local suppliedCount = 0
        for _, node in ipairs(nodes) do
            local chance = 0.40 + team.logistics / 230 + terrainBonus(team, state.region) / 110 - weather.modifier / 100
            if postures[team.city] == "supply" then chance = chance + 0.16 end
            if node.type == "supply" then chance = chance + 0.07 end
            chance = clamp(chance, 0.28, 0.95)

            if randKey(state.ctx.seasonKey .. "|supplyNode|" .. day .. "|" .. team.city .. "|" .. node.name) <= chance then
                node.supplied = true
                suppliedCount = suppliedCount + 1
                team.nodeDays = team.nodeDays + 1
                addScore(team, 3)
            else
                local abandonChance = clamp(0.025 + weather.modifier / 250, 0.025, 0.11)
                if randKey(state.ctx.seasonKey .. "|abandon|" .. day .. "|" .. team.city .. "|" .. node.name) < abandonChance then
                    node.owner = nil
                    node.heldSince = nil
                    team.nodesLost = team.nodesLost + 1
                    addScore(team, -5)
                    logEvent(
                        state, day, 5, "Isolated node", team.city, nil, node.name,
                        "The isolated garrison was withdrawn and the control node declared abandoned.", "−5"
                    )
                end
            end
        end

        if suppliedCount > 0 then
            team.supplyDays = team.supplyDays + 1
            team.currentSupplyStreak = team.currentSupplyStreak + 1
            if team.currentSupplyStreak > team.longestSupplyStreak then
                team.longestSupplyStreak = team.currentSupplyStreak
            end
            addScore(team, 4)
        else
            team.currentSupplyStreak = 0
        end
    end
end

local PENALTIES = {
    { name = "entered a protected civilian or sacred area", points = 50 },
    { name = "failed a simulated casualty-recovery obligation", points = 40 },
    { name = "obstructed a civilian transport window", points = 25 },
    { name = "used an unauthorised electronic-warfare channel", points = 35 },
    { name = "damaged protected infrastructure during a manoeuvre", points = 45 }
}

local function adjudicatePenalties(state, day, weather)
    for _, team in ipairs(state.teams) do
        local chance = 0.0015 + (100 - team.discipline) / 18000 + weather.modifier / 5000
        if randKey(state.ctx.seasonKey .. "|penalty|" .. day .. "|" .. team.city) < chance then
            local penalty = pick(PENALTIES, state.ctx.seasonKey .. "|penaltyType|" .. day .. "|" .. team.city)
            team.penalties = team.penalties + penalty.points
            team.penaltyIncidents = team.penaltyIncidents + 1
            addScore(team, -penalty.points)
            logEvent(
                state, day, 9, "Marshal penalty", team.city, nil, team.baseSector,
                "Marshalate ruling: " .. penalty.name .. ".", "−" .. tostring(penalty.points)
            )
        end
    end
end

local function simulateDay(state, day)
    local phase = phaseForDay(day)
    local weather = weatherForDay(state.ctx, state.region, day)
    state.weatherByDay[day] = weather

    local postures = {}
    local intents = {}
    for _, team in ipairs(state.teams) do
        local action = actionForTeam(state.ctx, team, phase, day)
        intents[team.city] = action
        if action == "defend" or action == "supply" then postures[team.city] = action end
    end

    local order = shuffledCopy(state.teams, state.ctx.seasonKey .. "|order|" .. day)
    for _, team in ipairs(order) do
        local action = intents[team.city]
        if action == "standard" then
            processStandardRaid(state, team, day, weather, postures)
        elseif action == "node" then
            processNodeAttack(state, team, day, weather, postures)
        elseif action == "recon" then
            processRecon(state, team, day, weather)
        elseif action == "service" then
            processService(state, team, day, weather)
        end
    end

    processJointOperation(state, day, weather)
    adjudicateSupply(state, day, weather, postures)
    adjudicatePenalties(state, day, weather)
end

local function computeRanks(state)
    table.sort(state.teams, function(a, b)
        if a.score ~= b.score then return a.score > b.score end
        if a.standardsTaken ~= b.standardsTaken then return a.standardsTaken > b.standardsTaken end
        if a.standardsLost ~= b.standardsLost then return a.standardsLost < b.standardsLost end
        if a.penalties ~= b.penalties then return a.penalties < b.penalties end
        if a.nodeDays ~= b.nodeDays then return a.nodeDays > b.nodeDays end
        return a.city < b.city
    end)
    for rank, team in ipairs(state.teams) do team.rank = rank end
end

local function computeDivisionStandings(state)
    local rows = {}
    for _, code in ipairs(DIVISION_ORDER) do
        local members = {}
        local totalPenalties = 0
        for _, team in ipairs(state.teams) do
            if team.division == code then
                members[#members + 1] = team
                totalPenalties = totalPenalties + team.penalties
            end
        end
        table.sort(members, function(a, b) return a.score > b.score end)
        local first = members[1] and members[1].score or 0
        local second = members[2] and members[2].score or first
        local topTwoAverage = (first + second) / 2
        local coordination = state.divisionCoordination[code] or 0
        local divisionalPenalty = math.floor(totalPenalties * 0.25)
        rows[#rows + 1] = {
            code = code,
            division = DIVISIONS[code],
            members = members,
            teamCount = #members,
            topTwoAverage = round(topTwoAverage),
            coordination = coordination,
            penalties = divisionalPenalty,
            score = round(topTwoAverage + coordination - divisionalPenalty)
        }
    end

    table.sort(rows, function(a, b)
        if a.score ~= b.score then return a.score > b.score end
        if a.coordination ~= b.coordination then return a.coordination > b.coordination end
        return a.division.name < b.division.name
    end)
    for rank, row in ipairs(rows) do row.rank = rank end
    state.divisionStandings = rows
end

local function buildState(ctx)
    local key = ctx.seasonKey .. "|" .. tostring(ctx.day)
    if CACHE[key] then return CACHE[key] end

    local state = newState(ctx)
    for day = 1, ctx.day do simulateDay(state, day) end
    computeRanks(state)
    computeDivisionStandings(state)
    CACHE[key] = state
    return state
end

------------------------------------------------------------------------
-- WIKITEXT HELPERS
------------------------------------------------------------------------

local function cityLink(city)
    return "[[" .. tostring(city) .. "]]"
end

local function divisionLink(code)
    local division = DIVISIONS[code]
    return division and division.link or tostring(code)
end

local function scoreText(value)
    value = tonumber(value) or 0
    if value > 0 then return "+" .. tostring(value) end
    return tostring(value)
end

local function statusText(ctx)
    if ctx.day == 0 then return "Pre-campaign muster" end
    if ctx.day >= DAYS_IN_CAMPAIGN then return "Final table" end
    return "Active competition"
end

local function weatherBadge(weather)
    local label = weather and weather.alert or "Green"
    local key = lower(label)
    local background = "#d4edda"
    local border = "#c3e6cb"
    if key:find("red", 1, true) then
        background, border = "#f8d7da", "#f5c6cb"
    elseif key:find("orange", 1, true) then
        background, border = "#ffe5b4", "#ffcc80"
    elseif key:find("yellow", 1, true) then
        background, border = "#fff3cd", "#ffeeba"
    end
    return '<span style="background:' .. background .. ';border:1px solid ' .. border .. ';padding:1px 5px;white-space:nowrap;">' .. tostring(label) .. '</span>'
end

local function findTeam(state, requested)
    local wanted = lower(trim(requested))
    if wanted == "" then return nil end
    for _, team in ipairs(state.teams) do
        if lower(team.city) == wanted then return team end
    end
    for _, team in ipairs(state.teams) do
        if lower(team.city):find(wanted, 1, true) then return team end
    end
    return nil
end

local function eventsForTeam(state, team)
    local events = {}
    for day = 1, state.ctx.day do
        for _, event in ipairs(state.eventsByDay[day] or {}) do
            if event.actor == team.city or event.target == team.city then
                events[#events + 1] = event
            end
        end
    end
    table.sort(events, function(a, b)
        if a.day ~= b.day then return a.day > b.day end
        return a.sequence > b.sequence
    end)
    return events
end

local function bestTeam(state, field, lowest)
    local winner = nil
    for _, team in ipairs(state.teams) do
        if not winner then
            winner = team
        elseif lowest then
            if (team[field] or 0) < (winner[field] or 0) then winner = team end
        elseif (team[field] or 0) > (winner[field] or 0) then
            winner = team
        end
    end
    return winner
end

------------------------------------------------------------------------
-- INTERNAL RENDERERS
------------------------------------------------------------------------

local function renderSeasonInfoFromState(state)
    local ctx = state.ctx
    local phase = phaseForDay(ctx.day)
    local leader = state.teams[1]
    local divisionLeader = state.divisionStandings[1]
    local weather = ctx.day > 0 and state.weatherByDay[ctx.day] or nil

    local out = {}
    out[#out + 1] = '{| class="wikitable" style="width:100%; max-width:850px;"'
    out[#out + 1] = '! colspan="2" | Bassaridian War Games League — Games of the Standards'
    out[#out + 1] = '|-'
    out[#out + 1] = '| Campaign || ' .. formatSeason(ctx)
    out[#out + 1] = '|-'
    out[#out + 1] = '| Campaign status || ' .. statusText(ctx)
    out[#out + 1] = '|-'
    out[#out + 1] = '| Campaign day || ' .. tostring(ctx.day) .. ' of ' .. tostring(DAYS_IN_CAMPAIGN)
    out[#out + 1] = '|-'
    out[#out + 1] = '| Phase || ' .. phase.name
    out[#out + 1] = '|-'
    out[#out + 1] = '| Host theatre || ' .. state.region.link
    out[#out + 1] = '|-'
    out[#out + 1] = '| Registered Games-Divisions || ' .. tostring(#state.teams)
    out[#out + 1] = '|-'
    out[#out + 1] = '| City leader || ' .. (ctx.day > 0 and (cityLink(leader.city) .. ' — ' .. tostring(leader.score) .. ' points') or 'No standings yet')
    out[#out + 1] = '|-'
    out[#out + 1] = '| Divisional leader || ' .. (ctx.day > 0 and (divisionLeader.division.link .. ' — ' .. tostring(divisionLeader.score) .. ' points') or 'No standings yet')
    if weather then
        out[#out + 1] = '|-'
        out[#out + 1] = '| Host-theatre forecast || ' .. weatherBadge(weather) .. ' ' .. weather.condition .. ' at ' .. cityLink(weather.city) .. '<br><small>' .. weather.impacts .. '</small>'
    end
    out[#out + 1] = '|-'
    out[#out + 1] = '| Stability rule || Results are deterministic for the same PSSC year and campaign day.'
    out[#out + 1] = '|}'
    return table.concat(out, "\n")
end

local function renderStandingsFromState(state, args)
    args = args or {}
    local limit = tonumber(trim(args.limit or args.rows)) or #state.teams
    limit = clamp(limit, 1, #state.teams)
    local divisionFilter = lower(trim(args.division or args.div or ""))

    local shown = 0
    local out = {}
    out[#out + 1] = '<div style="overflow-x:auto;">'
    out[#out + 1] = '{| class="wikitable sortable" style="width:100%; text-align:center;"'
    out[#out + 1] = '|+ City standings, ' .. formatSeason(state.ctx) .. ' — day ' .. tostring(state.ctx.day) .. ' of 61'
    out[#out + 1] = '|-'
    out[#out + 1] = '! Pos !! City Games-Division !! Parent division !! Standards<br>taken !! Standards<br>lost !! Recoveries !! Nodes<br>taken !! Supplied<br>node-days !! Recon !! Service !! Penalties !! Score'

    for _, team in ipairs(state.teams) do
        local division = DIVISIONS[team.division]
        local matches = divisionFilter == ""
            or lower(team.division):find(divisionFilter, 1, true)
            or lower(division.name):find(divisionFilter, 1, true)
        if matches and shown < limit then
            shown = shown + 1
            local style = ""
            if team.rank == 1 then
                style = ' style="background-color:#fff2cc;"'
            elseif team.rank <= 3 then
                style = ' style="background-color:#e6ffe6;"'
            end
            out[#out + 1] = '|-' .. style
            out[#out + 1] = '| ' .. tostring(team.rank)
                .. ' || style="text-align:left;" | ' .. cityLink(team.city)
                .. ' || style="text-align:left;" | ' .. division.link
                .. ' || ' .. tostring(team.standardsTaken)
                .. ' || ' .. tostring(team.standardsLost)
                .. ' || ' .. tostring(team.recoveries)
                .. ' || ' .. tostring(team.nodesCaptured)
                .. ' || ' .. tostring(team.nodeDays)
                .. ' || ' .. tostring(team.reconObjectives)
                .. ' || ' .. tostring(team.servicePoints)
                .. ' || ' .. tostring(team.penalties)
                .. " || '''" .. tostring(team.score) .. "'''"
        end
    end

    if shown == 0 then
        out[#out + 1] = '|-'
        out[#out + 1] = '| colspan="12" | No Games-Division matched the requested filter.'
    end
    out[#out + 1] = '|}'
    out[#out + 1] = '</div>'
    return table.concat(out, "\n")
end

local function renderDivisionStandingsFromState(state)
    local out = {}
    out[#out + 1] = '{| class="wikitable sortable" style="width:100%; text-align:center;"'
    out[#out + 1] = '|+ Parent-division standings, ' .. formatSeason(state.ctx) .. ' — day ' .. tostring(state.ctx.day) .. ' of 61'
    out[#out + 1] = '|-'
    out[#out + 1] = '! Pos !! Parent division !! Teams !! Leading city formations !! Top-two average !! Coordination !! Divisional penalties !! Score'

    for _, row in ipairs(state.divisionStandings) do
        local leaders = {}
        for i = 1, math.min(2, #row.members) do
            leaders[#leaders + 1] = cityLink(row.members[i].city) .. ' (' .. tostring(row.members[i].score) .. ')'
        end
        local style = row.rank == 1 and ' style="background-color:#fff2cc;"' or ""
        out[#out + 1] = '|-' .. style
        out[#out + 1] = '| ' .. tostring(row.rank)
            .. ' || style="text-align:left;" | ' .. row.division.link
            .. ' || ' .. tostring(row.teamCount)
            .. ' || style="text-align:left;" | ' .. join(leaders, '<br>')
            .. ' || ' .. tostring(row.topTwoAverage)
            .. ' || +' .. tostring(row.coordination)
            .. ' || −' .. tostring(row.penalties)
            .. " || '''" .. tostring(row.score) .. "'''"
    end

    out[#out + 1] = '|}'
    out[#out + 1] = '<small>Divisional score = average of the two highest city scores + coordination bonuses − one quarter of team penalties.</small>'
    return table.concat(out, "\n")
end

local function renderDailyCampaignFromState(state, args)
    args = args or {}
    local day = tonumber(trim(args.logday or args.eventday or args.day)) or state.ctx.day
    day = clamp(day, 0, state.ctx.day)
    local limit = tonumber(trim(args.limit or args.events)) or 14
    limit = clamp(limit, 1, 100)

    if day == 0 then
        return "''The campaign has not yet begun. Daily operations will appear on Opsitheiel 1.''"
    end

    local events = {}
    for _, event in ipairs(state.eventsByDay[day] or {}) do events[#events + 1] = event end
    table.sort(events, function(a, b)
        if a.importance ~= b.importance then return a.importance > b.importance end
        return a.sequence < b.sequence
    end)

    local weather = state.weatherByDay[day]
    local out = {}
    out[#out + 1] = '{| class="wikitable sortable" style="width:100%;"'
    out[#out + 1] = '|+ Campaign ledger, ' .. formatDate(state.ctx, day) .. ' — ' .. weatherBadge(weather) .. ' ' .. weather.condition
    out[#out + 1] = '|-'
    out[#out + 1] = '! Time !! Action !! Games-Division !! Rival / partner !! Location !! Marshal result !! Points'

    if #events == 0 then
        out[#out + 1] = '|-'
        out[#out + 1] = '| colspan="7" | No major marshal-reportable event was recorded. Routine manoeuvres and supply adjudication continued.'
    else
        for i = 1, math.min(limit, #events) do
            local event = events[i]
            out[#out + 1] = '|-'
            out[#out + 1] = '| ' .. event.time
                .. ' || ' .. event.kind
                .. ' || ' .. cityLink(event.actor)
                .. ' || ' .. (event.target and cityLink(event.target) or '—')
                .. ' || ' .. event.location
                .. ' || ' .. event.result
                .. ' || ' .. event.points
        end
    end

    out[#out + 1] = '|}'
    out[#out + 1] = '<small>Forecast impact: ' .. weather.impacts .. '.</small>'
    return table.concat(out, "\n")
end

local function renderControlBoardFromState(state)
    local out = {}
    out[#out + 1] = '{| class="wikitable sortable" style="width:100%;"'
    out[#out + 1] = '|+ Control-node board — ' .. state.region.name .. ', day ' .. tostring(state.ctx.day)
    out[#out + 1] = '|-'
    out[#out + 1] = '! Control node !! Type !! Current holder !! Supplied today !! Held since !! Capture value !! Daily supplied value'

    for _, node in ipairs(state.nodes) do
        out[#out + 1] = '|-'
        out[#out + 1] = '| ' .. node.name
            .. ' || ' .. node.type:gsub("^%l", string.upper)
            .. ' || ' .. (node.owner and cityLink(node.owner) or 'Unclaimed')
            .. ' || ' .. (node.supplied and 'Yes' or 'No')
            .. ' || ' .. (node.heldSince and formatDate(state.ctx, node.heldSince) or '—')
            .. ' || ' .. tostring(NODE_CAPTURE_POINTS[node.type] or 8)
            .. ' || 3'
    end
    out[#out + 1] = '|}'
    return table.concat(out, "\n")
end

local function renderRegionFromState(state)
    local region = state.region
    local out = {}
    out[#out + 1] = '{| class="wikitable" style="width:100%; max-width:900px;"'
    out[#out + 1] = '! colspan="2" | Host theatre — ' .. region.link
    out[#out + 1] = '|-'
    out[#out + 1] = '| Rotation position || ' .. tostring(((state.ctx.year - FIRST_CAMPAIGN_YEAR) % #REGION_ROTATION) + 1) .. ' of ' .. tostring(#REGION_ROTATION)
    out[#out + 1] = '|-'
    out[#out + 1] = '| Campaign conditions || ' .. region.description
    out[#out + 1] = '|-'
    out[#out + 1] = '| Terrain tags || ' .. join(region.terrain, ', ')
    out[#out + 1] = '|-'
    local linkedCities = {}
    for _, city in ipairs(region.hostCities) do linkedCities[#linkedCities + 1] = cityLink(city) end
    out[#out + 1] = '| Forecast hubs || ' .. join(linkedCities, ', ')
    out[#out + 1] = '|-'
    out[#out + 1] = '| Control nodes || ' .. tostring(#region.nodes)
    out[#out + 1] = '|}'
    return table.concat(out, "\n")
end

local function renderTeamLedgerFromState(state, args)
    args = args or {}
    local requested = trim(args.city or args.team or args[1])
    local team = findTeam(state, requested)
    if not team then
        return "Error: unknown Games-Division. Use <code>{{#invoke:BassaridianWarGamesLeague|renderTeamLedger|city=Vaeringheim}}</code>."
    end

    local out = {}
    out[#out + 1] = '{| class="wikitable" style="width:100%; max-width:900px;"'
    out[#out + 1] = '! colspan="2" | ' .. cityLink(team.city) .. ' Games-Division'
    out[#out + 1] = '|-'
    out[#out + 1] = '| Parent division || ' .. divisionLink(team.division)
    out[#out + 1] = '|-'
    out[#out + 1] = '| Registered base sector || ' .. team.baseSector
    out[#out + 1] = '|-'
    out[#out + 1] = '| Doctrine || ' .. team.doctrine
    out[#out + 1] = '|-'
    out[#out + 1] = '| Preferred terrain || ' .. join(team.terrain, ', ')
    out[#out + 1] = '|-'
    out[#out + 1] = '| Field profile || Attack ' .. team.attack .. '; defence ' .. team.defense .. '; reconnaissance ' .. team.recon .. '; logistics ' .. team.logistics .. '; discipline ' .. team.discipline .. '; mobility ' .. team.mobility
    out[#out + 1] = '|-'
    out[#out + 1] = '| Current rank and score || ' .. tostring(team.rank) .. ' / ' .. tostring(#state.teams) .. " — '''" .. tostring(team.score) .. " points'''"
    out[#out + 1] = '|-'
    out[#out + 1] = '| Standards || ' .. tostring(team.standardsTaken) .. ' taken; ' .. tostring(team.standardsLost) .. ' lost; ' .. tostring(team.recoveries) .. ' recovered'
    out[#out + 1] = '|-'
    out[#out + 1] = '| Corridor record || ' .. tostring(team.nodesCaptured) .. ' nodes captured; ' .. tostring(team.nodeDays) .. ' supplied node-days; longest supplied streak ' .. tostring(team.longestSupplyStreak) .. ' days'
    out[#out + 1] = '|-'
    out[#out + 1] = '| Supporting objectives || ' .. tostring(team.reconObjectives) .. ' reconnaissance; ' .. tostring(team.servicePoints) .. ' service points; ' .. tostring(team.coordinationPoints) .. ' coordination points'
    out[#out + 1] = '|-'
    out[#out + 1] = '| Discipline || ' .. tostring(team.penalties) .. ' penalty points across ' .. tostring(team.penaltyIncidents) .. ' incidents'
    out[#out + 1] = '|}'

    local events = eventsForTeam(state, team)
    local limit = clamp(tonumber(trim(args.limit)) or 12, 1, 50)
    out[#out + 1] = ''
    out[#out + 1] = '{| class="wikitable sortable" style="width:100%;"'
    out[#out + 1] = '|+ Recent campaign ledger entries'
    out[#out + 1] = '|-'
    out[#out + 1] = '! Date !! Action !! Other formation !! Location !! Result !! Points'
    if #events == 0 then
        out[#out + 1] = '|-'
        out[#out + 1] = '| colspan="6" | No major ledger entry has yet been recorded for this Games-Division.'
    else
        for i = 1, math.min(limit, #events) do
            local event = events[i]
            local other = event.actor == team.city and event.target or event.actor
            out[#out + 1] = '|-'
            out[#out + 1] = '| ' .. formatDate(state.ctx, event.day)
                .. ' || ' .. event.kind
                .. ' || ' .. (other and cityLink(other) or '—')
                .. ' || ' .. event.location
                .. ' || ' .. event.result
                .. ' || ' .. event.points
        end
    end
    out[#out + 1] = '|}'
    return table.concat(out, "\n")
end

local function honourRow(name, team, basis)
    return "|-\n| '''" .. name .. "''' || " .. (team and cityLink(team.city) or '—') .. ' || ' .. basis
end

local function renderHonoursFromState(state)
    if state.ctx.day == 0 then return "''Honours will be projected after the first day of competition.''" end

    local champion = state.teams[1]
    local divisionChampion = state.divisionStandings[1]
    local logistics = bestTeam(state, "supplyDays", false)
    local recon = bestTeam(state, "reconObjectives", false)
    local restraint = bestTeam(state, "penalties", true)
    local service = bestTeam(state, "servicePoints", false)
    local terrain = bestTeam(state, "terrainSuccess", false)
    local recovery = bestTeam(state, "recoveries", false)
    local road = bestTeam(state, "longestSupplyStreak", false)

    local out = {}
    out[#out + 1] = '{| class="wikitable" style="width:100%;"'
    out[#out + 1] = '|+ ' .. (state.ctx.day >= 61 and 'Campaign honours' or 'Projected campaign honours')
    out[#out + 1] = '|-'
    out[#out + 1] = '! Honour !! Holder !! Basis'
    out[#out + 1] = honourRow("Standard of the Commander General", champion, tostring(champion.score) .. ' points; ' .. tostring(champion.standardsTaken) .. ' standards taken')
    out[#out + 1] = "|-\n| '''Shield of the Nine Regions''' || " .. divisionChampion.division.link .. ' || ' .. tostring(divisionChampion.score) .. ' divisional points'
    out[#out + 1] = honourRow("Golden Route Pennant", logistics, tostring(logistics.supplyDays) .. ' supplied corridor-days')
    out[#out + 1] = honourRow("Far-Seeing Standard", recon, tostring(recon.reconObjectives) .. ' reconnaissance objectives')
    out[#out + 1] = honourRow("White Standard of Restraint", restraint, tostring(restraint.penalties) .. ' penalty points')
    out[#out + 1] = honourRow("Marshal's Knot", service, tostring(service.servicePoints) .. ' engineering, medical and recovery points')
    out[#out + 1] = honourRow("Host Region Laurel", terrain, tostring(terrain.terrainSuccess) .. ' difficult-terrain successes')
    out[#out + 1] = honourRow("Recovery Pennant", recovery, tostring(recovery.recoveries) .. ' city-standard recoveries')
    out[#out + 1] = honourRow("Unbroken Road Citation", road, tostring(road.longestSupplyStreak) .. '-day supplied streak')
    out[#out + 1] = '|}'
    return table.concat(out, "\n")
end

local function renderRosterTable(args)
    args = args or {}
    local divisionFilter = lower(trim(args.division or args.div or ""))
    local out = {}
    out[#out + 1] = '{| class="wikitable sortable" style="width:100%;"'
    out[#out + 1] = '|+ Games Registry'
    out[#out + 1] = '|-'
    out[#out + 1] = '! City !! Games-Division !! Parent division !! Preferred terrain !! Home doctrine'
    local shown = 0
    for _, entry in ipairs(ROSTER) do
        local division = DIVISIONS[entry.division]
        local matches = divisionFilter == ""
            or lower(entry.division):find(divisionFilter, 1, true)
            or lower(division.name):find(divisionFilter, 1, true)
        if matches then
            shown = shown + 1
            out[#out + 1] = '|-'
            out[#out + 1] = '| ' .. cityLink(entry.city)
                .. ' || ' .. entry.city .. ' Games-Division'
                .. ' || ' .. division.link
                .. ' || ' .. join(entry.terrain, ', ')
                .. ' || ' .. entry.doctrine
        end
    end
    if shown == 0 then
        out[#out + 1] = '|-'
        out[#out + 1] = '| colspan="5" | No Games-Division matched the requested filter.'
    end
    out[#out + 1] = '|}'
    return table.concat(out, "\n")
end

------------------------------------------------------------------------
-- PUBLIC FUNCTIONS
------------------------------------------------------------------------

function p.renderSeasonInfo(frame)
    local ctx = campaignArgs(frame)
    return renderSeasonInfoFromState(buildState(ctx))
end

function p.renderStandings(frame)
    local ctx = campaignArgs(frame)
    return renderStandingsFromState(buildState(ctx), ctx.args)
end

p.standings = p.renderStandings
p.leaderboard = p.renderStandings

function p.renderDivisionStandings(frame)
    local ctx = campaignArgs(frame)
    return renderDivisionStandingsFromState(buildState(ctx))
end

function p.renderDailyCampaign(frame)
    local ctx = campaignArgs(frame)
    return renderDailyCampaignFromState(buildState(ctx), ctx.args)
end

function p.renderControlBoard(frame)
    local ctx = campaignArgs(frame)
    return renderControlBoardFromState(buildState(ctx))
end

function p.renderTeamLedger(frame)
    local ctx = campaignArgs(frame)
    return renderTeamLedgerFromState(buildState(ctx), ctx.args)
end

function p.renderRegion(frame)
    local ctx = campaignArgs(frame)
    return renderRegionFromState(buildState(ctx))
end

function p.renderHonours(frame)
    local ctx = campaignArgs(frame)
    return renderHonoursFromState(buildState(ctx))
end

function p.renderRoster(frame)
    local args = (frame and frame.args) or {}
    return renderRosterTable(args)
end

function p.renderFull(frame)
    local ctx = campaignArgs(frame)
    local state = buildState(ctx)
    local args = ctx.args
    local out = {
        renderSeasonInfoFromState(state),
        "",
        renderRegionFromState(state),
        "",
        renderStandingsFromState(state, args),
        "",
        renderDivisionStandingsFromState(state),
        "",
        renderDailyCampaignFromState(state, args),
        "",
        renderControlBoardFromState(state),
        "",
        renderHonoursFromState(state)
    }
    return table.concat(out, "\n")
end

p.full = p.renderFull

function p.doc()
    return table.concat({
        "'''Bassaridian War Games League'''",
        "",
        "Deterministic simulator for the sixty-one-day Opsitheiel Games of the Standards.",
        "",
        "Common invocations:",
        "* <code>{{#invoke:BassaridianWarGamesLeague|renderFull}}</code>",
        "* <code>{{#invoke:BassaridianWarGamesLeague|renderFull|year=53|day=19}}</code>",
        "* <code>{{#invoke:BassaridianWarGamesLeague|renderStandings}}</code>",
        "* <code>{{#invoke:BassaridianWarGamesLeague|renderStandings|division=CKD|limit=12}}</code>",
        "* <code>{{#invoke:BassaridianWarGamesLeague|renderDivisionStandings}}</code>",
        "* <code>{{#invoke:BassaridianWarGamesLeague|renderDailyCampaign|limit=12}}</code>",
        "* <code>{{#invoke:BassaridianWarGamesLeague|renderDailyCampaign|year=53|day=61|logday=18}}</code>",
        "* <code>{{#invoke:BassaridianWarGamesLeague|renderControlBoard}}</code>",
        "* <code>{{#invoke:BassaridianWarGamesLeague|renderTeamLedger|city=Vaeringheim}}</code>",
        "* <code>{{#invoke:BassaridianWarGamesLeague|renderRegion}}</code>",
        "* <code>{{#invoke:BassaridianWarGamesLeague|renderHonours}}</code>",
        "* <code>{{#invoke:BassaridianWarGamesLeague|renderRoster}}</code>",
        "",
        "Outside Opsitheiel, an invocation without a day displays the pre-campaign board. Historical campaigns default to day 61."
    }, "\n")
end

return p