Module:AutoUpdateServPrices: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
NewZimiaGov (talk | contribs)
No edit summary
 
(28 intermediate revisions by the same user not shown)
Line 1: Line 1:
---------------------------------------------------------
---------------------------------------------------------
-- Module:DailyPriceTable
-- Module:AutoUpdateServPrices (OPTIMIZED)
-- Generates a daily-updated table where each cell
--
-- refreshes once per day, with random fluctuations
-- Fix for Scribunto timeout:
-- stable for that day but new on subsequent days.
-- • Caches city-level signals once per unique HQ city:
-- Rows are colored by price level:
--      - Forecast opsData
--   • Low     => light blue
--     - Hatch daily report city counts (one report per day; counts cached)
--   • Standard => light green
--     - War League table city counts (one table per day; counts cached)
--   • High    => light red
--      - 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 = {}
local p = {}
Line 13: Line 15:
---------------------------------------------------------
---------------------------------------------------------
-- 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 totalDaysElapsed = math.floor((currentDate - startDate) / secondsInDay)
local yearFraction = totalDaysElapsed / daysPerYear
local psscYear = math.floor(yearFraction)
local dayOfYear = math.floor((yearFraction - psscYear) * daysPerYear) + 1


    local currentDate = os.time()
local month, dayOfMonth
    local totalDaysElapsed = math.floor((currentDate - startDate) / secondsInDay)
if dayOfYear <= 61 then
month = 1
dayOfMonth = dayOfYear
elseif dayOfYear <= 122 then
month = 2
dayOfMonth = dayOfYear - 61
else
month = 3
dayOfMonth = dayOfYear - 122
end


    local yearFraction = totalDaysElapsed / daysPerYear
return string.format("%d/%d/%d [[PSSC]]", dayOfMonth, month, psscYear), dayOfMonth, month, psscYear, dayOfYear
    local psscYear = math.floor(yearFraction)
end
    local dayOfYear = math.floor((yearFraction - psscYear) * daysPerYear) + 1
 
---------------------------------------------------------
-- Product data (FULL LIST — from oldid=380847)
---------------------------------------------------------
local products = mw.loadData("Module:AutoUpdateServPrices/Data")


    local month, dayOfMonth
---------------------------------------------------------
    if dayOfYear <= 61 then
-- City-Signal Integrations
        month = 1
---------------------------------------------------------
        dayOfMonth = dayOfYear
local forecastOk, forecast = pcall(require, "Module:BassaridiaForecast")
    elseif dayOfYear <= 122 then
local hatchOk, hatch = pcall(require, "Module:HatchMinistryDailyOps")
        month = 2
local outbreakOk, outbreaks = pcall(require, "Module:BassaridiaOutbreaks")
        dayOfMonth = dayOfYear - 61
local warOk, war = pcall(require, "Module:WarLeagueDivisionOpsBVR")
    else
        month = 3
        dayOfMonth = dayOfYear - 122
    end


    return string.format("%d/%d/%d [[PSSC]]", dayOfMonth, month, psscYear), dayOfMonth, month
local function escapePattern(s)
s = tostring(s or "")
return s:gsub("([%%%^%$%(%)%.%[%]%*%+%-%?])", "%%%1")
end
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",


---------------------------------------------------------
---------------------------------------------------------
-- Product data remains the same
-- Imperial Federation Partner Companies
---------------------------------------------------------
---------------------------------------------------------
local products = {
 
    {"Draped Flag Linens (Foreign Vessels)", "Lake Morovia Blockade Fund - Hatch Ministry", 1.40, "sheets"},
["Dromosker Security Services Corporation"] = "",
    {"Dismantled Minting Equipment", "Lake Morovia Blockade Fund - Hatch Ministry", .7, "assemblies"},
["Abeisan Cruise Lines"] = "",
    {"Decertified Seal Stamps (Foreign Commissions)", "Lake Morovia Blockade Fund - Hatch Ministry", 1.9, "sets"},
    {"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"},
    {"Crated Trade Uniforms (Non-Domestic Origin)", "Lake Morovia Blockade Fund - Hatch Ministry", 1.30, "articles"},
    {"Defaced Customs Plaques", "Lake Morovia Blockade Fund - Hatch Ministry", 1.7, "plaques"},
    {"Baled Promotional Ephemera", "Lake Morovia Blockade Fund - Hatch Ministry", 4.80, "kg"},
    {"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"},
    {"Blocked Port Registry Books", "Lake Morovia Blockade Fund - Hatch Ministry", 1.2, "ledgers"},
    {"Disassembled Broadcast Relays", "Lake Morovia Blockade Fund - Hatch Ministry", 6, "units"},
    {"Stamps (Confiscated)", "Lake Morovia Blockade Fund - Hatch Ministry", 7.0, "sheets"},
    {"Confiscated Trade Crates", "Hatch Ministry", 1.1, "units"},
    {"Mixed Merchant Cargo", "Hatch Ministry", 5.2, "tons"},
    {"Captured Textiles (Raw & Finished)", "Hatch Ministry", 3.40, "bolts"},
    {"Illicit Grain Stores", "Hatch Ministry", 3.6, "tons"},
    {"Contraband Alcohol", "Hatch Ministry", 1.1, "bottles"},
    {"Seized Foreign Coinage", "Hatch Ministry", 10, "coins"},
    {"Recovered Cargo Manifests", "Hatch Ministry", 3.33, "scrolls"},
    {"Bulk Dyes and Spices (Unmarked Origin)", "Hatch Ministry", 7.220, "kg"},
    {"Captured Livestock (Deck Transport)", "Hatch Ministry", 2.6, "head"},
    {"Foreign Trade Seals and Emblems", "Hatch Ministry", 1.22, "sets"},
    {"Unregistered Luxury Cargo", "Hatch Ministry", 1.4, "tons"},
    {"Recovered Safe Boxes", "Hatch Ministry", .7, "chests"},
    {"Stamped Silver Bars", "Hatch Ministry", 3.90, "kg"},
    {"Captured Naval Uniforms and Insignia", "Hatch Ministry", 5.5, "sets"},
    {"Lost Port Authority Documents", "Hatch Ministry", 1.4, "scrolls"},
    {"High-Value Personal Effects", "Hatch Ministry", 6.8, "items"},
    {"Private Letters and Ship Logs", "Hatch Ministry", 4.1, "volumes"},
    {"Faces", "Maritime Guild of the Cult of Maskmakers", 0.03, "masks"},
    {"Spies", "Ergonian Spy Agency", 0.01, "assignments"},
    {"Missionaries of the Order Aurora Mystica", "Temple Bank of the Reformed Stripping Path", 0.05, "teams"},
    {"Missionaries of Harmony Sanctum", "Temple Bank of the Reformed Stripping Path", 0.09, "teams"},
    {"Missionaries of Ignis Aeternum", "Temple Bank of the Reformed Stripping Path", 0.09, "teams"},
    {"Missionaries of Celestial Harmony Sect", "Temple Bank of the Reformed Stripping Path", 0.08, "teams"},
    {"Missionaries of the Guild of Golden Shadows", "Temple Bank of the Reformed Stripping Path", 0.05, "teams"},
    {"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"},
    {"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"},
    {"Missionaries of Sanctum Vitalis", "Temple Bank of the Reformed Stripping Path", 0.06, "teams"},
    {"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"},
    {"Missionaries of the Mystery of the Stygian Veil", "Temple Bank of the Reformed Stripping Path", 0.03, "teams"},
    {"Missionaries of the Sylvan Fellowship", "Temple Bank of the Reformed Stripping Path", 0.09, "teams"},
    {"Missionaries of the Mystery of Red Mirth", "Temple Bank of the Reformed Stripping Path", 0.06, "teams"},
    {"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"},
    {"Missionaries of Ordo Amictia", "Temple Bank of the Reformed Stripping Path", 0.06, "teams"},
    {"Missionaries of Temple Illuminata", "Temple Bank of the Reformed Stripping Path", 0.05, "teams"},
    {"Couriers", "Couriers of the Lizard Queen", 0.04, "dispatches"},
    {"Wisp Ward Talismans", "Couriers of the Lizard Queen", 0.10, "amulets"},
    {"Credit Hours", "Temple University of Delphica", 3.00, "credits"},
    {"Circuses", "La Sái Ebile", 0.24, "venues"},
    {"Performances", "La Sái Ebile", 0.11, "acts"},
    {"Passenger Rail Tickets", "Trans-Morovian Express", 1.2, "tickets"},
    {"Luxury Cabin Reservations", "Trans-Morovian Express", 4.5, "cabins"},
    {"Freight Car Leasing Contracts", "Trans-Morovian Express", .60, "agreements"},
    {"Overland Courier Deliveries", "Trans-Morovian Express", .23, "parcels"},
    {"Secure Cargo Vaults", "Trans-Morovian Express", .39, "units"},
    {"Customs Bypass Licenses", "Trans-Morovian Express", .71, "permits"},
    {"Railway Advertising Rights", "Trans-Morovian Express", .18, "slots"},
    {"Timetable Access Codes", "Trans-Morovian Express", .5, "keys"},
    {"Locomotive Repair Services", "Trans-Morovian Express", .57, "contracts"},
    {"Historical Route Tours", "Trans-Morovian Express", .20, "tours"},
    {"Private Charter Lines", "Trans-Morovian Express", .84, "routes"},
    {"Coal Tender Refills", "Trans-Morovian Express", .33, "loads"},
    {"Hellhound (scout)", "Hellhound Breeders of Dragevik", 0.03, "hounds"},
    {"Hellhound (soldier)", "Hellhound Breeders of Dragevik", 0.05, "hounds"},
    {"Hellhound (spy)", "Hellhound Breeders of Dragevik", 0.15, "hounds"},
    {"Priestesses of the Pharmacon Sect (Anti-Parasitic Medicine)", "Pharmacon Sect", 0.07, "doses"},
    {"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"},
    {"Priestesses of the Pharmacon Sect (Memory and Mind Treatment)", "Pharmacon Sect", 0.06, "doses"},
    {"Priestesses of the Pharmacon Sect (Immune Boost Medicine)", "Pharmacon Sect", 0.05, "doses"},
    {"Priestesses of the Pharmacon Sect (Respiratory Clearing Medicine)", "Pharmacon Sect", 0.05, "doses"},
    {"Priestesses of the Pharmacon Sect (Fertility and Childbirth Medicine)", "Pharmacon Sect", 0.04, "doses"},
    {"Priestesses of the Pharmacon Sect (Ritual Purification Incense)", "Pharmacon Sect", 0.03, "sticks"},
    {"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"},
    {"Postal Workers", "Haifadora Postal Service*", 0.1, "Assignments"},
    {"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.20, "tons"},
    {"Harbor Pilotage (per transit)", "Port of Corumia", 4.80, "transits"},
    {"Drydock Berth (per day)", "Port of Corumia", 16.00, "days"},
    {"Tug Assist (harbor move)", "Port of Corumia", 2.40, "moves"},
    {"Customs Brokerage (filing)", "Port of Corumia", 1.10, "filings"},
    {"Bonded Warehousing", "Port of Corumia", 0.22, "m³·day"},
    {"Cold Storage", "Port of Corumia", 0.38, "m³·day"},
    {"Security Escort (quay-to-gate)", "Port of Corumia", 0.95, "escorts"},
    {"Quarantine Inspection (vessel)", "Port of Corumia", 2.10, "vessels"},
    {"Medical Clearance (per crew)", "Port of Corumia", 0.36, "crew"},
    {"Vessel Berth Window (per hour reserved)", "Pentiad Terminal Operations", 2.40, "hours"},
    {"Quay Crane Move (per container)", "Pentiad Terminal Operations", 1.50, "moves"},
    {"Breakbulk Stevedoring (per ton)", "Corytian Terminal Operations Agency", 1.10, "tons"},
    {"Gate Processing Fee (per truck visit)", "Pentiad Terminal Operations", 0.55, "visits"},
    {"Dry Yard Storage (per TEU·day)", "Pentiad Terminal Operations", 0.40, "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", 0.90, "container·day"},
    {"Reefer Temperature Monitoring (per container·day)", "Pentiad Terminal Operations", 0.60, "container·day"},
    {"Rail Intermodal Transfer (per TEU)", "Pentiad Terminal Operations", 1.45, "TEU"},
    {"Barge Transfer (per TEU)", "Pentiad Terminal Operations", 1.15, "TEU"},
    {"Documentation & EDI Processing (per bill)", "Pentiad Terminal Operations", 0.39, "bills"},
    {"ISPS Security Charge (per vessel call)", "Pentiad Terminal Operations", 1.70, "calls"},
    {"Harbor Pilot Boarding (Standard Transit)", "Azat Tug and Pilot Cooperative", 4.80, "transits"},
    {"Harbor Pilotage Extension (Night/Navigational Risk)", "Azat Tug and Pilot Cooperative", 3.30, "transits"},
    {"Escort Tug (Inbound Transit)", "Azat Tug and Pilot Cooperative", 2.40, "moves"},
    {"Escort Tug (Outbound Transit)", "Azat Tug and Pilot Cooperative", 2.40, "moves"},
    {"Azat Line Boat Support (Mooring/Unmooring)", "Azat Tug and Pilot Cooperative", 1.70, "deployments"},
    {"Tug Assist (Turning Basin Maneuver)", "Azat Tug and Pilot Cooperative", 2.00, "maneuvers"},
    {"Emergency Tow Response (Flat Rate)", "Azat Tug and Pilot Cooperative", 6.80, "responses"},
    {"Pilot Dispatch Cancellation Fee", "Azat Tug and Pilot Cooperative", 1.20, "cancellations"},
    {"Pilotage Slot Reservation (Peak Hours)", "Azat Tug and Pilot Cooperative", 2.60, "reservations"},
    {"Multi-Vessel Convoy Guidance (Straits)", "Azat Tug and Pilot Cooperative", 5.10, "convoys"},
    {"War-Risk Tug Escort (Haifa Exclusion Zone)", "Azat Tug and Pilot Cooperative", 7.00, "escort moves"},
    {"Docking Delay Standby Fee (per 30 minutes)", "Azat Tug and Pilot Cooperative", 1.50, "intervals"},
}
}
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


---------------------------------------------------------
---------------------------------------------------------
Line 262: Line 364:
---------------------------------------------------------
---------------------------------------------------------
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))
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


    if events[dateKey] then
local tb = getTempleBankSignal(pClass, ph.severity or "NONE", wx.opsLevel or 0)
        explanation = events[dateKey] .. ": Prices influenced by event dynamics."
cityAdd = cityAdd + (tb.mult or 0)
        changeFactor = changeFactor + 0.05  -- small event-based boost
if tb.note then table.insert(notes, tb.note) end
    else
end
        explanation = "General market fluctuations."
    end


    local newPrice = math.floor((basePrice * (1 + changeFactor)) * 100) / 100
cityAdd = clamp(cityAdd, -0.20, 0.20)
    return newPrice, changeFactor, explanation
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


---------------------------------------------------------
---------------------------------------------------------
-- Main function: generateTable
-- Args helpers
-- Seeds random daily, then builds table with row coloring
---------------------------------------------------------
---------------------------------------------------------
function p.generateTable()
local function getArgs(frame)
    -- Seed randomness
if type(frame) ~= "table" then return {} end
    local localTime = os.date("*t")
local args = {}
    local dailySeed = localTime.year * 1000 + localTime.yday
local fargs = frame.args or {}
    math.randomseed(dailySeed)
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


    -- Retrieve date
local function isNo(v)
    local dateString, dayOfMonth, month = getCurrentDate()
v = tostring(v or ""):lower()
return (v == "no" or v == "n" or v == "false" or v == "0")
end


    -- Table header
local function levelSymbol(level)
    local wikitable =
if level == "Low" then
        "{| class=\"wikitable\"\n" ..
return "↑"
        "! Date (Day/Month/Year [[PSSC]]) !! Product !! Producing Company !! Base Price !! Price Level !! Tier III Price !! Tier II Price !! Tier I Price !! Units !! Explanation\n"
elseif level == "High" then
return "↓"
else
return "•"
end
end


    -- Track totals for averages
---------------------------------------------------------
    local totalBase, totalNew, count = 0, 0, 0
-- 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


    -- Iterate products
local function productKey(name, company)
    for _, product in ipairs(products) do
return tostring(name or "") .. "||" .. normalizeCompany(company)
        local name, company, basePrice, units = unpack(product)
end
        local newPrice, _, explanation = simulatePriceChange(basePrice, dayOfMonth, month)
        local priceLevel = determinePriceLevel(basePrice, newPrice)


        -- Choose row color
local function getProductByNameAndCompany(name, company)
        local rowStyle
if productLookup == nil then
        if priceLevel == "Low" then
productLookup = {}
            rowStyle = ' style="background-color:lightblue;"'
for _, product in ipairs(products) do
        elseif priceLevel == "Standard" then
productLookup[productKey(product[1], product[2])] = product
            rowStyle = ' style="background-color:lightgreen;"'
end
        else
end
            rowStyle = ' style="background-color:#ffcccb;"'  -- light red
return productLookup[productKey(name, company)]
        end
end


        -- Compute tiers
local function formatConsumerPriceFromNil(totalNil)
        local tierIIIPrice = math.floor((newPrice / 3) * 100) / 100
totalNil = math.floor((tonumber(totalNil) or 0) + 0.5)
        local tierIIPrice  = math.floor((newPrice * 2 / 3) * 100) / 100
if totalNil < 1 then totalNil = 1 end
        local tierIPrice  = newPrice


        -- Accumulate totals
local poli = math.floor(totalNil / 200)
        totalBase = totalBase + basePrice
local rem = totalNil % 200
        totalNew  = totalNew + newPrice
local sik = math.floor(rem / 10)
        count = count + 1
local nilCoins = rem % 10


        -- Insert colored row
local parts = {}
        wikitable = wikitable .. string.format(
if poli > 0 then table.insert(parts, tostring(poli) .. "₱̈") end
            "|-%s\n| %s || %s || %s || %.2f || %s || %.2f || %.2f || %.2f || %s || %s\n",
if sik > 0 then table.insert(parts, tostring(sik) .. "Ƨ̈") end
            rowStyle, dateString, name, company, basePrice,
if nilCoins > 0 then table.insert(parts, tostring(nilCoins) .. "•") end
            priceLevel, tierIIIPrice, tierIIPrice, tierIPrice, units, explanation
if #parts == 0 then return "1•" end
        )
return table.concat(parts, " ")
    end
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
))


    -- Compute averages
totalBase = totalBase + basePrice
    local avgBase = totalBase / count
totalNew = totalNew + newPrice
    local avgNew  = totalNew / count
count = count + 1
    local averagePriceLevel
end
    if avgNew > avgBase then
        averagePriceLevel = "Low"
    elseif avgNew < avgBase then
        averagePriceLevel = "High"
    else
        averagePriceLevel = "Standard"
    end


    -- Final row
local avgBase = totalBase / count
    wikitable = wikitable .. string.format(
local avgNew = totalNew / count
        "|-\n! colspan=\"10\" | Average adjusted price (%.2f) compared to average base price (%.2f) ⇒ **%s**\n",
local avgLevel
        avgNew, avgBase, averagePriceLevel
if avgNew > avgBase then
    )
avgLevel = "Low"
elseif avgNew < avgBase then
avgLevel = "High"
else
avgLevel = "Standard"
end


    -- Close table
return {
    wikitable = wikitable .. "|}"
label = "Services",
    return wikitable
avgBase = avgBase,
avgNew = avgNew,
avgLevel = avgLevel,
avgSym = levelSymbol(avgLevel),
pctChange = ((avgNew - avgBase) / avgBase) * 100
}
end
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