Module:AutoUpdateServPrices

From MicrasWiki
Jump to navigationJump to search

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

---------------------------------------------------------
-- Module:AutoUpdateServPrices (OPTIMIZED)
--
-- Fix for Scribunto timeout:
--  • Caches city-level signals once per unique HQ city:
--      - Forecast opsData
--      - Hatch daily report city counts (one report per day; counts cached)
--      - War League table city counts (one table per day; counts cached)
--      - Outbreak severity (one call per city; cached)
--  • Avoids repeated string concatenation in loops (uses table buffers).
--  • Keeps same output format and pricing logic as the previous rewrite.
---------------------------------------------------------
local p = {}

---------------------------------------------------------
-- getCurrentDate
---------------------------------------------------------
local function getCurrentDate()
	local startDate = os.time({ year = 1999, month = 8, day = 6 })
	local secondsInDay = 86400
	local daysPerYear = 183
	local currentDate = os.time()
	local totalDaysElapsed = math.floor((currentDate - startDate) / secondsInDay)
	local yearFraction = totalDaysElapsed / daysPerYear
	local psscYear = math.floor(yearFraction)
	local dayOfYear = math.floor((yearFraction - psscYear) * daysPerYear) + 1

	local month, dayOfMonth
	if dayOfYear <= 61 then
		month = 1
		dayOfMonth = dayOfYear
	elseif dayOfYear <= 122 then
		month = 2
		dayOfMonth = dayOfYear - 61
	else
		month = 3
		dayOfMonth = dayOfYear - 122
	end

	return string.format("%d/%d/%d [[PSSC]]", dayOfMonth, month, psscYear), dayOfMonth, month, psscYear, dayOfYear
end

---------------------------------------------------------
-- Product data (FULL LIST — from oldid=380847)
---------------------------------------------------------
local products = mw.loadData("Module:AutoUpdateServPrices/Data")

---------------------------------------------------------
-- City-Signal Integrations
---------------------------------------------------------
local forecastOk, forecast = pcall(require, "Module:BassaridiaForecast")
local hatchOk, hatch = pcall(require, "Module:HatchMinistryDailyOps")
local outbreakOk, outbreaks = pcall(require, "Module:BassaridiaOutbreaks")
local warOk, war = pcall(require, "Module:WarLeagueDivisionOpsBVR")

local function escapePattern(s)
	s = tostring(s or "")
	return s:gsub("([%%%^%$%(%)%.%[%]%*%+%-%?])", "%%%1")
end

local function normalizeCompany(company)
	company = tostring(company or "")
	company = company:gsub("%s+$", "")
	company = company:gsub("%*$", "")
	company = company:gsub("%s+$", "")
	return company
end

local companyHQOverrides = {
	["Hatch Ministry"] = "Vaeringheim",
	["Lake Morovia Blockade Fund - Hatch Ministry"] = "Vaeringheim",
	["Maritime Guild of the Cult of Maskmakers"] = "Jogi",
	["Ergonian Spy Agency"] = "Jogi",
	["Temple Bank of the Reformed Stripping Path"] = "Vaeringheim",
	["Couriers of the Lizard Queen"] = "Delphica",
	["Temple University of Delphica"] = "Delphica",
	["La Sái Ebile"] = "Erythros",
	["Trans-Morovian Express"] = "Riddersborg",
	["Hellhound Breeders of Dragevik"] = "Dragevik",
	["Pharmacon Sect"] = "Acheron",
	["Skyrophian Security Contractors"] = "Skýrophos",
	["Haifadora Postal Service"] = "Vaeringheim",
	["Fleet of Rouge Divers"] = "Rouge",
	["Merchant Caravans of the Candy Kingdom"] = "Butterscotch",
	["Bassaridian Broadcasting Corporation"] = "Vaeringheim",
	["Temple of Aprobelle"] = "Vaeringheim",
	["Keltian Steppe Caravan Company"] = "Tonar",
	["Sårensby Depot"] = "Sårensby",
	["Haifa Compliance Exchange"] = "Vaeringheim",
	["Port of Corumia"] = "",
	["Pentiad Terminal Operations"] = "Vaeringheim",
	["Pentiad Terminal Operations Agency"] = "Vaeringheim",
	["Azat Tug and Pilot Cooperative"] = "Vaeringheim",
	["Calliope Shipyard Repair Agency"] = "Vaeringheim",
	["Bulhanu Maritime Insurance Association"] = "Vaeringheim",
	["Morovian Customs & Freight Syndicate"] = "Vaeringheim",
	["Morovian Sanitation and Bilge Company"] = "Vaeringheim",
	["Ordo Ebríumä Oneira (Highland Guides)"] = "",
	["Lothayan Skybridge Airways"] = "Vaeringheim",
	["Seventh Lamp Fiduciary & Valuation House"] = "Aurelia",
	["Sunbridge Water Stewardship Collective"] = "Sylvapolis",

---------------------------------------------------------
-- Imperial Federation Partner Companies
---------------------------------------------------------

	["Dromosker Security Services Corporation"] = "",
	["Abeisan Cruise Lines"] = "",
}

local function inferHQCity(companyName)
	local c = normalizeCompany(companyName)
	return companyHQOverrides[c] or ""
end

local function clamp(x, lo, hi)
	if x < lo then return lo end
	if x > hi then return hi end
	return x
end

local function opsLevelToNumber(levelOrLabel)
	local n = tonumber(levelOrLabel)
	if n then return n end
	local t = tostring(levelOrLabel or ""):lower()
	if t:find("green") then return 0 end
	if t:find("yellow") then return 1 end
	if t:find("orange") then return 2 end
	if t:find("red") then return 3 end
	return 0
end

local function productClassServ(productName, companyName)
	local n = tostring(productName or ""):lower()
	local c = normalizeCompany(companyName):lower()

	if n:find("sanitation") or n:find("bilge") or n:find("quarantine") or n:find("decontamination")
		or n:find("medical") or n:find("pathogen") or n:find("chlorination") or n:find("hygiene") or n:find("abatement")
		or n:find("customs") or n:find("clearance") or n:find("tariff") or n:find("bonded") or n:find("freight")
		or n:find("pilotage") or n:find("tug") or n:find("berth") or n:find("stevedoring") or n:find("intermodal")
		or n:find("escort") or n:find("security") or n:find("risk") or n:find("insurance") or n:find("arbitration")
		or n:find("postal") or n:find("delivery") or n:find("courier")
		or c:find("sanitation") or c:find("customs") or c:find("insurance") or c:find("terminal") or c:find("tug")
	then
		return "essential"
	end

	if c:find("aprobelle") or n:find("loyalty") or n:find("behavior") or n:find("polygraph") or n:find("anomaly") then
		return "governance"
	end

	if n:find("circus") or n:find("performance") or n:find("parade") or n:find("tickets") or n:find("tour")
		or n:find("cabin") or n:find("luxury") or n:find("advert") or n:find("sponsorship") or n:find("broadcast")
		or n:find("salvaged") or n:find("antique") or n:find("treasure") or n:find("jewelry") or n:find("artifact")
		or c:find("divers") or c:find("candy") or c:find("broadcasting")
	then
		return "discretionary"
	end

	if c:find("hatch ministry") or c:find("blockade fund") then
		return "volatile"
	end

	return "standard"
end

-- Cache tables (filled per generateTable call)
local cache = {
	forecast = {},   -- city -> {mult,note,opsLevel}
	war = {},        -- city -> {mult,note} (depends on opsLevel + war count)
	hatchCount = {}, -- city -> integer
	health = {},     -- city -> {mult,severity,note}
}

local function getForecastSignal(city, year, dayOfYear)
	local v = cache.forecast[city]
	if v then return v end

	if not (forecastOk and forecast and type(forecast.getOpsData) == "function") then
		v = { mult = 0, note = nil, opsLevel = 0 }
		cache.forecast[city] = v
		return v
	end

	local op = forecast.getOpsData(city, year, dayOfYear)
	if not op or not op.alert or not op.impacts then
		v = { mult = 0, note = nil, opsLevel = 0 }
		cache.forecast[city] = v
		return v
	end

	local m = 0
	local ground = tostring(op.impacts.ground or "OK")
	local sea    = tostring(op.impacts.sea or "OK")
	local air    = tostring(op.impacts.air or "OK")

	if ground == "Slow" then m = m + 0.02
	elseif ground == "Closed" then m = m + 0.08 end

	if sea == "Restricted" then m = m + 0.03
	elseif sea == "Closed" then m = m + 0.06 end

	if air == "Limited" then m = m + 0.01
	elseif air == "No-Go" then m = m + 0.04 end

	local cond = tostring(op.condition or "")
	local lc = cond:lower()
	if lc:find("blizzard") then m = m + 0.05
	elseif lc:find("storm") then m = m + 0.03
	elseif lc:find("fog") then m = m + 0.02
	elseif lc:find("rain") then m = m + 0.01 end

	local opsLevel = opsLevelToNumber(op.alert.level)
	local note = string.format("WX/Ops %s (%s; G:%s S:%s A:%s)", tostring(op.alert.label or ""), cond, ground, sea, air)

	v = { mult = m, note = note, opsLevel = opsLevel }
	cache.forecast[city] = v
	return v
end

-- Precompute hatch city counts once (one report render + per-city pattern counts only for cities we need)
local hatchReportText = nil
local function getHatchCityCount(city, year, dayOfYear)
	local v = cache.hatchCount[city]
	if v ~= nil then return v end

	if not hatchReportText then
		if hatchOk and hatch and type(hatch.dailyReport) == "function" then
			hatchReportText = tostring(hatch.dailyReport({ args = { year = tostring(year), day = tostring(dayOfYear), legend = "no" } }) or "")
		else
			hatchReportText = ""
		end
	end

	local needle = "%[%[List_of_cities_in_Bassaridia_Vaeringheim#" .. escapePattern(city) .. "%|" .. escapePattern(city) .. "%]%]"
	local c = 0
	for _ in hatchReportText:gmatch(needle) do c = c + 1 end
	cache.hatchCount[city] = c
	return c
end

local warTableText = nil
local function getWarCityCount(city, year, dayOfYear)
	-- store under hatchCount? no; just compute per city using warTableText, but keep as cache.warCount
	cache.warCount = cache.warCount or {}
	local v = cache.warCount[city]
	if v ~= nil then return v end

	if not warTableText then
		if warOk and war and type(war.makeTable) == "function" then
			warTableText = tostring(war.makeTable({ args = { rows = "48", showlegend = "no", seed = tostring(year * 1000 + dayOfYear) } }) or "")
		else
			warTableText = ""
		end
	end

	local needle = "%[%[List_of_cities_in_Bassaridia_Vaeringheim#" .. escapePattern(city) .. "%|" .. escapePattern(city) .. "%]%]"
	local c = 0
	for _ in warTableText:gmatch(needle) do c = c + 1 end
	cache.warCount[city] = c
	return c
end

local function getHatchSignal(city, year, dayOfYear)
	local n = getHatchCityCount(city, year, dayOfYear)
	if n <= 0 then return { mult = 0, note = nil } end
	local m = math.min(0.05, n * 0.005)
	return { mult = m, note = string.format("Hatch activity %d (≈+%.1f%%)", n, m*100) }
end

local function parseOutbreakSeverity(outText)
	outText = tostring(outText or "")
	local sev = outText:match('style="background:[^"]+"%s*|%s*([A-Z]+)%s*||')
	if sev == "CRITICAL" or sev == "HIGH" or sev == "MEDIUM" or sev == "LOW" or sev == "NONE" then
		return sev
	end
	return "NONE"
end

local function getHealthSignal(city)
	local v = cache.health[city]
	if v then return v end

	if not (outbreakOk and outbreaks and type(outbreaks.outbreaksForCity) == "function") then
		v = { mult = 0, severity = "NONE", note = nil }
		cache.health[city] = v
		return v
	end

	local txt = outbreaks.outbreaksForCity({ args = { city = city } })
	local sev = parseOutbreakSeverity(txt)

	local m = 0
	if sev == "LOW" then m = 0.01
	elseif sev == "MEDIUM" then m = 0.03
	elseif sev == "HIGH" then m = 0.06
	elseif sev == "CRITICAL" then m = 0.10 end

	local note = (sev ~= "NONE") and ("Health " .. sev .. " (≈+" .. string.format("%.0f%%", m*100) .. ")") or nil
	v = { mult = m, severity = sev, note = note }
	cache.health[city] = v
	return v
end

local function getWarLeagueSignal(city, opsLevel, year, dayOfYear)
	-- war signal depends on opsLevel, so cache per city+opsLevel
	cache.war = cache.war or {}
	cache.war[city] = cache.war[city] or {}
	local v = cache.war[city][opsLevel]
	if v then return v end

	local m = 0
	if opsLevel == 1 then m = 0.01
	elseif opsLevel == 2 then m = 0.03
	elseif opsLevel >= 3 then m = 0.06 end

	local notes = {}
	if opsLevel > 0 then
		table.insert(notes, "War posture +" .. string.format("%.0f%%", m*100))
	end

	local c = getWarCityCount(city, year, dayOfYear)
	if c > 0 then
		local add = math.min(0.04, c * 0.003)
		m = m + add
		table.insert(notes, string.format("War League tempo %d (≈+%.1f%%)", c, add*100))
	end

	v = { mult = m, note = (#notes > 0) and table.concat(notes, "; ") or nil }
	cache.war[city][opsLevel] = v
	return v
end

local function getTempleBankSignal(pClass, healthSeverity, opsLevel)
	local crisis = (healthSeverity == "MEDIUM" or healthSeverity == "HIGH" or healthSeverity == "CRITICAL" or opsLevel >= 2)
	if not crisis then return { mult = 0, note = nil } end

	local m = 0
	if pClass == "essential" then
		if healthSeverity == "CRITICAL" then m = -0.04
		elseif healthSeverity == "HIGH" then m = -0.025
		else m = -0.015 end
		if opsLevel >= 3 then m = m - 0.01 end
		return { mult = m, note = string.format("Temple Bank stabilization (%.0f%%)", m*100) }
	elseif pClass == "governance" then
		if healthSeverity == "CRITICAL" or opsLevel >= 3 then m = 0.035
		elseif healthSeverity == "HIGH" or opsLevel >= 2 then m = 0.02
		else m = 0.01 end
		return { mult = m, note = string.format("Temple compliance demand (+%.0f%%)", m*100) }
	elseif pClass == "discretionary" then
		if healthSeverity == "CRITICAL" then m = 0.03
		elseif healthSeverity == "HIGH" then m = 0.02
		else m = 0.01 end
		return { mult = m, note = string.format("Temple market friction (+%.0f%%)", m*100) }
	else
		if opsLevel >= 3 or healthSeverity == "CRITICAL" then m = 0.02
		elseif opsLevel >= 2 or healthSeverity == "HIGH" then m = 0.01 end
		return { mult = m, note = (m ~= 0) and string.format("Temple finance friction (+%.0f%%)", m*100) or nil }
	end
end

---------------------------------------------------------
-- Special Date Events
---------------------------------------------------------
local events = {
	["1,6"]  = "Bayram al-Nur (Festival of Light) [Vaeringheim]",
	["1,18"] = "Chag Or Hadash (Festival of New Light) [Luminaria]",
	["1,30"] = "Symposion Eirinis (Symposium of Harmony) [Serena]",
	["1,43"] = "Alev Günü (Day of Flame) [Pyralis]",
	["1,48"] = "Day of Abandonment [Vaeringheim]",
	["1,55"] = "Tikkun Tzel (Repair of Shadows) [Symphonara]",
	["2,6"]  = "Panegyris Chrysou (Golden Gathering) [Aurelia]",
	["2,17"] = "Constitution Day [Luminaria]",
	["2,19"] = "Mehtap Dalgası (Moonlit Tide) [Vaeringheim]",
	["2,31"] = "Oneiro Foteino (Dream of Illumination) [Somniumpolis]",
	["2,39"] = "Anniversary of victory in the New South Jangsong Campaign [Nexa]",
	["2,44"] = "Erev Galgal (Eve of Cycles) [Nexa]",
	["2,45"] = "Bassaridia Festival [Somniumpolis]",
	["2,48"] = "Anniversary of victory in the Southern Lake Morovia Campaign [Somniumpolis]",
	["2,54"] = "Leilat al-Kamar (Night of the Moon) [Lunalis Sancta]",
	["2,61"] = "Taşrakah (Reverence of the Stone) [Luminaria]",
	["3,6"]  = "Chag Tvuah (Festival of Harvest) [Sylvapolis]",
	["3,18"] = "Anagenesis Eirmos (Procession of Rebirth) [Acheron]",
	["3,28"] = "Panagia Therizis (Holy Day of the Reaper) [Sylvapolis]",
	["3,37"] = "Anniversary of victory in the Morovian Frontier Campaign [Acheron]",
	["3,43"] = "Karnavali Thysias (Carnival of Celebration) [Erythros]",
	["3,53"] = "Sefar Yashar (Straight Path Celebration) [Catonis Atrium]",
}

---------------------------------------------------------
-- simulatePriceChange (same logic, but uses cached city signals)
---------------------------------------------------------
local function simulatePriceChange(basePrice, dayOfMonth, month, psscYear, dayOfYear, city, productName, companyName)
	basePrice = tonumber(basePrice)
	if not basePrice then
		error("BAD PRICE for: " .. tostring(productName) .. " / " .. tostring(companyName))
	end

	local dateKey = string.format("%d,%d", month, dayOfMonth)

	local changeFactor = math.random(-20, 20) / 100
	local explanationParts = {}

	if events[dateKey] then
		table.insert(explanationParts, events[dateKey] .. ": Prices influenced by event dynamics")
		changeFactor = changeFactor + 0.05
	else
		table.insert(explanationParts, "General market fluctuations")
	end

	local cityAdd = 0
	local notes = {}

	city = tostring(city or "")
	if city ~= "" then
		local pClass = productClassServ(productName, companyName)

		local wx = getForecastSignal(city, psscYear, dayOfYear)
		cityAdd = cityAdd + (wx.mult or 0)
		if wx.note then table.insert(notes, wx.note) end

		local wl = getWarLeagueSignal(city, wx.opsLevel or 0, psscYear, dayOfYear)
		cityAdd = cityAdd + (wl.mult or 0)
		if wl.note then table.insert(notes, wl.note) end

		local hm = getHatchSignal(city, psscYear, dayOfYear)
		cityAdd = cityAdd + (hm.mult or 0)
		if hm.note then table.insert(notes, hm.note) end

		local ph = getHealthSignal(city)
		cityAdd = cityAdd + (ph.mult or 0)
		if ph.note then table.insert(notes, ph.note) end

		local tb = getTempleBankSignal(pClass, ph.severity or "NONE", wx.opsLevel or 0)
		cityAdd = cityAdd + (tb.mult or 0)
		if tb.note then table.insert(notes, tb.note) end
	end

	cityAdd = clamp(cityAdd, -0.20, 0.20)
	changeFactor = clamp(changeFactor + cityAdd, -0.35, 0.35)

	if #notes > 0 then
		table.insert(explanationParts, "Local signals: " .. table.concat(notes, "; "))
	end

	local explanation = table.concat(explanationParts, ". ") .. "."
	local newPrice = math.floor((basePrice * (1 + changeFactor)) * 100) / 100
	return newPrice, changeFactor, explanation
end

---------------------------------------------------------
-- determinePriceLevel
---------------------------------------------------------
local function determinePriceLevel(basePrice, adjustedPrice)
	if adjustedPrice > basePrice then
		return "Low"
	elseif adjustedPrice < basePrice then
		return "High"
	else
		return "Standard"
	end
end

---------------------------------------------------------
-- Args helpers
---------------------------------------------------------
local function getArgs(frame)
	if type(frame) ~= "table" then return {} end
	local args = {}
	local fargs = frame.args or {}
	local pargs = {}
	if frame.getParent then
		local parent = frame:getParent()
		if parent and parent.args then
			pargs = parent.args
		end
	end
	for k, v in pairs(pargs) do if v ~= nil and v ~= "" then args[k] = v end end
	for k, v in pairs(fargs) do if v ~= nil and v ~= "" then args[k] = v end end
	return args
end

local function isNo(v)
	v = tostring(v or ""):lower()
	return (v == "no" or v == "n" or v == "false" or v == "0")
end

local function levelSymbol(level)
	if level == "Low" then
		return "↑"
	elseif level == "High" then
		return "↓"
	else
		return "•"
	end
end

---------------------------------------------------------
-- 5.5 Consumer price helpers
--    Converts the services ledger into ordinary household,
--    small-merchant, and civic-counter prices. The ledger
--    values are units redeemable per poli, so consumer
--    prices move inversely: higher adjusted quantity per
--    poli = lower retail price.
---------------------------------------------------------
local productLookup = nil

local function productKey(name, company)
	return tostring(name or "") .. "||" .. normalizeCompany(company)
end

local function getProductByNameAndCompany(name, company)
	if productLookup == nil then
		productLookup = {}
		for _, product in ipairs(products) do
			productLookup[productKey(product[1], product[2])] = product
		end
	end
	return productLookup[productKey(name, company)]
end

local function formatConsumerPriceFromNil(totalNil)
	totalNil = math.floor((tonumber(totalNil) or 0) + 0.5)
	if totalNil < 1 then totalNil = 1 end

	local poli = math.floor(totalNil / 200)
	local rem = totalNil % 200
	local sik = math.floor(rem / 10)
	local nilCoins = rem % 10

	local parts = {}
	if poli > 0 then table.insert(parts, tostring(poli) .. "₱̈") end
	if sik > 0 then table.insert(parts, tostring(sik) .. "Ƨ̈") end
	if nilCoins > 0 then table.insert(parts, tostring(nilCoins) .. "•") end
	if #parts == 0 then return "1•" end
	return table.concat(parts, " ")
end

local function consumerRow(productName, companyName, consumerUse, retailUnit, baseNil, note)
	return {
		productName = productName,
		companyName = companyName,
		consumerUse = consumerUse,
		retailUnit = retailUnit,
		baseNil = baseNil,
		note = note or ""
	}
end

local consumerCatalog = {
	-- Maskmaking, ritual identity, and personal craft services
	consumerRow("Faces", "Maritime Guild of the Cult of Maskmakers", "Basic mask purchase", "1 mask", 80, "Ordinary face-mask purchase."),
	consumerRow("Festival Faces (painted set)", "Maritime Guild of the Cult of Maskmakers", "Festival costume and procession use", "1 painted set", 140, "Seasonal festival mask set."),
	consumerRow("Plain Mask Blanks (carving-grade)", "Maritime Guild of the Cult of Maskmakers", "Home carving, craft, and apprentice work", "1 blank", 50, "Unfinished carving-grade mask."),
	consumerRow("Ceremonial Masks (lacquered)", "Maritime Guild of the Cult of Maskmakers", "Ritual and formal wear", "1 mask", 180, "Higher-value ritual mask."),
	consumerRow("Dockworker Faceplates (saltproof)", "Maritime Guild of the Cult of Maskmakers", "Work protection", "1 faceplate", 90, "Durable saltproof work mask."),
	consumerRow("Theatrical Half-Masks (performance)", "Maritime Guild of the Cult of Maskmakers", "Performance and festival theater", "1 mask", 75, "Performance mask."),
	consumerRow("Mask Straps & Fasteners (bundled)", "Maritime Guild of the Cult of Maskmakers", "Mask repair", "1 bundle", 15, "Small repair bundle."),
	consumerRow("Mask Repair & Refinish Service", "Maritime Guild of the Cult of Maskmakers", "Repair and refinishing", "1 repair", 35, "Common repair service."),
	consumerRow("Identity Veil Charms (token)", "Maritime Guild of the Cult of Maskmakers", "Personal charm or token", "1 token", 40, "Small identity charm."),

	-- Couriers, messages, mail, and documents
	consumerRow("Couriers", "Couriers of the Lizard Queen", "Local message dispatch", "1 dispatch", 20, "Basic courier dispatch."),
	consumerRow("Night-Run Dispatch (priority)", "Couriers of the Lizard Queen", "Urgent night message", "1 dispatch", 50, "Priority courier service."),
	consumerRow("Canal Skiff Courier Trip", "Couriers of the Lizard Queen", "Canal message or parcel delivery", "1 trip", 35, "Canal courier trip."),
	consumerRow("Sealed Packet Service (tamperproof)", "Couriers of the Lizard Queen", "Secure document delivery", "1 packet", 60, "Tamperproof packet."),
	consumerRow("Ciphered Message Prep (batch)", "Couriers of the Lizard Queen", "Private coded message preparation", "1 batch", 45, "Small coded-message batch."),
	consumerRow("Ward Chalk (threshold marking)", "Couriers of the Lizard Queen", "Household threshold marking", "1 pack", 20, "Protective marking chalk."),
	consumerRow("Ward Incense Sticks (protective)", "Couriers of the Lizard Queen", "Protective household incense", "1 small bundle", 25, "Protective incense sticks."),
	consumerRow("Emergency Beacon Token (messenger call)", "Couriers of the Lizard Queen", "Emergency courier call", "1 token", 90, "Emergency messenger token."),
	consumerRow("Standard Letter Delivery (bundle)", "Haifadora Postal Service", "Letter delivery", "1 small bundle", 10, "Ordinary mail delivery."),
	consumerRow("Parcel Shipment (domestic)", "Haifadora Postal Service", "Domestic parcel shipment", "1 parcel", 25, "Standard parcel shipment."),
	consumerRow("Express Courier Dispatch (same-day)", "Haifadora Postal Service", "Same-day delivery", "1 dispatch", 50, "Express mail service."),
	consumerRow("Registered Mail Filing", "Haifadora Postal Service", "Registered mail proof", "1 filing", 25, "Registered mail paperwork."),
	consumerRow("P.O. Box Rental (monthly)", "Haifadora Postal Service", "Monthly mail box rental", "1 month", 80, "Monthly postal box."),
	consumerRow("Stamp Sheets (standard)", "Haifadora Postal Service", "Postage", "1 sheet", 20, "Standard stamp sheet."),
	consumerRow("Customs Mail Forms (certified)", "Haifadora Postal Service", "Certified mail forms", "1 form set", 15, "Certified forms for cross-border mail."),
	consumerRow("Overland Courier Deliveries", "Trans-Morovian Express", "Rail-linked parcel delivery", "1 parcel", 35, "Overland parcel service."),

	-- Education, certification, translation, and public information
	consumerRow("Credit Hours", "Temple University of Delphica", "Course enrollment", "1 credit", 120, "Formal education credit."),
	consumerRow("Tutorial Sessions (small group)", "Temple University of Delphica", "Tutoring", "1 session", 60, "Small-group tutoring."),
	consumerRow("Lab Access Pass (day)", "Temple University of Delphica", "Laboratory access", "1 day pass", 40, "Day lab access."),
	consumerRow("Library Archive Access (restricted)", "Temple University of Delphica", "Archive access", "1 pass", 50, "Restricted archive pass."),
	consumerRow("Exam Proctoring (certified)", "Temple University of Delphica", "Certified exam service", "1 exam", 45, "Certified proctoring."),
	consumerRow("Trade Certification (clerks & tally)", "Temple University of Delphica", "Trade-clerk certification", "1 certificate", 140, "Professional certification."),
	consumerRow("Language Translation (academic)", "Temple University of Delphica", "Document translation", "1 page", 15, "Academic translation page."),
	consumerRow("Research Abstract Review (submission)", "Temple University of Delphica", "Research submission review", "1 submission", 35, "Abstract review service."),
	consumerRow("Public Service Bulletins", "Bassaridian Broadcasting Corporation", "Public bulletin placement", "1 bulletin", 35, "Public notice broadcast."),
	consumerRow("Morning Program Advertisements", "Bassaridian Broadcasting Corporation", "Small advertisement", "1 ad", 80, "Morning broadcast advertisement."),
	consumerRow("Interview and Feature Packages", "Bassaridian Broadcasting Corporation", "Short public feature", "1 segment", 100, "Feature or interview segment."),
	consumerRow("Daily Enforcement Report Subscription", "Haifa Compliance Exchange", "Compliance news access", "1 issue", 15, "Daily enforcement report."),

	-- Entertainment, festivals, travel, and hospitality
	consumerRow("Performances", "La Sái Ebile", "Performance ticket or act access", "1 ticket/act share", 25, "Everyday entertainment purchase."),
	consumerRow("Musician Set (evening)", "La Sái Ebile", "Evening music booking", "1 set", 120, "Small event music service."),
	consumerRow("Ticket Blocks (printed)", "La Sái Ebile", "Printed tickets", "1 small block", 25, "Printed event tickets."),
	consumerRow("Costume Hire (bundle)", "La Sái Ebile", "Costume rental", "1 bundle", 50, "Festival costume rental."),
	consumerRow("Show Permits & Routing (festival)", "La Sái Ebile", "Festival paperwork", "1 permit", 60, "Small festival routing permit."),
	consumerRow("Passenger Rail Tickets", "Trans-Morovian Express", "Rail travel", "1 ticket", 50, "Ordinary rail ticket."),
	consumerRow("Luxury Cabin Reservations", "Trans-Morovian Express", "Luxury rail cabin", "1 cabin", 180, "Premium rail reservation."),
	consumerRow("Historical Route Tours", "Trans-Morovian Express", "Tourism", "1 tour", 60, "Historical route tour."),
	consumerRow("Timetable Access Codes", "Trans-Morovian Express", "Schedule access", "1 key", 20, "Timetable access key."),
	consumerRow("Pilgrim Caravans (Sacred Route Access)", "Keltian Steppe Caravan Company", "Pilgrim route access", "1 passage", 80, "Sacred route convoy access."),
	consumerRow("Seal of Safe Crossing (Alperkin Stamp)", "Keltian Steppe Caravan Company", "Safe-passage mark", "1 mark", 25, "Alperkin safe-crossing stamp."),
	consumerRow("Steppe Route Navigation Charts", "Keltian Steppe Caravan Company", "Travel navigation", "1 chart", 60, "Route chart."),
	consumerRow("Water Cache Delivery Refills", "Keltian Steppe Caravan Company", "Travel water service", "1 refill", 35, "Water-cache refill."),
	consumerRow("Haifan Mainline Sky-Berth (Lykopolis–Lewisburg)", "Lothayan Skybridge Airways", "Skybridge passenger travel", "1 berth", 90, "Mainline passenger berth."),
	consumerRow("Haifan Branchline Pilgrim Berth (Tel-Amin–Diamandis–Thermosalem)", "Lothayan Skybridge Airways", "Pilgrim sky travel", "1 berth", 75, "Pilgrim branchline berth."),
	consumerRow("Somniumpolis–Aureum–Erythros Long-Range Sky-Berth", "Lothayan Skybridge Airways", "Long-range sky travel", "1 berth", 95, "Long-range passenger berth."),
	consumerRow("Erythros–Nexa–Delphica–Saluria Shrine Pilgrimage Berth", "Lothayan Skybridge Airways", "Shrine pilgrimage travel", "1 berth", 70, "Shrine pilgrimage berth."),
	consumerRow("Acheron–Koinonía–Sylvapolis Canyonlands Night Tour", "Lothayan Skybridge Airways", "Night tour", "1 tour", 80, "Canyonlands night tour."),
	consumerRow("Vaeringheim–Luminaria–Jezeraah City Dawnliner Passenger Berth", "Lothayan Skybridge Airways", "Urban sky travel", "1 berth", 65, "City dawnliner berth."),

	-- Medicine, personal care, and sanitation
	consumerRow("Priestesses of the Pharmacon Sect (Anti-Parasitic Medicine)", "Pharmacon Sect", "Medicine", "1 dose", 35, "Anti-parasitic dose."),
	consumerRow("Priestesses of the Pharmacon Sect (Antifungal/Respiratory Medicine)", "Pharmacon Sect", "Medicine", "1 dose", 40, "Antifungal/respiratory dose."),
	consumerRow("Priestesses of the Pharmacon Sect (Trauma/Battle Medicine)", "Pharmacon Sect", "Medicine", "1 dose", 50, "Trauma medicine dose."),
	consumerRow("Priestesses of the Pharmacon Sect (Memory and Mind Treatment)", "Pharmacon Sect", "Treatment", "1 dose", 45, "Mind-treatment dose."),
	consumerRow("Priestesses of the Pharmacon Sect (Immune Boost Medicine)", "Pharmacon Sect", "Medicine", "1 dose", 30, "Immune-support dose."),
	consumerRow("Priestesses of the Pharmacon Sect (Respiratory Clearing Medicine)", "Pharmacon Sect", "Medicine", "1 dose", 30, "Respiratory clearing dose."),
	consumerRow("Priestesses of the Pharmacon Sect (Fertility and Childbirth Medicine)", "Pharmacon Sect", "Medicine", "1 dose", 45, "Fertility and childbirth medicine."),
	consumerRow("Priestesses of the Pharmacon Sect (Ritual Purification Incense)", "Pharmacon Sect", "Ritual incense", "1 stick", 15, "Purification incense."),
	consumerRow("Priestesses of the Pharmacon Sect (Medicinal Alcohol)", "Pharmacon Sect", "Medicinal alcohol", "1 small bottle", 20, "Medicinal alcohol bottle."),
	consumerRow("Priestesses of the Pharmacon Sect (Detoxification Medicine)", "Pharmacon Sect", "Medicine", "1 dose", 35, "Detoxification dose."),
	consumerRow("Dockside Latrine Service (per unit·day)", "Morovian Sanitation and Bilge Company", "Market-stall sanitation", "1 unit-day", 15, "Temporary latrine service."),
	consumerRow("Waste Sorting & Removal (per m³)", "Morovian Sanitation and Bilge Company", "Waste removal", "small pickup", 20, "Small waste removal."),
	consumerRow("Cistern Shock Chlorination (per cistern)", "Morovian Sanitation and Bilge Company", "Water safety service", "1 cistern", 35, "Household or shop cistern treatment."),
	consumerRow("Food-Handler Hygiene Audits & Licensing (per kitchen·stall)", "Morovian Sanitation and Bilge Company", "Food stall licensing", "1 inspection", 40, "Kitchen or stall hygiene license."),
	consumerRow("Rodent Trap Deployment & Monitoring (per trap·month)", "Morovian Sanitation and Bilge Company", "Pest control", "1 trap-month", 10, "Rodent control."),
	consumerRow("Hand-Wash Station Deployment & Service (per unit·day)", "Morovian Sanitation and Bilge Company", "Market hygiene service", "1 unit-day", 15, "Hand-wash station service."),

	-- Appraisal, fiduciary, estate, and authentication services
	consumerRow("Gold & Bullion Assay", "Seventh Lamp Fiduciary & Valuation House", "Gold, bullion, and precious-metal valuation", "1 assay", 70, "Professional assay of precious metals for purity and value."),
	consumerRow("Jewelry & Gemstone Appraisal", "Seventh Lamp Fiduciary & Valuation House", "Jewelry and gemstone valuation", "1 appraisal", 90, "Professional valuation of jewelry, gems, and personal valuables."),
	consumerRow("Gemstone Certification", "Seventh Lamp Fiduciary & Valuation House", "Gemstone identification and certification", "1 certificate", 120, "Formal identification and certification of a gemstone."),
	consumerRow("Artifact Authentication & Provenance Review", "Seventh Lamp Fiduciary & Valuation House", "Artifact and antiquity authentication", "1 review", 180, "Authentication and preliminary provenance review for an artifact or valuable object."),
	consumerRow("Commercial Provenance Investigation", "Seventh Lamp Fiduciary & Valuation House", "Ownership-history investigation", "1 investigation", 260, "Detailed investigation of the documented ownership and transfer history of valuable property."),
	consumerRow("Estate Inventory & Valuation", "Seventh Lamp Fiduciary & Valuation House", "Estate property inventory and appraisal", "1 estate", 180, "Inventory and professional valuation of a modest household or merchant estate."),
	consumerRow("Standard Estate Settlement", "Seventh Lamp Fiduciary & Valuation House", "Estate administration", "1 settlement", 350, "Administration of an ordinary estate, including claims, valuation, and distribution."),
	consumerRow("Merchant Succession Planning Package", "Seventh Lamp Fiduciary & Valuation House", "Business succession planning", "1 package", 300, "Succession arrangements for small merchants, partnerships, and family businesses."),
	consumerRow("Sealed-Ledger Custody (annual)", "Seventh Lamp Fiduciary & Valuation House", "Secure document and instruction custody", "1 account-year", 120, "Annual secure custody of sealed wills, contracts, ledgers, keys, or instructions."),
	consumerRow("Commercial Escrow Service (standard transaction)", "Seventh Lamp Fiduciary & Valuation House", "Trusted settlement of a commercial transaction", "1 escrow", 160, "Neutral custody of funds, documents, keys, or titles pending completion of agreed conditions."),
	consumerRow("Counterfeit Coin & Bullion Examination", "Seventh Lamp Fiduciary & Valuation House", "Counterfeit precious-metal examination", "1 examination", 100, "Examination of suspicious coinage, bullion, and precious-metal objects."),
	consumerRow("Document & Seal Forgery Examination", "Seventh Lamp Fiduciary & Valuation House", "Document and commercial-seal authentication", "1 examination", 120, "Professional examination of suspected forged contracts, seals, certificates, or ownership papers."),
	consumerRow("Disputed Asset Ownership Trace", "Seventh Lamp Fiduciary & Valuation House", "Asset ownership investigation", "1 investigation", 280, "Tracing of lawful ownership claims through ledgers, contracts, transfers, and estate records."),
	consumerRow("Heptapolitan Final Accounts Preparation", "Seventh Lamp Fiduciary & Valuation House", "Funerary and legacy accounting", "1 ledger", 140, "Preparation of a formal final ledger recording debts, obligations, gifts, holdings, and testamentary dispositions."),
	consumerRow("Seven-Lamp Memorial Bequest Administration", "Seventh Lamp Fiduciary & Valuation House", "Memorial and charitable bequest administration", "1 estate", 240, "Administration of memorial gifts and designated charitable, familial, or cultic bequests."),

	-- Small security, vetting, compliance, and insurance
	consumerRow("Background Dossier (merchant vetting)", "Ergonian Spy Agency", "Merchant background check", "1 report", 120, "Vetting report for trade trust."),
	consumerRow("Counterintelligence Sweep (port office)", "Ergonian Spy Agency", "Office security review", "1 operation", 180, "Higher-cost office sweep."),
	consumerRow("Signal Intercept Review (limited)", "Ergonian Spy Agency", "Security review", "1 review", 140, "Limited signal review."),
	consumerRow("Risk Assessment Report (site)", "Skyrophian Security Contractors", "Site security assessment", "1 report", 90, "Small site risk assessment."),
	consumerRow("Security Training Session (staff)", "Skyrophian Security Contractors", "Staff safety training", "1 session", 80, "Basic staff training."),
	consumerRow("Access Badge Screening (batch)", "Skyrophian Security Contractors", "Access screening", "1 small batch", 60, "Badge screening batch."),
	consumerRow("Local Guard Detail (basic)", "Skyrophian Security Contractors", "Short-term guard detail", "1 assignment", 120, "Basic local guard service."),
	consumerRow("Gatehouse Rotation (weekly)", "Skyrophian Security Contractors", "Weekly gate guard", "1 week", 150, "Gatehouse security rotation."),
	consumerRow("Compliance Bonds (standard issue)", "Haifa Compliance Exchange", "Trade compliance bond", "1 bond", 100, "Standard compliance bond."),
	consumerRow("Documentation Services (cargo manifest)", "Haifa Compliance Exchange", "Cargo paperwork", "1 filing", 75, "Manifest filing service."),
	consumerRow("Inspection Scheduling (priority slot)", "Haifa Compliance Exchange", "Inspection scheduling", "1 slot", 60, "Priority inspection slot."),
	consumerRow("Port Entry Clearance Certificates", "Haifa Compliance Exchange", "Entry clearance", "1 certificate", 90, "Port entry certificate."),
	consumerRow("Sacred Buffer Compliance Certificates", "Haifa Compliance Exchange", "Sacred-buffer compliance", "1 stamp", 80, "Sacred buffer compliance stamp."),
	consumerRow("Search-and-Rescue Priority Registration", "Haifa Compliance Exchange", "Emergency registration", "1 slot", 120, "Priority SAR registration."),
	consumerRow("Cargo Delay Coverage (per day)", "Bulhanu Maritime Insurance Association", "Delay insurance", "1 day", 45, "Small cargo delay coverage."),
	consumerRow("Voyage Cancellation Refund Guarantee", "Bulhanu Maritime Insurance Association", "Travel cancellation protection", "1 guarantee", 90, "Voyage refund guarantee."),
	consumerRow("Crew Repatriation & Illness Indemnity", "Bulhanu Maritime Insurance Association", "Crew/travel health indemnity", "1 policy", 100, "Illness and repatriation policy."),
	consumerRow("Customs Clearance Filing (Standard Entry)", "Morovian Customs & Freight Syndicate", "Customs filing", "1 filing", 90, "Standard customs clearance."),
	consumerRow("Tariff Classification Advisory (per commodity)", "Morovian Customs & Freight Syndicate", "Tariff advice", "1 consultation", 60, "Commodity tariff advice."),
	consumerRow("Freight Forwarding (Port-to-Gate, Domestic)", "Morovian Customs & Freight Syndicate", "Domestic freight forwarding", "1 contract", 120, "Small domestic forwarding contract."),
	consumerRow("Import License Facilitation (Non-Standard Goods)", "Morovian Customs & Freight Syndicate", "Import license help", "1 license", 160, "Non-standard import facilitation."),

	-- Household goods, recovered goods, sweets, and northern depot counter goods
	consumerRow("Wisp Ward Talismans", "Couriers of the Lizard Queen", "Protective charm", "1 amulet", 70, "Small protective talisman."),
	consumerRow("Antique Pottery", "Fleet of Rouge Divers", "Household ornament", "1 piece", 80, "Recovered pottery."),
	consumerRow("Recovered Jewelry", "Fleet of Rouge Divers", "Jewelry and gifts", "1 small set", 160, "Recovered jewelry."),
	consumerRow("Glassware (Recovered)", "Fleet of Rouge Divers", "Household glassware", "1 piece", 30, "Recovered glassware."),
	consumerRow("Milk Caramels", "Merchant Caravans of the Candy Kingdom", "Sweet snack", "1 pack", 25, "Candy pack."),
	consumerRow("Marzipan Candy", "Merchant Caravans of the Candy Kingdom", "Sweet snack", "1 pack", 25, "Candy pack."),
	consumerRow("Honey-Glazed Almonds", "Merchant Caravans of the Candy Kingdom", "Sweet snack", "1 bag", 30, "Sweet nut snack."),
	consumerRow("Seasonal Festival Candies", "Merchant Caravans of the Candy Kingdom", "Festival sweets", "1 bundle", 30, "Seasonal candy bundle."),
	consumerRow("Frozen Reindeer Cuts", "Sårensby Depot", "Household meat", "1 package", 70, "Northern frozen meat."),
	consumerRow("Wool Blankets (Nordic Pattern)", "Sårensby Depot", "Household bedding", "1 blanket", 130, "Nordic-pattern wool blanket."),
	consumerRow("Smoked Elk Sausages", "Sårensby Depot", "Preserved meat", "1 link", 45, "Smoked sausage."),
	consumerRow("Pickled Arctic Herring", "Sårensby Depot", "Preserved fish", "1 jar", 40, "Pickled herring."),
	consumerRow("Smoked Goose Breast", "Sårensby Depot", "Preserved meat", "1 cut", 70, "Smoked goose."),
	consumerRow("Seal Oil Lantern-Grade", "Sårensby Depot", "Lamp oil", "1 flask", 35, "Lantern oil."),
	consumerRow("Wild Berry Preserves", "Sårensby Depot", "Sweet preserve", "1 jar", 35, "Northern berry preserve."),
	consumerRow("Lingonberry Jam (Sweetened)", "Sårensby Depot", "Sweet preserve", "1 jar", 35, "Lingonberry jam."),
	consumerRow("Reindeer Jerky Trail Packs", "Sårensby Depot", "Travel food", "1 pack", 35, "Jerky trail pack."),
	consumerRow("Frozen Whitefish Fillets", "Sårensby Depot", "Household fish", "1 tray", 60, "Frozen fish tray."),

---------------------------------------------------------
-- Southern Strait Route-Registry Divan
-- Lacaran Hodos Divan
---------------------------------------------------------
	{"Night-Road Registry", "Lacaran Hodos Divan", 0.42, "registries"},
	{"velai lodging rolls", "Lacaran Hodos Divan", 0.28, "records"},
	{"akron pass filings", "Lacaran Hodos Divan", 0.36, "filings"},
	{"route-insurance bonds", "Lacaran Hodos Divan", 0.92, "bonds"},
	{"Qhapaq Guide-License Docket", "Lacaran Hodos Divan", 0.55, "dockets"},
	{"ribbon-sealed cargo ledgers", "Lacaran Hodos Divan", 0.48, "papers"},
	{"Selen Missing-Traveler Filing", "Lacaran Hodos Divan", 0.64, "filings"},
	{"Qullqa Bonded Transit Record", "Lacaran Hodos Divan", 0.70, "records"},
	{"Hodos Highland-Coast Certification", "Lacaran Hodos Divan", 0.82, "certificates"},
	{"Pilgrim Guide Claim Review", "Lacaran Hodos Divan", 0.50, "reviews"},
}

---------------------------------------------------------
-- Public invocation:
--   {{#invoke:AutoUpdateServPrices|consumerPrices}}
--   {{#invoke:AutoUpdateServPrices|consumerPrices|fast=yes}}
--   {{#invoke:AutoUpdateServPrices|consumerPrices|limit=50}}
---------------------------------------------------------
function p.consumerPrices(frame)
	local args = getArgs(frame)
	local limit = tonumber(args.limit or "")
	local fast = tostring(args.fast or ""):lower()
	local useSignals = not (fast == "yes" or fast == "true" or fast == "1")

	local localTime = os.date("*t")
	local dailySeed = localTime.year * 1000 + localTime.yday
	math.randomseed(dailySeed)

	local dateString, dayOfMonth, month, psscYear, dayOfYear = getCurrentDate()

	cache.forecast = {}
	cache.war = {}
	cache.warCount = {}
	cache.hatchCount = {}
	cache.health = {}
	hatchReportText = nil
	warTableText = nil

	local out =
		"{| class=\"wikitable sortable\"\n" ..
		"|+ Consumer services price list for " .. dateString .. "\n" ..
		"! Date !! Δ !! Service / Item !! Provider !! Consumer use !! Retail unit !! Consumer price !! Notes\n"

	local totalBaseNil, totalTodayNil, count = 0, 0, 0

	for _, item in ipairs(consumerCatalog) do
		if not limit or count < limit then
			local product = getProductByNameAndCompany(item.productName, item.companyName)
			if product then
				local name = product[1]
				local company = product[2]
				local basePrice = tonumber(product[3])
				local city = useSignals and inferHQCity(company) or ""
				local adjustedQty = select(1, simulatePriceChange(
					basePrice, dayOfMonth, month, psscYear, dayOfYear, city, name, company
				))

				local consumerNil = item.baseNil
				if adjustedQty and adjustedQty > 0 then
					consumerNil = item.baseNil * (basePrice / adjustedQty)
				end

				local sym
				local rowStyle
				if consumerNil < item.baseNil then
					sym = "↓"
					rowStyle = ' style="background-color:lightblue;"'
				elseif consumerNil > item.baseNil then
					sym = "↑"
					rowStyle = ' style="background-color:#ffcccb;"'
				else
					sym = "•"
					rowStyle = ' style="background-color:lightgreen;"'
				end

				totalBaseNil = totalBaseNil + item.baseNil
				totalTodayNil = totalTodayNil + consumerNil
				count = count + 1

				out = out .. string.format(
					"|-%s\n| %s || %s || %s || %s || %s || %s || %s || %s\n",
					rowStyle,
					dateString,
					sym,
					name,
					normalizeCompany(company),
					item.consumerUse,
					item.retailUnit,
					formatConsumerPriceFromNil(consumerNil),
					item.note
				)
			end
		end
	end

	local avgBase = totalBaseNil / math.max(count, 1)
	local avgToday = totalTodayNil / math.max(count, 1)
	local avgLabel = "Standard"
	local avgSym = "•"
	if avgToday < avgBase then
		avgLabel = "Lower consumer prices"
		avgSym = "↓"
	elseif avgToday > avgBase then
		avgLabel = "Higher consumer prices"
		avgSym = "↑"
	end

	out = out .. string.format(
		"|-\n! colspan=\"8\" | Consumer average: %s %s; baseline %s, current %s across %d listed services and service-like goods\n|}\n",
		avgSym,
		avgLabel,
		formatConsumerPriceFromNil(avgBase),
		formatConsumerPriceFromNil(avgToday),
		count
	)

	return out
end



---------------------------------------------------------
-- generateTable (OPTIMIZED output building)
---------------------------------------------------------
function p.generateTable(frame)
	local args = getArgs(frame)
local fast = tostring(args.fast or ""):lower()
local useSignals = not (fast == "yes" or fast == "true" or fast == "1")

	local mode = tostring(args.mode or "both"):lower()
	local collapse = not isNo(args.collapse)
	local showSummary = (mode == "both" or mode == "summary")
	local showBreakdown = (mode == "both" or mode == "breakdown")

	-- Seed randomness per day
	local localTime = os.date("*t")
	local dailySeed = localTime.year * 1000 + localTime.yday
	math.randomseed(dailySeed)

	-- Date
	local dateString, dayOfMonth, month, psscYear, dayOfYear = getCurrentDate()

	-- Reset caches per invocation (avoid cross-page pollution)
	cache.forecast = {}
	cache.war = {}
	cache.warCount = {}
	cache.hatchCount = {}
	cache.health = {}
	hatchReportText = nil
	warTableText = nil

	-- Determine which unique cities we actually need (avoid scanning patterns for unused cities)
	local neededCities = {}
	for _, product in ipairs(products) do
		local _, company = product[1], product[2]
		local city = inferHQCity(company)
		if city and city ~= "" then
			neededCities[city] = true
		end
	end

	-- Warm up caches cheaply for each city (one outbreak call per city; one pattern scan per city)
	if useSignals then
	for city, _ in pairs(neededCities) do
		getForecastSignal(city, psscYear, dayOfYear)     -- cached
		getHatchCityCount(city, psscYear, dayOfYear)    -- cached (builds report once)
		getWarCityCount(city, psscYear, dayOfYear)      -- cached (builds table once)
		getHealthSignal(city)                           -- cached
	end
end

	local outParts = {}

	-- Summary header
	local summaryParts = {}
	if showSummary then
		table.insert(summaryParts, "{| class=\"wikitable sortable\"\n")
		table.insert(summaryParts, "! Date (Day/Month/Year [[PSSC]]) !! Δ !! Product !! Producing Company !! Tier I Price !! Units !! Notes\n")
	end

	-- Breakdown header
	local breakdownParts = {}
	if showBreakdown then
		local collapseClass = collapse and " mw-collapsible mw-collapsed" or ""
		table.insert(breakdownParts, "{| class=\"wikitable sortable" .. collapseClass .. "\"\n")
		table.insert(breakdownParts, "|+ Full tier breakdown" .. (collapse and " (click to expand)" or "") .. "\n")
		table.insert(breakdownParts, "! Date !! Product !! Producing Company !! Base Price !! Level !! Tier III !! Tier II !! Tier I !! Units !! Explanation\n")
	end

	local totalBase, totalNew, count = 0, 0, 0

for i, product in ipairs(products) do
  local name = product and product[1] or nil
  local company = product and product[2] or nil
  local basePrice = product and tonumber(product[3]) or nil
  local units = product and product[4] or nil

  if not (name and company and basePrice and units) then
    error("BAD PRODUCT ROW #" .. tostring(i) .. " => " .. tostring(name) .. " / " .. tostring(company) .. " / price=" .. tostring(product and product[3]))
  end


		local city = inferHQCity(company)

		local newPrice, _, explanation = simulatePriceChange(basePrice, dayOfMonth, month, psscYear, dayOfYear, city, name, company)
		local priceLevel = determinePriceLevel(basePrice, newPrice)
		local sym = levelSymbol(priceLevel)

		local rowStyle
		if priceLevel == "Low" then
			rowStyle = ' style="background-color:lightblue;"'
		elseif priceLevel == "Standard" then
			rowStyle = ' style="background-color:lightgreen;"'
		else
			rowStyle = ' style="background-color:#ffcccb;"'
		end

		local tierIIIPrice = math.floor((newPrice / 3) * 100) / 100
		local tierIIPrice  = math.floor((newPrice * 2 / 3) * 100) / 100
		local tierIPrice   = newPrice

		totalBase = totalBase + basePrice
		totalNew  = totalNew + newPrice
		count = count + 1

		if showSummary then
			table.insert(summaryParts, string.format(
				"|-%s\n| %s || %s || %s || %s || %.2f || %s || %s\n",
				rowStyle, dateString, sym, name, normalizeCompany(company), tierIPrice, units, explanation
			))
		end

		if showBreakdown then
			table.insert(breakdownParts, string.format(
				"|-%s\n| %s || %s || %s || %.2f || %s || %.2f || %.2f || %.2f || %s || %s\n",
				rowStyle, dateString, name, normalizeCompany(company), basePrice, priceLevel,
				tierIIIPrice, tierIIPrice, tierIPrice, units, explanation
			))
		end
	end

	local avgBase = totalBase / count
	local avgNew  = totalNew / count
	local avgLevel
	if avgNew > avgBase then
		avgLevel = "Low"
	elseif avgNew < avgBase then
		avgLevel = "High"
	else
		avgLevel = "Standard"
	end
	local avgSym = levelSymbol(avgLevel)

	if showSummary then
		table.insert(summaryParts, string.format(
			"|-\n! colspan=\"7\" | Average adjusted (%.2f) vs base (%.2f): %s %s\n|}\n",
			avgNew, avgBase, avgSym, avgLevel
		))
		table.insert(outParts, table.concat(summaryParts))
		table.insert(outParts, "\n")
	end

	if showBreakdown then
		table.insert(breakdownParts, string.format(
			"|-\n! colspan=\"10\" | Average adjusted (%.2f) vs base (%.2f): %s %s\n|}\n",
			avgNew, avgBase, avgSym, avgLevel
		))
		table.insert(outParts, table.concat(breakdownParts))
	end

	return table.concat(outParts)
end

function p.full(frame)
	frame = frame or {}
	frame.args = frame.args or {}
	frame.args.mode = "breakdown"
	frame.args.collapse = "no"
	return p.generateTable(frame)
end

function p.getAverageData()
	local localTime = os.date("*t")
	local dailySeed = localTime.year * 1000 + localTime.yday
	math.randomseed(dailySeed)

	local dateString, dayOfMonth, month, psscYear, dayOfYear = getCurrentDate()

	local totalBase, totalNew, count = 0, 0, 0

	for i, product in ipairs(products) do
		local name = product[1]
		local company = product[2]
		local basePrice = product[3]
		local units = product[4]

		local city = inferHQCity(company)

		local newPrice = select(1, simulatePriceChange(
			basePrice, dayOfMonth, month, psscYear, dayOfYear, city, name, company
		))

		totalBase = totalBase + basePrice
		totalNew = totalNew + newPrice
		count = count + 1
	end

	local avgBase = totalBase / count
	local avgNew = totalNew / count
	local avgLevel
	if avgNew > avgBase then
		avgLevel = "Low"
	elseif avgNew < avgBase then
		avgLevel = "High"
	else
		avgLevel = "Standard"
	end

	return {
		label = "Services",
		avgBase = avgBase,
		avgNew = avgNew,
		avgLevel = avgLevel,
		avgSym = levelSymbol(avgLevel),
		pctChange = ((avgNew - avgBase) / avgBase) * 100
	}
end

function p.getCompanyEmploymentInputs()
	local localTime = os.date("*t")
	local dailySeed = localTime.year * 1000 + localTime.yday
	math.randomseed(dailySeed)

	local _, dayOfMonth, month, psscYear, dayOfYear = getCurrentDate()

	local companyMap = {}

	for _, product in ipairs(products) do
		local name = product[1]
		local company = product[2]
		local basePrice = product[3]
		local city = inferHQCity(company) or "Vaeringheim"

		local newPrice = select(1, simulatePriceChange(
			basePrice, dayOfMonth, month, psscYear, dayOfYear, city, name, company
		))

		if not companyMap[company] then
			companyMap[company] = {
				name = company,
				sector = "Services",
				city = city,
				totalBase = 0,
				totalNew = 0,
				count = 0
			}
		end

		local row = companyMap[company]
		row.totalBase = row.totalBase + basePrice
		row.totalNew = row.totalNew + newPrice
		row.count = row.count + 1
	end

	local out = {}
	for _, row in pairs(companyMap) do
		row.avgBase = row.totalBase / row.count
		row.avgNew = row.totalNew / row.count
		row.pctChange = ((row.avgNew - row.avgBase) / row.avgBase) * 100
		table.insert(out, row)
	end

	table.sort(out, function(a, b) return a.name < b.name end)
	return out
end
return p