Module:WarLeagueUnitUtilizationBVR

From MicrasWiki
Revision as of 23:10, 26 January 2026 by NewZimiaGov (talk | contribs)
Jump to navigationJump to search

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

-- Module:WarLeagueUnitUtilizationBVR
local p = {}

--------------------------------------------------------------------------------
-- 1) FULL EMBEDDED TABLE (KEEP EXACTLY AS YOU HAVE IT)
--------------------------------------------------------------------------------
local RAW_BASE_TABLE = [=[
{| class="wikitable sortable" style="width:100%;"
|+ '''Unit Numbers (Activated 32/3/52 PSSC)'''
... (UNCHANGED: keep your full table here exactly) ...
|}
]=]

--------------------------------------------------------------------------------
-- 2) Column order must match the 11 investor/division columns in the table
--------------------------------------------------------------------------------
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",
}

--------------------------------------------------------------------------------
-- 3) TO&E templates (NOW CONSISTENT WITH ORGANIZATIONAL BREAKDOWN)
-- Ground hierarchy used:
--   1 Cohors = 4 Centuriae; 1 Centuria = 5 Manipuli; 1 Manipulus = 3 Ordinis;
--   1 Ordo = 2 Contubernia; 1 Contubernium = 2 Turmae :contentReference[oaicite:2]{index=2}
--
-- Naval hierarchy used:
--   1 Ordu = 2 Tabur-i Derya; 1 Tabur = 3 Birlik-i Gemi; 1 Birlik = 2 ships :contentReference[oaicite:3]{index=3}
--
-- Air hierarchy used:
--   1 Moira = 3 Ptiseis (~12 a/c); 1 Ptisis = 2 Zeygi (~4 a/c); 1 Zeygi = 2 aircraft :contentReference[oaicite:4]{index=4}
--------------------------------------------------------------------------------
local TOE = {

	-- Ground
	["Turmae"] = {
		["Active Units"]=6,
		["Kalithros Class Rifle"]=6,
		["Bubalus XT Class Revolver"]=1,
	},
	["Contubernia"] = {
		["Active Units"]=12,
		["Kalithros Class Rifle"]=12,
		["Bubalus XT Class Revolver"]=2,
		["Delphica Class Grenadier Rifle"]=1,
	},
	["Ordinis"] = {
		["Active Units"]=24,
		["Kalithros Class Rifle"]=24,
		["Bubalus XT Class Revolver"]=4,
		["Delphica Class Grenadier Rifle"]=2,
		["Chrysos Class Commando Rifle"]=1,
		["Regavis Class DMR"]=1,
	},
	["Manipuli"] = {
		["Active Units"]=72, -- 3 Ordinis x 24 :contentReference[oaicite:5]{index=5}
		["Kalithros Class Rifle"]=72,
		["Bubalus XT Class Revolver"]=12,
		["Delphica Class Grenadier Rifle"]=6,
		["Chrysos Class Commando Rifle"]=2,
		["Lothaya Class Sniper Rifle"]=1,
		["Cathartes Class RPG"]=1,
		["Regavis Class DMR"]=2,

		-- light support (kept small so it doesn't over-highlight)
		["Corythia Class Dedicated Logistics and Transport Truck"]=2,
		["Onceanic Recon Vehicle"]=1,
	},
	["Centuriae"] = {
		["Active Units"]=360, -- 5 Manipuli x 72 :contentReference[oaicite:6]{index=6}
		["Kalithros Class Rifle"]=360,
		["Bubalus XT Class Revolver"]=60,
		["Delphica Class Grenadier Rifle"]=30,
		["Chrysos Class Commando Rifle"]=10,
		["Lothaya Class Sniper Rifle"]=4,
		["Cathartes Class RPG"]=4,
		["Regavis Class DMR"]=12,

		["Syrinx Class Armored Infantry"]=6,
		["Arachne Class Light Tank"]=2,
		["Ephyra Class Anti-Aircraft Vehicle"]=1,
		["Corythia Class Dedicated Logistics and Transport Truck"]=8,
		["Ampelos Class Armored Recovery Vehicle"]=1,
		["Icaria Class Mine-Clearing / Combat Engineering Vehicle"]=1,
	},
	["Cohortes"] = {
		["Active Units"]=1440, -- 4 Centuriae x 360 :contentReference[oaicite:7]{index=7}
		["Kalithros Class Rifle"]=1440,
		["Bubalus XT Class Revolver"]=240,
		["Delphica Class Grenadier Rifle"]=120,
		["Chrysos Class Commando Rifle"]=40,
		["Lothaya Class Sniper Rifle"]=12,
		["Cathartes Class RPG"]=16,
		["Regavis Class DMR"]=48,

		["Syrinx Class Armored Infantry"]=24,
		["Makra Class Battle Tank"]=2,
		["Thalassa Class Main Battle Tank"]=1,
		["Arachne Class Light Tank"]=6,
		["Aithra Class Howitzer"]=2,
		["Aetheris Class Towed Howitzer"]=4,
		["Iynas Class Field Gun"]=1,
		["Corythia Class Dedicated Logistics and Transport Truck"]=30,
		["Ampelos Class Armored Recovery Vehicle"]=2,
		["Penthia Class Bridge-Laying Vehicle"]=1,
		["Icaria Class Mine-Clearing / Combat Engineering Vehicle"]=2,
	},

	-- Naval (generic 2/6/12 ship draws; tuned to your inventory rows)
	["Birlik-i Gemi (Patrol Element)"] = {
		["Vaeringheim Class Corvette"]=1,
		["Saluria Class Gunboat"]=1,
	},
	["Tabur-i Derya (Task Unit)"] = {
		-- 3 Birlik x (1 corvette + 1 gunboat) = 6 ships :contentReference[oaicite:8]{index=8}
		["Vaeringheim Class Corvette"]=3,
		["Saluria Class Gunboat"]=3,
		["Cetomagna Class Minehunter"]=1, -- common lane-assurance element in examples
	},
	["Ordu (Task Group)"] = {
		-- 2 Tabur ~= 12 ships :contentReference[oaicite:9]{index=9}
		["Vaeringheim Class Corvette"]=6,
		["Saluria Class Gunboat"]=4,
		["Sylvapolis Class Frigate"]=2,
		["Cetomagna Class Minehunter"]=1,
	},

	-- Air (2/4/12 aircraft draws)
	["Zeygi (Element)"] = {
		-- 2 aircraft :contentReference[oaicite:10]{index=10}
		["Lotos Class Tactical UAV"]=2,
	},
	["Ptisis (Flight)"] = {
		-- 4 aircraft total: 2 Zeygi :contentReference[oaicite:11]{index=11}
		["Lotos Class Tactical UAV"]=2,
		["Fluxus Class Maritime Patrol Aircraft"]=1,
		["Ventiflor Eye Class AEW&C Aircraft"]=1,
	},
	["Moira (Squadron)"] = {
		-- ~12 aircraft: 3 Ptiseis :contentReference[oaicite:12]{index=12}
		["Lotos Class Tactical UAV"]=6,
		["Fluxus Class Maritime Patrol Aircraft"]=3,
		["Ventiflor Eye Class AEW&C Aircraft"]=3,
	},
}

--------------------------------------------------------------------------------
-- 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(wikilinkCell)
	local c = tostring(wikilinkCell 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]+", "")) -- avoid preformatted table rendering
	end
	return table.concat(out, "\n")
end

-- FIX: sanitize ops segments so echelon matching works even with wiki/HTML markup
local function sanitizeOpsSegment(seg)
	seg = tostring(seg or "")
	seg = seg:gsub("<br%s*/?>", "\n")
	seg = seg:gsub("<[^>]+>", "")                 -- strip HTML tags
	seg = seg:gsub("''+", "")                     -- strip wiki bold/italics markers
	seg = seg:gsub("%[%[[^%]|]-|([^%]]-)%]%]", "%1") -- [[link|text]] -> text
	seg = seg:gsub("%[%[([^%]]-)%]%]", "%1")         -- [[link]] -> link
	seg = seg:gsub("&nbsp;", " ")
	seg = seg:gsub("%s+", " ")
	return trim(seg)
end

-- FIX: parse formation as "echelon (DIV-XX)" where echelon may contain parentheses
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*$", "")) -- remove ONLY trailing (DIV-XX)
	return echelon, divCode
end

--------------------------------------------------------------------------------
-- Coverage enforcement (no unit 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

local function buildToeCoverageReport(allUnits)
	local lines = {}
	push(lines, "''TO&E Coverage Report (any unit not explicitly counted defaults to 0):''")
	local echelons = {}
	for echelon, _ in pairs(TOE) do push(echelons, echelon) end
	table.sort(echelons)
	for _, echelon in ipairs(echelons) do
		local toe = TOE[echelon]
		local fallback = 0
		for unitName, _ in pairs(allUnits) do
			if toe[unitName] == 0 then fallback = fallback + 1 end
		end
		push(lines, string.format("* <b>%s</b>: %d fallback rows", echelon, fallback))
	end
	return table.concat(lines, "\n")
end

--------------------------------------------------------------------------------
-- Parse ops module -> utilization demand by DIV and Unit (FIXED)
--------------------------------------------------------------------------------
local function computeUtilization(args)
	local util = {}
	local debugRows = {}

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

	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 tbl = ops.makeTable({ args = {
		rows = rows,
		seed = seed,
		showlegend = "no",
		flagsize = args.flagsize or "20",
		division = args.division or "",
	}})

	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 r = {
					date = parts[1] or "", division = parts[2] or "", city = parts[3] or "",
					ground = parts[9] or "—", naval = parts[10] or "—", air = parts[11] or "—"
				}
				push(debugRows, r)

				local function handle(forceCell)
					forceCell = tostring(forceCell or "—")
					if forceCell == "—" or trim(forceCell) == "" then return end

					forceCell = forceCell:gsub("<br%s*/?>", "\n")
					for _, seg in ipairs(mw.text.split(forceCell, "\n")) do
						seg = trim(seg)
						if seg ~= "" and seg ~= "—" then
							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)
				handle(r.naval)
				handle(r.air)
			end
		end
	end

	return util, debugRows, true
end

--------------------------------------------------------------------------------
-- Overlay highlights (NEVER exceeding availability) - your allocation/cap logic
--------------------------------------------------------------------------------
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])

				-- group columns by ops division for this row
				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 {}
						push(divCols[divCode], { idx = i, avail = avail })
					end
				end

				-- allocate demand across columns, capped by availability
				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(a, b)
									local ca = (a.avail or 0) - (usedByIdx[a.idx] or 0)
									local cb = (b.avail or 0) - (usedByIdx[b.idx] or 0)
									return ca > cb
								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

				-- apply styling
				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> cells tint when that column’s mapped ops-division drew from that row today (TO&E-based).")
	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, "</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)

	if tostring(args.report or ""):lower() == "yes" then
		return buildToeCoverageReport(allUnits)
	end

	local util, debugRows, ok = computeUtilization(args)
	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 deployments from <i>Module:WarLeagueDivisionOpsBVR</i> output used for today/seed.")
		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<br/>G: %s<br/>N: %s<br/>A: %s</li>",
				tostring(r.date or ""), tostring(r.division or ""), tostring(r.city 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