Module:WarLeagueUnitUtilizationBVR: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
NewZimiaGov (talk | contribs)
No edit summary
Line 1: Line 1:
-- Module:WarLeagueUnitUtilizationBVR
-- Module:WarLeagueUnitUtilizationBVR
-- Fixes: "attempt to index global 'a' (a nil value)" by removing the stray usedByIdx[a.idx]
-- Broad, doctrine-informed utilization overlay for the Unit Numbers table.
-- Ensures: (1) full embedded table renders identically, (2) robust DailyOps parsing (naval/air parentheses),
--
-- (3) never uses more than available (allocation + cap), (4) no unit excluded (TO&E coverage), (5) debug mode.
-- What this module does:
--  1) Renders the embedded Unit Numbers table exactly as-authored.
--  2) Reads today's (or seeded) ops output from Module:WarLeagueDivisionOpsBVR.makeTable().
--   3) Converts formations + operation text/urgency/impact into "demand" for equipment.
--  4) Allocates and caps usage so it NEVER exceeds availability (per cell + per shared-division group).
--  5) Expands the variety of equipment drawn (breadth) using role-based matching from the SSF hardware list
--      (implemented via a role/tag system + heuristics on the equipment names in the table).
--
-- Invoke:
--  {{#invoke:WarLeagueUnitUtilizationBVR|render}}
--  {{#invoke:WarLeagueUnitUtilizationBVR|render|seed=520154|opsrows=40}}
--  {{#invoke:WarLeagueUnitUtilizationBVR|render|breadth=high}}      -- low|standard|high
--  {{#invoke:WarLeagueUnitUtilizationBVR|render|debug=yes|debugrows=8}}


local p = {}
local p = {}


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- 1) FULL EMBEDDED TABLE (embedded exactly as provided)
-- 1) FULL EMBEDDED TABLE (exactly as provided)
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
local RAW_BASE_TABLE = [=[
local RAW_BASE_TABLE = [=[
Line 228: Line 240:


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- 2) Column order must match the 11 investor/division columns in the table
-- Column order and mapping
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
local COL_KEYS = { "VREG","ALPK","ODFR","HAFA","JESR","HMIN","NSJ","HBAS","BNOR","OURN","CKD" }
local COL_KEYS = { "VREG","ALPK","ODFR","HAFA","JESR","HMIN","NSJ","HBAS","BNOR","OURN","CKD" }
local COL_TO_DIV = {
local COL_TO_DIV = {
VREG="VAE", ALPK="ALP", ODFR="ODF",
VREG="VAE", ALPK="ALP", ODFR="ODF",
Line 239: Line 250:


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- 3) TO&E templates (as provided earlier in your module)
-- Baseline TOE by echelon (your original templates)
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
local TOE = {
local TOE = {
Line 305: Line 316:


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Helpers (use push() everywhere)
-- Helpers
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
local function push(t, v) t[#t+1] = v end
local function push(t, v) t[#t+1] = v end
 
local function trim(s) s=tostring(s or ""); return (s:gsub("^%s+",""):gsub("%s+$","")) end
local function trim(s)
s = tostring(s or "")
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end
 
local function toNumber(s)
local function toNumber(s)
s = tostring(s or "")
s=tostring(s or "")
local n = s:gsub("[^0-9]", "")
local n=s:gsub("[^0-9]","")
if n == "" then return nil end
if n=="" then return nil end
return tonumber(n)
return tonumber(n)
end
end
local function fmt(n)
local function fmt(n)
if not n then return "" end
if not n then return "" end
local s = tostring(math.floor(n))
local s=tostring(math.floor(n))
local out = s:reverse():gsub("(%d%d%d)", "%1,"):reverse()
local out=s:reverse():gsub("(%d%d%d)","%1,"):reverse()
return out:gsub("^,", "")
return out:gsub("^,","")
end
end
 
local function extractDisplayText(cell)
local function extractDisplayText(wikilinkCell)
local c=tostring(cell or "")
local c = tostring(wikilinkCell or "")
local display=c:match("%[%[[^%]]-%|([^%]]-)%]%]")
local display = c:match("%[%[[^%]]-%|([^%]]-)%]%]")
if display and display~="" then return display end
if display and display ~= "" then return display end
display=c:match("%[%[([^%]]-)%]%]")
display = c:match("%[%[([^%]]-)%]%]")
if display and display~="" then return display end
if display and display ~= "" then return display end
return trim(c:gsub("[%[%]]",""))
return trim(c:gsub("[%[%]]", ""))
end
end
local function cellColor(pct)
local function cellColor(pct)
if pct <= 0.02 then return "#d4edda" end
if pct <= 0.02 then return "#d4edda" end
Line 343: Line 346:
return "#f8d7da"
return "#f8d7da"
end
end
local function normalizeWikitextTable(text)
local function normalizeWikitextTable(text)
local out = {}
local out={}
for line in tostring(text):gmatch("([^\n]*)\n?") do
for line in tostring(text):gmatch("([^\n]*)\n?") do
-- do not break on blank lines; strip indentation to avoid preformatted rendering
push(out, line:gsub("^[ \t]+",""))
push(out, line:gsub("^[ \t]+", ""))
end
end
return table.concat(out, "\n")
return table.concat(out,"\n")
end
end


-- Sanitize ops segments so echelon matching survives wiki/HTML markup
--------------------------------------------------------------------------------
-- Ops parsing (robust naval/air parsing with internal parentheses + wiki markup)
--------------------------------------------------------------------------------
local function sanitizeOpsSegment(seg)
local function sanitizeOpsSegment(seg)
seg = tostring(seg or "")
seg=tostring(seg or "")
seg = seg:gsub("<br%s*/?>", "\n")
seg=seg:gsub("<br%s*/?>","\n")
seg = seg:gsub("<[^>]+>", "")
seg=seg:gsub("<[^>]+>","")
seg = seg:gsub("''+", "")
seg=seg:gsub("''+","")
seg = seg:gsub("%[%[[^%]|]-|([^%]]-)%]%]", "%1")
seg=seg:gsub("%[%[[^%]|]-|([^%]]-)%]%]","%1")
seg = seg:gsub("%[%[([^%]]-)%]%]", "%1")
seg=seg:gsub("%[%[([^%]]-)%]%]","%1")
seg = seg:gsub("&nbsp;", " ")
seg=seg:gsub("&nbsp;"," ")
seg = seg:gsub("%s+", " ")
seg=seg:gsub("%s+"," ")
return trim(seg)
return trim(seg)
end
end
-- Parse formation "Echelon (DIV-XX)" where echelon may contain parentheses
local function parseFormation(seg)
local function parseFormation(seg)
seg = sanitizeOpsSegment(seg)
seg = sanitizeOpsSegment(seg)
local divCode = seg:match("%((%u%u%u)%-[%w]+%s*%)%s*$")
local divCode = seg:match("%((%u%u%u)%-[%w]+%s*%)%s*$")
if not divCode then return nil, nil end
if not divCode then return nil, nil end
local echelon = trim(seg:gsub("%(%u%u%u%-%w+%s*%)%s*$", ""))
local echelon = trim(seg:gsub("%(%u%u%u%-%w+%s*%)%s*$",""))
return echelon, divCode
return echelon, divCode
end
local function urgencyScore(urg)
urg=tostring(urg or "")
if urg=="CRITICAL" then return 4 end
if urg=="HIGH" then return 3 end
if urg=="MEDIUM" then return 2 end
if urg=="LOW" then return 1 end
return 0
end
local function parseImpact(impactCell)
local s=tostring(impactCell or "")
local g=s:match("Gnd:%s*([^;]+)") or "OK"
local sea=s:match("Sea:%s*([^;]+)") or "OK"
local air=s:match("Air:%s*([^;]+)") or "OK"
return { ground=trim(g), sea=trim(sea), air=trim(air) }
end
local function impactAllows(imp, domain)
if not imp then return true end
local v = (domain=="sea" and imp.sea) or (domain=="air" and imp.air) or imp.ground
v=tostring(v or "OK"):lower()
if v:find("no%-go",1,true) then return false end
if v:find("closed",1,true) then return false end
return true
end
end


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Coverage enforcement (no unit excluded)
-- Lua 5.1-safe deterministic hash/picker (no bitwise ops)
--------------------------------------------------------------------------------
local function hash32(s)
s = tostring(s or "")
local h = 0
for i=1,#s do
h = (h * 131 + s:byte(i)) % 4294967296
end
return h
end
local function pickDeterministic(list, k, salt)
if not list or #list==0 or k<=0 then return {} end
local picked, used = {}, {}
local h = hash32(salt)
for _=1,k do
if #picked >= #list then break end
h = (1103515245 * h + 12345) % 4294967296
local idx = (h % #list) + 1
local guard = 0
while used[idx] and guard < 100 do
h = (1103515245 * h + 12345) % 4294967296
idx = (h % #list) + 1
guard = guard + 1
end
if not used[idx] then
used[idx] = true
picked[#picked+1] = list[idx]
end
end
return picked
end
 
--------------------------------------------------------------------------------
-- Coverage enforcement: ensure no unit is excluded
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
local function collectAllUnitsFromBase(baseText)
local function collectAllUnitsFromBase(baseText)
local units = {}
local units={}
for line in tostring(baseText):gmatch("[^\n]+") do
for line in tostring(baseText):gmatch("[^\n]+") do
if line:match("^|%s*%[%[File:") and line:find("||", 1, true) then
if line:match("^|%s*%[%[File:") and line:find("||",1,true) then
local parts = mw.text.split(line:gsub("^|%s*", ""), "%s*||%s*")
local parts = mw.text.split(line:gsub("^|%s*",""), "%s*||%s*")
if #parts == 14 then
if #parts == 14 then
local unitName = extractDisplayText(parts[2])
local unitName = extractDisplayText(parts[2])
if unitName ~= "" then units[unitName] = true end
if unitName ~= "" then units[unitName]=true end
end
end
end
end
Line 391: Line 448:
return units
return units
end
end
local function normalizeToeTemplates(allUnits)
local function normalizeToeTemplates(allUnits)
for _, toe in pairs(TOE) do
for _, toe in pairs(TOE) do
if type(toe) == "table" then
if type(toe)=="table" then
for unitName, _ in pairs(allUnits) do
for unitName,_ in pairs(allUnits) do
if toe[unitName] == nil then toe[unitName] = 0 end
if toe[unitName]==nil then toe[unitName]=0 end
end
end
end
end
Line 402: Line 458:
end
end


local function buildToeCoverageReport(allUnits)
--------------------------------------------------------------------------------
local lines = {}
-- Breadth system: role pools derived from equipment names in the table
push(lines, "''TO&E Coverage Report (any unit not explicitly counted defaults to 0):''")
--------------------------------------------------------------------------------
local echelons = {}
local function normName(name)
for echelon, _ in pairs(TOE) do push(echelons, echelon) end
name=tostring(name or ""):lower()
table.sort(echelons)
name=name:gsub("[^a-z0-9%-%s/()]", " "):gsub("%s+"," ")
for _, echelon in ipairs(echelons) do
return trim(name)
local toe = TOE[echelon]
end
local fallback = 0
local function rolesForUnit(unitName)
for unitName, _ in pairs(allUnits) do
local n=normName(unitName)
if toe[unitName] == 0 then fallback = fallback + 1 end
local roles={}
local function add(r) roles[r]=true end
 
if n:find("active units",1,true) or n:find("reserve units",1,true) then add("personnel"); return roles end
 
if n:find("rifle",1,true) or n:find("pistol",1,true) or n:find("revolver",1,true)
or n:find("shotgun",1,true) or n:find("submachine",1,true) or n:find("dmr",1,true)
or n:find("sniper",1,true) or n:find("rpg",1,true) then add("small_arms"); add("ground") end
 
if n:find("battle tank",1,true) or n:find("main battle tank",1,true) or n:find("heavy tank",1,true) or n:find("light tank",1,true) then add("armor"); add("ground") end
if n:find("armored infantry",1,true) then add("ifv"); add("ground") end
if n:find("command vehicle",1,true) then add("c2_ground"); add("ground") end
if n:find("recon vehicle",1,true) then add("recon_ground"); add("ground") end
if n:find("anti-aircraft",1,true) then add("air_defense_ground"); add("ground") end
if n:find("recovery vehicle",1,true) then add("recovery"); add("ground") end
if n:find("bridge%-laying",1,true) then add("bridging"); add("engineering"); add("ground") end
if n:find("rail%-laying",1,true) then add("rail"); add("engineering"); add("ground") end
if n:find("transport truck",1,true) or n:find("logistics",1,true) then add("logistics"); add("ground") end
if n:find("mine%-clearing",1,true) or n:find("combat engineering",1,true) then add("engineering"); add("mine_clearance"); add("ground") end
 
if n:find("howitzer",1,true) or n:find("field gun",1,true) or n:find("mortar",1,true) then add("artillery"); add("ground") end
if n:find("coastal gun",1,true) or n:find("coastal missile",1,true) then add("coastal_defense"); add("coastal_fires") end
if n:find("surveillance tower",1,true) then add("surveillance"); add("coastal_defense") end
 
if n:find("quadwalker",1,true) then add("autonomous"); add("ground") end
if n:find("prototype",1,true) then add("prototype"); add("autonomous") end
if n:find("air defense system",1,true) or n:find("static emplacement",1,true) or n:find("thaad",1,true) then add("air_defense"); add("fixed_defense") end
if n:find("citadel train",1,true) then add("rail"); add("corridor_security"); add("ground") end
 
if n:find("patrol ship",1,true) or n:find("gunboat",1,true) then add("naval_patrol"); add("sea") end
if n:find("corvette",1,true) or n:find("frigate",1,true) or n:find("cruiser",1,true) or n:find("battlecruiser",1,true) then add("naval_surface"); add("sea") end
if n:find("submarine",1,true) or n:find("submersible",1,true) then add("naval_subsurface"); add("sea") end
if n:find("minelayer",1,true) or n:find("minehunter",1,true) then add("mine_warfare"); add("sea") end
if n:find("landing ship",1,true) or n:find("amphibious",1,true) or n:find("airstrip barge",1,true) then add("amphibious"); add("sea") end
if n:find("privateer",1,true) then add("privateer"); add("sea") end
 
if n:find("uav",1,true) then add("uav"); add("air") end
if n:find("aew",1,true) then add("aew"); add("air") end
if n:find("tanker aircraft",1,true) then add("tanker"); add("air") end
if n:find("transport plane",1,true) or n:find("transport helicopter",1,true) then add("transport_air"); add("air") end
if n:find("attack helicopter",1,true) then add("rotary_attack"); add("air") end
if n:find("heavy transport helicopter",1,true) or n:find("medium transport helicopter",1,true) then add("rotary_transport"); add("air") end
if n:find("bomber",1,true) then add("bomber"); add("air") end
if n:find("intercept",1,true) then add("intercept"); add("air") end
if n:find("proximity aircraft",1,true) then add("strike"); add("air") end
if n:find("maritime patrol aircraft",1,true) then add("mpa"); add("air") end
 
return roles
end
 
local function buildRolePools(allUnits)
local pools, allRoles = {}, {}
local seen = {}
for unitName,_ in pairs(allUnits) do
local roles = rolesForUnit(unitName)
for r,_v in pairs(roles) do
pools[r] = pools[r] or {}
pools[r][#pools[r]+1] = unitName
if not seen[r] then
seen[r]=true
allRoles[#allRoles+1]=r
end
end
end
push(lines, string.format("* <b>%s</b>: %d fallback rows", echelon, fallback))
end
end
return table.concat(lines, "\n")
for r,list in pairs(pools) do table.sort(list) end
table.sort(allRoles)
return pools, allRoles
end
end


--------------------------------------------------------------------------------
local function breadthLevel(args)
-- Parse ops module -> utilization demand by DIV and Unit
local b=tostring((args and args.breadth) or "standard"):lower()
--------------------------------------------------------------------------------
if b=="low" then return 1 end
local function computeUtilization(args)
if b=="high" then return 3 end
local util = {}
return 2
local debugRows = {}
end
 
local function addDemand(util, divCode, unitName, amount)
if not divCode or divCode=="" or not unitName or unitName=="" or not amount or amount<=0 then return end
util[divCode]=util[divCode] or {}
util[divCode][unitName]=(util[divCode][unitName] or 0) + amount
end
 
local function applyRoleDraw(util, pools, divCode, role, k, salt, amountPerItem)
local list = pools[role]
if not list or #list==0 then return end
local picks = pickDeterministic(list, k, salt.."|"..role)
for _,u in ipairs(picks) do addDemand(util, divCode, u, amountPerItem) end
end
 
local function computeRoleDemands(opText, uScore, impact, bLevel, allRoles, salt)
opText = normName(opText)
local wanted = {}
local function want(role, w) wanted[role]=math.max(wanted[role] or 0, w or 1) end
 
-- Baseline always-on
want("c2_ground", 1); want("logistics", 1)
 
if opText:find("hazard",1,true) or opText:find("evac",1,true) or opText:find("debris",1,true) or opText:find("clearance",1,true) then
want("engineering",2); want("recovery",2); want("bridging",1)
want("rotary_transport",2); want("transport_air",1)
end
 
if opText:find("vbss",1,true) or opText:find("interdiction",1,true) or opText:find("boarding",1,true)
or opText:find("contraband",1,true) or opText:find("anti-smuggling",1,true) then
want("small_arms",2); want("recon_ground",1)
want("naval_patrol",2); want("naval_surface",1); want("mine_warfare",1)
want("uav",2); want("mpa",1); want("aew",1)
end
 
if opText:find("isr",1,true) or opText:find("surveillance",1,true) or opText:find("overwatch",1,true) or opText:find("recon",1,true) then
want("uav",2); want("aew",1); want("mpa",1); want("recon_ground",1)
end
 
if opText:find("blockade",1,true) or opText:find("compliance",1,true) or opText:find("harbor",1,true) or opText:find("port",1,true) then
want("naval_patrol",2); want("naval_surface",2); want("coastal_defense",1); want("mine_warfare",1)
want("surveillance",1)
end
 
if opText:find("strike",1,true) or opText:find("deterr",1,true) or opText:find("show of force",1,true) then
want("armor",2); want("artillery",2); want("air_defense_ground",1)
want("strike",1); want("intercept",1)
end
 
if opText:find("corridor",1,true) or opText:find("rail",1,true) or opText:find("transit",1,true) then
want("rail",2); want("corridor_security",2); want("engineering",1); want("recon_ground",1)
end
 
if uScore>=3 then want("autonomous",1); want("air_defense",1); want("fixed_defense",1) end
if uScore>=4 then want("prototype",1) end
 
-- Respect impact constraints
if impact and not impactAllows(impact,"sea") then
wanted["naval_patrol"]=nil; wanted["naval_surface"]=nil; wanted["naval_subsurface"]=nil
wanted["mine_warfare"]=nil; wanted["amphibious"]=nil; wanted["privateer"]=nil
end
if impact and not impactAllows(impact,"air") then
wanted["uav"]=nil; wanted["aew"]=nil; wanted["mpa"]=nil; wanted["tanker"]=nil
wanted["transport_air"]=nil; wanted["rotary_transport"]=nil; wanted["rotary_attack"]=nil
wanted["strike"]=nil; wanted["intercept"]=nil; wanted["bomber"]=nil
end


local function bump(div, unitName, amount)
-- Breadth boost: add a couple extra roles on high
if not div or div == "" or not unitName or unitName == "" or not amount or amount <= 0 then return end
if bLevel>=3 and allRoles and #allRoles>0 then
util[div] = util[div] or {}
local extra = pickDeterministic(allRoles, 2, salt.."|extraRoles")
util[div][unitName] = (util[div][unitName] or 0) + amount
for _,r in ipairs(extra) do
if wanted[r]==nil then wanted[r]=1 end
end
end
end


return wanted
end
--------------------------------------------------------------------------------
-- Compute utilization from DailyOps (baseline TOE + breadth role draws)
--------------------------------------------------------------------------------
local function computeUtilization(args, pools, allRoles)
local util, debugRows = {}, {}
local ok, ops = pcall(require, "Module:WarLeagueDivisionOpsBVR")
local ok, ops = pcall(require, "Module:WarLeagueDivisionOpsBVR")
if not ok or not ops or type(ops.makeTable) ~= "function" then
if not ok or not ops or type(ops.makeTable) ~= "function" then
Line 439: Line 632:
local rows = tonumber(args.opsrows) or tonumber(args.rows) or 36
local rows = tonumber(args.opsrows) or tonumber(args.rows) or 36
local seed = args.seed
local seed = args.seed
local bLevel = breadthLevel(args)


local tbl = ops.makeTable({ args = {
local tbl = ops.makeTable({ args = {
Line 447: Line 641:
division = args.division or "",
division = args.division or "",
}})
}})
local function applyFormationCell(cell, opText, uScore, impact, salt)
cell=tostring(cell or "—")
if cell=="—" or trim(cell)=="" then return end
cell = cell:gsub("<br%s*/?>","\n")
for _,seg in ipairs(mw.text.split(cell, "\n")) do
seg=trim(seg)
if seg~="" and seg~="—" then
local echelon, divCode = parseFormation(seg)
if echelon and divCode then
-- baseline TOE
local toe = TOE[echelon]
if toe then
for unitName, amount in pairs(toe) do
if amount and amount>0 then addDemand(util, divCode, unitName, amount) end
end
end
-- breadth role draws
local wanted = computeRoleDemands(opText, uScore, impact, bLevel, allRoles, salt.."|"..divCode)
for role, w in pairs(wanted) do
local k = w
if uScore>=4 then k=k+1 end
if k>5 then k=5 end
local amountPer = 1
if role=="small_arms" then amountPer = (uScore>=3 and 250) or 150
elseif role=="logistics" then amountPer = (uScore>=3 and 3) or 2
else amountPer = 1 end
applyRoleDraw(util, pools, divCode, role, k, salt, amountPer)
end
end
end
end
end


for line in tostring(tbl):gmatch("[^\n]+") do
for line in tostring(tbl):gmatch("[^\n]+") do
if line:match("^|%s") and line:find("||", 1, true) then
if line:match("^|%s") and line:find("||",1,true) then
local parts = mw.text.split(line:gsub("^|%s*", ""), "%s*||%s*")
local parts = mw.text.split(line:gsub("^|%s*",""), "%s*||%s*")
if #parts >= 11 then
if #parts >= 11 then
local r = {
local dateCell = parts[1] or ""
date = parts[1] or "", division = parts[2] or "", city = parts[3] or "",
local urg = parts[5] or ""
ground = parts[9] or "—", naval = parts[10] or "—", air = parts[11] or "—"
local uScore = urgencyScore(urg)
}
local impact = parseImpact(parts[7] or "")
push(debugRows, r)
local opText = parts[8] or ""
local ground = parts[9] or "—"
local naval = parts[10] or "—"
local air   = parts[11] or "—"


local function handle(forceCell)
local salt = tostring(seed or "").."|"..tostring(dateCell).."|"..tostring(parts[2] or "").."|"..tostring(parts[3] or "").."|"..tostring(opText)
forceCell = tostring(forceCell or "—")
if forceCell == "—" or trim(forceCell) == "" then return end


forceCell = forceCell:gsub("<br%s*/?>", "\n")
push(debugRows, {
for _, seg in ipairs(mw.text.split(forceCell, "\n")) do
date=dateCell, division=parts[2] or "", city=parts[3] or "",
seg = trim(seg)
urgency=urg, impact=parts[7] or "", op=opText,
if seg ~= "" and seg ~= "—" then
ground=ground, naval=naval, air=air
local echelon, divCode = parseFormation(seg)
})
if echelon and divCode then
local toe = TOE[echelon]
if toe then
for unitName, amount in pairs(toe) do
if amount and amount > 0 then bump(divCode, unitName, amount) end
end
end
end
end
end
end


handle(r.ground)
if impactAllows(impact,"ground") then applyFormationCell(ground, opText, uScore, impact, salt.."|G") end
handle(r.naval)
if impactAllows(impact,"sea") then applyFormationCell(naval, opText, uScore, impact, salt.."|N") end
handle(r.air)
if impactAllows(impact,"air") then applyFormationCell(air, opText, uScore, impact, salt.."|A") end
end
end
end
end
Line 490: Line 708:


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Overlay highlights (NEVER exceeding availability) [FIXED: no global 'a' usage]
-- Overlay highlights (never exceed availability)
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
local function overlayTable(baseText, util)
local function overlayTable(baseText, util)
local out = {}
local out={}
local inTable = false
local inTable=false


for line in tostring(baseText):gmatch("([^\n]*)\n?") do
for line in tostring(baseText):gmatch("([^\n]*)\n?") do
if line:match("^%{%|") then
if line:match("^%{%|") then
inTable = true
inTable=true
push(out, line)
push(out,line)
elseif inTable and line:match("^|%}%s*$") then
elseif inTable and line:match("^|%}%s*$") then
inTable = false
inTable=false
push(out, line)
push(out,line)
elseif inTable and line:match("^|%s*%[%[File:") and line:find("||", 1, true) then
elseif inTable and line:match("^|%s*%[%[File:") and line:find("||",1,true) then
local parts = mw.text.split(line:gsub("^|%s*", ""), "%s*||%s*")
local parts = mw.text.split(line:gsub("^|%s*",""), "%s*||%s*")
if #parts == 14 then
if #parts == 14 then
local unitName = extractDisplayText(parts[2])
local unitName = extractDisplayText(parts[2])


-- Group columns by ops division for this row
local divCols, availByIdx = {}, {}
local divCols, availByIdx = {}, {}
for i = 4, 14 do
for i=4,14 do
local colKey = COL_KEYS[i - 3]
local colKey = COL_KEYS[i-3]
local divCode = COL_TO_DIV[colKey]
local divCode = COL_TO_DIV[colKey]
local avail = toNumber(parts[i]) or 0
local avail = toNumber(parts[i]) or 0
availByIdx[i] = avail
availByIdx[i]=avail
if divCode then
if divCode then
divCols[divCode] = divCols[divCode] or {}
divCols[divCode]=divCols[divCode] or {}
push(divCols[divCode], { idx = i, avail = avail })
divCols[divCode][#divCols[divCode]+1] = { idx=i, avail=avail }
end
end
end
end


-- Allocate demand across columns, capped by availability
local usedByIdx = {}
local usedByIdx = {}


for divCode, cols in pairs(divCols) do
for divCode, cols in pairs(divCols) do
local demand = (util[divCode] and util[divCode][unitName]) or 0
local demand = (util[divCode] and util[divCode][unitName]) or 0
if demand > 0 then
if demand>0 then
local totalAvail = 0
local totalAvail=0
for _, c in ipairs(cols) do totalAvail = totalAvail + (c.avail or 0) end
for _,c in ipairs(cols) do totalAvail = totalAvail + (c.avail or 0) end
local usedTotal = math.min(demand, totalAvail)
local usedTotal = math.min(demand, totalAvail)


if totalAvail > 0 and usedTotal > 0 then
if totalAvail>0 and usedTotal>0 then
-- Pass 1: proportional floor allocation
local assigned=0
local assigned = 0
for _,c in ipairs(cols) do
for _, c in ipairs(cols) do
local share = math.floor(usedTotal * c.avail / totalAvail)
local share = math.floor(usedTotal * c.avail / totalAvail)
if share > c.avail then share = c.avail end
if share>c.avail then share=c.avail end
usedByIdx[c.idx] = share
usedByIdx[c.idx]=share
assigned = assigned + share
assigned=assigned+share
end
end
-- Pass 2: distribute remainder
local rem = usedTotal - assigned
local rem = usedTotal - assigned
if rem > 0 then
if rem>0 then
table.sort(cols, function(x, y)
table.sort(cols, function(x,y)
local cx = (x.avail or 0) - (usedByIdx[x.idx] or 0)
local cx = (x.avail or 0) - (usedByIdx[x.idx] or 0)
local cy = (y.avail or 0) - (usedByIdx[y.idx] or 0)
local cy = (y.avail or 0) - (usedByIdx[y.idx] or 0)
return cx > cy
return cx>cy
end)
end)
 
local safety=0
local safety = 0
while rem>0 and safety<2000 do
while rem > 0 and safety < 2000 do
safety=safety+1
safety = safety + 1
local progressed=false
local progressed = false
for _,c in ipairs(cols) do
for _, c in ipairs(cols) do
if rem<=0 then break end
if rem <= 0 then break end
local cap = (c.avail or 0) - (usedByIdx[c.idx] or 0)
local cap = (c.avail or 0) - (usedByIdx[c.idx] or 0)
if cap > 0 then
if cap>0 then
usedByIdx[c.idx] = (usedByIdx[c.idx] or 0) + 1
usedByIdx[c.idx]=(usedByIdx[c.idx] or 0)+1
rem = rem - 1
rem=rem-1
progressed = true
progressed=true
end
end
end
end
Line 570: Line 782:
end
end


-- Apply styling based on allocated used (hard-capped)
for i=4,14 do
for i = 4, 14 do
local avail = availByIdx[i] or 0
local avail = availByIdx[i] or 0
local used = usedByIdx[i] or 0
local used = usedByIdx[i] or 0
if used > avail then used = avail end
if used>avail then used=avail end
 
if avail>0 and used>0 then
if avail > 0 and used > 0 then
local pct = used/avail
local pct = used / avail
if pct>1 then pct=1 end
if pct > 1 then pct = 1 end
local bg = cellColor(pct)
local bg = cellColor(pct)
local usedText = string.format("<br/><span style=\"font-size:85%%;\"><b>used:</b> %s</span>", fmt(used))
local usedText = string.format("<br/><span style=\"font-size:85%%;\"><b>used:</b> %s</span>", fmt(used))
Line 596: Line 806:
push(out, "")
push(out, "")
push(out, "<div style=\"margin-top:0.5em; font-size:90%;\">")
push(out, "<div style=\"margin-top:0.5em; font-size:90%;\">")
push(out, "<b>Utilization highlight:</b> cells tint when that column’s mapped ops-division drew from that row today (TO&E-based).")
push(out, "<b>Utilization highlight:</b> derived from <i>Module:WarLeagueDivisionOpsBVR</i> (formations + operation text + urgency + impact).")
push(out, "<br/><b>Hard rule:</b> used is allocated and capped so it never exceeds availability in any cell or across a shared ops-division column-group.")
push(out, "<br/><b>Hard rule:</b> used is allocated and capped so it never exceeds availability in any cell or across a shared ops-division column-group.")
push(out, "<br/><b>Breadth:</b> <code>breadth=low|standard|high</code> widens the range of equipment selected from the SSF inventory list.")
push(out, "</div>")
push(out, "</div>")


Line 614: Line 825:
normalizeToeTemplates(allUnits)
normalizeToeTemplates(allUnits)


if tostring(args.report or ""):lower() == "yes" then
local pools, allRoles = buildRolePools(allUnits)
return buildToeCoverageReport(allUnits)
end


local util, debugRows, ok = computeUtilization(args)
local util, debugRows, ok = computeUtilization(args, pools, allRoles)
if not ok then
if not ok then
return base .. "\n<div style=\"margin-top:0.5em; font-size:90%;\"><b>Note:</b> Ops module unavailable; showing base table only.</div>"
return base .. "\n<div style=\"margin-top:0.5em; font-size:90%;\"><b>Note:</b> Ops module unavailable; showing base table only.</div>"
Line 630: Line 839:
push(dbg, "")
push(dbg, "")
push(dbg, "<div style=\"margin-top:0.75em; font-size:90%;\">")
push(dbg, "<div style=\"margin-top:0.75em; font-size:90%;\">")
push(dbg, "<b>Debug (proof of source):</b> Parsed deployments from <i>Module:WarLeagueDivisionOpsBVR</i> output used for today/seed.")
push(dbg, "<b>Debug (proof of source):</b> Parsed rows from <i>Module:WarLeagueDivisionOpsBVR</i> (Operation/Urgency/Impact + force cells).")
push(dbg, "<ul>")
push(dbg, "<ul>")
for i = 1, math.min(max, #debugRows) do
for i=1, math.min(max, #debugRows) do
local r = debugRows[i]
local r = debugRows[i]
push(dbg, string.format(
push(dbg, string.format(
"<li><b>%s</b> | %s | %s<br/>G: %s<br/>N: %s<br/>A: %s</li>",
"<li><b>%s</b> | %s | %s | <b>%s</b><br/>Impact: %s<br/>Op: %s<br/>G: %s<br/>N: %s<br/>A: %s</li>",
tostring(r.date or ""), tostring(r.division or ""), tostring(r.city or ""),
tostring(r.date or ""), tostring(r.division or ""), tostring(r.city or ""),
tostring(r.urgency or ""), tostring(r.impact or ""), tostring(r.op or ""),
tostring(r.ground or ""), tostring(r.naval or ""), tostring(r.air or "")
tostring(r.ground or ""), tostring(r.naval or ""), tostring(r.air or "")
))
))

Revision as of 02:30, 27 January 2026

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

-- Module:WarLeagueUnitUtilizationBVR
-- Broad, doctrine-informed utilization overlay for the Unit Numbers table.
--
-- What this module does:
--   1) Renders the embedded Unit Numbers table exactly as-authored.
--   2) Reads today's (or seeded) ops output from Module:WarLeagueDivisionOpsBVR.makeTable().
--   3) Converts formations + operation text/urgency/impact into "demand" for equipment.
--   4) Allocates and caps usage so it NEVER exceeds availability (per cell + per shared-division group).
--   5) Expands the variety of equipment drawn (breadth) using role-based matching from the SSF hardware list
--      (implemented via a role/tag system + heuristics on the equipment names in the table).
--
-- Invoke:
--   {{#invoke:WarLeagueUnitUtilizationBVR|render}}
--   {{#invoke:WarLeagueUnitUtilizationBVR|render|seed=520154|opsrows=40}}
--   {{#invoke:WarLeagueUnitUtilizationBVR|render|breadth=high}}       -- low|standard|high
--   {{#invoke:WarLeagueUnitUtilizationBVR|render|debug=yes|debugrows=8}}

local p = {}

--------------------------------------------------------------------------------
-- 1) FULL EMBEDDED TABLE (exactly as provided)
--------------------------------------------------------------------------------
local RAW_BASE_TABLE = [=[
{| class="wikitable sortable" style="width:100%;"
|+ '''Unit Numbers (Activated 32/3/52 PSSC)'''
! rowspan="2" | Image
! rowspan="2" | Unit
! rowspan="2" | Total
! colspan="6" | Regional investors
! colspan="5" | Service divisions / national allocations
|-
! [[File:VaeringheimDivisionFlag.png|25px]] Vaeringheim Regional Investor
! [[File:AlpazkigzDivisionFlag.png|25px]] Alpazkigz Regional Investor
! [[File:OdiferiaFlag.png|25px]] Odiferia Regional Investor
! [[File:HafaanFlag.png|25px]] Hafaan Regional Investor
! [[File:JeseriFlag.png|25px]] Jeseri Regional Investor
! [[File:HatchMinistryFlag.png|25px]] Hatch Ministry Regional Investor
! [[File:NewSouthJangsongFlag.png|25px]] New South Jangsong Division
! [[File:HaifaDivisionFlag.png|25px]] Haifan Bassaridia Division
! [[File:BassaridianNormarkFlagB.png|25px]] Bassaridian Normark
! [[File:OurianaFlag.png|25px]] Ouriana Division
! [[File:CouncilofKingsFlag.png|25px]] Council of Kings Division

|- 
! colspan="14" style="background:#f2f2f2; text-align:left;" | '''Personnel & readiness'''
|-
| [[File:ActiveUnit1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Active_Units|Active Units]] || 249,973 || 25,866 || 20,847 || 25,042 || 1,749 || 1,008 || 7,246 || 10,187 || 15,385 || 8,354 || 21,206 || 113,083
|-
| [[File:ReserveSoldier1226.jpeg|150px|border]]  || [[Somniant_Stock_Fund_Military_Hardware#Reserve_Units|Reserve Units]] || 305,525 || 31,615 || 25,480 || 30,608 || 2,138 || 1,232 || 8,857 || 12,451 || 18,803 || 10,211 || 25,918 || 138,212

|-
! colspan="14" style="background:#f2f2f2; text-align:left;" | '''Small arms & infantry weapons'''
|-
| [[File:KalithrosClass1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Kalithros_Class_Rifle|Kalithros Class Rifle]] || 169,837 || 17,563 || 14,156 || 17,006 || 1,192 || 688 || 4,931 || 6,916 || 10,466 || 5,684 || 14,402 || 76,832
|-
| [[File:DelphicaClass1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Delphica_Class_Grenadier_Rifle|Delphica Class Grenadier Rifle]] || 44,520 || 4,604 || 3,711 || 4,458 || 312 || 180 || 1,293 || 1,813 || 2,744 || 1,490 || 3,775 || 20,140
|-
| [[File:ChrysosClass1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Chrysos_Class_Commando_Rifle|Chrysos Class Commando Rifle]] || 19,821 || 2,050 || 1,652 || 1,985 || 139 || 80 || 575 || 807 || 1,221 || 663 || 1,681 || 8,967
|-
| [[File:Lothaya1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Lothaya_Class_Sniper_Rifle|Lothaya Class Sniper Rifle]] || 6,099 || 631 || 509 || 611 || 43 || 25 || 177 || 248 || 375 || 204 || 517 || 2,556
|-
| [[File:ErythrosPist1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Erythros_Class_Pistol|Erythros Class Pistol]] || 28,540 || 2,952 || 2,379 || 2,858 || 200 || 116 || 828 || 1,162 || 1,758 || 955 || 2,421 || 12,911
|-
| [[File:Bubalus1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Bubalus_XT_Class_Revolver|Bubalus XT Class Revolver]] || 150,285 || 15,541 || 12,526 || 15,049 || 1,055 || 609 || 4,363 || 6,120 || 9,261 || 5,030 || 12,744 || 67,987
|-
| [[File:Cathartes1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Cathartes_Class_RPG|Cathartes Class RPG]] || 6,182 || 640 || 516 || 620 || 43 || 25 || 179 || 251 || 380 || 206 || 525 || 2,797
|-
| [[File:Hapyriya1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Harpyia_Class_Submachine_Gun|Harpyia Class Submachine Gun]] || 22,349 || 2,312 || 1,863 || 2,238 || 157 || 91 || 649 || 910 || 1,377 || 748 || 1,896 || 10,110
|-
| [[File:StrixShotgun1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Strix_Class_Combat_Shotgun|Strix Class Combat Shotgun]] || 35,555 || 3,677 || 2,964 || 3,561 || 249 || 144 || 1,032 || 1,448 || 2,190 || 1,190 || 3,015 || 15,346
|-
| [[File:Regavis1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Regavis_Class_DMR|Regavis Class DMR]] || 43,178 || 4,465 || 3,599 || 4,323 || 303 || 175 || 1,254 || 1,759 || 2,661 || 1,445 || 3,661 || 19,534

|-
! colspan="14" style="background:#f2f2f2; text-align:left;" | '''Armored vehicles & ground support'''
|-
| [[File:Makra1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Makra_Class_Battle_Tank|Makra Class Battle Tank]] || 1,290 || 133 || 107 || 129 || 9 || 5 || 37 || 53 || 79 || 43 || 109 || 584
|-
| [[File:LayaClass1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Laya_Class_Heavy_Tank|Laya Class Heavy Tank]] || 444 || 46 || 37 || 45 || 3 || 2 || 13 || 18 || 27 || 15 || 38 || 201
|-
| [[File:Arachne1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Arachne_Class_Light_Tank|Arachne Class Light Tank]] || 1,560 || 162 || 130 || 157 || 11 || 6 || 45 || 63 || 95 || 52 || 133 || 705
|-
| [[File:ThalassaMBT1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Thalassa_Class_Main_Battle_Tank|Thalassa Class Main Battle Tank]] || 765 || 80 || 64 || 77 || 5 || 3 || 22 || 30 || 46 || 25 || 66 || 346
|-
| [[File:Syrinx1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Syrinx_Class_Armored_Infantry|Syrinx Class Armored Infantry]] || 7,067 || 731 || 589 || 708 || 50 || 29 || 205 || 288 || 436 || 237 || 599 || 3,198
|-
| [[File:Bijarian1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Bijarian_Command_Vehicle|Bijarian Command Vehicle]] || 115 || 12 || 10 || 12 || 1 || 0 || 3 || 4 || 6 || 3 || 10 || 52
|-
| [[File:Onceanic1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Onceanic_Recon_Vehicle|Onceanic Recon Vehicle]] || 495 || 52 || 42 || 50 || 3 || 2 || 14 || 20 || 30 || 16 || 43 || 223
|-
| [[File:Ephyra1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Ephyra_Class_Anti-Aircraft_Vehicle|Ephyra Class Anti-Aircraft Vehicle]] || 218 || 23 || 19 || 22 || 1 || 1 || 6 || 8 || 13 || 7 || 19 || 99
|-
| [[File:AmpelosARV1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Ampelos_Class_Armored_Recovery_Vehicle|Ampelos Class Armored Recovery Vehicle]] || 495 || 52 || 42 || 50 || 3 || 2 || 14 || 20 || 30 || 16 || 43 || 223
|-
| [[File:Penthia1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Penthia_Class_Bridge-Laying_Vehicle|Penthia Class Bridge-Laying Vehicle]] || 146 || 16 || 13 || 15 || 1 || 0 || 4 || 5 || 8 || 4 || 13 || 66
|-
| [[File:Ampelostr1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Ampelos_Class_Rail-Laying_Engineering_Vehicle|Ampelos Class Rail-Laying Engineering Vehicle]] || 301 || 32 || 26 || 31 || 2 || 1 || 8 || 12 || 17 || 9 || 26 || 137
|-
| [[File:Corythia1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Corythia_Class_Dedicated_Logistics_and_Transport_Truck|Corythia Class Dedicated Logistics and Transport Truck]] || 301 || 32 || 26 || 31 || 2 || 1 || 8 || 12 || 17 || 9 || 26 || 137
|-
| [[File:Icaria1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Icaria_Class_Mine-Clearing_/Combat_Engineering_Vehicle|Icaria Class Mine-Clearing / Combat Engineering Vehicle]] || 134 || 14 || 11 || 14 || 1 || 0 || 4 || 5 || 8 || 4 || 11 || 61

|-
! colspan="14" style="background:#f2f2f2; text-align:left;" | '''Artillery, mortars & coastal fires'''
|-
| [[File:Aithra1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Aithra_Class_Howitzer|Aithra Class Howitzer]] || 321 || 34 || 27 || 33 || 2 || 1 || 9 || 13 || 19 || 10 || 28 || 145
|-
| [[File:Aetheris1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Aetheris_Class_Towed_Howitzer|Aetheris Class Towed Howitzer]] || 941 || 98 || 79 || 95 || 7 || 4 || 27 || 38 || 57 || 31 || 80 || 426
|-
| [[File:Iynas1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Iynas_Class_Field_Gun|Iynas Class Field Gun]] || 753 || 78 || 63 || 76 || 5 || 3 || 22 || 30 || 46 || 25 || 64 || 341
|-
| [[File:Secutor1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#300mm_Secutor_Class_Coastal_Gun|300mm Secutor Class Coastal Gun]] || 516 || 53 || 43 || 52 || 4 || 2 || 15 || 21 || 32 || 17 || 43 || 234
|-
| [[File:Halicarn1226.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#155mm_Halicarn_Class_Self-Propelled_Howitzer|155mm Halicarn Class Self-Propelled Howitzer]] || 373 || 39 || 32 || 38 || 3 || 1 || 10 || 15 || 22 || 12 || 32 || 169
|-
| [[File:Odiferian1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#120mm_Odiferian_Class_Mortar_Carrier|120mm Odiferian Class Mortar Carrier]] || 218 || 23 || 19 || 22 || 1 || 1 || 6 || 8 || 13 || 7 || 19 || 99

|-
! colspan="14" style="background:#f2f2f2; text-align:left;" | '''Autonomous systems, prototypes & fixed defenses'''
|-
| [[File:Houndbot231227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Quadwalker_“Horehound”_4170|Quadwalker “Horehound” 4170]] || 495 || 52 || 42 || 50 || 3 || 2 || 14 || 20 || 30 || 16 || 43 || 223
|-
| [[File:Dungbeetle1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Quadwalker_“Dungbeetle”_4172|Quadwalker “Dungbeetle” 4172]] || 361 || 37 || 30 || 36 || 3 || 1 || 10 || 15 || 22 || 12 || 30 || 164
|-
| [[File:KillBot1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Quadwalker_“Killbot”_4200|Quadwalker “Killbot” 4200]] || 734 || 76 || 62 || 74 || 5 || 3 || 21 || 29 || 45 || 24 || 62 || 333
|-
| [[File:Mechaoble1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Quadwalker_“Oble-Lisea”_4189|Quadwalker “Oble-Lisea” 4189]] || 528 || 55 || 44 || 53 || 4 || 2 || 15 || 21 || 32 || 17 || 45 || 239
|-
| [[File:Nexa1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Nexa_Class_Advanced_Prototype|Nexa Class Advanced Prototype]] || 1,095 || 114 || 92 || 110 || 8 || 4 || 31 || 44 || 67 || 36 || 93 || 496
|-
| [[File:GarganiClass1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Gargani_Class_Quadwalker|Gargani Class Quadwalker]] || 683 || 71 || 57 || 69 || 5 || 3 || 19 || 27 || 41 || 22 || 58 || 309
|-
| [[File:AbeisMobile1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Abeis_Class_Advanced_Air_Defense_System_(Mobile_Laser)|Abeis Class Advanced Air Defense System (Mobile Laser)]] || 146 || 16 || 13 || 15 || 1 || 0 || 4 || 5 || 8 || 4 || 13 || 66
|-
| [[File:AbeisStatic1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Abeis_Class_Static_Emplacement_(Fixed_Laser)|Abeis Class Static Emplacement (Fixed Laser)]] || 95 || 11 || 9 || 10 || 0 || 0 || 2 || 3 || 5 || 3 || 9 || 43
|-
| [[File:Fenruvian1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Fenruvian_THAAD_System_Facility|Fenruvian THAAD System Facility]] || 12 || 1 || 1 || 1 || 0 || 0 || 0 || 1 || 1 || 1 || 1 || 5
|-
| [[File:DelphisTower1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Delphis_Class_Coastal_Surveillance_Tower|Delphis Class Coastal Surveillance Tower]] || 28 || 2 || 2 || 2 || 2 || 2 || 2 || 2 || 2 || 2 || 2 || 8
|-
| [[File:AnguillaClass1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Anguilla_Class_Coastal_Missile_Battery|Anguilla Class Coastal Missile Battery]] || 373 || 39 || 32 || 38 || 3 || 1 || 10 || 15 || 22 || 12 || 32 || 169
|-
| [[File:V991227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Model_V-99_Thunderguard_Citadel_Train|Model V-99 Thunderguard Citadel Train]] || 31 || 4 || 3 || 3 || 0 || 0 || 1 || 1 || 2 || 1 || 3 || 14

|-
! colspan="14" style="background:#f2f2f2; text-align:left;" | '''Naval forces & maritime platforms'''
|-
| [[File:Erigone1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Abeis-Erigone_Class_Littoral_Orbital_&_Missile_Intercept_Catamaran|Abeis-Erigone Class Littoral Orbital & Missile Intercept Catamaran]] || 12 || 2 || 1 || 2 || 0 || 0 || 0 || 0 || 0 || 0 || 2 || 5
|-
| [[File:GenportManf1225.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Abeis-Empress_Class_Littoral_Battlecruiser|Abeis-Empress Class Littoral Battlecruiser]] || 31 || 4 || 3 || 3 || 0 || 0 || 1 || 1 || 2 || 1 || 3 || 14
|-
| [[File:Aetherion1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Abeis-Aetherion Class Multidomain Hover-Corvette|Abeis-Aetherion Class Multidomain Hover-Corvette]]<br> ('''active 1/2/52 [[PSSC]]''') || 4 || 0 || 0 || 0 || 0 || 0 || 0 || 0 || 0 || 0 || 0 || 4
|-
| [[File:KalithrosBarge1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Kalithros-Class_Amphibious_Airstrip_Barge|Kalithros-Class Amphibious Airstrip Barge]] || 12 || 2 || 1 || 2 || 0 || 0 || 0 || 0 || 0 || 0 || 2 || 5
|-
| [[File:Aiji1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Aiji_Class_Air-Caravan|Aiji Class Air-Caravan]] || 31 || 4 || 3 || 3 || 0 || 0 || 1 || 1 || 2 || 1 || 3 || 14
|-
| [[File:Seidolon1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Sideron_Class_Coastal_Battlecruiser|Sideron Class Coastal Battlecruiser]] || 31 || 4 || 3 || 3 || 0 || 0 || 1 || 1 || 2 || 1 || 3 || 14
|-
| [[File:Eidolon1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Eidolon_Class_Cruiser|Eidolon Class Cruiser]] || 31 || 4 || 3 || 3 || 0 || 0 || 1 || 1 || 2 || 1 || 3 || 14
|-
| [[File:Acheron1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Acheron_Class_Submersible_Destroyer|Acheron Class Submersible Destroyer]] || 12 || 2 || 1 || 2 || 0 || 0 || 0 || 0 || 0 || 0 || 2 || 5
|-
| [[File:Xylanda1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Xylanda_Class_Attack_Submarine|Xylanda Class Attack Submarine]] || 31 || 4 || 3 || 3 || 0 || 0 || 1 || 1 || 2 || 1 || 3 || 14
|-
| [[File:Bijarian1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Bijarian_Ballistic_Missile_Submarine|Bijarian Ballistic Missile Submarine]] || 12 || 2 || 1 || 2 || 0 || 0 || 0 || 0 || 0 || 0 || 2 || 5
|-
| [[File:Vaeringheim1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Vaeringheim_Class_Corvette|Vaeringheim Class Corvette]] || 83 || 9 || 7 || 9 || 0 || 0 || 2 || 3 || 5 || 3 || 7 || 38
|-
| [[File:Sylvapolis1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Sylvapolis_Class_Frigate|Sylvapolis Class Frigate]] || 43 || 5 || 4 || 5 || 0 || 0 || 1 || 1 || 2 || 1 || 4 || 20
|-
| [[File:Atteran1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Atterian_Class_Combat_Ship|Atterian Class Combat Ship]] || 12 || 2 || 1 || 2 || 0 || 0 || 0 || 0 || 0 || 0 || 2 || 5
|-
| [[File:Aetherium1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Aetherium_Class_Patrol_Ship|Aetherium Class Patrol Ship]] || 95 || 11 || 9 || 10 || 0 || 0 || 2 || 3 || 5 || 3 || 9 || 43
|-
| [[File:Velkara1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Velkara_Class_Littoral_Landing_Ship|Velkara Class Littoral Landing Ship]] || 31 || 4 || 3 || 3 || 0 || 0 || 1 || 1 || 2 || 1 || 3 || 14
|-
| [[File:Saluria1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Saluria_Class_Gunboat|Saluria Class Gunboat]] || 43 || 5 || 4 || 5 || 0 || 0 || 1 || 1 || 2 || 1 || 4 || 20
|-
| [[File:Riceship.jpg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Cetus_Class_Attack_Craft|Cetus Class Attack Craft]] || 64 || 7 || 6 || 7 || 0 || 0 || 1 || 2 || 3 || 2 || 6 || 29
|-
| [[File:Tartarian1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Tartarian_Amphibious_Assault_Craft|Tartarian Amphibious Assault Craft]] || 83 || 9 || 7 || 9 || 0 || 0 || 2 || 3 || 5 || 3 || 7 || 38
|-
| [[File:Ismael1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Ismael_Class_Privateer_Commission|Ismael Class Privateer Commission]] || 50 || 0 || 0 || 0 || 0 || 0 || 0 || 50 || 0 || 0 || 0 || 0
|-
| [[File:Odobenus1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Odobenus_Class_Minelayer|Odobenus Class Minelayer]] || 31 || 4 || 3 || 3 || 0 || 0 || 1 || 1 || 2 || 1 || 3 || 14
|-
| [[File:Cetomagna1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Cetomagna_Class_Minehunter|Cetomagna Class Minehunter]] || 31 || 4 || 3 || 3 || 0 || 0 || 1 || 1 || 2 || 1 || 3 || 14

|-
! colspan="14" style="background:#f2f2f2; text-align:left;" | '''Mobile platforms, UAVs & aviation'''
|-
| [[File:Bulhanu1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Abeis-Bulhanu_Class_High-Speed_Skirmisher_(Hoverbike)|Abeis-Bulhanu Class High-Speed Skirmisher (Hoverbike)]] || 1,290 || 133 || 107 || 129 || 9 || 5 || 37 || 53 || 79 || 43 || 109 || 584
|-
| [[File:Khamsin1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Abeis-Bulhanu_Khamsin-Class_Desert_Skirmisher|Abeis-Bulhanu Khamsin-Class Desert Skirmisher]] || 1,063 || 110 || 89 || 107 || 7 || 4 || 31 || 43 || 65 || 35 || 90 || 481
|-
| [[File:Virelia1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Abeis-Bulhanu_Virelia-Class_Urban_Pacifier|Abeis-Bulhanu Virelia-Class Urban Pacifier]] || 671 || 69 || 56 || 67 || 5 || 3 || 19 || 27 || 41 || 22 || 57 || 303
|-
| [[File:Hrimthurs1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Abeis-Bulhanu_Hrimthurs-Class_Arctic_Hoverbike|Abeis-Bulhanu Hrimthurs-Class Arctic Hoverbike]] || 805 || 84 || 67 || 81 || 5 || 3 || 23 || 33 || 49 || 27 || 69 || 364
|-
| [[File:IsmaelHover1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Abeis-Ismael_Class_“Cathartes”_Littoral_Hoverbike|Abeis-Ismael Class “Cathartes” Littoral Hoverbike]] || 857 || 89 || 72 || 86 || 6 || 3 || 25 || 35 || 52 || 28 || 73 || 387
|-
| [[File:Strix1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Abeis-Empress_Class_“Strix-E”_Swarm-Interdictor_Littoral_Hoverbike|Abeis-Empress Class “Strix-E” Swarm-Interdictor Littoral Hoverbike]] || 683 || 71 || 57 || 69 || 5 || 3 || 19 || 27 || 41 || 22 || 58 || 309
|-
| [[File:Minerva1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Abeis-Minerva_Class_Combat_Littoral_Strike_Aircraft|Abeis-Minerva Class Combat Littoral Strike Aircraft]] || 83 || 9 || 7 || 9 || 0 || 0 || 2 || 3 || 5 || 3 || 7 || 38
|-
| [[File:Boreas1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Abeis-Boreas_Littoral_Dominance_Helicopter|Abeis-Boreas Littoral Dominance Helicopter]] || 134 || 14 || 11 || 14 || 1 || 0 || 4 || 5 || 8 || 4 || 11 || 61
|-
| [[File:AithraFighter1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Aithra_Class_Proximity_Aircraft|Aithra Class Proximity Aircraft]] || 146 || 16 || 13 || 15 || 1 || 0 || 4 || 5 || 8 || 4 || 13 || 66
|-
| [[File:Intercept1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Symphonara_Class_Compound_Intercept|Symphonara Class Compound Intercept]] || 83 || 9 || 7 || 9 || 0 || 0 || 2 || 3 || 5 || 3 || 7 || 38
|-
| [[File:ThalassaCopt1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Thalassa_Class_Attack_Helicopter|Thalassa Class Attack Helicopter]] || 198 || 21 || 17 || 21 || 1 || 1 || 5 || 7 || 11 || 6 || 17 || 90
|-
| [[File:Catonisuav1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Catonis_Class_Unmanned_Aircraft_(UAV)|Catonis Class Unmanned Aircraft (UAV)]] || 95 || 11 || 9 || 10 || 0 || 0 || 2 || 3 || 5 || 3 || 9 || 43
|-
| [[File:Lotos1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Lotos_Class_Tactical_UAV|Lotos Class Tactical UAV]] || 167 || 18 || 14 || 17 || 1 || 1 || 4 || 6 || 10 || 5 || 15 || 75
|-
| [[File:Aurantius1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Aurantius_Class_Multi-Role_UAV|Aurantius Class Multi-Role UAV]] || 167 || 18 || 14 || 17 || 1 || 1 || 4 || 6 || 10 || 5 || 15 || 75
|-
| [[File:SeaBomber1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Chrysos_Class_Sea_Bomber|Chrysos Class Sea Bomber]] || 218 || 23 || 19 || 22 || 1 || 1 || 6 || 8 || 13 || 7 || 19 || 99
|-
| [[File:MisttalonBomber1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Misttalon_Class_Heavy_Bomber|Misttalon Class Heavy Bomber]] || 31 || 4 || 3 || 3 || 0 || 0 || 1 || 1 || 2 || 1 || 3 || 14
|-
| [[File:Change into a 202512291428.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Orphali_Class_Tanker_Aircraft|Orphali Class Tanker Aircraft]] || 64 || 7 || 6 || 7 || 0 || 0 || 1 || 2 || 3 || 2 || 6 || 29
|-
| [[File:Ventiflor1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Ventiflor_Eye_Class_AEW&C_Aircraft|Ventiflor Eye Class AEW&C Aircraft]] || 31 || 4 || 3 || 3 || 0 || 0 || 1 || 1 || 2 || 1 || 3 || 14
|-
| [[File:Fluxus1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Fluxus_Class_Maritime_Patrol_Aircraft|Fluxus Class Maritime Patrol Aircraft]] || 95 || 11 || 9 || 10 || 0 || 0 || 2 || 3 || 5 || 3 || 9 || 43
|-
| [[File:Hostica1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Hostica_Class_Maritime_Patrol_Aircraft|Hostica Class Maritime Patrol Aircraft]] || 95 || 11 || 9 || 10 || 0 || 0 || 2 || 3 || 5 || 3 || 9 || 43
|-
| [[File:NefelianTransport1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Nefelian_Transport_Plane|Nefelian Transport Plane]] || 115 || 12 || 10 || 12 || 1 || 0 || 3 || 4 || 6 || 3 || 10 || 52
|-
| [[File:Noctiluna1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Noctiluna_Class_Medium_Transport_Helicopter|Noctiluna Class Medium Transport Helicopter]] || 134 || 14 || 11 || 14 || 1 || 0 || 4 || 5 || 8 || 4 || 11 || 61
|-
| [[File:Umbraclaw1227.jpeg|150px|border]] || [[Somniant_Stock_Fund_Military_Hardware#Umbraclaw_Class_Heavy_Transport_Helicopter|Umbraclaw Class Heavy Transport Helicopter]] || 115 || 12 || 10 || 12 || 1 || 0 || 3 || 4 || 6 || 3 || 10 || 52
|}
]=]

--------------------------------------------------------------------------------
-- Column order and mapping
--------------------------------------------------------------------------------
local COL_KEYS = { "VREG","ALPK","ODFR","HAFA","JESR","HMIN","NSJ","HBAS","BNOR","OURN","CKD" }
local COL_TO_DIV = {
	VREG="VAE", ALPK="ALP", ODFR="ODF",
	HAFA="HFA", JESR="HFA", HBAS="HFA",
	HMIN="HAT", NSJ="NSJ", BNOR="BNO", OURN="OUR", CKD="CKD",
}

--------------------------------------------------------------------------------
-- Baseline TOE by echelon (your original templates)
--------------------------------------------------------------------------------
local TOE = {
	["Turmae"] = {
		["Active Units"]=120, ["Kalithros Class Rifle"]=120, ["Bubalus XT Class Revolver"]=20,
		["Syrinx Class Armored Infantry"]=2, ["Onceanic Recon Vehicle"]=1,
		["Corythia Class Dedicated Logistics and Transport Truck"]=2,
	},
	["Contubernia"] = {
		["Active Units"]=60, ["Kalithros Class Rifle"]=60, ["Bubalus XT Class Revolver"]=10,
		["Syrinx Class Armored Infantry"]=1, ["Corythia Class Dedicated Logistics and Transport Truck"]=1,
	},
	["Ordinis"] = {
		["Active Units"]=240, ["Kalithros Class Rifle"]=240, ["Delphica Class Grenadier Rifle"]=24,
		["Bubalus XT Class Revolver"]=40, ["Syrinx Class Armored Infantry"]=6, ["Onceanic Recon Vehicle"]=2,
		["Corythia Class Dedicated Logistics and Transport Truck"]=6,
	},
	["Manipuli"] = {
		["Active Units"]=900, ["Kalithros Class Rifle"]=900, ["Delphica Class Grenadier Rifle"]=120,
		["Chrysos Class Commando Rifle"]=60, ["Lothaya Class Sniper Rifle"]=18,
		["Bubalus XT Class Revolver"]=120, ["Cathartes Class RPG"]=18,
		["Syrinx Class Armored Infantry"]=24, ["Makra Class Battle Tank"]=2, ["Arachne Class Light Tank"]=4,
		["Ephyra Class Anti-Aircraft Vehicle"]=2, ["Corythia Class Dedicated Logistics and Transport Truck"]=30,
		["Ampelos Class Armored Recovery Vehicle"]=2,
	},
	["Centuriae"] = {
		["Active Units"]=2400, ["Kalithros Class Rifle"]=2400, ["Delphica Class Grenadier Rifle"]=360,
		["Chrysos Class Commando Rifle"]=120, ["Lothaya Class Sniper Rifle"]=36,
		["Bubalus XT Class Revolver"]=240, ["Cathartes Class RPG"]=36,
		["Syrinx Class Armored Infantry"]=80, ["Makra Class Battle Tank"]=8, ["Thalassa Class Main Battle Tank"]=4,
		["Arachne Class Light Tank"]=16, ["Aithra Class Howitzer"]=6, ["Aetheris Class Towed Howitzer"]=12,
		["Corythia Class Dedicated Logistics and Transport Truck"]=90, ["Ampelos Class Armored Recovery Vehicle"]=6,
		["Icaria Class Mine-Clearing / Combat Engineering Vehicle"]=4,
	},
	["Cohortes"] = {
		["Active Units"]=8000, ["Kalithros Class Rifle"]=8000, ["Delphica Class Grenadier Rifle"]=1200,
		["Chrysos Class Commando Rifle"]=400, ["Lothaya Class Sniper Rifle"]=120,
		["Bubalus XT Class Revolver"]=900, ["Cathartes Class RPG"]=120,
		["Syrinx Class Armored Infantry"]=240, ["Makra Class Battle Tank"]=24, ["Thalassa Class Main Battle Tank"]=12,
		["Arachne Class Light Tank"]=48, ["Aithra Class Howitzer"]=18, ["Aetheris Class Towed Howitzer"]=36,
		["Iynas Class Field Gun"]=12, ["Corythia Class Dedicated Logistics and Transport Truck"]=300,
		["Ampelos Class Armored Recovery Vehicle"]=18, ["Penthia Class Bridge-Laying Vehicle"]=4,
		["Icaria Class Mine-Clearing / Combat Engineering Vehicle"]=12,
	},
	["Birlik-i Gemi (Patrol Element)"] = { ["Aetherium Class Patrol Ship"]=1, ["Cetus Class Attack Craft"]=2 },
	["Tabur-i Derya (Task Unit)"] = {
		["Vaeringheim Class Corvette"]=2, ["Aetherium Class Patrol Ship"]=2, ["Cetus Class Attack Craft"]=6,
		["Odobenus Class Minelayer"]=1,
	},
	["Ordu (Task Group)"] = {
		["Sylvapolis Class Frigate"]=2, ["Vaeringheim Class Corvette"]=4, ["Aetherium Class Patrol Ship"]=6,
		["Cetus Class Attack Craft"]=12, ["Cetomagna Class Minehunter"]=1, ["Odobenus Class Minelayer"]=1,
	},
	["Zeygi (Element)"] = { ["Catonis Class Unmanned Aircraft (UAV)"]=1, ["Lotos Class Tactical UAV"]=1 },
	["Ptisis (Flight)"] = {
		["Thalassa Class Attack Helicopter"]=1, ["Noctiluna Class Medium Transport Helicopter"]=1,
		["Catonis Class Unmanned Aircraft (UAV)"]=2,
	},
	["Moira (Squadron)"] = {
		["Abeis-Boreas Littoral Dominance Helicopter"]=2, ["Thalassa Class Attack Helicopter"]=4,
		["Noctiluna Class Medium Transport Helicopter"]=2, ["Catonis Class Unmanned Aircraft (UAV)"]=6,
		["Aurantius Class Multi-Role UAV"]=2,
	},
}

--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------
local function push(t, v) t[#t+1] = v end
local function trim(s) s=tostring(s or ""); return (s:gsub("^%s+",""):gsub("%s+$","")) end
local function toNumber(s)
	s=tostring(s or "")
	local n=s:gsub("[^0-9]","")
	if n=="" then return nil end
	return tonumber(n)
end
local function fmt(n)
	if not n then return "" end
	local s=tostring(math.floor(n))
	local out=s:reverse():gsub("(%d%d%d)","%1,"):reverse()
	return out:gsub("^,","")
end
local function extractDisplayText(cell)
	local c=tostring(cell or "")
	local display=c:match("%[%[[^%]]-%|([^%]]-)%]%]")
	if display and display~="" then return display end
	display=c:match("%[%[([^%]]-)%]%]")
	if display and display~="" then return display end
	return trim(c:gsub("[%[%]]",""))
end
local function cellColor(pct)
	if pct <= 0.02 then return "#d4edda" end
	if pct <= 0.05 then return "#fff3cd" end
	if pct <= 0.10 then return "#ffe5cc" end
	return "#f8d7da"
end
local function normalizeWikitextTable(text)
	local out={}
	for line in tostring(text):gmatch("([^\n]*)\n?") do
		push(out, line:gsub("^[ \t]+",""))
	end
	return table.concat(out,"\n")
end

--------------------------------------------------------------------------------
-- Ops parsing (robust naval/air parsing with internal parentheses + wiki markup)
--------------------------------------------------------------------------------
local function sanitizeOpsSegment(seg)
	seg=tostring(seg or "")
	seg=seg:gsub("<br%s*/?>","\n")
	seg=seg:gsub("<[^>]+>","")
	seg=seg:gsub("''+","")
	seg=seg:gsub("%[%[[^%]|]-|([^%]]-)%]%]","%1")
	seg=seg:gsub("%[%[([^%]]-)%]%]","%1")
	seg=seg:gsub("&nbsp;"," ")
	seg=seg:gsub("%s+"," ")
	return trim(seg)
end
local function parseFormation(seg)
	seg = sanitizeOpsSegment(seg)
	local divCode = seg:match("%((%u%u%u)%-[%w]+%s*%)%s*$")
	if not divCode then return nil, nil end
	local echelon = trim(seg:gsub("%(%u%u%u%-%w+%s*%)%s*$",""))
	return echelon, divCode
end
local function urgencyScore(urg)
	urg=tostring(urg or "")
	if urg=="CRITICAL" then return 4 end
	if urg=="HIGH" then return 3 end
	if urg=="MEDIUM" then return 2 end
	if urg=="LOW" then return 1 end
	return 0
end
local function parseImpact(impactCell)
	local s=tostring(impactCell or "")
	local g=s:match("Gnd:%s*([^;]+)") or "OK"
	local sea=s:match("Sea:%s*([^;]+)") or "OK"
	local air=s:match("Air:%s*([^;]+)") or "OK"
	return { ground=trim(g), sea=trim(sea), air=trim(air) }
end
local function impactAllows(imp, domain)
	if not imp then return true end
	local v = (domain=="sea" and imp.sea) or (domain=="air" and imp.air) or imp.ground
	v=tostring(v or "OK"):lower()
	if v:find("no%-go",1,true) then return false end
	if v:find("closed",1,true) then return false end
	return true
end

--------------------------------------------------------------------------------
-- Lua 5.1-safe deterministic hash/picker (no bitwise ops)
--------------------------------------------------------------------------------
local function hash32(s)
	s = tostring(s or "")
	local h = 0
	for i=1,#s do
		h = (h * 131 + s:byte(i)) % 4294967296
	end
	return h
end
local function pickDeterministic(list, k, salt)
	if not list or #list==0 or k<=0 then return {} end
	local picked, used = {}, {}
	local h = hash32(salt)
	for _=1,k do
		if #picked >= #list then break end
		h = (1103515245 * h + 12345) % 4294967296
		local idx = (h % #list) + 1
		local guard = 0
		while used[idx] and guard < 100 do
			h = (1103515245 * h + 12345) % 4294967296
			idx = (h % #list) + 1
			guard = guard + 1
		end
		if not used[idx] then
			used[idx] = true
			picked[#picked+1] = list[idx]
		end
	end
	return picked
end

--------------------------------------------------------------------------------
-- Coverage enforcement: ensure no unit is excluded
--------------------------------------------------------------------------------
local function collectAllUnitsFromBase(baseText)
	local units={}
	for line in tostring(baseText):gmatch("[^\n]+") do
		if line:match("^|%s*%[%[File:") and line:find("||",1,true) then
			local parts = mw.text.split(line:gsub("^|%s*",""), "%s*||%s*")
			if #parts == 14 then
				local unitName = extractDisplayText(parts[2])
				if unitName ~= "" then units[unitName]=true end
			end
		end
	end
	return units
end
local function normalizeToeTemplates(allUnits)
	for _, toe in pairs(TOE) do
		if type(toe)=="table" then
			for unitName,_ in pairs(allUnits) do
				if toe[unitName]==nil then toe[unitName]=0 end
			end
		end
	end
end

--------------------------------------------------------------------------------
-- Breadth system: role pools derived from equipment names in the table
--------------------------------------------------------------------------------
local function normName(name)
	name=tostring(name or ""):lower()
	name=name:gsub("[^a-z0-9%-%s/()]", " "):gsub("%s+"," ")
	return trim(name)
end
local function rolesForUnit(unitName)
	local n=normName(unitName)
	local roles={}
	local function add(r) roles[r]=true end

	if n:find("active units",1,true) or n:find("reserve units",1,true) then add("personnel"); return roles end

	if n:find("rifle",1,true) or n:find("pistol",1,true) or n:find("revolver",1,true)
		or n:find("shotgun",1,true) or n:find("submachine",1,true) or n:find("dmr",1,true)
		or n:find("sniper",1,true) or n:find("rpg",1,true) then add("small_arms"); add("ground") end

	if n:find("battle tank",1,true) or n:find("main battle tank",1,true) or n:find("heavy tank",1,true) or n:find("light tank",1,true) then add("armor"); add("ground") end
	if n:find("armored infantry",1,true) then add("ifv"); add("ground") end
	if n:find("command vehicle",1,true) then add("c2_ground"); add("ground") end
	if n:find("recon vehicle",1,true) then add("recon_ground"); add("ground") end
	if n:find("anti-aircraft",1,true) then add("air_defense_ground"); add("ground") end
	if n:find("recovery vehicle",1,true) then add("recovery"); add("ground") end
	if n:find("bridge%-laying",1,true) then add("bridging"); add("engineering"); add("ground") end
	if n:find("rail%-laying",1,true) then add("rail"); add("engineering"); add("ground") end
	if n:find("transport truck",1,true) or n:find("logistics",1,true) then add("logistics"); add("ground") end
	if n:find("mine%-clearing",1,true) or n:find("combat engineering",1,true) then add("engineering"); add("mine_clearance"); add("ground") end

	if n:find("howitzer",1,true) or n:find("field gun",1,true) or n:find("mortar",1,true) then add("artillery"); add("ground") end
	if n:find("coastal gun",1,true) or n:find("coastal missile",1,true) then add("coastal_defense"); add("coastal_fires") end
	if n:find("surveillance tower",1,true) then add("surveillance"); add("coastal_defense") end

	if n:find("quadwalker",1,true) then add("autonomous"); add("ground") end
	if n:find("prototype",1,true) then add("prototype"); add("autonomous") end
	if n:find("air defense system",1,true) or n:find("static emplacement",1,true) or n:find("thaad",1,true) then add("air_defense"); add("fixed_defense") end
	if n:find("citadel train",1,true) then add("rail"); add("corridor_security"); add("ground") end

	if n:find("patrol ship",1,true) or n:find("gunboat",1,true) then add("naval_patrol"); add("sea") end
	if n:find("corvette",1,true) or n:find("frigate",1,true) or n:find("cruiser",1,true) or n:find("battlecruiser",1,true) then add("naval_surface"); add("sea") end
	if n:find("submarine",1,true) or n:find("submersible",1,true) then add("naval_subsurface"); add("sea") end
	if n:find("minelayer",1,true) or n:find("minehunter",1,true) then add("mine_warfare"); add("sea") end
	if n:find("landing ship",1,true) or n:find("amphibious",1,true) or n:find("airstrip barge",1,true) then add("amphibious"); add("sea") end
	if n:find("privateer",1,true) then add("privateer"); add("sea") end

	if n:find("uav",1,true) then add("uav"); add("air") end
	if n:find("aew",1,true) then add("aew"); add("air") end
	if n:find("tanker aircraft",1,true) then add("tanker"); add("air") end
	if n:find("transport plane",1,true) or n:find("transport helicopter",1,true) then add("transport_air"); add("air") end
	if n:find("attack helicopter",1,true) then add("rotary_attack"); add("air") end
	if n:find("heavy transport helicopter",1,true) or n:find("medium transport helicopter",1,true) then add("rotary_transport"); add("air") end
	if n:find("bomber",1,true) then add("bomber"); add("air") end
	if n:find("intercept",1,true) then add("intercept"); add("air") end
	if n:find("proximity aircraft",1,true) then add("strike"); add("air") end
	if n:find("maritime patrol aircraft",1,true) then add("mpa"); add("air") end

	return roles
end

local function buildRolePools(allUnits)
	local pools, allRoles = {}, {}
	local seen = {}
	for unitName,_ in pairs(allUnits) do
		local roles = rolesForUnit(unitName)
		for r,_v in pairs(roles) do
			pools[r] = pools[r] or {}
			pools[r][#pools[r]+1] = unitName
			if not seen[r] then
				seen[r]=true
				allRoles[#allRoles+1]=r
			end
		end
	end
	for r,list in pairs(pools) do table.sort(list) end
	table.sort(allRoles)
	return pools, allRoles
end

local function breadthLevel(args)
	local b=tostring((args and args.breadth) or "standard"):lower()
	if b=="low" then return 1 end
	if b=="high" then return 3 end
	return 2
end

local function addDemand(util, divCode, unitName, amount)
	if not divCode or divCode=="" or not unitName or unitName=="" or not amount or amount<=0 then return end
	util[divCode]=util[divCode] or {}
	util[divCode][unitName]=(util[divCode][unitName] or 0) + amount
end

local function applyRoleDraw(util, pools, divCode, role, k, salt, amountPerItem)
	local list = pools[role]
	if not list or #list==0 then return end
	local picks = pickDeterministic(list, k, salt.."|"..role)
	for _,u in ipairs(picks) do addDemand(util, divCode, u, amountPerItem) end
end

local function computeRoleDemands(opText, uScore, impact, bLevel, allRoles, salt)
	opText = normName(opText)
	local wanted = {}
	local function want(role, w) wanted[role]=math.max(wanted[role] or 0, w or 1) end

	-- Baseline always-on
	want("c2_ground", 1); want("logistics", 1)

	if opText:find("hazard",1,true) or opText:find("evac",1,true) or opText:find("debris",1,true) or opText:find("clearance",1,true) then
		want("engineering",2); want("recovery",2); want("bridging",1)
		want("rotary_transport",2); want("transport_air",1)
	end

	if opText:find("vbss",1,true) or opText:find("interdiction",1,true) or opText:find("boarding",1,true)
		or opText:find("contraband",1,true) or opText:find("anti-smuggling",1,true) then
		want("small_arms",2); want("recon_ground",1)
		want("naval_patrol",2); want("naval_surface",1); want("mine_warfare",1)
		want("uav",2); want("mpa",1); want("aew",1)
	end

	if opText:find("isr",1,true) or opText:find("surveillance",1,true) or opText:find("overwatch",1,true) or opText:find("recon",1,true) then
		want("uav",2); want("aew",1); want("mpa",1); want("recon_ground",1)
	end

	if opText:find("blockade",1,true) or opText:find("compliance",1,true) or opText:find("harbor",1,true) or opText:find("port",1,true) then
		want("naval_patrol",2); want("naval_surface",2); want("coastal_defense",1); want("mine_warfare",1)
		want("surveillance",1)
	end

	if opText:find("strike",1,true) or opText:find("deterr",1,true) or opText:find("show of force",1,true) then
		want("armor",2); want("artillery",2); want("air_defense_ground",1)
		want("strike",1); want("intercept",1)
	end

	if opText:find("corridor",1,true) or opText:find("rail",1,true) or opText:find("transit",1,true) then
		want("rail",2); want("corridor_security",2); want("engineering",1); want("recon_ground",1)
	end

	if uScore>=3 then want("autonomous",1); want("air_defense",1); want("fixed_defense",1) end
	if uScore>=4 then want("prototype",1) end

	-- Respect impact constraints
	if impact and not impactAllows(impact,"sea") then
		wanted["naval_patrol"]=nil; wanted["naval_surface"]=nil; wanted["naval_subsurface"]=nil
		wanted["mine_warfare"]=nil; wanted["amphibious"]=nil; wanted["privateer"]=nil
	end
	if impact and not impactAllows(impact,"air") then
		wanted["uav"]=nil; wanted["aew"]=nil; wanted["mpa"]=nil; wanted["tanker"]=nil
		wanted["transport_air"]=nil; wanted["rotary_transport"]=nil; wanted["rotary_attack"]=nil
		wanted["strike"]=nil; wanted["intercept"]=nil; wanted["bomber"]=nil
	end

	-- Breadth boost: add a couple extra roles on high
	if bLevel>=3 and allRoles and #allRoles>0 then
		local extra = pickDeterministic(allRoles, 2, salt.."|extraRoles")
		for _,r in ipairs(extra) do
			if wanted[r]==nil then wanted[r]=1 end
		end
	end

	return wanted
end

--------------------------------------------------------------------------------
-- Compute utilization from DailyOps (baseline TOE + breadth role draws)
--------------------------------------------------------------------------------
local function computeUtilization(args, pools, allRoles)
	local util, debugRows = {}, {}
	local ok, ops = pcall(require, "Module:WarLeagueDivisionOpsBVR")
	if not ok or not ops or type(ops.makeTable) ~= "function" then
		return util, debugRows, false
	end

	local rows = tonumber(args.opsrows) or tonumber(args.rows) or 36
	local seed = args.seed
	local bLevel = breadthLevel(args)

	local tbl = ops.makeTable({ args = {
		rows = rows,
		seed = seed,
		showlegend = "no",
		flagsize = args.flagsize or "20",
		division = args.division or "",
	}})

	local function applyFormationCell(cell, opText, uScore, impact, salt)
		cell=tostring(cell or "—")
		if cell=="—" or trim(cell)=="" then return end
		cell = cell:gsub("<br%s*/?>","\n")
		for _,seg in ipairs(mw.text.split(cell, "\n")) do
			seg=trim(seg)
			if seg~="" and seg~="—" then
				local echelon, divCode = parseFormation(seg)
				if echelon and divCode then
					-- baseline TOE
					local toe = TOE[echelon]
					if toe then
						for unitName, amount in pairs(toe) do
							if amount and amount>0 then addDemand(util, divCode, unitName, amount) end
						end
					end

					-- breadth role draws
					local wanted = computeRoleDemands(opText, uScore, impact, bLevel, allRoles, salt.."|"..divCode)
					for role, w in pairs(wanted) do
						local k = w
						if uScore>=4 then k=k+1 end
						if k>5 then k=5 end
						local amountPer = 1
						if role=="small_arms" then amountPer = (uScore>=3 and 250) or 150
						elseif role=="logistics" then amountPer = (uScore>=3 and 3) or 2
						else amountPer = 1 end
						applyRoleDraw(util, pools, divCode, role, k, salt, amountPer)
					end
				end
			end
		end
	end

	for line in tostring(tbl):gmatch("[^\n]+") do
		if line:match("^|%s") and line:find("||",1,true) then
			local parts = mw.text.split(line:gsub("^|%s*",""), "%s*||%s*")
			if #parts >= 11 then
				local dateCell = parts[1] or ""
				local urg = parts[5] or ""
				local uScore = urgencyScore(urg)
				local impact = parseImpact(parts[7] or "")
				local opText = parts[8] or ""
				local ground = parts[9] or "—"
				local naval  = parts[10] or "—"
				local air    = parts[11] or "—"

				local salt = tostring(seed or "").."|"..tostring(dateCell).."|"..tostring(parts[2] or "").."|"..tostring(parts[3] or "").."|"..tostring(opText)

				push(debugRows, {
					date=dateCell, division=parts[2] or "", city=parts[3] or "",
					urgency=urg, impact=parts[7] or "", op=opText,
					ground=ground, naval=naval, air=air
				})

				if impactAllows(impact,"ground") then applyFormationCell(ground, opText, uScore, impact, salt.."|G") end
				if impactAllows(impact,"sea") then applyFormationCell(naval, opText, uScore, impact, salt.."|N") end
				if impactAllows(impact,"air") then applyFormationCell(air, opText, uScore, impact, salt.."|A") end
			end
		end
	end

	return util, debugRows, true
end

--------------------------------------------------------------------------------
-- Overlay highlights (never exceed availability)
--------------------------------------------------------------------------------
local function overlayTable(baseText, util)
	local out={}
	local inTable=false

	for line in tostring(baseText):gmatch("([^\n]*)\n?") do
		if line:match("^%{%|") then
			inTable=true
			push(out,line)
		elseif inTable and line:match("^|%}%s*$") then
			inTable=false
			push(out,line)
		elseif inTable and line:match("^|%s*%[%[File:") and line:find("||",1,true) then
			local parts = mw.text.split(line:gsub("^|%s*",""), "%s*||%s*")
			if #parts == 14 then
				local unitName = extractDisplayText(parts[2])

				local divCols, availByIdx = {}, {}
				for i=4,14 do
					local colKey = COL_KEYS[i-3]
					local divCode = COL_TO_DIV[colKey]
					local avail = toNumber(parts[i]) or 0
					availByIdx[i]=avail
					if divCode then
						divCols[divCode]=divCols[divCode] or {}
						divCols[divCode][#divCols[divCode]+1] = { idx=i, avail=avail }
					end
				end

				local usedByIdx = {}

				for divCode, cols in pairs(divCols) do
					local demand = (util[divCode] and util[divCode][unitName]) or 0
					if demand>0 then
						local totalAvail=0
						for _,c in ipairs(cols) do totalAvail = totalAvail + (c.avail or 0) end
						local usedTotal = math.min(demand, totalAvail)

						if totalAvail>0 and usedTotal>0 then
							local assigned=0
							for _,c in ipairs(cols) do
								local share = math.floor(usedTotal * c.avail / totalAvail)
								if share>c.avail then share=c.avail end
								usedByIdx[c.idx]=share
								assigned=assigned+share
							end
							local rem = usedTotal - assigned
							if rem>0 then
								table.sort(cols, function(x,y)
									local cx = (x.avail or 0) - (usedByIdx[x.idx] or 0)
									local cy = (y.avail or 0) - (usedByIdx[y.idx] or 0)
									return cx>cy
								end)
								local safety=0
								while rem>0 and safety<2000 do
									safety=safety+1
									local progressed=false
									for _,c in ipairs(cols) do
										if rem<=0 then break end
										local cap = (c.avail or 0) - (usedByIdx[c.idx] or 0)
										if cap>0 then
											usedByIdx[c.idx]=(usedByIdx[c.idx] or 0)+1
											rem=rem-1
											progressed=true
										end
									end
									if not progressed then break end
								end
							end
						end
					end
				end

				for i=4,14 do
					local avail = availByIdx[i] or 0
					local used = usedByIdx[i] or 0
					if used>avail then used=avail end
					if avail>0 and used>0 then
						local pct = used/avail
						if pct>1 then pct=1 end
						local bg = cellColor(pct)
						local usedText = string.format("<br/><span style=\"font-size:85%%;\"><b>used:</b> %s</span>", fmt(used))
						parts[i] = string.format("style=\"background:%s; text-align:center;\" | %s%s", bg, parts[i], usedText)
					end
				end

				push(out, "| " .. table.concat(parts, " || "))
			else
				push(out, line)
			end
		else
			push(out, line)
		end
	end

	push(out, "")
	push(out, "<div style=\"margin-top:0.5em; font-size:90%;\">")
	push(out, "<b>Utilization highlight:</b> derived from <i>Module:WarLeagueDivisionOpsBVR</i> (formations + operation text + urgency + impact).")
	push(out, "<br/><b>Hard rule:</b> used is allocated and capped so it never exceeds availability in any cell or across a shared ops-division column-group.")
	push(out, "<br/><b>Breadth:</b> <code>breadth=low|standard|high</code> widens the range of equipment selected from the SSF inventory list.")
	push(out, "</div>")

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

--------------------------------------------------------------------------------
-- Public entrypoint
--------------------------------------------------------------------------------
function p.render(frame)
	local args = (frame and frame.args) or {}

	local base = normalizeWikitextTable(RAW_BASE_TABLE)

	local allUnits = collectAllUnitsFromBase(base)
	normalizeToeTemplates(allUnits)

	local pools, allRoles = buildRolePools(allUnits)

	local util, debugRows, ok = computeUtilization(args, pools, allRoles)
	if not ok then
		return base .. "\n<div style=\"margin-top:0.5em; font-size:90%;\"><b>Note:</b> Ops module unavailable; showing base table only.</div>"
	end

	local rendered = overlayTable(base, util)

	if tostring(args.debug or ""):lower() == "yes" then
		local max = tonumber(args.debugrows) or 8
		local dbg = {}
		push(dbg, "")
		push(dbg, "<div style=\"margin-top:0.75em; font-size:90%;\">")
		push(dbg, "<b>Debug (proof of source):</b> Parsed rows from <i>Module:WarLeagueDivisionOpsBVR</i> (Operation/Urgency/Impact + force cells).")
		push(dbg, "<ul>")
		for i=1, math.min(max, #debugRows) do
			local r = debugRows[i]
			push(dbg, string.format(
				"<li><b>%s</b> | %s | %s | <b>%s</b><br/>Impact: %s<br/>Op: %s<br/>G: %s<br/>N: %s<br/>A: %s</li>",
				tostring(r.date or ""), tostring(r.division or ""), tostring(r.city or ""),
				tostring(r.urgency or ""), tostring(r.impact or ""), tostring(r.op or ""),
				tostring(r.ground or ""), tostring(r.naval or ""), tostring(r.air or "")
			))
		end
		push(dbg, "</ul></div>")
		rendered = rendered .. table.concat(dbg, "\n")
	end

	return rendered
end

return p