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