Module:AutoUpdateServPrices: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
NewZimiaGov (talk | contribs)
No edit summary
 
(15 intermediate revisions by the same user not shown)
Line 1: Line 1:
---------------------------------------------------------
---------------------------------------------------------
-- Module:AutoUpdateServPrices
-- Module:AutoUpdateServPrices (OPTIMIZED)
-- Generates a daily-updated services-sector price table.
--
--
-- Display update:
-- Fix for Scribunto timeout:
--  • Summary table (Tier I headline) + Δ symbol column (↑ Low / ↓ High / • Standard)
--  • Caches city-level signals once per unique HQ city:
--  • Collapsible full breakdown table (Base/Level/Tiers)
--     - Forecast opsData
--  • Modes so editors can force full expanded output when updating/auditing
--     - Hatch daily report city counts (one report per day; counts cached)
--
--     - War League table city counts (one table per day; counts cached)
-- Usage:
--     - Outbreak severity (one call per city; cached)
--   {{#invoke:AutoUpdateServPrices|generateTable}}
-- • Avoids repeated string concatenation in loops (uses table buffers).
--     - default: summary + collapsed breakdown
-- • Keeps same output format and pricing logic as the previous rewrite.
--   {{#invoke:AutoUpdateServPrices|generateTable|mode=summary}}
--    - summary only
--   {{#invoke:AutoUpdateServPrices|generateTable|mode=breakdown}}
--    - breakdown only (collapsed by default)
--   {{#invoke:AutoUpdateServPrices|generateTable|mode=breakdown|collapse=no}}
--    - breakdown only (expanded)
--   {{#invoke:AutoUpdateServPrices|full}}
--     - breakdown only (expanded) convenience function
---------------------------------------------------------
---------------------------------------------------------
local p = {}
local p = {}


---------------------------------------------------------
---------------------------------------------------------
-- getCurrentDate
-- getCurrentDate
-- Returns today's date in PSSC format,
-- along with dayOfMonth and month for daily logic.
---------------------------------------------------------
---------------------------------------------------------
local function getCurrentDate()
local function getCurrentDate()
    local startDate = os.time({year = 1999, month = 8, day = 6})
local startDate = os.time({ year = 1999, month = 8, day = 6 })
    local secondsInDay = 86400
local secondsInDay = 86400
    local daysPerYear = 183
local daysPerYear = 183
    local currentDate = os.time()
local currentDate = os.time()
    local totalDaysElapsed = math.floor((currentDate - startDate) / secondsInDay)
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 yearFraction = totalDaysElapsed / daysPerYear
local month, dayOfMonth
    local psscYear = math.floor(yearFraction)
if dayOfYear <= 61 then
    local dayOfYear = math.floor((yearFraction - psscYear) * daysPerYear) + 1
month = 1
    local month, dayOfMonth
dayOfMonth = dayOfYear
    if dayOfYear <= 61 then
elseif dayOfYear <= 122 then
        month = 1
month = 2
        dayOfMonth = dayOfYear
dayOfMonth = dayOfYear - 61
    elseif dayOfYear <= 122 then
else
        month = 2
month = 3
        dayOfMonth = dayOfYear - 61
dayOfMonth = dayOfYear - 122
    else
end
        month = 3
        dayOfMonth = dayOfYear - 122
    end


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


Line 56: Line 44:
-- Product data (FULL LIST — from oldid=380847)
-- Product data (FULL LIST — from oldid=380847)
---------------------------------------------------------
---------------------------------------------------------
local products = {
local products = mw.loadData("Module:AutoUpdateServPrices/Data")
    {"Draped Flag Linens (Foreign Vessels)", "Lake Morovia Blockade Fund - Hatch Ministry", 1.40, "sheets"},
 
    {"Dismantled Minting Equipment", "Lake Morovia Blockade Fund - Hatch Ministry", .7, "assemblies"},
---------------------------------------------------------
    {"Decertified Seal Stamps (Foreign Commissions)", "Lake Morovia Blockade Fund - Hatch Ministry", 1.9, "sets"},
-- City-Signal Integrations
    {"Declassified Naval Cargo Covers", "Lake Morovia Blockade Fund - Hatch Ministry", 6.0, "tarps"},
---------------------------------------------------------
    {"Confiscated Calibration Weights", "Lake Morovia Blockade Fund - Hatch Ministry", 8.8, "sets"},
local forecastOk, forecast = pcall(require, "Module:BassaridiaForecast")
    {"Crated Trade Uniforms (Non-Domestic Origin)", "Lake Morovia Blockade Fund - Hatch Ministry", 1.30, "articles"},
local hatchOk, hatch = pcall(require, "Module:HatchMinistryDailyOps")
    {"Defaced Customs Plaques", "Lake Morovia Blockade Fund - Hatch Ministry", 1.7, "plaques"},
local outbreakOk, outbreaks = pcall(require, "Module:BassaridiaOutbreaks")
    {"Baled Promotional Ephemera", "Lake Morovia Blockade Fund - Hatch Ministry", 4.80, "kg"},
local warOk, war = pcall(require, "Module:WarLeagueDivisionOpsBVR")
    {"Foreign Coinage (Struck Before Blockade)", "Lake Morovia Blockade Fund - Hatch Ministry", 3.1, "coins"},
 
    {"Embroidered Foreign Diplomatic Standards", "Lake Morovia Blockade Fund - Hatch Ministry", 4, "banners"},
local function escapePattern(s)
    {"Blocked Port Registry Books", "Lake Morovia Blockade Fund - Hatch Ministry", 1.2, "ledgers"},
s = tostring(s or "")
    {"Disassembled Broadcast Relays", "Lake Morovia Blockade Fund - Hatch Ministry", 6, "units"},
return s:gsub("([%%%^%$%(%)%.%[%]%*%+%-%?])", "%%%1")
    {"Stamps (Confiscated)", "Lake Morovia Blockade Fund - Hatch Ministry", 7.0, "sheets"},
end
    {"Confiscated Trade Crates", "Hatch Ministry", 1.1, "units"},
 
    {"Mixed Merchant Cargo", "Hatch Ministry", 5.2, "tons"},
local function normalizeCompany(company)
    {"Captured Textiles (Raw & Finished)", "Hatch Ministry", 3.40, "bolts"},
company = tostring(company or "")
    {"Illicit Grain Stores", "Hatch Ministry", 3.6, "tons"},
company = company:gsub("%s+$", "")
    {"Contraband Alcohol", "Hatch Ministry", 1.1, "bottles"},
company = company:gsub("%*$", "")
    {"Seized Foreign Coinage", "Hatch Ministry", 10, "coins"},
company = company:gsub("%s+$", "")
    {"Recovered Cargo Manifests", "Hatch Ministry", 3.33, "scrolls"},
return company
    {"Bulk Dyes and Spices (Unmarked Origin)", "Hatch Ministry", 7.220, "kg"},
end
    {"Captured Livestock (Deck Transport)", "Hatch Ministry", 2.6, "head"},
 
    {"Foreign Trade Seals and Emblems", "Hatch Ministry", 1.22, "sets"},
local companyHQOverrides = {
    {"Unregistered Luxury Cargo", "Hatch Ministry", 1.4, "tons"},
["Hatch Ministry"] = "Vaeringheim",
    {"Recovered Safe Boxes", "Hatch Ministry", .7, "chests"},
["Lake Morovia Blockade Fund - Hatch Ministry"] = "Vaeringheim",
    {"Stamped Silver Bars", "Hatch Ministry", 3.90, "kg"},
["Maritime Guild of the Cult of Maskmakers"] = "Jogi",
    {"Captured Naval Uniforms and Insignia", "Hatch Ministry", 5.5, "sets"},
["Ergonian Spy Agency"] = "Jogi",
    {"Lost Port Authority Documents", "Hatch Ministry", 1.4, "scrolls"},
["Temple Bank of the Reformed Stripping Path"] = "Vaeringheim",
    {"High-Value Personal Effects", "Hatch Ministry", 6.8, "items"},
["Couriers of the Lizard Queen"] = "Delphica",
    {"Private Letters and Ship Logs", "Hatch Ministry", 4.1, "volumes"},
["Temple University of Delphica"] = "Delphica",
    { "Faces", "Maritime Guild of the Cult of Maskmakers", 0.03, "masks" },
["La Sái Ebile"] = "Erythros",
    { "Festival Faces (painted set)", "Maritime Guild of the Cult of Maskmakers", 0.06, "sets" },
["Trans-Morovian Express"] = "Riddersborg",
    { "Plain Mask Blanks (carving-grade)", "Maritime Guild of the Cult of Maskmakers", 0.04, "masks" },
["Hellhound Breeders of Dragevik"] = "Dragevik",
    { "Ceremonial Masks (lacquered)", "Maritime Guild of the Cult of Maskmakers", 0.08, "masks" },
["Pharmacon Sect"] = "Acheron",
    { "Dockworker Faceplates (saltproof)", "Maritime Guild of the Cult of Maskmakers", 0.05, "masks" },
["Skyrophian Security Contractors"] = "Skýrophos",
    { "Theatrical Half-Masks (performance)", "Maritime Guild of the Cult of Maskmakers", 0.04, "masks" },
["Haifadora Postal Service"] = "Vaeringheim",
    { "Mask Straps & Fasteners (bundled)", "Maritime Guild of the Cult of Maskmakers", 0.02, "bundles" },
["Fleet of Rouge Divers"] = "Rouge",
    { "Mask Repair & Refinish Service", "Maritime Guild of the Cult of Maskmakers", 0.03, "repairs" },
["Merchant Caravans of the Candy Kingdom"] = "Butterscotch",
    { "Identity Veil Charms (token)", "Maritime Guild of the Cult of Maskmakers", 0.05, "tokens" },
["Bassaridian Broadcasting Corporation"] = "Vaeringheim",
    { "Crated Mask Lots (mixed export)", "Maritime Guild of the Cult of Maskmakers", 0.12, "crates" },
["Temple of Aprobelle"] = "Vaeringheim",
    { "Spies", "Ergonian Spy Agency", 0.01, "assignments" },
["Keltian Steppe Caravan Company"] = "Tonar",
    { "Surveillance Package (basic tail)", "Ergonian Spy Agency", 0.04, "assignments" },
["Sårensby Depot"] = "Sårensby",
    { "Counterintelligence Sweep (port office)", "Ergonian Spy Agency", 0.09, "operations" },
["Haifa Compliance Exchange"] = "Vaeringheim",
    { "Background Dossier (merchant vetting)", "Ergonian Spy Agency", 0.06, "reports" },
["Port of Corumia"] = "",
    { "Cipher & Codebook Service (per batch)", "Ergonian Spy Agency", 0.03, "batches" },
["Pentiad Terminal Operations"] = "Vaeringheim",
    { "Dead-Drop Setup (secure route)", "Ergonian Spy Agency", 0.05, "routes" },
["Pentiad Terminal Operations Agency"] = "Vaeringheim",
    { "Infiltration Placement (short term)", "Ergonian Spy Agency", 0.12, "placements" },
["Azat Tug and Pilot Cooperative"] = "Vaeringheim",
    { "Rumor Injection (market shaping)", "Ergonian Spy Agency", 0.07, "campaigns" },
["Calliope Shipyard Repair Agency"] = "Vaeringheim",
    { "Signal Intercept Review (limited)", "Ergonian Spy Agency", 0.08, "reviews" },
["Bulhanu Maritime Insurance Association"] = "Vaeringheim",
    { "Safehouse Lease (weekly)", "Ergonian Spy Agency", 0.10, "weeks" },
["Morovian Customs & Freight Syndicate"] = "Vaeringheim",
    {"Missionaries of the Order Aurora Mystica", "Temple Bank of the Reformed Stripping Path", 0.05, "teams"},
["Morovian Sanitation and Bilge Company"] = "Vaeringheim",
    {"Missionaries of Harmony Sanctum", "Temple Bank of the Reformed Stripping Path", 0.09, "teams"},
["Ordo Ebríumä Oneira (Highland Guides)"] = "",
    {"Missionaries of Ignis Aeternum", "Temple Bank of the Reformed Stripping Path", 0.09, "teams"},
["Lothayan Skybridge Airways"] = "Vaeringheim",
    {"Missionaries of Celestial Harmony Sect", "Temple Bank of the Reformed Stripping Path", 0.08, "teams"},
["Seventh Lamp Fiduciary & Valuation House"] = "Aurelia",
    {"Missionaries of the Guild of Golden Shadows", "Temple Bank of the Reformed Stripping Path", 0.05, "teams"},
["Sunbridge Water Stewardship Collective"] = "Sylvapolis",
    {"Missionaries of the Azure Sentinel Sect", "Temple Bank of the Reformed Stripping Path", 0.01, "teams"},
 
    {"Missionaries of Reverie Nebulous", "Temple Bank of the Reformed Stripping Path", 0.10, "teams"},
---------------------------------------------------------
    {"Missionaries of the Eon Fellowship", "Temple Bank of the Reformed Stripping Path", 0.09, "teams"},
-- Imperial Federation Partner Companies
    {"Missionaries of the Order of the Umbral Oracle", "Temple Bank of the Reformed Stripping Path", 0.07, "teams"},
---------------------------------------------------------
    {"Missionaries of the Mystery of the Verdant Embrace", "Temple Bank of the Reformed Stripping Path", 0.10, "teams"},
 
    {"Missionaries of Conclace Illuminara", "Temple Bank of the Reformed Stripping Path", 0.02, "teams"},
["Dromosker Security Services Corporation"] = "",
    {"Missionaries of Sanctum Vitalis", "Temple Bank of the Reformed Stripping Path", 0.06, "teams"},
["Abeisan Cruise Lines"] = "",
    {"Missionaries of Temple Alabaster", "Temple Bank of the Reformed Stripping Path", 0.09, "teams"},
}
    {"Missionaries of the Court of the Ironclad", "Temple Bank of the Reformed Stripping Path", 0.06, "teams"},
 
    {"Missionaries of Concord Accordia", "Temple Bank of the Reformed Stripping Path", 0.02, "teams"},
local function inferHQCity(companyName)
    {"Missionaries of the Mystery of the Stygian Veil", "Temple Bank of the Reformed Stripping Path", 0.03, "teams"},
local c = normalizeCompany(companyName)
    {"Missionaries of the Sylvan Fellowship", "Temple Bank of the Reformed Stripping Path", 0.09, "teams"},
return companyHQOverrides[c] or ""
    {"Missionaries of the Mystery of Red Mirth", "Temple Bank of the Reformed Stripping Path", 0.06, "teams"},
end
    {"Missionaries of Rex Catonis", "Temple Bank of the Reformed Stripping Path", 0.07, "teams"},
 
    {"Missionaries of Sanctum Delphica", "Temple Bank of the Reformed Stripping Path", 0.09, "teams"},
local function clamp(x, lo, hi)
    {"Missionaries of Ordo Amictia", "Temple Bank of the Reformed Stripping Path", 0.06, "teams"},
if x < lo then return lo end
    {"Missionaries of Temple Illuminata", "Temple Bank of the Reformed Stripping Path", 0.05, "teams"},
if x > hi then return hi end
    { "Couriers", "Couriers of the Lizard Queen", 0.04, "dispatches" },
return x
    { "Wisp Ward Talismans", "Couriers of the Lizard Queen", 0.10, "amulets" },
end
    { "Night-Run Dispatch (priority)", "Couriers of the Lizard Queen", 0.08, "dispatches" },
 
    { "Canal Skiff Courier Trip", "Couriers of the Lizard Queen", 0.06, "trips" },
local function opsLevelToNumber(levelOrLabel)
    { "Caravan Relay Courier (long route)", "Couriers of the Lizard Queen", 0.12, "dispatches" },
local n = tonumber(levelOrLabel)
    { "Sealed Packet Service (tamperproof)", "Couriers of the Lizard Queen", 0.09, "packets" },
if n then return n end
    { "Ciphered Message Prep (batch)", "Couriers of the Lizard Queen", 0.05, "batches" },
local t = tostring(levelOrLabel or ""):lower()
    { "Ward Chalk (threshold marking)", "Couriers of the Lizard Queen", 0.04, "packs" },
if t:find("green") then return 0 end
    { "Ward Incense Sticks (protective)", "Couriers of the Lizard Queen", 0.06, "sticks" },
if t:find("yellow") then return 1 end
    { "Emergency Beacon Token (messenger call)", "Couriers of the Lizard Queen", 0.14, "tokens" },
if t:find("orange") then return 2 end
    {"Wisp Ward Talismans", "Couriers of the Lizard Queen", 0.10, "amulets"},
if t:find("red") then return 3 end
    { "Credit Hours", "Temple University of Delphica", 3.00, "credits" },
return 0
    { "Tutorial Sessions (small group)", "Temple University of Delphica", 0.60, "sessions" },
end
    { "Lab Access Pass (day)", "Temple University of Delphica", 0.35, "passes" },
 
    { "Library Archive Access (restricted)", "Temple University of Delphica", 0.55, "passes" },
local function productClassServ(productName, companyName)
    { "Exam Proctoring (certified)", "Temple University of Delphica", 0.40, "exams" },
local n = tostring(productName or ""):lower()
    { "Trade Certification (clerks & tally)", "Temple University of Delphica", 0.95, "certificates" },
local c = normalizeCompany(companyName):lower()
    { "Language Translation (academic)", "Temple University of Delphica", 0.12, "pages" },
 
    { "Lecture Hall Rental (per hour)", "Temple University of Delphica", 0.45, "hours" },
if n:find("sanitation") or n:find("bilge") or n:find("quarantine") or n:find("decontamination")
    { "Field Study Course Seat (port logistics)", "Temple University of Delphica", 1.20, "seats" },
or n:find("medical") or n:find("pathogen") or n:find("chlorination") or n:find("hygiene") or n:find("abatement")
    { "Research Abstract Review (submission)", "Temple University of Delphica", 0.25, "submissions" },
or n:find("customs") or n:find("clearance") or n:find("tariff") or n:find("bonded") or n:find("freight")
    { "Circuses", "La Sái Ebile", 0.24, "venues" },
or n:find("pilotage") or n:find("tug") or n:find("berth") or n:find("stevedoring") or n:find("intermodal")
    { "Performances", "La Sái Ebile", 0.11, "acts" },
or n:find("escort") or n:find("security") or n:find("risk") or n:find("insurance") or n:find("arbitration")
    { "Acrobat Troupe Booking", "La Sái Ebile", 0.16, "troupes" },
or n:find("postal") or n:find("delivery") or n:find("courier")
    { "Musician Set (evening)", "La Sái Ebile", 0.14, "sets" },
or c:find("sanitation") or c:find("customs") or c:find("insurance") or c:find("terminal") or c:find("tug")
    { "Masked Parade Package", "La Sái Ebile", 0.20, "parades" },
then
    { "Ticket Blocks (printed)", "La Sái Ebile", 0.09, "tickets" },
return "essential"
    { "Stage Rental (per day)", "La Sái Ebile", 0.18, "days" },
end
    { "Lighting & Rigging Kit", "La Sái Ebile", 0.15, "kits" },
 
    { "Costume Hire (bundle)", "La Sái Ebile", 0.10, "bundles" },
if c:find("aprobelle") or n:find("loyalty") or n:find("behavior") or n:find("polygraph") or n:find("anomaly") then
    { "Show Permits & Routing (festival)", "La Sái Ebile", 0.13, "permits" },
return "governance"
    {"Passenger Rail Tickets", "Trans-Morovian Express", 1.2, "tickets"},
end
    {"Luxury Cabin Reservations", "Trans-Morovian Express", 4.5, "cabins"},
 
    {"Freight Car Leasing Contracts", "Trans-Morovian Express", .60, "agreements"},
if n:find("circus") or n:find("performance") or n:find("parade") or n:find("tickets") or n:find("tour")
    {"Overland Courier Deliveries", "Trans-Morovian Express", .23, "parcels"},
or n:find("cabin") or n:find("luxury") or n:find("advert") or n:find("sponsorship") or n:find("broadcast")
    {"Secure Cargo Vaults", "Trans-Morovian Express", .39, "units"},
or n:find("salvaged") or n:find("antique") or n:find("treasure") or n:find("jewelry") or n:find("artifact")
    {"Customs Bypass Licenses", "Trans-Morovian Express", .71, "permits"},
or c:find("divers") or c:find("candy") or c:find("broadcasting")
    {"Railway Advertising Rights", "Trans-Morovian Express", .18, "slots"},
then
    {"Timetable Access Codes", "Trans-Morovian Express", .5, "keys"},
return "discretionary"
    {"Locomotive Repair Services", "Trans-Morovian Express", .57, "contracts"},
end
    {"Historical Route Tours", "Trans-Morovian Express", .20, "tours"},
 
    {"Private Charter Lines", "Trans-Morovian Express", .84, "routes"},
if c:find("hatch ministry") or c:find("blockade fund") then
    {"Coal Tender Refills", "Trans-Morovian Express", .33, "loads"},
return "volatile"
    {"Hellhound (scout)", "Hellhound Breeders of Dragevik", 0.03, "hounds"},
end
    {"Hellhound (soldier)", "Hellhound Breeders of Dragevik", 0.05, "hounds"},
 
    {"Hellhound (spy)", "Hellhound Breeders of Dragevik", 0.15, "hounds"},
return "standard"
    {"Priestesses of the Pharmacon Sect (Anti-Parasitic Medicine)", "Pharmacon Sect", 0.07, "doses"},
end
    {"Priestesses of the Pharmacon Sect (Antifungal/Respiratory Medicine)", "Pharmacon Sect", 0.08, "doses"},
 
    {"Priestesses of the Pharmacon Sect (Trauma/Battle Medicine)", "Pharmacon Sect", 0.09, "doses"},
-- Cache tables (filled per generateTable call)
    {"Priestesses of the Pharmacon Sect (Memory and Mind Treatment)", "Pharmacon Sect", 0.06, "doses"},
local cache = {
    {"Priestesses of the Pharmacon Sect (Immune Boost Medicine)", "Pharmacon Sect", 0.05, "doses"},
forecast = {},   -- city -> {mult,note,opsLevel}
    {"Priestesses of the Pharmacon Sect (Respiratory Clearing Medicine)", "Pharmacon Sect", 0.05, "doses"},
war = {},       -- city -> {mult,note} (depends on opsLevel + war count)
    {"Priestesses of the Pharmacon Sect (Fertility and Childbirth Medicine)", "Pharmacon Sect", 0.04, "doses"},
hatchCount = {}, -- city -> integer
    {"Priestesses of the Pharmacon Sect (Ritual Purification Incense)", "Pharmacon Sect", 0.03, "sticks"},
health = {},     -- city -> {mult,severity,note}
    {"Priestesses of the Pharmacon Sect (Medicinal Alcohol)", "Pharmacon Sect", 0.02, "bottles"},
    {"Priestesses of the Pharmacon Sect (Detoxification Medicine)", "Pharmacon Sect", 0.05, "doses"},
    { "Security Contractors", "Skyrophian Security Contractors*", 0.03, "Assignments" },
    { "Armed Escort Detail (merchant convoy)", "Skyrophian Security Contractors*", 0.18, "details" },
    { "Gate Guard Shift (8-hour)", "Skyrophian Security Contractors*", 0.10, "shifts" },
    { "Warehouse Patrol Contract (daily)", "Skyrophian Security Contractors*", 0.12, "contracts" },
    { "VIP Protection (event)", "Skyrophian Security Contractors*", 0.22, "events" },
    { "Risk Assessment Report (site)", "Skyrophian Security Contractors*", 0.15, "reports" },
    { "Incident Response Dispatch (rapid)", "Skyrophian Security Contractors*", 0.20, "responses" },
    { "Maritime Watch Rotation (dockside)", "Skyrophian Security Contractors*", 0.14, "rotations" },
    { "Security Training Session (staff)", "Skyrophian Security Contractors*", 0.08, "sessions" },
    { "Access Badge Screening (batch)", "Skyrophian Security Contractors*", 0.06, "batches" },
    { "Postal Workers", "Haifadora Postal Service*", 0.10, "Assignments" },
    { "Standard Letter Delivery (bundle)", "Haifadora Postal Service*", 0.06, "bundles" },
    { "Parcel Shipment (domestic)", "Haifadora Postal Service*", 0.12, "parcels" },
    { "Express Courier Dispatch (same-day)", "Haifadora Postal Service*", 0.18, "dispatches" },
    { "Registered Mail Filing", "Haifadora Postal Service*", 0.09, "filings" },
    { "P.O. Box Rental (monthly)", "Haifadora Postal Service*", 0.22, "months" },
    { "Stamp Sheets (standard)", "Haifadora Postal Service*", 0.08, "sheets" },
    { "Sorting & Bagging Service (bulk)", "Haifadora Postal Service*", 0.11, "services" },
    { "Cross-Straits Mail Sack (sealed)", "Haifadora Postal Service*", 0.20, "sacks" },
    { "Customs Mail Forms (certified)", "Haifadora Postal Service*", 0.05, "forms" },
    {"Recovered Treasure Chests", "Fleet of Rouge Divers", 6, "units"},
    {"Antique Pottery", "Fleet of Rouge Divers", 40, "pieces"},
    {"Salvaged Artifacts", "Fleet of Rouge Divers", 55, "items"},
    {"Driftwood Timber", "Fleet of Rouge Divers", 3.2, "tons"},
    {"Recovered Jewelry", "Fleet of Rouge Divers", 12, "sets"},
    {"Glassware (Recovered)", "Fleet of Rouge Divers", 90, "pieces"},
    {"Metal Ingots (Mixed)", "Fleet of Rouge Divers", 70, "kg"},
    {"Butterscotch Merchants", "Merchant Caravans of the Candy Kingdom", .36, "Caravans"},
    {"Chocolate Merchants", "Merchant Caravans of the Candy Kingdom", 1.13, "Caravans"},
    {"Candycane Merchants", "Merchant Caravans of the Candy Kingdom", 1.21, "Caravans"},
    {"Merchants of Unicorn Ivory", "Merchant Caravans of the Candy Kingdom", .033, "Caravans"},
    {"Wild Ice Cream Mochi", "Merchant Caravans of the Candy Kingdom", .73, "Caravans"},
    {"Farm-Raised Ice Cream Mochi", "Merchant Caravans of the Candy Kingdom", 1.35, "Caravans"},
    {"Public News Bulletins (Domestic)", "Bassaridian Broadcasting Corporation", 3.0, "broadcasts"},
    {"Cultural Programming Syndication", "Bassaridian Broadcasting Corporation", 2.5, "packages"},
    {"Religious Festival Audio Coverage", "Bassaridian Broadcasting Corporation", 1.7, "segments"},
    {"Foreign Language Radio Translation", "Bassaridian Broadcasting Corporation", 2.2, "programs"},
    {"Emergency Broadcast Override Services", "Bassaridian Broadcasting Corporation", 4.1, "networks"},
    {"Commercial Ad Placement (Tier II)", "Bassaridian Broadcasting Corporation", 1.8, "slots"},
    {"Live Event Broadcasting (Vaeringheim)", "Bassaridian Broadcasting Corporation", 6.5, "sessions"},
    {"Archival Audio Access (Public Use)", "Bassaridian Broadcasting Corporation", 0.9, "reels"},
    {"Broadcast Tower Leasing (Rural)", "Bassaridian Broadcasting Corporation", 5.0, "units"},
    {"Satellite Feed Subscription (Standard)", "Bassaridian Broadcasting Corporation", 3.6, "feeds"},
    {"Independent Musician Airplay Review", "Bassaridian Broadcasting Corporation", 1.1, "reviews"},
    {"Syndicated Talk Show Distribution", "Bassaridian Broadcasting Corporation", 2.4, "contracts"},
    {"Priestesses of the Ledger (Employee Loyalty Audits)", "Temple of Aprobelle", 2.7, "reports"},
    {"Watchful Priestesses (Real-Time Behavior Monitoring)", "Temple of Aprobelle", 9.5, "profiles"},
    {"Priestesses of Discourse (Disloyalty Flagging Alerts)", "Temple of Aprobelle", 8.1, "alerts"},
    {"Priestesses of the Veil (Insider Threat Screening)", "Temple of Aprobelle", 2.1, "cycles"},
    {"Priestesses of the Pulse (Routine Polygraph Sessions)", "Temple of Aprobelle", 2.3, "sessions"},
    {"Priestesses of the Mirror (Population Mood Tracking)", "Temple of Aprobelle", 6.8, "surveys"},
    {"Silencing Priestesses (Rumor Suppression Bulletins)", "Temple of Aprobelle", 3.5, "issues"},
    {"Resolute Priestesses (Anomaly Response Team Dispatches)", "Temple of Aprobelle", 0.14, "responses"},
    {"Priestesses of Encouragement (Whistleblower Reward Packages)", "Temple of Aprobelle", 0.55, "bonuses"},
    {"Camel Train Transport (Local Cargo)", "Keltian Steppe Caravan Company", 1.15, "tons"},
    {"Alperkin Couriers (Highland Legs)", "Keltian Steppe Caravan Company", 0.18, "dispatches"},
    {"Custom Caravan Escorts (Armed)", "Keltian Steppe Caravan Company", 0.25, "contracts"},
    {"Steppe Route Navigation Charts", "Keltian Steppe Caravan Company", 1.1, "scrolls"},
    {"Water Cache Delivery Refills", "Keltian Steppe Caravan Company", 0.46, "loads"},
    {"Salt Brick Exports (Packaged for Caravan)", "Keltian Steppe Caravan Company", 0.75, "blocks"},
    {"Dry Ridge Campground Leasing (Seasonal)", "Keltian Steppe Caravan Company", 3.5, "stations"},
    {"Pilgrim Caravans (Sacred Route Access)", "Keltian Steppe Caravan Company", 0.35, "convoys"},
    {"Seal of Safe Crossing (Alperkin Stamp)", "Keltian Steppe Caravan Company", 0.05, "marks"},
    {"Cross-Winds Risk Insurance (Tonar-Bashkim)", "Keltian Steppe Caravan Company", 0.29, "policies"},
    {"Iron Ingots (Refined)", "Sårensby Depot", 1.45, "tons"},
    {"Copper Scrap Bundles", "Sårensby Depot", 0.88, "crates"},
    {"Rare-Earth Ore Fragments", "Sårensby Depot", 2.3, "satchels"},
    {"Frozen Reindeer Cuts", "Sårensby Depot", 0.67, "packages"},
    {"Cured Reindeer Leather", "Sårensby Depot", 1.15, "hides"},
    {"Seal Fur Pelts", "Sårensby Depot", 2.8, "rolls"},
    {"Reindeer Antler Tools", "Sårensby Depot", 0.42, "sets"},
    {"Wool Blankets (Nordic Pattern)", "Sårensby Depot", 0.39, "bundles"},
    {"Smoked Elk Sausages", "Sårensby Depot", 0.51, "links"},
    {"Pickled Arctic Herring", "Sårensby Depot", 0.6, "jars"},
    {"Salted Cod (Barrel Aged)", "Sårensby Depot", 0.83, "barrels"},
    {"Smoked Goose Breast", "Sårensby Depot", 0.49, "cuts"},
    {"Seal Oil Lantern-Grade", "Sårensby Depot", 0.73, "flasks"},
    {"Whale Bone (Industrial Grade)", "Sårensby Depot", 1.95, "pieces"},
    {"Blubber Oil (Purified)", "Sårensby Depot", 1.2, "casks"},
    {"Wild Berry Preserves", "Sårensby Depot", 0.34, "jars"},
    {"Lingonberry Jam (Sweetened)", "Sårensby Depot", 0.27, "jars"},
    {"Conifer Lumber", "Sårensby Depot", 1.1, "planks"},
    {"Processed Granite Slabs", "Sårensby Depot", 1.7, "tiles"},
    {"Reindeer Jerky Trail Packs", "Sårensby Depot", 0.23, "packs"},
    {"Caledonian Cured Ham", "Sårensby Depot", 6.9, "hams"},
    {"Sturgeon Caviar", "Sårensby Depot", 9.8, "tins"},
    {"Frozen Whitefish Fillets", "Sårensby Depot", 0.64, "trays"},
    {"Processed Wild Duck Feathers", "Sårensby Depot", 0.36, "bundles"},
    {"Textiles (Woolen Shawls)", "Sårensby Depot", 0.47, "folds"},
    {"Medicinal Root Extracts", "Sårensby Depot", 0.59, "bottles"},
    {"Recovered Machine Parts (Green Sector)", "Sårensby Depot", 2.1, "crates"},
    {"Smuggled Caviar (Black Market)", "Sårensby Depot", 7.3, "tins"},
    {"Specialty Fur Cloaks", "Sårensby Depot", 1.4, "garments"},
    {"Rye Flour (Stone Milled)", "Sårensby Depot", 0.33, "sacks"},
    {"Frozen Boar Cuts", "Sårensby Depot", 0.72, "cuts"},
    {"Wheat Grain (Northern Harvest)", "Sårensby Depot", 0.25, "bushels"},
    {"Salt Blocks (Lake Caledonia)", "Sårensby Depot", 0.4, "bricks"},
    {"Hand-Forged Iron Tools", "Sårensby Depot", 0.58, "tools"},
    {"Frozen Pike Roe (Bulk)", "Sårensby Depot", 2.7, "jars"},
    {"Pilotage Scheduling", "Haifa Compliance Exchange", 0.42, "transits"},
    {"Straits Passage Documentation (Certified)", "Haifa Compliance Exchange", 0.33, "folios"},
    {"Tonnage Declaration Bonds", "Haifa Compliance Exchange", 1.1, "contracts"},
    {"Cargo Seal Verification (War League Escort)", "Haifa Compliance Exchange", 0.55, "checks"},
    {"Sacred Buffer Compliance Certificates", "Haifa Compliance Exchange", 0.46, "stamps"},
    {"Environmental Buffer Routing Plans", "Haifa Compliance Exchange", 0.62, "charts"},
    {"Emergency Leviathan Protocol Insurance", "Haifa Compliance Exchange", 1.7, "policies"},
    {"Search-and-Rescue Priority Registration", "Haifa Compliance Exchange", 0.83, "slots"},
    {"Anchorage Review Mediation (Expedited)", "Haifa Compliance Exchange", 0.95, "hearings"},
    {"Daily Enforcement Report Subscription", "Haifa Compliance Exchange", 0.22, "issues"},
    {"Freight Handling (per ton)", "Port of Corumia", 1.80, "tons"},
    {"Harbor Pilotage (per transit)", "Port of Corumia", 5.30, "transits"},
    {"Drydock Berth (per day)", "Port of Corumia", 16.60, "days"},
    {"Tug Assist (harbor move)", "Port of Corumia", 3.20, "moves"},
    {"Customs Brokerage (filing)", "Port of Corumia", 1.850, "filings"},
    {"Bonded Warehousing", "Port of Corumia", 0.52, "m³·day"},
    {"Cold Storage", "Port of Corumia", 0.68, "m³·day"},
    {"Security Escort (quay-to-gate)", "Port of Corumia", 1.85, "escorts"},
    {"Quarantine Inspection (vessel)", "Port of Corumia", 2.70, "vessels"},
    {"Medical Clearance (per crew)", "Port of Corumia", 0.66, "crew"},
    {"Vessel Berth Window (per hour reserved)", "Pentiad Terminal Operations", .340, "hours"},
    {"Quay Crane Move (per container)", "Pentiad Terminal Operations", .150, "moves"},
    {"Breakbulk Stevedoring (per ton)", "Pentiad Terminal Operations Agency", .110, "tons"},
    {"Gate Processing Fee (per truck visit)", "Pentiad Terminal Operations", .055, "visits"},
    {"Dry Yard Storage (per TEU·day)", "Pentiad Terminal Operations", .040, "TEU·day"},
    {"Demurrage (per TEU·day beyond free time)", "Pentiad Terminal Operations", 0.80, "TEU·day"},
    {"Reefer Plug-In (per container·day)", "Pentiad Terminal Operations", .090, "container·day"},
    {"Reefer Temperature Monitoring (per container·day)", "Pentiad Terminal Operations", .060, "container·day"},
    {"Rail Intermodal Transfer (per TEU)", "Pentiad Terminal Operations", .145, "TEU"},
    {"Barge Transfer (per TEU)", "Pentiad Terminal Operations", .115, "TEU"},
    {"Documentation & EDI Processing (per bill)", "Pentiad Terminal Operations", .039, "bills"},
    {"ISPS Security Charge (per vessel call)", "Pentiad Terminal Operations", .170, "calls"},
    {"Harbor Pilot Boarding (Standard Transit)", "Azat Tug and Pilot Cooperative", .480, "transits"},
    {"Harbor Pilotage Extension (Night/Navigational Risk)", "Azat Tug and Pilot Cooperative", .330, "transits"},
    {"Escort Tug (Inbound Transit)", "Azat Tug and Pilot Cooperative", .240, "moves"},
    {"Escort Tug (Outbound Transit)", "Azat Tug and Pilot Cooperative", .240, "moves"},
    {"Azat Line Boat Support (Mooring/Unmooring)", "Azat Tug and Pilot Cooperative", .170, "deployments"},
    {"Tug Assist (Turning Basin Maneuver)", "Azat Tug and Pilot Cooperative", .200, "maneuvers"},
    {"Emergency Tow Response (Flat Rate)", "Azat Tug and Pilot Cooperative", .680, "responses"},
    {"Pilot Dispatch Cancellation Fee", "Azat Tug and Pilot Cooperative", .120, "cancellations"},
    {"Pilotage Slot Reservation (Peak Hours)", "Azat Tug and Pilot Cooperative", .260, "reservations"},
    {"Multi-Vessel Convoy Guidance (Straits)", "Azat Tug and Pilot Cooperative", .510, "convoys"},
    {"War-Risk Tug Escort (Haifa Exclusion Zone)", "Azat Tug and Pilot Cooperative", .700, "escort moves"},
    {"Docking Delay Standby Fee (per 30 minutes)", "Azat Tug and Pilot Cooperative", .150, "intervals"},
    {"Hull Inspection and Ultrasonic Survey", "Calliope Shipyard Repair Agency", .360, "calls"},
    {"Routine Hull Patching (Standard Plate)", "Calliope Shipyard Repair Agency", .580, "repairs"},
    {"Dry Dock Berth (per day)", "Calliope Shipyard Repair Agency", .1600, "days"},
    {"Propulsion Shaft Alignment (Standard Class)", "Calliope Shipyard Repair Agency", .720, "alignments"},
    {"Ballast Tank Cleaning and Recoating", "Calliope Shipyard Repair Agency", .850, "jobs"},
    {"Emergency Weld Team Dispatch", "Calliope Shipyard Repair Agency", .490, "calls"},
    {"Underwater ROV Survey (Sub-10m Draft)", "Calliope Shipyard Repair Agency", .660, "surveys"},
    {"Deck Equipment Overhaul (Winches & Davits)", "Calliope Shipyard Repair Agency", .440, "units"},
    {"Cold-Ironing System Repair", "Calliope Shipyard Repair Agency", .670, "systems"},
    {"Lifeboat System Inspection & Recertification", "Calliope Shipyard Repair Agency", .290, "systems"},
    {"Steel Renewal (per metric ton)", "Calliope Shipyard Repair Agency", .120, "tons"},
    {"Hybrid Propulsion Diagnostics (Multi-Fuel Systems)", "Calliope Shipyard Repair Agency", .530, "sessions"},
    {"Hull and Machinery Insurance (Standard Transit)", "Bulhanu Maritime Insurance Association", .280, "policies"},
    {"War Risk Hull Extension (Haifa Corridor Zone)", "Bulhanu Maritime Insurance Association", .340, "endorsements"},
    {"Protection & Indemnity (Straits Compliant)", "Bulhanu Maritime Insurance Association", .310, "contracts"},
    {"Freight, Demurrage & Defense (FDD) Coverage", "Bulhanu Maritime Insurance Association", .260, "policies"},
    {"General Average Guarantee Bond", "Bulhanu Maritime Insurance Association", .20, "bonds"},
    {"Environmental Buffer Spill Liability Coverage", "Bulhanu Maritime Insurance Association", .290, "policies"},
    {"Terrorism & Sabotage Coverage (Portside)", "Bulhanu Maritime Insurance Association", 3.60, "endorsements"},
    {"Cargo Delay Indemnity (Per Voyage Contract)", "Bulhanu Maritime Insurance Association", .170, "contracts"},
    {"Voyage Cancellation Refund Guarantee", "Bulhanu Maritime Insurance Association", .240, "guarantees"},
    {"Crew Repatriation & Illness Indemnity", "Bulhanu Maritime Insurance Association", .190, "policies"},
    {"Charter Party Breach Risk Mitigation (Vaeringheim Court)", "Bulhanu Maritime Insurance Association", .450, "agreements"},
    {"Marine Arbitration Retainer (Tier II Cases)", "Bulhanu Maritime Insurance Association", .210, "retainers"},
    {"Customs Clearance Filing (Standard Entry)", "Morovian Customs & Freight Syndicate", 1.10, "filings"},
    {"Tariff Classification Advisory (per commodity)", "Morovian Customs & Freight Syndicate", 0.60, "consultations"},
    {"Bonded Warehouse Transfer (Inward Entry)", "Morovian Customs & Freight Syndicate", 0.80, "transfers"},
    {"Freight Forwarding (Port-to-Gate, Domestic)", "Morovian Customs & Freight Syndicate", 1.50, "contracts"},
    {"Freight Forwarding (Cross-Straits, Interport)", "Morovian Customs & Freight Syndicate", 2.90, "contracts"},
    {"Cargo Consolidation (LCL Containerized)", "Morovian Customs & Freight Syndicate", 1.40, "shipments"},
    {"Cargo Deconsolidation & Palletization", "Morovian Customs & Freight Syndicate", 1.10, "shipments"},
    {"Duty Suspension Arrangement (Transit Cargo)", "Morovian Customs & Freight Syndicate", 0.95, "agreements"},
    {"Customs Escort Assignment (Risk Tier II+)", "Morovian Customs & Freight Syndicate", 1.85, "assignments"},
    {"Import License Facilitation (Non-Standard Goods)", "Morovian Customs & Freight Syndicate", 2.10, "licenses"},
    {"Voucher Redemption Processing (Cultic Commodities)", "Morovian Customs & Freight Syndicate", 1.75, "cases"},
    {"Sanctioned Cargo Screening Compliance Report", "Morovian Customs & Freight Syndicate", 2.60, "reports"},
    {"Pod Training & Conditioning (per dolphin·month)", "Nefelean Seas Armory", 2.40, "months"},
    {"Operational Escort Deployment (per convoy)", "Nefelean Seas Armory", 5.80, "convoys"},
    {"Mine & Diver Interdiction Patrol (per sortie)", "Nefelean Seas Armory", 3.20, "sorties"},
    {"Reconnaissance Sweep (echolocation grid, per km²)", "Nefelean Seas Armory", 1.75, "km²"},
    {"Sonic Disruption Support (counter-sonar, per hour)", "Nefelean Seas Armory", 0.95, "hours"},
    {"Emergency Response Pod Dispatch (flat rate)", "Nefelean Seas Armory", 4.60, "responses"},
    {"Harbor Defense Stationing (per pod·week)", "Nefelean Seas Armory", 6.20, "weeks"},
    {"Convoy Readiness Drills (joint exercise, per session)", "Nefelean Seas Armory", 2.10, "sessions"},
    {"Target Mark & Tag (submersible tracking, per operation)", "Nefelean Seas Armory", 1.85, "operations"},
    {"Specialized Payload Delivery (per assignment)", "Nefelean Seas Armory", 3.90, "assignments"},
    {"Bilge & Canal Pump-Out and Disposal (per cubic meter)", "Morovian Sanitation and Bilge Company", .085, "m³"},
    {"Ratproofing Certification (per facility·month)", "Morovian Sanitation and Bilge Company", .120, "months"},
    {"Canal & Irrigation Flush Coordination (per sluice·operation)", "Morovian Sanitation and Bilge Company", .340, "operations"},
    {"Facility Decontamination & Terminal Sanitation (per site call)", "Morovian Sanitation and Bilge Company", .210, "calls"},
    {"Vector Abatement Spray (per hectare)", "Morovian Sanitation and Bilge Company", .060, "ha"},
    {"Mosquito Larvicide Application (per water basin)", "Morovian Sanitation and Bilge Company", .055, "basins"},
    {"Snail Habitat Elimination (per irrigation channel·km)", "Morovian Sanitation and Bilge Company", .075, "km"},
    {"Medical Waste Incineration (per kg)", "Morovian Sanitation and Bilge Company", .065, "kg"},
    {"Emergency Spill & Contamination Response (flat rate)", "Morovian Sanitation and Bilge Company", .720, "responses"},
    {"Wastewater Pathogen Monitoring (per assay)", "Morovian Sanitation and Bilge Company", .125, "assays"},
    {"Cistern Shock Chlorination (per cistern)", "Morovian Sanitation and Bilge Company", .050, "cisterns"},
    {"Potable-Water Chlorination Dosing & Certification (per 100 m³)", "Morovian Sanitation and Bilge Company", .040, "100 m³"},
    {"Food-Handler Hygiene Audits & Licensing (per kitchen·stall)", "Morovian Sanitation and Bilge Company", .065, "inspections"},
    {"Rodent Trap Deployment & Monitoring (per trap·month)", "Morovian Sanitation and Bilge Company", .022, "months"},
    {"Hand-Wash Station Deployment & Service (per unit·day)", "Morovian Sanitation and Bilge Company", .030, "unit·day"},
    {"Highland Route Guidance (Merchant Convoy Escort)", "Ordo Ebríumä Oneira (Highland Guides)", 2.80, "routes"},
    {"Pilgrim Procession Escort (Seasonal)", "Ordo Ebríumä Oneira (Highland Guides)", 1.45, "processions"},
    {"Ridge Ropeway Licensing & Certification", "Ordo Ebríumä Oneira (Highland Guides)", 3.10, "licenses"},
    {"Avalanche Risk Survey & Signage", "Ordo Ebríumä Oneira (Highland Guides)", 2.60, "surveys"},
    {"Highland Rescue Team Dispatch (Emergency)", "Ordo Ebríumä Oneira (Highland Guides)", 4.90, "responses"},
    {"Pack Stock Portage (Caravan Animals Included)", "Ordo Ebríumä Oneira (Highland Guides)", 3.25, "contracts"},
    {"Cairn and Trail Maintenance (Vow Renewal Service)", "Ordo Ebríumä Oneira (Highland Guides)", 1.75, "sites"},
    {"Tar-Lamp Night Vigil Coverage", "Ordo Ebríumä Oneira (Highland Guides)", 0.85, "nights"},
    {"Stone-Reader Boundary Arbitration", "Ordo Ebríumä Oneira (Highland Guides)", 1.10, "hearings"},
    {"Weather Observation & Route Planning Consultancy", "Ordo Ebríumä Oneira (Highland Guides)", 0.95, "reports"},
    {"Shrine-Stair Reconstruction Crew Hire", "Ordo Ebríumä Oneira (Highland Guides)", 2.40, "projects"},
    {"Eclipse Silent-Watch Contract (Seasonal Oath)", "Ordo Ebríumä Oneira (Highland Guides)", 1.30, "events"},
    {"Haifan Mainline Sky-Berth (Lykopolis–Lewisburg)", "Lothayan Skybridge Airways", .95, "berths"},
    {"Haifan Branchline Pilgrim Berth (Tel-Amin–Diamandis–Thermosalem)", "Lothayan Skybridge Airways", .770, "berths"},
    {"Haifan Littoral Cargo Lift (Lykopolis–Sufriya–Mylecia)", "Lothayan Skybridge Airways", .410, "tons"},
    {"Keybir-Aviv–Tel-Amin–Jogi–Abeis–Blore Heath Coastal Mail Loop", "Lothayan Skybridge Airways", .230, "contracts"},
    {"Somniumpolis–Odiferian Highlands Tribal Freight", "Lothayan Skybridge Airways", .450, "tons"},
    {"Somniumpolis–Aureum–Erythros Long-Range Sky-Berth", "Lothayan Skybridge Airways", .135, "berths"},
    {"Erythros–Pyralis Alpine Connector Passenger & Freight Line", "Lothayan Skybridge Airways", .110, "berths"},
    {"Erythros–Nexa–Delphica–Saluria Shrine Pilgrimage Berth", "Lothayan Skybridge Airways", .105, "berths"},
    {"Acheron–Koinonía–Sylvapolis Canyonlands Night Tour", "Lothayan Skybridge Airways", .120, "tours"},
    {"Vaeringheim–Luminaria–Jezeraah City Dawnliner Passenger Berth", "Lothayan Skybridge Airways", .110, "berths"},
    {"Symphonara–Delphica–Aurelia–Serena Cultural Circuit Cabin", "Lothayan Skybridge Airways", .180, "cabins"},
    {"Lunalis Sancta–Catonis Atrium Ecclesiastical Charter", "Lothayan Skybridge Airways", .590, "charters"},
    {"Vaeringheim–Ferrum Citadel Industrial Sky-Freight", "Lothayan Skybridge Airways", .475, "tons"},
    {"Skýrophos–Pelagia Fjordline Passenger Berth", "Lothayan Skybridge Airways", .105, "berths"},
    {"Norsolyra–Thorsalon–Rein Harvest Vintage Passenger Line", "Lothayan Skybridge Airways",.115, "berths"},
    {"Bjornopolis–Aegirheim–Ephyra–Myrene Highlands Passenger & Mail Service", "Lothayan Skybridge Airways", .120, "berths"},
    {"Riddersborg–Ardclach–Redcliff Citadel Normark Passenger Aerial Ferry", "Lothayan Skybridge Airways", .105, "berths"},
    {"Ourid–Caspazani–Aetherium Agricultural Worker Passenger Line", "Lothayan Skybridge Airways", .125, "berths"},
    {"Sårensby–Elijah’s Rest Trans-Keltian Diplomatic Charter", "Lothayan Skybridge Airways", .690, "charters"},
    {"Slevik–Suncliff–Somniumpolis Coastal Connector Sky-Berth", "Lothayan Skybridge Airways", .125, "berths"},
}
}
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 (unchanged)
-- Special Date Events
---------------------------------------------------------
---------------------------------------------------------
local events = {
local events = {
    ["1,6"]  = "Bayram al-Nur (Festival of Light) [Vaeringheim]",
["1,6"]  = "Bayram al-Nur (Festival of Light) [Vaeringheim]",
    ["1,18"] = "Chag Or Hadash (Festival of New Light) [Luminaria]",
["1,18"] = "Chag Or Hadash (Festival of New Light) [Luminaria]",
    ["1,30"] = "Symposion Eirinis (Symposium of Harmony) [Serena]",
["1,30"] = "Symposion Eirinis (Symposium of Harmony) [Serena]",
    ["1,43"] = "Alev Günü (Day of Flame) [Pyralis]",
["1,43"] = "Alev Günü (Day of Flame) [Pyralis]",
    ["1,48"] = "Day of Abandonment [Vaeringheim]",
["1,48"] = "Day of Abandonment [Vaeringheim]",
    ["1,55"] = "Tikkun Tzel (Repair of Shadows) [Symphonara]",
["1,55"] = "Tikkun Tzel (Repair of Shadows) [Symphonara]",
    ["2,6"]  = "Panegyris Chrysou (Golden Gathering) [Aurelia]",
["2,6"]  = "Panegyris Chrysou (Golden Gathering) [Aurelia]",
    ["2,17"] = "Constitution Day [Luminaria]",
["2,17"] = "Constitution Day [Luminaria]",
    ["2,19"] = "Mehtap Dalgası (Moonlit Tide) [Vaeringheim]",
["2,19"] = "Mehtap Dalgası (Moonlit Tide) [Vaeringheim]",
    ["2,31"] = "Oneiro Foteino (Dream of Illumination) [Somniumpolis]",
["2,31"] = "Oneiro Foteino (Dream of Illumination) [Somniumpolis]",
    ["2,39"] = "Anniversary of victory in the New South Jangsong Campaign [Nexa]",
["2,39"] = "Anniversary of victory in the New South Jangsong Campaign [Nexa]",
    ["2,44"] = "Erev Galgal (Eve of Cycles) [Nexa]",
["2,44"] = "Erev Galgal (Eve of Cycles) [Nexa]",
    ["2,45"] = "Bassaridia Festival [Somniumpolis]",
["2,45"] = "Bassaridia Festival [Somniumpolis]",
    ["2,48"] = "Anniversary of victory in the Southern Lake Morovia Campaign [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,54"] = "Leilat al-Kamar (Night of the Moon) [Lunalis Sancta]",
    ["2,61"] = "Taşrakah (Reverence of the Stone) [Luminaria]",
["2,61"] = "Taşrakah (Reverence of the Stone) [Luminaria]",
    ["3,6"]  = "Chag Tvuah (Festival of Harvest) [Sylvapolis]",
["3,6"]  = "Chag Tvuah (Festival of Harvest) [Sylvapolis]",
    ["3,18"] = "Anagenesis Eirmos (Procession of Rebirth) [Acheron]",
["3,18"] = "Anagenesis Eirmos (Procession of Rebirth) [Acheron]",
    ["3,28"] = "Panagia Therizis (Holy Day of the Reaper) [Sylvapolis]",
["3,28"] = "Panagia Therizis (Holy Day of the Reaper) [Sylvapolis]",
    ["3,37"] = "Anniversary of victory in the Morovian Frontier Campaign [Acheron]",
["3,37"] = "Anniversary of victory in the Morovian Frontier Campaign [Acheron]",
    ["3,43"] = "Karnavali Thysias (Carnival of Celebration) [Erythros]",
["3,43"] = "Karnavali Thysias (Carnival of Celebration) [Erythros]",
    ["3,53"] = "Sefar Yashar (Straight Path Celebration) [Catonis Atrium]",
["3,53"] = "Sefar Yashar (Straight Path Celebration) [Catonis Atrium]",
}
}


---------------------------------------------------------
---------------------------------------------------------
-- simulatePriceChange
-- simulatePriceChange (same logic, but uses cached city signals)
-- Applies a daily random factor; special date events
-- add a small positive boost to the final price.
---------------------------------------------------------
---------------------------------------------------------
local function simulatePriceChange(basePrice, dayOfMonth, month)
local function simulatePriceChange(basePrice, dayOfMonth, month, psscYear, dayOfYear, city, productName, companyName)
    local dateKey = string.format("%d,%d", month, dayOfMonth)
basePrice = tonumber(basePrice)
    local changeFactor = math.random(-20, 20) / 100
if not basePrice then
    local explanation
error("BAD PRICE for: " .. tostring(productName) .. " / " .. tostring(companyName))
    if events[dateKey] then
end
        explanation = events[dateKey] .. ": Prices influenced by event dynamics."
 
        changeFactor = changeFactor + 0.05 -- small event-based boost
local dateKey = string.format("%d,%d", month, dayOfMonth)
    else
 
        explanation = "General market fluctuations."
local changeFactor = math.random(-20, 20) / 100
    end
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 newPrice = math.floor((basePrice * (1 + changeFactor)) * 100) / 100
local tb = getTempleBankSignal(pClass, ph.severity or "NONE", wx.opsLevel or 0)
    return newPrice, changeFactor, explanation
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
end


---------------------------------------------------------
---------------------------------------------------------
-- determinePriceLevel
-- determinePriceLevel
-- Swapped so that:
--  adjustedPrice > basePrice ⇒ "Low"
--  adjustedPrice < basePrice ⇒ "High"
--  otherwise ⇒ "Standard"
---------------------------------------------------------
---------------------------------------------------------
local function determinePriceLevel(basePrice, adjustedPrice)
local function determinePriceLevel(basePrice, adjustedPrice)
    if adjustedPrice > basePrice then
if adjustedPrice > basePrice then
        return "Low"
return "Low"
    elseif adjustedPrice < basePrice then
elseif adjustedPrice < basePrice then
        return "High"
return "High"
    else
else
        return "Standard"
return "Standard"
    end
end
end
end


---------------------------------------------------------
---------------------------------------------------------
-- Args helpers (modes)
-- Args helpers
---------------------------------------------------------
---------------------------------------------------------
local function getArgs(frame)
local function getArgs(frame)
    if type(frame) ~= "table" then
if type(frame) ~= "table" then return {} end
        return {}
local args = {}
    end
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 args = {}
local function isNo(v)
    local fargs = frame.args or {}
v = tostring(v or ""):lower()
return (v == "no" or v == "n" or v == "false" or v == "0")
end


    local pargs = {}
local function levelSymbol(level)
    if frame.getParent then
if level == "Low" then
        local parent = frame:getParent()
return "↑"
        if parent and parent.args then
elseif level == "High" then
            pargs = parent.args
return "↓"
        end
else
    end
return "•"
end
end


    for k, v in pairs(pargs) do
---------------------------------------------------------
        if v ~= nil and v ~= "" then args[k] = v end
-- 5.5 Consumer price helpers
    end
--    Converts the services ledger into ordinary household,
    for k, v in pairs(fargs) do
--    small-merchant, and civic-counter prices. The ledger
        if v ~= nil and v ~= "" then args[k] = v end
--    values are units redeemable per poli, so consumer
    end
--    prices move inversely: higher adjusted quantity per
--    poli = lower retail price.
---------------------------------------------------------
local productLookup = nil


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


local function isNo(v)
local function getProductByNameAndCompany(name, company)
    v = tostring(v or ""):lower()
if productLookup == nil then
    return (v == "no" or v == "n" or v == "false" or v == "0")
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
end


local function levelSymbol(level)
local consumerCatalog = {
    if level == "Low" then
-- Maskmaking, ritual identity, and personal craft services
        return "↑"
consumerRow("Faces", "Maritime Guild of the Cult of Maskmakers", "Basic mask purchase", "1 mask", 80, "Ordinary face-mask purchase."),
    elseif level == "High" then
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."),
        return "↓"
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."),
    else
consumerRow("Ceremonial Masks (lacquered)", "Maritime Guild of the Cult of Maskmakers", "Ritual and formal wear", "1 mask", 180, "Higher-value ritual mask."),
        return "•"
consumerRow("Dockworker Faceplates (saltproof)", "Maritime Guild of the Cult of Maskmakers", "Work protection", "1 faceplate", 90, "Durable saltproof work mask."),
    end
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
end


---------------------------------------------------------
---------------------------------------------------------
-- Main function: generateTable (summary + breakdown)
-- generateTable (OPTIMIZED output building)
---------------------------------------------------------
---------------------------------------------------------
function p.generateTable(frame)
function p.generateTable(frame)
    local args = getArgs(frame)
local args = getArgs(frame)
    local mode = tostring(args.mode or "both"):lower() -- both | summary | breakdown
local fast = tostring(args.fast or ""):lower()
    local collapse = not isNo(args.collapse)           -- default true
local useSignals = not (fast == "yes" or fast == "true" or fast == "1")


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


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


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


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


    local summary = ""
-- Determine which unique cities we actually need (avoid scanning patterns for unused cities)
    if showSummary then
local neededCities = {}
        summary =
for _, product in ipairs(products) do
            "{| class=\"wikitable sortable\"\n" ..
local _, company = product[1], product[2]
            "! Date (Day/Month/Year [[PSSC]]) !! Δ !! Product !! Producing Company !! Tier I Price !! Units !! Notes\n"
local city = inferHQCity(company)
    end
if city and city ~= "" then
neededCities[city] = true
end
end


    local breakdown = ""
-- Warm up caches cheaply for each city (one outbreak call per city; one pattern scan per city)
    if showBreakdown then
if useSignals then
        local collapseClass = collapse and " mw-collapsible mw-collapsed" or ""
for city, _ in pairs(neededCities) do
        breakdown =
getForecastSignal(city, psscYear, dayOfYear)    -- cached
            "{| class=\"wikitable sortable" .. collapseClass .. "\"\n" ..
getHatchCityCount(city, psscYear, dayOfYear)    -- cached (builds report once)
            "|+ Full tier breakdown" .. (collapse and " (click to expand)" or "") .. "\n" ..
getWarCityCount(city, psscYear, dayOfYear)      -- cached (builds table once)
            "! Date (Day/Month/Year [[PSSC]]) !! Product !! Producing Company !! Base Price !! Price Level !! Tier III Price !! Tier II Price !! Tier I Price !! Units !! Explanation\n"
getHealthSignal(city)                           -- cached
    end
end
end


    local totalBase, totalNew, count = 0, 0, 0
local outParts = {}


    for _, product in ipairs(products) do
-- Summary header
        local name, company, basePrice, units = unpack(product)
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


        local newPrice, _, explanation = simulatePriceChange(basePrice, dayOfMonth, month)
-- Breakdown header
        local priceLevel = determinePriceLevel(basePrice, newPrice)
local breakdownParts = {}
        local sym = levelSymbol(priceLevel)
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 rowStyle
local totalBase, totalNew, count = 0, 0, 0
        if priceLevel == "Low" then
            rowStyle = ' style="background-color:lightblue;"'
        elseif priceLevel == "Standard" then
            rowStyle = ' style="background-color:lightgreen;"'
        else
            rowStyle = ' style="background-color:#ffcccb;"'  -- light red
        end


        local tierIIIPrice = math.floor((newPrice / 3) * 100) / 100
for i, product in ipairs(products) do
        local tierIIPrice  = math.floor((newPrice * 2 / 3) * 100) / 100
  local name = product and product[1] or nil
        local tierIPrice  = newPrice
  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


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


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


        if showBreakdown then
local city = inferHQCity(company)
            breakdown = breakdown .. string.format(
                "|-%s\n| %s || %s || %s || %.2f || %s || %.2f || %.2f || %.2f || %s || %s\n",
                rowStyle, dateString, name, company, basePrice, priceLevel,
                tierIIIPrice, tierIIPrice, tierIPrice, units, explanation
            )
        end
    end


    local avgBase = totalBase / count
local newPrice, _, explanation = simulatePriceChange(basePrice, dayOfMonth, month, psscYear, dayOfYear, city, name, company)
    local avgNew  = totalNew / count
local priceLevel = determinePriceLevel(basePrice, newPrice)
local sym = levelSymbol(priceLevel)


    local avgLevel
local rowStyle
    if avgNew > avgBase then
if priceLevel == "Low" then
        avgLevel = "Low"
rowStyle = ' style="background-color:lightblue;"'
    elseif avgNew < avgBase then
elseif priceLevel == "Standard" then
        avgLevel = "High"
rowStyle = ' style="background-color:lightgreen;"'
    else
else
        avgLevel = "Standard"
rowStyle = ' style="background-color:#ffcccb;"'
    end
end
    local avgSym = levelSymbol(avgLevel)


    if showSummary then
local tierIIIPrice = math.floor((newPrice / 3) * 100) / 100
        summary = summary .. string.format(
local tierIIPrice  = math.floor((newPrice * 2 / 3) * 100) / 100
            "|-\n! colspan=\"7\" | Average adjusted (%.2f) vs base (%.2f): %s %s\n|}\n",
local tierIPrice  = newPrice
            avgNew, avgBase, avgSym, avgLevel
        )
        out = out .. summary .. "\n"
    end


    if showBreakdown then
totalBase = totalBase + basePrice
        breakdown = breakdown .. string.format(
totalNew  = totalNew + newPrice
            "|-\n! colspan=\"10\" | Average adjusted (%.2f) vs base (%.2f): %s %s\n|}\n",
count = count + 1
            avgNew, avgBase, avgSym, avgLevel
        )
        out = out .. breakdown
    end


    return out
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
end


---------------------------------------------------------
-- Convenience: full expanded breakdown (for updates/audits)
---------------------------------------------------------
function p.full(frame)
function p.full(frame)
    frame = frame or {}
frame = frame or {}
    frame.args = frame.args or {}
frame.args = frame.args or {}
    frame.args.mode = "breakdown"
frame.args.mode = "breakdown"
    frame.args.collapse = "no"
frame.args.collapse = "no"
    return p.generateTable(frame)
return p.generateTable(frame)
end
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
return p

Latest revision as of 13:24, 10 August 2026

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