Module:VaeringheimMerchantLeagueInvitational: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
Created page with "-- Module:VaeringheimMerchantLeagueInvitational -- Simulates the Vaeringheim Merchant League - Invitational Division. -- Each season lasts one Bassaridian month: 61 days. -- Seasons reset on Atosiel 1, Thalassiel 1, and Opsitheiel 1. local p = {} ------------------------------------------------------------------------ -- 0) CALENDAR / OPTIONAL MARKET MODULES ------------------------------------------------------------------------ local DAYS_IN_YEAR = 183 local DAYS_IN..."
 
NewZimiaGov (talk | contribs)
No edit summary
 
(4 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:VaeringheimMerchantLeagueInvitational
-- Module:VaeringheimMerchantLeagueInvitational
-- Simulates the Vaeringheim Merchant League - Invitational Division.
-- Lightweight version.
-- Each season lasts one Bassaridian month: 61 days.
-- Simulates the Vaeringheim Merchant League - Invitational Division
-- without replaying every tender of every day.
--
-- Each season lasts one PSSC month: 61 days.
-- Seasons reset on Atosiel 1, Thalassiel 1, and Opsitheiel 1.
-- Seasons reset on Atosiel 1, Thalassiel 1, and Opsitheiel 1.


Line 7: Line 10:


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 0) CALENDAR / OPTIONAL MARKET MODULES
-- BASIC CALENDAR
------------------------------------------------------------------------
------------------------------------------------------------------------


Line 30: Line 33:


local okDate, date = pcall(require, "Module:BassaridianCalendar")
local okDate, date = pcall(require, "Module:BassaridianCalendar")
local okAg, agPrices = pcall(require, "Module:AutoUpdateAgPrices")
local okManf, manfPrices = pcall(require, "Module:AutoUpdateManfPrices")
local okServ, servPrices = pcall(require, "Module:AutoUpdateServPrices")
local okServData, servData = pcall(mw.loadData, "Module:AutoUpdateServPrices/Data")


local function clamp(x, lo, hi)
local function clamp(x, lo, hi)
Line 106: Line 105:
local now = currentPSSC()
local now = currentPSSC()


local year = tonumber(args.year or args.season or args[1] or "") or now.year
local year = tonumber(args.year or args.season or "") or now.year
local monthIndex = monthIndexFrom(args.month or args.m or args[2]) or now.monthIndex
local monthIndex = monthIndexFrom(args.month or args.m or "") or now.monthIndex


local day = tonumber(args.day or args.d or args[3] or "")
local day = tonumber(args.day or args.d or "")


if not day then
if not day then
Line 139: Line 138:


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 1) DETERMINISTIC RANDOM HELPERS
-- DETERMINISTIC RANDOM
------------------------------------------------------------------------
------------------------------------------------------------------------


Line 163: Line 162:
if idx > #list then idx = #list end
if idx > #list then idx = #list end
return list[idx]
return list[idx]
end
local function chooseWeighted(list, key, weightFn)
if not list or #list == 0 then return nil end
local total = 0
local weights = {}
for i, item in ipairs(list) do
local w = tonumber(weightFn(item, i)) or 0
if w < 0 then w = 0 end
weights[i] = w
total = total + w
end
if total <= 0 then
return pick(list, key)
end
local r = randKey(key) * total
local acc = 0
for i, item in ipairs(list) do
acc = acc + weights[i]
if r <= acc then return item end
end
return list[#list]
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 2) MONEY FORMAT
-- MONEY
------------------------------------------------------------------------
------------------------------------------------------------------------


-- Internal simulation stores contract income in nil-coins:
-- Internal values are stored as nil-coins.
-- 1 poli = 200 nil-coins; 1 sik = 10 nil-coins.
-- 1 poli = 200 nil-coins; 1 sik = 10 nil-coins.
local function formatMoney(nilValue)
local function formatMoney(nilValue)
local n = math.max(0, round(nilValue or 0))
local n = math.max(0, round(nilValue or 0))
Line 215: Line 187:


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 3) MERCHANT HOUSES
-- MERCHANT HOUSES
------------------------------------------------------------------------
------------------------------------------------------------------------


Line 224: Line 196:
house = "House Qamar",
house = "House Qamar",
origin = "Vaeringheim",
origin = "Vaeringheim",
location = "Vaeringheim",
profile = "Luxury shrine-road brokerage and ceremonial cargo.",
style = "Luxury shrine-road brokerage and ceremonial cargo.",
cargo = { "luxury shrine linens", "ceremonial oil", "sealed relic cases", "festival incense" },
profile = "A polished house known for shrine consignments, sealed ledgers, and expensive clients.",
mode = "road",
skill = 0.88,
pace = 0.94,
reliability = 0.86,
reliability = 0.86,
risk = 0.46,
risk = 0.46,
rep = 14,
baseRep = 14,
stable = 5,
stable = 5
tags = { luxury = 4, ritual = 4, service = 2, sealed = 3 }
},
},
{
{
Line 239: Line 210:
house = "Eikskald Factors",
house = "Eikskald Factors",
origin = "Riddersborg",
origin = "Riddersborg",
location = "Riddersborg",
profile = "Cold-road wool, timber, and northern freight.",
style = "Cold-road wool, timber, and northern freight.",
cargo = { "cold-road wool", "timber beams", "salted provisions", "northern ironwork" },
profile = "A hard-weather ledger with reliable northern carriers and a reputation for endurance.",
mode = "rail",
skill = 0.82,
pace = 0.88,
reliability = 0.90,
reliability = 0.90,
risk = 0.38,
risk = 0.38,
rep = 12,
baseRep = 12,
stable = 4,
stable = 4
tags = { wool = 4, timber = 4, bulk = 3, cold = 4 }
},
},
{
{
Line 254: Line 224:
house = "Blue Quay Papers",
house = "Blue Quay Papers",
origin = "Mylecia",
origin = "Mylecia",
location = "Mylecia",
profile = "Water, medicine, ferry freight, and wetland routes.",
style = "Water, medicine, ferry freight, and wetland routes.",
cargo = { "clean water casks", "medicine crates", "ferry manifests", "wetland provisions" },
profile = "A careful wet-road broker whose clerks are trusted with water, medicine, and ferry claims.",
mode = "ferry",
skill = 0.84,
pace = 0.91,
reliability = 0.88,
reliability = 0.88,
risk = 0.42,
risk = 0.42,
rep = 13,
baseRep = 13,
stable = 3,
stable = 3
tags = { water = 4, medicine = 3, ferry = 4, perishable = 2 }
},
},
{
{
Line 269: Line 238:
house = "Aurelian Grain Desk",
house = "Aurelian Grain Desk",
origin = "Aurelia",
origin = "Aurelia",
location = "Aurelia",
profile = "Grain, lamp oil, staple food, and bulk provisions.",
style = "Grain, lamp oil, staple food, and bulk provisions.",
cargo = { "Agrithor Orys grain", "lamp oil", "staple flour", "bulk provisions" },
profile = "A high-volume desk that wins by moving ordinary staples with unusual punctuality.",
mode = "road",
skill = 0.81,
pace = 1.02,
reliability = 0.84,
reliability = 0.84,
risk = 0.35,
risk = 0.35,
rep = 11,
baseRep = 11,
stable = 3,
stable = 3
tags = { grain = 5, oil = 3, staple = 5, bulk = 4 }
},
},
{
{
Line 284: Line 252:
house = "Ardashir Import Rooms",
house = "Ardashir Import Rooms",
origin = "Delphica",
origin = "Delphica",
location = "Delphica",
profile = "Euranidom imports, fine horse stock, and prestige contracts.",
style = "Euranidom imports, fine horse stock, and prestige contracts.",
cargo = { "fine horse stock papers", "Euranidom imports", "perfumed leather", "prestige parcels" },
profile = "An import house with elegant accounts, costly preferences, and a taste for risky prestige tenders.",
mode = "road",
skill = 0.86,
pace = 0.86,
reliability = 0.78,
reliability = 0.78,
risk = 0.68,
risk = 0.68,
rep = 13,
baseRep = 13,
stable = 6,
stable = 6
tags = { luxury = 4, animal = 4, import = 4, prestige = 5 }
},
},
{
{
Line 299: Line 266:
house = "Salt-Valed Ledger",
house = "Salt-Valed Ledger",
origin = "Sufriya",
origin = "Sufriya",
location = "Sufriya",
profile = "Salt, fish, hard weather routes, and rough-margin tenders.",
style = "Salt, fish, hard weather routes, and rough-margin tenders.",
cargo = { "salt fish", "rough-weather provisions", "barrel salt", "coastal tar" },
profile = "A lean house that accepts poor weather, thin margins, and cargo other merchants avoid.",
mode = "ferry",
skill = 0.79,
pace = 0.97,
reliability = 0.80,
reliability = 0.80,
risk = 0.72,
risk = 0.72,
rep = 9,
baseRep = 9,
stable = 3,
stable = 3
tags = { salt = 5, fish = 4, cold = 3, rough = 5, ferry = 2 }
},
},
{
{
Line 313: Line 279:
merchant = "Orvida Thal-Maen",
merchant = "Orvida Thal-Maen",
house = "Thal-Maen Water Desk",
house = "Thal-Maen Water Desk",
origin = "Thal Qencha",
origin = "Thal Lenaia",
location = "Thal Qencha",
profile = "Clean water, fish, mountain greens, and perishables.",
style = "Clean water, fish, mountain greens, and perishables.",
cargo = { "clean water casks", "spring-pond trout", "mountain greens", "ice-box produce" },
profile = "A mountain-water desk respected for inspection discipline and clean cask seals.",
mode = "rail",
skill = 0.83,
pace = 0.90,
reliability = 0.89,
reliability = 0.89,
risk = 0.41,
risk = 0.41,
rep = 12,
baseRep = 12,
stable = 4,
stable = 4
tags = { water = 5, fish = 3, greens = 4, perishable = 5 }
},
},
{
{
Line 329: Line 294:
house = "Sandveil Commissions",
house = "Sandveil Commissions",
origin = "Somniumpolis",
origin = "Somniumpolis",
location = "Somniumpolis",
profile = "Dream-city luxuries, sealed consignments, and dusk tenders.",
style = "Dream-city luxuries, sealed consignments, and dusk tenders.",
cargo = { "dream-city luxury parcels", "sealed dusk consignments", "Somniumpolis perfumes", "private commission packets" },
profile = "A theatrical and sometimes uncanny commission house with a talent for dusk deliveries.",
mode = "ferry",
skill = 0.85,
pace = 0.89,
reliability = 0.77,
reliability = 0.77,
risk = 0.70,
risk = 0.70,
rep = 12,
baseRep = 12,
stable = 5,
stable = 5
tags = { luxury = 4, sealed = 5, ritual = 2, service = 3, night = 5 }
},
},
{
{
Line 344: Line 308:
house = "Jogi Ropehouse",
house = "Jogi Ropehouse",
origin = "Jogi",
origin = "Jogi",
location = "Jogi",
profile = "Dock rope, reed mats, wet-road hauling, and marsh labor.",
style = "Dock rope, reed mats, wet-road hauling, and marsh labor.",
cargo = { "dock rope", "reed mats", "cargo padding", "cane-fiber dock twine" },
profile = "A dockside house built on rope, reed, rough labor, and an intimate knowledge of marsh delay.",
mode = "road",
skill = 0.78,
pace = 1.00,
reliability = 0.82,
reliability = 0.82,
risk = 0.58,
risk = 0.58,
rep = 10,
baseRep = 10,
stable = 2,
stable = 2
tags = { rope = 5, reed = 5, ferry = 3, rough = 3, bulk = 3 }
},
},
{
{
Line 359: Line 322:
house = "Ourid Shrine Accounts",
house = "Ourid Shrine Accounts",
origin = "Ourid",
origin = "Ourid",
location = "Ourid",
profile = "Pass-road courier work and shrine-route contracts.",
style = "Pass-road courier work and shrine-route contracts.",
cargo = { "courier packets", "shrine ledgers", "sealed relic permits", "pass-road offerings" },
profile = "A shrine-route courier house that treats stamps, relic boxes, and pass ledgers as sacred matter.",
mode = "pass-road",
skill = 0.87,
pace = 0.92,
reliability = 0.85,
reliability = 0.85,
risk = 0.52,
risk = 0.52,
rep = 13,
baseRep = 13,
stable = 4,
stable = 4
tags = { ritual = 5, courier = 5, sealed = 3, pass = 4 }
},
},
{
{
Line 374: Line 336:
house = "Velai Glass & Scrip",
house = "Velai Glass & Scrip",
origin = "Luminaria",
origin = "Luminaria",
location = "Luminaria",
profile = "Glassware, scrip, invoice brokerage, and social tenders.",
style = "Glassware, scrip, invoice brokerage, and social tenders.",
cargo = { "glassware crates", "invoice scrip packets", "brokerage ledgers", "social tender seals" },
profile = "A nimble house whose glass consignments and scrip brokerage thrive on reputation.",
mode = "road",
skill = 0.84,
pace = 0.93,
reliability = 0.81,
reliability = 0.81,
risk = 0.47,
risk = 0.47,
rep = 12,
baseRep = 12,
stable = 3,
stable = 3
tags = { glass = 5, scrip = 5, service = 4, fragile = 4 }
},
},
{
{
Line 389: Line 350:
house = "Giid-Tally Yard",
house = "Giid-Tally Yard",
origin = "Acheron",
origin = "Acheron",
location = "Acheron",
profile = "Prestige caravan work, heavy hauling, and Giid-Lisea-drawn cargo.",
style = "Prestige caravan work, heavy hauling, and Giid-Lisea-drawn cargo.",
cargo = { "heavy dock hardware", "timber loads", "prestige caravan freight", "Giid-Lisea harness gear" },
profile = "The heaviest yard in the division, feared for slow starts but massive hauling capacity.",
mode = "road",
skill = 0.80,
pace = 0.84,
reliability = 0.83,
reliability = 0.83,
risk = 0.45,
risk = 0.45,
rep = 11,
baseRep = 11,
stable = 7,
stable = 7
tags = { heavy = 5, prestige = 4, bulk = 5, animal = 2, timber = 3 }
}
}
}
}
Line 407: Line 367:


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 4) STABLE / HAULING PRESTIGE
-- STABLE PRESTIGE
------------------------------------------------------------------------
------------------------------------------------------------------------


local STABLES = {
local STABLES = {
[1] = { name = "licensed porter crew", prestige = 10, capacity = 1.0 },
[1] = { name = "licensed porter crew", prestige = 10 },
[2] = { name = "reed-cart pair", prestige = 22, capacity = 1.2 },
[2] = { name = "reed-cart pair", prestige = 22 },
[3] = { name = "mule and wagon string", prestige = 36, capacity = 1.5 },
[3] = { name = "mule and wagon string", prestige = 36 },
[4] = { name = "woolly horse caravan", prestige = 52, capacity = 1.8 },
[4] = { name = "woolly horse caravan", prestige = 52 },
[5] = { name = "sealed carriage account", prestige = 68, capacity = 1.6 },
[5] = { name = "sealed carriage account", prestige = 68 },
[6] = { name = "prestige horse stock", prestige = 84, capacity = 1.7 },
[6] = { name = "prestige horse stock", prestige = 84 },
[7] = { name = "Giid-Lisea heavy team", prestige = 105, capacity = 2.4 }
[7] = { name = "Giid-Lisea heavy team", prestige = 105 }
}
}


Line 428: Line 388:
local s = STABLES[idx] or STABLES[1]
local s = STABLES[idx] or STABLES[1]
return s.prestige or 0
return s.prestige or 0
end
local function stableCapacity(idx)
local s = STABLES[idx] or STABLES[1]
return s.capacity or 1
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 5) CARGO POOL
-- COMPREHENSIVE CARGO ATLAS
-- Static data only. This does not make the main standings calculation heavy.
------------------------------------------------------------------------
------------------------------------------------------------------------


local CARGO = {
local CARGO = {
{ name = "Rice", sector = "Agriculture", base = 9.00, unit = "tons", company = "Gladeseed Farmers' Union", tags = { "grain", "staple", "bulk" }, risk = 0.08 },
-- Agriculture / food staples
{ name = "Agrithor Orys grain", sector = "Agriculture", base = 12.50, unit = "tons", company = "Wardens of the Fields of Callope", tags = { "grain", "staple", "bulk" }, risk = 0.10 },
{ name = "Rice", sector = "Agriculture", company = "Gladeseed Farmers' Union", unit = "tons", class = "staple", risk = 0.08 },
{ name = "Lamp oil", sector = "Agriculture", base = 18.00, unit = "casks", company = "Wardens of the Fields of Callope", tags = { "oil", "ritual", "staple" }, risk = 0.18 },
{ name = "Edible Algae", sector = "Agriculture", company = "Gladeseed Farmers' Union", unit = "tons", class = "staple", risk = 0.12 },
{ name = "Clean water casks", sector = "Agriculture", base = 16.00, unit = "casks", company = "Thal-Maen Water Desk", tags = { "water", "staple", "perishable" }, risk = 0.12 },
{ name = "Shrimp", sector = "Agriculture", company = "Gladeseed Farmers' Union", unit = "tons", class = "perishable", risk = 0.22 },
{ name = "Mountain greens", sector = "Agriculture", base = 22.00, unit = "crates", company = "Lenaian Water Company", tags = { "greens", "perishable", "water" }, risk = 0.25 },
{ name = "Anterran Spicy Onion", sector = "Agriculture", company = "Gladeseed Farmers' Union", unit = "tons", class = "spice", risk = 0.14 },
{ name = "Spring-pond trout", sector = "Agriculture", base = 26.00, unit = "ice crates", company = "Lenaian Water Company", tags = { "fish", "perishable", "water" }, risk = 0.30 },
{ name = "Queen's Garlic", sector = "Agriculture", company = "Gladeseed Farmers' Union", unit = "tons", class = "spice", risk = 0.15 },
{ name = "Salt fish", sector = "Agriculture", base = 14.00, unit = "barrels", company = "Salt-Valed Ledger", tags = { "fish", "salt", "rough" }, risk = 0.16 },
{ name = "Wheat", sector = "Agriculture", company = "Plains of Jogi", unit = "tons", class = "grain", risk = 0.07 },
{ name = "Cistern melons", sector = "Agriculture", base = 20.00, unit = "hampers", company = "Pontic Esperance Homestead Association", tags = { "perishable", "staple", "desert" }, risk = 0.24 },
{ name = "Oats", sector = "Agriculture", company = "Plains of Jogi", unit = "tons", class = "grain", risk = 0.06 },
{ name = "Ferry mats", sector = "Manufacturing", base = 13.00, unit = "bundles", company = "Thyrsa Maena Harbor Cooperative", tags = { "reed", "rope", "ferry", "bulk" }, risk = 0.10 },
{ name = "Hay", sector = "Agriculture", company = "Plains of Jogi", unit = "bales", class = "fodder", risk = 0.06 },
{ name = "Cargo padding", sector = "Manufacturing", base = 11.00, unit = "rolls", company = "Thyrsa Maena Harbor Cooperative", tags = { "reed", "rope", "bulk" }, risk = 0.08 },
{ name = "Cereal", sector = "Agriculture", company = "Plains of Jogi", unit = "pallets", class = "processed food", risk = 0.07 },
{ name = "Cane-fiber dock twine", sector = "Manufacturing", base = 17.00, unit = "spools", company = "Thyrsa Maena Harbor Cooperative", tags = { "rope", "reed", "ferry" }, risk = 0.08 },
{ name = "Glassware crates", sector = "Manufacturing", base = 31.00, unit = "crates", company = "Velai Glass & Scrip", tags = { "glass", "fragile", "luxury" }, risk = 0.38 },
{ name = "Invoice scrip packets", sector = "Services", base = 27.00, unit = "sealed packets", company = "Velai Glass & Scrip", tags = { "scrip", "service", "sealed", "courier" }, risk = 0.22 },
{ name = "Temple incense and anointing oil", sector = "Agriculture", base = 34.00, unit = "sealed jars", company = "Temple Bank of the Reformed Stripping Path", tags = { "ritual", "oil", "sealed", "luxury" }, risk = 0.28 },
{ name = "Relic-container seals", sector = "Services", base = 40.00, unit = "sets", company = "Temple Bank of the Reformed Stripping Path", tags = { "ritual", "sealed", "courier" }, risk = 0.32 },
{ name = "Fine horse stock papers", sector = "Services", base = 46.00, unit = "ledgers", company = "Ardashir Import Rooms", tags = { "animal", "prestige", "import", "sealed" }, risk = 0.35 },
{ name = "Luxury shrine linens", sector = "Services", base = 38.00, unit = "chests", company = "House Qamar", tags = { "luxury", "ritual", "sealed" }, risk = 0.30 },
{ name = "Courier packets", sector = "Services", base = 21.00, unit = "packets", company = "Ourid Shrine Accounts", tags = { "courier", "sealed", "pass" }, risk = 0.20 },
{ name = "Timber beams", sector = "Manufacturing", base = 19.00, unit = "loads", company = "Eikskald Factors", tags = { "timber", "bulk", "heavy", "cold" }, risk = 0.18 },
{ name = "Heavy dock hardware", sector = "Manufacturing", base = 29.00, unit = "crates", company = "Giid-Tally Yard", tags = { "heavy", "bulk", "prestige" }, risk = 0.22 },
{ name = "Regulated guard equipment", sector = "Manufacturing", base = 42.00, unit = "sealed cases", company = "Baratar Corporation", tags = { "sealed", "prestige", "rough" }, risk = 0.48 },
{ name = "Skybridge courier satchels", sector = "Services", base = 36.00, unit = "satchels", company = "Lothayan Skybridge Airways", tags = { "courier", "service", "sealed" }, risk = 0.26 },
{ name = "Dream-city luxury parcels", sector = "Services", base = 44.00, unit = "parcels", company = "Sandveil Commissions", tags = { "luxury", "night", "sealed" }, risk = 0.34 }
}
 
------------------------------------------------------------------------
-- 6) MARKET SIGNALS
------------------------------------------------------------------------


local function marketAverage(module, label)
-- Livestock / animal products
if module and type(module.getAverageData) == "function" then
{ name = "Morovian Water Buffalo", sector = "Agriculture", company = "Atterian Cattle Drivers", unit = "head", class = "livestock", risk = 0.26 },
local ok, data = pcall(module.getAverageData)
{ name = "Morovian Water Buffalo Beef", sector = "Agriculture", company = "Atterian Cattle Drivers", unit = "tons", class = "meat", risk = 0.20 },
if ok and type(data) == "table" then
{ name = "Morovian Water Buffalo Milk (raw)", sector = "Agriculture", company = "Atterian Cattle Drivers", unit = "barrels", class = "perishable", risk = 0.28 },
return {
{ name = "Morovian Water Buffalo Cheese (aged wheels)", sector = "Agriculture", company = "Atterian Cattle Drivers", unit = "wheels", class = "preserved food", risk = 0.12 },
label = data.label or label,
{ name = "Morovian Water Buffalo Hides (cured)", sector = "Agriculture", company = "Atterian Cattle Drivers", unit = "sheets", class = "hide", risk = 0.10 },
level = data.avgLevel or "Standard",
{ name = "Camels", sector = "Agriculture", company = "Camel Herders of the Jangsong Province", unit = "head", class = "livestock", risk = 0.28 },
pct = tonumber(data.pctChange) or 0,
{ name = "Camel Milk", sector = "Agriculture", company = "Camel Herders of the Jangsong Province", unit = "barrels", class = "perishable", risk = 0.26 },
sym = data.avgSym or "•"
{ name = "Camel Hump Water", sector = "Agriculture", company = "Camel Herders of the Jangsong Province", unit = "barrels", class = "water", risk = 0.16 },
}
end
end
return { label = label, level = "Standard", pct = 0, sym = "•" }
end


local function marketSignals()
-- Ritual / medicinal / plant goods
return {
{ name = "Sanctified Noctic Leaf (ritual-grade)", sector = "Agriculture", company = "Noctic Fleet", unit = "bundles", class = "ritual", risk = 0.34 },
Agriculture = marketAverage(okAg and agPrices or nil, "Agriculture"),
{ name = "Noctic-Rabrev Infused Incense", sector = "Agriculture", company = "Noctic Fleet", unit = "packs", class = "ritual", risk = 0.24 },
Manufacturing = marketAverage(okManf and manfPrices or nil, "Mining / Manufacturing / Energy"),
{ name = "Noctic-Rabrev Anointing Balm", sector = "Agriculture", company = "Noctic Fleet", unit = "jars", class = "ritual", risk = 0.26 },
Services = marketAverage(okServ and servPrices or nil, "Services")
{ name = "Noctic-Rabrev Ritual Ink", sector = "Agriculture", company = "Noctic Fleet", unit = "bottles", class = "ritual", risk = 0.25 },
}
{ name = "Zoe Elm Resin (Refined)", sector = "Agriculture", company = "Keepers of the Grove of Zoe Elm", unit = "barrels", class = "resin", risk = 0.13 },
end
{ name = "Zoe Elm Incense Resin (temple blend)", sector = "Agriculture", company = "Keepers of the Grove of Zoe Elm", unit = "packs", class = "ritual", risk = 0.18 },
{ name = "Zoe Elm Pitch (waterproofing grade)", sector = "Agriculture", company = "Keepers of the Grove of Zoe Elm", unit = "barrels", class = "ship supply", risk = 0.12 },
{ name = "Poppy Seeds (culinary)", sector = "Agriculture", company = "Merchant Guild of the Poppy Goddess", unit = "sacks", class = "culinary", risk = 0.18 },
{ name = "Poppy Seed Oil (pressed)", sector = "Agriculture", company = "Merchant Guild of the Poppy Goddess", unit = "barrels", class = "oil", risk = 0.20 },
{ name = "Poppy Incense Cones (temple blend)", sector = "Agriculture", company = "Merchant Guild of the Poppy Goddess", unit = "packs", class = "ritual", risk = 0.22 },


local function sectorPulse(sector, signals)
-- Beverages / preserves
local s = signals and signals[sector]
{ name = "Manwine", sector = "Agriculture", company = "Jogiani Merchants of Manwine", unit = "barrels", class = "beverage", risk = 0.15 },
if not s then return 1.0 end
{ name = "Milkwine", sector = "Agriculture", company = "Jogiani Merchants of Manwine", unit = "barrels", class = "beverage", risk = 0.18 },
{ name = "Aged Manwine (oak-rested)", sector = "Agriculture", company = "Jogiani Merchants of Manwine", unit = "barrels", class = "luxury beverage", risk = 0.16 },
{ name = "Manwine Vinegar (culinary)", sector = "Agriculture", company = "Jogiani Merchants of Manwine", unit = "barrels", class = "culinary", risk = 0.08 },
{ name = "Sea Elm Flavored Mead", sector = "Agriculture", company = "Sea-Elm Southern Meadery", unit = "barrels", class = "beverage", risk = 0.14 },
{ name = "Sea Elm Mead Vinegar (culinary)", sector = "Agriculture", company = "Sea-Elm Southern Meadery", unit = "barrels", class = "culinary", risk = 0.08 },
{ name = "Honey Cakes (sealed tins)", sector = "Agriculture", company = "Grand Cave Bee Meadery", unit = "boxes", class = "sealed food", risk = 0.10 },
{ name = "Cave Honey (strained)", sector = "Agriculture", company = "Grand Cave Bee Meadery", unit = "jars", class = "food", risk = 0.11 },
{ name = "Beeswax Candles (standard)", sector = "Agriculture", company = "Grand Cave Bee Meadery", unit = "bundles", class = "ritual supply", risk = 0.07 },


-- In the General Port price ledgers, "High" means pressure/scarcity;
-- Coastal / shell / luxury goods
-- this increases tender value and demand.
{ name = "Pearls", sector = "Agriculture", company = "Anterran Imports and Services", unit = "sacks", class = "luxury", risk = 0.32 },
if s.level == "High" then return 1.14 end
{ name = "Abalone", sector = "Agriculture", company = "Anterran Imports and Services", unit = "crates", class = "perishable luxury", risk = 0.24 },
if s.level == "Low" then return 0.94 end
{ name = "Mother-of-Pearl Buttons (sorted)", sector = "Agriculture", company = "Anterran Imports and Services", unit = "crates", class = "luxury component", risk = 0.18 },
return 1.0
{ name = "Abalone Shell Inlay Blanks", sector = "Agriculture", company = "Anterran Imports and Services", unit = "crates", class = "craft luxury", risk = 0.16 },
end


------------------------------------------------------------------------
-- Textiles and fine materials
-- 7) ROUTE NETWORK
{ name = "Silk", sector = "Agriculture", company = "Seamstresses of Rouge", unit = "rolls", class = "textile", risk = 0.18 },
------------------------------------------------------------------------
{ name = "Dyed Silk (Rouge crimson)", sector = "Agriculture", company = "Seamstresses of Rouge", unit = "rolls", class = "luxury textile", risk = 0.20 },
{ name = "Patterned Silk Brocade", sector = "Agriculture", company = "Seamstresses of Rouge", unit = "rolls", class = "luxury textile", risk = 0.22 },
{ name = "Woven Silk Sashes", sector = "Agriculture", company = "Seamstresses of Rouge", unit = "bundles", class = "festival textile", risk = 0.18 },
{ name = "Velvet Worm Silk Thread (spools)", sector = "Agriculture", company = "The Velvet Company", unit = "spools", class = "textile component", risk = 0.18 },
{ name = "Velvet Worm Silk Cloth (bolts)", sector = "Agriculture", company = "The Velvet Company", unit = "bolts", class = "luxury textile", risk = 0.22 },


local ROUTES = {
-- Stone, metal, construction
{ from = "Vaeringheim", to = "Mylecia", mode = "skybridge", km = 1479, risk = 0.18 },
{ name = "Purple Granite", sector = "Manufacturing", company = "Thalassian Temples Granite Export Cooperative", unit = "blocks", class = "stone", risk = 0.16 },
{ from = "Vaeringheim", to = "Luminaria", mode = "road", km = 320, risk = 0.20 },
{ name = "Purple Granite (Polished Slabs)", sector = "Manufacturing", company = "Thalassian Temples Granite Export Cooperative", unit = "slabs", class = "stone", risk = 0.18 },
{ from = "Vaeringheim", to = "Serena", mode = "ferry", km = 210, risk = 0.18 },
{ name = "Purple Granite Sealstone Tablets", sector = "Manufacturing", company = "Thalassian Temples Granite Export Cooperative", unit = "tablets", class = "ritual stone", risk = 0.20 },
{ from = "Vaeringheim", to = "Delphica", mode = "road", km = 410, risk = 0.22 },
{ name = "Iron", sector = "Manufacturing", company = "East Keltian Iron Company", unit = "tons", class = "metal", risk = 0.14 },
{ from = "Vaeringheim", to = "Somniumpolis", mode = "ferry", km = 520, risk = 0.30 },
{ name = "Steel", sector = "Manufacturing", company = "East Keltian Iron Company", unit = "tons", class = "metal", risk = 0.15 },
{ from = "Mylecia", to = "Jogi", mode = "road", km = 310, risk = 0.36 },
{ name = "Concrete", sector = "Manufacturing", company = "East Keltian Iron Company", unit = "tons", class = "construction", risk = 0.10 },
{ from = "Mylecia", to = "Sufriya", mode = "ferry", km = 460, risk = 0.34 },
{ name = "Timber beams", sector = "Manufacturing", company = "Eikskald Factors", unit = "loads", class = "construction", risk = 0.16 },
{ from = "Mylecia", to = "Hayr Chaskal", mode = "rail", km = 880, risk = 0.30 },
{ name = "Heavy dock hardware", sector = "Manufacturing", company = "Giid-Tally Yard", unit = "crates", class = "heavy freight", risk = 0.24 },
{ from = "Jogi", to = "Lykopolis", mode = "road", km = 240, risk = 0.38 },
{ from = "Sufriya", to = "Keybir-Aviv", mode = "ferry", km = 360, risk = 0.40 },
{ from = "Keybir-Aviv", to = "Lykopolis", mode = "road", km = 290, risk = 0.42 },
{ from = "Hayr Chaskal", to = "Saruq Thyrsa", mode = "road", km = 430, risk = 0.33 },
{ from = "Saruq Thyrsa", to = "Thal Qencha", mode = "rail", km = 520, risk = 0.28 },
{ from = "Thal Qencha", to = "Mirovia", mode = "rail", km = 690, risk = 0.31 },
{ from = "Mirovia", to = "Ayllu Nysara", mode = "road", km = 570, risk = 0.35 },
{ from = "Ayllu Nysara", to = "Mylecia", mode = "skybridge", km = 1480, risk = 0.24 },
{ from = "Aurelia", to = "Vaeringheim", mode = "road", km = 360, risk = 0.19 },
{ from = "Erythros", to = "Aurelia", mode = "road", km = 300, risk = 0.24 },
{ from = "Acheron", to = "Erythros", mode = "road", km = 440, risk = 0.30 },
{ from = "Acheron", to = "Ourid", mode = "pass-road", km = 510, risk = 0.37 },
{ from = "Luminaria", to = "Ourid", mode = "pass-road", km = 480, risk = 0.34 },
{ from = "Riddersborg", to = "Vaeringheim", mode = "rail", km = 760, risk = 0.24 },
{ from = "Riddersborg", to = "Acheron", mode = "road", km = 620, risk = 0.32 },
{ from = "Delphica", to = "Luminaria", mode = "road", km = 340, risk = 0.20 },
{ from = "Delphica", to = "Vaeringheim", mode = "road", km = 410, risk = 0.22 },
{ from = "Somniumpolis", to = "Vaeringheim", mode = "ferry", km = 520, risk = 0.30 }
}


local ROUTES_FROM = {}
-- Energy / infrastructure services as cargo-equivalent contracts
{ name = "Electricity (Peak Dispatch)", sector = "Manufacturing", company = "Roving Wind Farm Corporation", unit = "contracts", class = "energy contract", risk = 0.20 },
{ name = "Grid Stabilization (Frequency Response)", sector = "Manufacturing", company = "Roving Wind Farm Corporation", unit = "contracts", class = "energy contract", risk = 0.22 },
{ name = "Shore Power Hookups (Vessels)", sector = "Manufacturing", company = "Port of Qandra", unit = "connections", class = "port utility", risk = 0.18 },
{ name = "Backup Generator Dispatch (Emergency)", sector = "Manufacturing", company = "Port of Qandra", unit = "dispatches", class = "emergency utility", risk = 0.30 },
{ name = "High-Load Foundry Power Package", sector = "Manufacturing", company = "Bijarian Energy Corporation", unit = "contracts", class = "industrial utility", risk = 0.26 },


local function addRoute(r)
-- Licensed security / controlled freight
ROUTES_FROM[r.from] = ROUTES_FROM[r.from] or {}
{ name = "Thorakitai Field Armor Composite-Plated", sector = "Manufacturing", company = "Baratar Corporation", unit = "kits", class = "regulated security", risk = 0.46 },
table.insert(ROUTES_FROM[r.from], r)
{ name = "Cataphract Saddle Harness Reinforced", sector = "Manufacturing", company = "Baratar Corporation", unit = "sets", class = "regulated harness", risk = 0.30 },
end
{ name = "Xyston Utility Pack 14L Pilgrim Model", sector = "Manufacturing", company = "Baratar Corporation", unit = "packs", class = "pilgrim equipment", risk = 0.14 },
{ name = "Mycale Boots Knee-High Water-Wicking", sector = "Manufacturing", company = "Baratar Corporation", unit = "pairs", class = "field gear", risk = 0.12 },
{ name = "Thymiari Signal Kit Reedflare Tricolor", sector = "Manufacturing", company = "Baratar Corporation", unit = "units", class = "signal equipment", risk = 0.28 },
{ name = "Fanarion Torch Dual Beam Ritual Use", sector = "Manufacturing", company = "Baratar Corporation", unit = "units", class = "ritual equipment", risk = 0.20 },


for _, r in ipairs(ROUTES) do
-- General Port / Hatch / customs services and salvage
addRoute(r)
{ name = "Confiscated Trade Crates", sector = "Services", company = "Hatch Ministry", unit = "units", class = "salvage", risk = 0.42 },
addRoute({
{ name = "Mixed Merchant Cargo", sector = "Services", company = "Hatch Ministry", unit = "tons", class = "salvage", risk = 0.38 },
from = r.to,
{ name = "Recovered Cargo Manifests", sector = "Services", company = "Hatch Ministry", unit = "scrolls", class = "documents", risk = 0.30 },
to = r.from,
{ name = "Stamped Silver Bars", sector = "Services", company = "Hatch Ministry", unit = "kg", class = "high value", risk = 0.48 },
mode = r.mode,
{ name = "High-Value Personal Effects", sector = "Services", company = "Hatch Ministry", unit = "items", class = "high value", risk = 0.46 },
km = r.km,
{ name = "Private Letters and Ship Logs", sector = "Services", company = "Hatch Ministry", unit = "volumes", class = "documents", risk = 0.32 },
risk = r.risk + 0.01
{ name = "Blocked Port Registry Books", sector = "Services", company = "Lake Morovia Blockade Fund - Hatch Ministry", unit = "ledgers", class = "documents", risk = 0.34 },
})
{ name = "Confiscated Calibration Weights", sector = "Services", company = "Lake Morovia Blockade Fund - Hatch Ministry", unit = "sets", class = "port equipment", risk = 0.28 },
end


local function routesFrom(city)
-- Maskmakers / intelligence / temple / courier services
return ROUTES_FROM[city] or {}
{ name = "Ceremonial Masks (lacquered)", sector = "Services", company = "Maritime Guild of the Cult of Maskmakers", unit = "masks", class = "ritual craft", risk = 0.22 },
end
{ name = "Crated Mask Lots (mixed export)", sector = "Services", company = "Maritime Guild of the Cult of Maskmakers", unit = "crates", class = "craft export", risk = 0.18 },
{ name = "Background Dossier (merchant vetting)", sector = "Services", company = "Ergonian Spy Agency", unit = "reports", class = "intelligence", risk = 0.48 },
{ name = "Cipher & Codebook Service (per batch)", sector = "Services", company = "Ergonian Spy Agency", unit = "batches", class = "intelligence", risk = 0.50 },
{ name = "Dead-Drop Setup (secure route)", sector = "Services", company = "Ergonian Spy Agency", unit = "routes", class = "intelligence", risk = 0.54 },
{ name = "Missionaries of Harmony Sanctum", sector = "Services", company = "Temple Bank of the Reformed Stripping Path", unit = "teams", class = "mission escort", risk = 0.34 },
{ name = "Missionaries of the Azure Sentinel Sect", sector = "Services", company = "Temple Bank of the Reformed Stripping Path", unit = "teams", class = "mission escort", risk = 0.30 },
{ name = "Missionaries of Reverie Nebulous", sector = "Services", company = "Temple Bank of the Reformed Stripping Path", unit = "teams", class = "mission escort", risk = 0.38 },
{ name = "Missionaries of the Order of the Umbral Oracle", sector = "Services", company = "Temple Bank of the Reformed Stripping Path", unit = "teams", class = "mission escort", risk = 0.36 },


local function pickRoute(rec, house, ctx, day, idx)
-- Local Southern Strait / custom league goods
local opts = routesFrom(rec.location or house.location or house.origin)
{ name = "Agrithor Orys grain", sector = "Agriculture", company = "Wardens of the Fields of Callope", unit = "tons", class = "grain", risk = 0.08 },
if not opts or #opts == 0 then opts = ROUTES end
{ name = "Aureum lamp oil", sector = "Agriculture", company = "Wardens of the Fields of Callope", unit = "casks", class = "oil", risk = 0.14 },
{ name = "Clean water casks", sector = "Agriculture", company = "Lenaian Water Company", unit = "casks", class = "water", risk = 0.12 },
{ name = "Spring-fed watercress", sector = "Agriculture", company = "Lenaian Water Company", unit = "crates", class = "perishable greens", risk = 0.26 },
{ name = "Spring-pond trout", sector = "Agriculture", company = "Lenaian Water Company", unit = "ice crates", class = "perishable fish", risk = 0.32 },
{ name = "Cistern melons", sector = "Agriculture", company = "Pontic Esperance Homestead Association", unit = "hampers", class = "desert produce", risk = 0.24 },
{ name = "Dryland cucumbers", sector = "Agriculture", company = "Pontic Esperance Homestead Association", unit = "crates", class = "desert produce", risk = 0.22 },
{ name = "Ferry mats", sector = "Manufacturing", company = "Thyrsa Maena Harbor Cooperative", unit = "bundles", class = "dock supply", risk = 0.10 },
{ name = "Cargo padding", sector = "Manufacturing", company = "Thyrsa Maena Harbor Cooperative", unit = "rolls", class = "dock supply", risk = 0.08 },
{ name = "Cane-fiber dock twine", sector = "Manufacturing", company = "Thyrsa Maena Harbor Cooperative", unit = "spools", class = "dock supply", risk = 0.08 },
{ name = "Reed screens", sector = "Manufacturing", company = "Thyrsa Maena Harbor Cooperative", unit = "panels", class = "dock supply", risk = 0.09 },
{ name = "Glassware crates", sector = "Manufacturing", company = "Velai Glass & Scrip", unit = "crates", class = "fragile luxury", risk = 0.38 },
{ name = "Invoice scrip packets", sector = "Services", company = "Velai Glass & Scrip", unit = "sealed packets", class = "documents", risk = 0.24 },
{ name = "Luxury shrine linens", sector = "Services", company = "House Qamar", unit = "chests", class = "ritual luxury", risk = 0.30 },
{ name = "Courier packets", sector = "Services", company = "Ourid Shrine Accounts", unit = "packets", class = "courier", risk = 0.22 },
{ name = "Dream-city luxury parcels", sector = "Services", company = "Sandveil Commissions", unit = "parcels", class = "luxury", risk = 0.34 }
}


return chooseWeighted(opts, ctx.seasonKey .. "|route|" .. house.id .. "|" .. day .. "|" .. idx, function(r)
------------------------------------------------------------------------
local w = 1.0
-- ROUTES
if house.tags.ferry and r.mode == "ferry" then w = w + house.tags.ferry * 0.45 end
------------------------------------------------------------------------
if house.tags.pass and r.mode == "pass-road" then w = w + house.tags.pass * 0.40 end
if house.tags.courier and r.mode == "skybridge" then w = w + house.tags.courier * 0.18 end
if house.tags.rough and r.risk >= 0.35 then w = w + house.tags.rough * 0.25 end
if house.risk < 0.45 and r.risk > 0.36 then w = w * 0.72 end
return w
end)
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 8) CARGO SELECTION
-- COMPREHENSIVE ROUTE ATLAS
-- Distances are gameplay/wiki approximations for league scoring.
------------------------------------------------------------------------
------------------------------------------------------------------------


local function hasTag(cargo, tag)
local ROUTES = {
for _, t in ipairs(cargo.tags or {}) do
-- Lake Morovia / General Port core
if t == tag then return true end
{ from = "Vaeringheim", to = "Mylecia", mode = "skybridge", km = 1479, risk = 0.18, corridor = "Morovia-Haifa trunk" },
end
{ from = "Vaeringheim", to = "Serena", mode = "ferry", km = 210, risk = 0.18, corridor = "Lake Morovia ferry" },
return false
{ from = "Vaeringheim", to = "Luminaria", mode = "road", km = 320, risk = 0.20, corridor = "Central Bassaridian road" },
end
{ from = "Vaeringheim", to = "Delphica", mode = "road", km = 410, risk = 0.22, corridor = "Central Bassaridian road" },
{ from = "Vaeringheim", to = "Somniumpolis", mode = "ferry", km = 520, risk = 0.30, corridor = "Lake Morovia night ferry" },
{ from = "Vaeringheim", to = "Aurelia", mode = "road", km = 360, risk = 0.19, corridor = "Aurelian road" },
{ from = "Vaeringheim", to = "Riddersborg", mode = "rail", km = 760, risk = 0.24, corridor = "Northern rail" },
{ from = "Vaeringheim", to = "Pyralis", mode = "road", km = 390, risk = 0.23, corridor = "Temple road" },
{ from = "Vaeringheim", to = "Sylvapolis", mode = "road", km = 440, risk = 0.25, corridor = "Verdant road" },
{ from = "Vaeringheim", to = "Catonis Atrium", mode = "road", km = 275, risk = 0.17, corridor = "Inner market road" },
{ from = "Vaeringheim", to = "Aetherium", mode = "skybridge", km = 620, risk = 0.16, corridor = "Skybridge feeder" },
{ from = "Vaeringheim", to = "Saluria", mode = "ferry", km = 340, risk = 0.21, corridor = "Lake Morovia ferry" },
 
-- Alpazkigz / inland commercial roads
{ from = "Aurelia", to = "Erythros", mode = "road", km = 300, risk = 0.24, corridor = "Alpazkigz road" },
{ from = "Erythros", to = "Acheron", mode = "road", km = 440, risk = 0.30, corridor = "Acheron road" },
{ from = "Acheron", to = "Ourid", mode = "pass-road", km = 510, risk = 0.37, corridor = "Ourid pass road" },
{ from = "Luminaria", to = "Ourid", mode = "pass-road", km = 480, risk = 0.34, corridor = "Shrine-pass road" },
{ from = "Aurelia", to = "Koinonia", mode = "road", km = 280, risk = 0.21, corridor = "Aurelian civic road" },
{ from = "Aurelia", to = "Ferrum Citadel", mode = "rail", km = 390, risk = 0.26, corridor = "Metal road" },
{ from = "Ferrum Citadel", to = "Nexa", mode = "road", km = 240, risk = 0.25, corridor = "Iron road" },
{ from = "Nexa", to = "Acheron", mode = "road", km = 360, risk = 0.31, corridor = "Eastern Alpazkigz road" },
{ from = "Acheron", to = "Riddersborg", mode = "road", km = 620, risk = 0.32, corridor = "Northern hard-road" },


local function cargoWeightForHouse(house, cargo, signals)
-- Mylecia / Haifan Bassaridia / wetland and coastal links
local w = 1.0
{ from = "Mylecia", to = "Jogi", mode = "road", km = 310, risk = 0.36, corridor = "Jogi wet-road" },
{ from = "Mylecia", to = "Sufriya", mode = "ferry", km = 460, risk = 0.34, corridor = "Haifan coastal ferry" },
{ from = "Mylecia", to = "Hayr Chaskal", mode = "rail", km = 880, risk = 0.30, corridor = "Southern Strait approach" },
{ from = "Mylecia", to = "Nysara Velai", mode = "skybridge", km = 1480, risk = 0.24, corridor = "Southern Strait skybridge" },
{ from = "Jogi", to = "Lykopolis", mode = "road", km = 240, risk = 0.38, corridor = "Wetland road" },
{ from = "Jogi", to = "Sufriya", mode = "ferry", km = 330, risk = 0.35, corridor = "Haifan ferry" },
{ from = "Sufriya", to = "Keybir-Aviv", mode = "ferry", km = 360, risk = 0.40, corridor = "Coastal ferry" },
{ from = "Keybir-Aviv", to = "Lykopolis", mode = "road", km = 290, risk = 0.42, corridor = "Haifan coast road" },
{ from = "Lykopolis", to = "Mylecia", mode = "road", km = 390, risk = 0.37, corridor = "Haifan return road" },


for _, tag in ipairs(cargo.tags or {}) do
-- Southern Strait Corridor
if house.tags[tag] then
{ from = "Hayr Chaskal", to = "Saruq Thyrsa", mode = "road", km = 430, risk = 0.33, corridor = "Southern Strait road" },
w = w + house.tags[tag] * 0.70
{ from = "Saruq Thyrsa", to = "Thal Lenaia", mode = "rail", km = 520, risk = 0.28, corridor = "Lacaran Hodos rail" },
end
{ from = "Thal Lenaia", to = "Mirovia", mode = "rail", km = 690, risk = 0.31, corridor = "Lacaran Hodos rail" },
end
{ from = "Mirovia", to = "Nysara Velai", mode = "road", km = 570, risk = 0.35, corridor = "Esperance road" },
{ from = "Nysara Velai", to = "Rumi-Kefaya", mode = "road", km = 420, risk = 0.36, corridor = "Southern Strait inland road" },
{ from = "Rumi-Kefaya", to = "Mirovia", mode = "road", km = 390, risk = 0.34, corridor = "Mirovian inland road" },
{ from = "Saruq Thyrsa", to = "Mirovia", mode = "rail", km = 960, risk = 0.32, corridor = "Long Southern Strait rail" },
{ from = "Hayr Chaskal", to = "Thal Lenaia", mode = "road", km = 810, risk = 0.36, corridor = "Snowholme approach road" },
{ from = "Mylecia", to = "Saruq Thyrsa", mode = "rail", km = 1180, risk = 0.32, corridor = "Southern Strait trunk" },
{ from = "Mylecia", to = "Mirovia", mode = "rail", km = 1760, risk = 0.34, corridor = "Southern Strait trunk" },


w = w * sectorPulse(cargo.sector, signals)
-- Northern / cold-road and timber routes
{ from = "Riddersborg", to = "Dragevik", mode = "road", km = 410, risk = 0.33, corridor = "Cold-road" },
{ from = "Dragevik", to = "Sårensby", mode = "road", km = 360, risk = 0.35, corridor = "Northern timber road" },
{ from = "Sårensby", to = "Vaeringheim", mode = "rail", km = 890, risk = 0.29, corridor = "Northern return rail" },
{ from = "Riddersborg", to = "Tonar", mode = "road", km = 540, risk = 0.36, corridor = "Steppe road" },
{ from = "Tonar", to = "Aurelia", mode = "road", km = 650, risk = 0.33, corridor = "Steppe-to-market road" },


if cargo.risk and house.risk then
-- Maritime / port-service routes
if cargo.risk > 0.35 and house.risk > 0.60 then
{ from = "Vaeringheim", to = "Port of Qandra", mode = "ferry", km = 710, risk = 0.30, corridor = "Port utility ferry" },
w = w + 1.4
{ from = "Port of Qandra", to = "Serena", mode = "ferry", km = 460, risk = 0.26, corridor = "Utility ferry" },
elseif cargo.risk > 0.35 and house.risk < 0.45 then
{ from = "Port of Qandra", to = "Mylecia", mode = "ferry", km = 940, risk = 0.36, corridor = "Long-haul ferry" },
w = w * 0.65
{ from = "Vaeringheim", to = "Pentiad Terminal", mode = "ferry", km = 190, risk = 0.14, corridor = "Terminal shuttle" },
end
{ from = "Pentiad Terminal", to = "Mylecia", mode = "ferry", km = 780, risk = 0.31, corridor = "Container ferry" },
end
{ from = "Pentiad Terminal", to = "Serena", mode = "ferry", km = 260, risk = 0.18, corridor = "Terminal ferry" },


return w
-- Odiferia / Somniumpolis / dream-city goods
end
{ from = "Somniumpolis", to = "Odiferian Tribes", mode = "road", km = 360, risk = 0.39, corridor = "Odiferian meadow road" },
{ from = "Somniumpolis", to = "Vaeringheim", mode = "ferry", km = 520, risk = 0.30, corridor = "Lake Morovia night ferry" },
{ from = "Odiferian Tribes", to = "Luminaria", mode = "road", km = 640, risk = 0.38, corridor = "Odiferian caravan road" },
{ from = "Odiferian Tribes", to = "Aurelia", mode = "road", km = 780, risk = 0.40, corridor = "Long meadow road" },


local function pickCargo(house, ctx, day, idx, signals)
-- Foreign / ally desk routes
return chooseWeighted(CARGO, ctx.seasonKey .. "|cargo|" .. house.id .. "|" .. day .. "|" .. idx, function(c)
{ from = "Vaeringheim", to = "Imperial Federation Desk", mode = "ferry", km = 1320, risk = 0.36, corridor = "Foreign desk sea-lane" },
return cargoWeightForHouse(house, c, signals)
{ from = "Vaeringheim", to = "Moorland Desk", mode = "ferry", km = 980, risk = 0.34, corridor = "Foreign desk sea-lane" },
end)
{ from = "Mylecia", to = "Imperial Federation Desk", mode = "ferry", km = 1420, risk = 0.38, corridor = "Foreign desk sea-lane" },
end
{ from = "Vaeringheim", to = "Haifa Compliance Exchange", mode = "road", km = 60, risk = 0.08, corridor = "Administrative route" },
{ from = "General Port Customs Yard", to = "Pentiad Terminal", mode = "road", km = 24, risk = 0.06, corridor = "Port shuttle" },
{ from = "General Port Customs Yard", to = "Haifa Compliance Exchange", mode = "road", km = 18, risk = 0.05, corridor = "Administrative route" },


------------------------------------------------------------------------
-- High-risk special contracts
-- 9) SIMULATION
{ from = "Jogi", to = "General Port Customs Yard", mode = "ferry", km = 620, risk = 0.42, corridor = "Customs recovery route" },
------------------------------------------------------------------------
{ from = "Sufriya", to = "Pentiad Terminal", mode = "ferry", km = 740, risk = 0.44, corridor = "Customs recovery route" },
{ from = "Keybir-Aviv", to = "Haifa Compliance Exchange", mode = "road", km = 820, risk = 0.45, corridor = "Compliance route" },
{ from = "Hayr Chaskal", to = "General Port Customs Yard", mode = "rail", km = 1640, risk = 0.37, corridor = "Inspection return rail" },
{ from = "Mirovia", to = "Temple Bank Mission Yard", mode = "road", km = 44, risk = 0.12, corridor = "Mission-yard shuttle" },
{ from = "Saruq Thyrsa", to = "Temple Bank Mission Yard", mode = "road", km = 52, risk = 0.13, corridor = "Mission-yard shuttle" }
}


local INCIDENTS = {
local INCIDENTS = {
Line 629: Line 628:
}
}


local function emptyRecord(house)
------------------------------------------------------------------------
return {
-- LIGHTWEIGHT RECORD CALCULATION
house = house,
------------------------------------------------------------------------
location = house.location or house.origin,
 
contracts = 0,
local function currentStable(house, ctx)
late = 0,
local upgradeSeed = randKey(ctx.seasonKey .. "|stable|" .. house.id)
earnings = 0,
local bonus = 0
trade = 0,
rep = house.rep or 0,
incidents = 0,
sealed = 0,
ritual = 0,
km = 0,
stable = house.stable or 1,
stablePrestige = stablePrestige(house.stable or 1),
cargoMap = {},
modeMap = {},
lastTender = nil,
score = 0
}
end


local function tendersForDay(house, ctx, day)
if ctx.day >= 21 and upgradeSeed > 0.78 then bonus = bonus + 1 end
local r = randKey(ctx.seasonKey .. "|tenders|" .. house.id .. "|" .. day)
if ctx.day >= 44 and upgradeSeed > 0.92 then bonus = bonus + 1 end
local active = clamp((house.skill or 0.8) + (house.risk or 0.5) * 0.12, 0.65, 1.05)


if r < 0.18 - active * 0.06 then return 0 end
return clamp((house.stable or 1) + bonus, 1, #STABLES)
if r < 0.76 then return 1 end
if r < 0.95 then return 2 end
return 3
end
end


local function routePayMultiplier(mode)
local function topCargo(house, ctx)
if mode == "skybridge" then return 1.26 end
return pick(house.cargo, ctx.seasonKey .. "|cargo|" .. house.id)
if mode == "rail" then return 1.12 end
if mode == "ferry" then return 1.08 end
if mode == "pass-road" then return 1.18 end
return 1.00
end
end


local function routeSpeed(mode)
local function computeRecord(house, ctx)
if mode == "skybridge" then return 140 end
local day = ctx.day
if mode == "rail" then return 70 end
local seasonSeed = randKey(ctx.seasonKey .. "|house|" .. house.id)
if mode == "ferry" then return 28 end
local formSeed = randKey(ctx.seasonKey .. "|form|" .. house.id)
if mode == "pass-road" then return 18 end
local riskSeed = randKey(ctx.seasonKey .. "|risk|" .. house.id)
return 24
end


local function simulateTender(rec, house, ctx, day, idx, signals)
local form = 0.88 + formSeed * 0.24
local route = pickRoute(rec, house, ctx, day, idx)
local pace = (house.pace or 0.9) * form
local cargo = pickCargo(house, ctx, day, idx, signals)


local capacity = stableCapacity(rec.stable or house.stable or 1)
local contracts = math.floor(day * pace + seasonSeed * 3)
local qtyPulse = 0.65 + randKey(ctx.seasonKey .. "|qty|" .. house.id .. "|" .. day .. "|" .. idx) * 1.10
if contracts < 0 then contracts = 0 end
local quantity = math.max(1, round(qtyPulse * capacity * 4))


local basePay = 64
local lateRate = 0.035
+ (tonumber(route.km) or 0) * 0.115
+ (1 - (house.reliability or 0.8)) * 0.18
+ (tonumber(cargo.base) or 10) * 3.4
+ (house.risk or 0.5) * 0.055
+ (tonumber(route.risk) or 0.2) * 190
+ riskSeed * 0.025
+ (tonumber(cargo.risk) or 0.1) * 120
+ quantity * 12


basePay = basePay * routePayMultiplier(route.mode)
local late = math.floor(contracts * lateRate + randKey(ctx.seasonKey .. "|late|" .. house.id) * 2.1)
basePay = basePay * sectorPulse(cargo.sector, signals)
if late > contracts then late = contracts end


local sealed = hasTag(cargo, "sealed") or randKey(ctx.seasonKey .. "|sealed|" .. house.id .. "|" .. day .. "|" .. idx) < 0.16
local onTime = contracts - late
local ritual = hasTag(cargo, "ritual")
local stable = currentStable(house, ctx)
local prestige = stablePrestige(stable)


if sealed then basePay = basePay * 1.08 end
local sealed = math.floor(contracts * (0.10 + (house.risk or 0.5) * 0.045) + randKey(ctx.seasonKey .. "|sealed|" .. house.id) * 2)
if ritual then basePay = basePay * 1.10 end
local ritual = 0
if house.id == "qamar" or house.id == "ourid" or house.id == "sanvei" then
ritual = math.floor(contracts * 0.18 + randKey(ctx.seasonKey .. "|ritual|" .. house.id) * 2)
elseif house.id == "bluequay" or house.id == "thalmaen" then
ritual = math.floor(contracts * 0.06 + randKey(ctx.seasonKey .. "|ritual|" .. house.id) * 1)
else
ritual = math.floor(contracts * 0.03 + randKey(ctx.seasonKey .. "|ritual|" .. house.id))
end


local riskLoad = (route.risk or 0.2) * 0.55 + (cargo.risk or 0.1) * 0.45
local averageTender = 310
local onTimeChance =
+ prestige * 3.2
(house.skill or 0.8) * 0.48
+ (house.risk or 0.5) * 130
+ (house.reliability or 0.8) * 0.40
+ (house.reliability or 0.8) * 60
+ (stableCapacity(rec.stable or house.stable or 1) - 1.0) * 0.055
+ seasonSeed * 90
- riskLoad * 0.24


if route.mode == "skybridge" then onTimeChance = onTimeChance + 0.05 end
local income = contracts * averageTender
if route.mode == "ferry" then onTimeChance = onTimeChance - 0.02 end
income = income - late * averageTender * 0.35
if hasTag(cargo, "perishable") then onTimeChance = onTimeChance - 0.04 end
income = income + sealed * 55 + ritual * 70
if hasTag(cargo, "fragile") then onTimeChance = onTimeChance - 0.04 end


onTimeChance = clamp(onTimeChance, 0.35, 0.96)
local trade = income * (0.18 + randKey(ctx.seasonKey .. "|trade|" .. house.id) * 0.34)


local onTime = randKey(ctx.seasonKey .. "|ontime|" .. house.id .. "|" .. day .. "|" .. idx) <= onTimeChance
local avgKm = 260 + (house.risk or 0.5) * 350 + randKey(ctx.seasonKey .. "|km|" .. house.id) * 210
if house.mode == "skybridge" then avgKm = avgKm + 520 end
if house.mode == "rail" then avgKm = avgKm + 180 end
if house.mode == "ferry" then avgKm = avgKm + 120 end
if house.mode == "pass-road" then avgKm = avgKm + 160 end


local incidentChance = clamp(
local km = contracts * avgKm
(route.risk or 0.2) * 0.12
+ (cargo.risk or 0.1) * 0.08
+ (1 - (house.reliability or 0.8)) * 0.06,
0.02,
0.30
)


local incident = nil
local rep = (house.baseRep or 10)
if randKey(ctx.seasonKey .. "|incident|" .. house.id .. "|" .. day .. "|" .. idx) < incidentChance then
+ onTime * (0.085 + (house.reliability or 0.8) * 0.045)
incident = pick(INCIDENTS, ctx.seasonKey .. "|incidentPick|" .. house.id .. "|" .. day .. "|" .. idx)
- late * 1.45
end
+ sealed * 0.20
+ ritual * 0.32
+ prestige * 0.025


if incident and randKey(ctx.seasonKey .. "|incidentLate|" .. house.id .. "|" .. day .. "|" .. idx) < 0.55 then
rep = clamp(rep, 0, 40)
onTime = false
end


local pay = round(basePay)
local incidents = math.floor(contracts * ((house.risk or 0.5) * 0.045) + randKey(ctx.seasonKey .. "|incidents|" .. house.id) * 2)
local paid = onTime and pay or round(pay * 0.62)
if incidents > contracts then incidents = contracts end


local travelHours = (route.km or 0) / routeSpeed(route.mode)
local score =
if not onTime then travelHours = travelHours * 1.25 end
contracts * 62
if incident then travelHours = travelHours * 1.12 end
+ income * 0.31
+ trade * 0.035
+ rep * 24
+ prestige * 0.70
+ sealed * 24
+ ritual * 28
+ km * 0.018
- late * 72
- incidents * 18


local tender = {
local rec = {
day = day,
house = house,
date = formatDate(ctx, day),
contracts = contracts,
houseId = house.id,
merchant = house.merchant,
house = house.house,
from = route.from,
to = route.to,
mode = route.mode,
km = route.km,
cargo = cargo.name,
sector = cargo.sector,
company = cargo.company,
unit = cargo.unit,
quantity = quantity,
value = pay,
paid = paid,
onTime = onTime,
onTime = onTime,
late = not onTime,
late = late,
incident = incident,
earnings = round(income),
trade = round(trade),
rep = rep,
incidents = incidents,
sealed = sealed,
sealed = sealed,
ritual = ritual,
ritual = ritual,
travelHours = travelHours,
km = round(km),
risk = riskLoad
stable = stable,
stablePrestige = prestige,
topCargo = topCargo(house, ctx),
mode = house.mode,
score = round(score)
}
}


rec.contracts = rec.contracts + 1
return rec
rec.earnings = rec.earnings + paid
rec.trade = rec.trade + round(paid * (0.20 + randKey(ctx.seasonKey .. "|trade|" .. house.id .. "|" .. day .. "|" .. idx) * 0.55))
rec.km = rec.km + (route.km or 0)
rec.location = route.to
rec.lastTender = tender
 
if not onTime then rec.late = rec.late + 1 end
if incident then rec.incidents = rec.incidents + 1 end
if sealed then rec.sealed = rec.sealed + 1 end
if ritual then rec.ritual = rec.ritual + 1 end
 
rec.rep = rec.rep + (onTime and 1.15 or -1.65)
if sealed and onTime then rec.rep = rec.rep + 0.45 end
if ritual and onTime then rec.rep = rec.rep + 0.55 end
if incident then rec.rep = rec.rep - 0.35 end
rec.rep = clamp(rec.rep, 0, 40)
 
rec.cargoMap[cargo.name] = (rec.cargoMap[cargo.name] or 0) + 1
rec.modeMap[route.mode] = (rec.modeMap[route.mode] or 0) + 1
 
return tender
end
end


local function maybeStableUpgrade(rec, house, ctx, day, notices)
local function buildBoard(ctx)
local r = randKey(ctx.seasonKey .. "|stable|" .. house.id .. "|" .. day)
local list = {}
local threshold = 0.018 + (house.risk or 0.5) * 0.012
 
if r < threshold then
rec.stable = clamp((rec.stable or house.stable or 1) + 1, 1, #STABLES)
rec.stablePrestige = stablePrestige(rec.stable)
 
table.insert(notices, {
day = day,
type = "stable",
text = house.merchant .. " upgraded stable papers to a " .. stableName(rec.stable) .. "."
})
end
end
 
local function computeScore(rec)
local score =
rec.contracts * 70
+ rec.earnings * 0.30
+ rec.trade * 0.045
+ rec.rep * 25
+ rec.stablePrestige * 0.55
+ rec.sealed * 30
+ rec.ritual * 25
+ rec.km * 0.028
- rec.late * 70
- rec.incidents * 20
 
rec.score = round(score)
return rec.score
end
 
local function simulateBoard(ctx)
local signals = marketSignals()
local records = {}
local tendersByDay = {}
local notices = {}


for _, house in ipairs(HOUSES) do
for _, house in ipairs(HOUSES) do
records[house.id] = emptyRecord(house)
table.insert(list, computeRecord(house, ctx))
end
 
for day = 1, ctx.day do
tendersByDay[day] = {}
 
for _, house in ipairs(HOUSES) do
local rec = records[house.id]
local n = tendersForDay(house, ctx, day)
 
for i = 1, n do
local tender = simulateTender(rec, house, ctx, day, i, signals)
table.insert(tendersByDay[day], tender)
 
if tender.value >= 520 or tender.sealed or tender.ritual or tender.incident or tender.late then
local status = tender.onTime and "completed" or "settled late"
local extra = ""
if tender.incident then extra = " after " .. tender.incident end
table.insert(notices, {
day = day,
type = tender.late and "late" or (tender.incident and "incident" or "contract"),
text = tender.merchant .. " " .. status .. " a " .. tender.cargo .. " tender from " .. tender.from .. " to " .. tender.to .. extra .. "."
})
end
end
 
maybeStableUpgrade(rec, house, ctx, day, notices)
end
 
table.sort(tendersByDay[day], function(a, b)
if a.value ~= b.value then return a.value > b.value end
return a.merchant < b.merchant
end)
end
 
local list = {}
for _, rec in pairs(records) do
computeScore(rec)
table.insert(list, rec)
end
end


Line 879: Line 760:
end)
end)


local byId = {}
for i, rec in ipairs(list) do
for i, rec in ipairs(list) do
rec.rank = i
rec.rank = i
byId[rec.house.id] = rec
end
end


return {
return {
ctx = ctx,
ctx = ctx,
signals = signals,
records = records,
list = list,
list = list,
tendersByDay = tendersByDay,
byId = byId
notices = notices
}
}
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 10) RENDER HELPERS
-- DAILY TENDER FLAVOR
------------------------------------------------------------------------
------------------------------------------------------------------------


local function topCargo(rec)
local function generateTender(ctx, board, index)
local bestName, bestCount = "—", 0
local rec = board.list[((index - 1) % #board.list) + 1]
for name, count in pairs(rec.cargoMap or {}) do
local house = rec.house
if count > bestCount or (count == bestCount and name < bestName) then
 
bestName = name
local route = pick(ROUTES, ctx.seasonKey .. "|dailyRoute|" .. ctx.day .. "|" .. house.id .. "|" .. index)
bestCount = count
local cargo = pick(house.cargo, ctx.seasonKey .. "|dailyCargo|" .. ctx.day .. "|" .. house.id .. "|" .. index)
end
local incident = nil
 
local incidentChance = 0.05 + (house.risk or 0.5) * 0.08
if randKey(ctx.seasonKey .. "|dailyIncident|" .. ctx.day .. "|" .. house.id .. "|" .. index) < incidentChance then
incident = pick(INCIDENTS, ctx.seasonKey .. "|incidentPick|" .. ctx.day .. "|" .. house.id .. "|" .. index)
end
end
if bestCount <= 0 then return "—" end
return bestName .. " (" .. tostring(bestCount) .. ")"
end


local function modeSummary(rec)
local onTimeChance = clamp(
local parts = {}
0.62
local order = { "road", "rail", "ferry", "skybridge", "pass-road" }
+ (house.reliability or 0.8) * 0.28
for _, mode in ipairs(order) do
- (route.risk or 0.2) * 0.22
local n = rec.modeMap and rec.modeMap[mode] or 0
- (incident and 0.16 or 0),
if n > 0 then table.insert(parts, mode .. " " .. tostring(n)) end
0.32,
end
0.96
if #parts == 0 then return "—" end
)
return table.concat(parts, "<br>")
 
end
local onTime = randKey(ctx.seasonKey .. "|dailyOnTime|" .. ctx.day .. "|" .. house.id .. "|" .. index) <= onTimeChance
 
local value = 260
+ (route.km or 0) * 0.26
+ rec.stablePrestige * 2.4
+ (house.risk or 0.5) * 95
+ randKey(ctx.seasonKey .. "|dailyValue|" .. ctx.day .. "|" .. house.id .. "|" .. index) * 120


local function renderSignalLine(signals)
return {
local parts = {}
merchant = house.merchant,
for _, sector in ipairs({ "Agriculture", "Manufacturing", "Services" }) do
house = house.house,
local s = signals[sector]
cargo = cargo,
if s then
from = route.from,
table.insert(parts, (s.label or sector) .. ": " .. (s.level or "Standard") .. " " .. string.format("(%.2f%%)", s.pct or 0))
to = route.to,
end
mode = route.mode,
end
km = route.km,
return table.concat(parts, " · ")
value = round(value),
onTime = onTime,
incident = incident
}
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- 11) PUBLIC RENDER FUNCTIONS
-- INTERNAL RENDERERS
------------------------------------------------------------------------
------------------------------------------------------------------------


function p.renderSeasonInfo(frame)
local function renderSeasonInfoFromBoard(board)
local ctx = seasonArgs(frame)
local ctx = board.ctx
local board = simulateBoard(ctx)
local leader = board.list[1]
local leader = board.list[1]


Line 951: Line 840:
out[#out+1] = '| Board status || ' .. (ctx.day >= 61 and "Final table" or "Active competition")
out[#out+1] = '| Board status || ' .. (ctx.day >= 61 and "Final table" or "Active competition")
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '| Current leader || ' .. (leader and (leader.house.merchant .. " (" .. leader.house.house .. ")") or "—")
out[#out+1] = '| Current leader || ' .. leader.house.merchant .. ' (' .. leader.house.house .. ')'
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '| Market signals || ' .. renderSignalLine(board.signals)
out[#out+1] = '| Reset rule || Board resets at the beginning of each PSSC month.'
out[#out+1] = '|}'
out[#out+1] = '|}'
return table.concat(out, "\n")
return table.concat(out, "\n")
end
end


function p.renderStandings(frame)
local function renderStandingsFromBoard(board)
local ctx = seasonArgs(frame)
local ctx = board.ctx
local board = simulateBoard(ctx)
local out = {}


local out = {}
out[#out+1] = '{| class="wikitable sortable"'
out[#out+1] = '{| class="wikitable sortable"'
out[#out+1] = '|+ Vaeringheim Merchant League - Invitational Division standings, ' .. formatSeason(ctx) .. ' — day ' .. tostring(ctx.day) .. ' of 61'
out[#out+1] = '|+ Vaeringheim Merchant League - Invitational Division standings, ' .. formatSeason(ctx) .. ' — day ' .. tostring(ctx.day) .. ' of 61'
Line 988: Line 876:
.. ' || ' .. string.format("%.1f", rec.rep)
.. ' || ' .. string.format("%.1f", rec.rep)
.. ' || ' .. stableName(rec.stable)
.. ' || ' .. stableName(rec.stable)
.. ' || ' .. topCargo(rec)
.. ' || ' .. rec.topCargo
.. ' || ' .. tostring(rec.score)
.. ' || ' .. tostring(rec.score)
end
end
Line 996: Line 884:
end
end


p.standings = p.renderStandings
local function renderTenderBoardFromBoard(board, limit)
p.leaderboard = p.renderStandings
local ctx = board.ctx
 
limit = tonumber(limit) or 8
function p.renderSchedule(frame)
limit = clamp(limit, 1, 12)
local args = (frame and frame.args) or {}
local ctx = seasonArgs(frame)
 
local startDay = tonumber(args.start or "") or 1
local endDay = tonumber(args["end"] or args.finish or "") or DAYS_IN_SEASON
 
startDay = clamp(startDay, 1, DAYS_IN_SEASON)
endDay = clamp(endDay, startDay, DAYS_IN_SEASON)


local out = {}
local out = {}
out[#out+1] = '{| class="wikitable sortable"'
out[#out+1] = '{| class="wikitable sortable"'
out[#out+1] = '|+ Daily featured tender schedule, ' .. formatSeason(ctx)
out[#out+1] = '|+ Public tender board, ' .. formatDate(ctx, ctx.day)
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '! Day !! Date !! Featured board notice !! Likely route !! Leading claimant'
out[#out+1] = '! Rank !! Claimant !! Cargo !! Route !! Mode !! Posted value !! Result !! Incident'
 
for day = startDay, endDay do
local fakeCtx = {
year = ctx.year,
monthIndex = ctx.monthIndex,
month = ctx.month,
day = day,
seasonKey = ctx.seasonKey
}


local house = pick(HOUSES, ctx.seasonKey .. "|scheduleHouse|" .. day)
for i = 1, limit do
local rec = emptyRecord(house)
local t = generateTender(ctx, board, i)
local signals = marketSignals()
local route = pickRoute(rec, house, fakeCtx, day, 1)
local cargo = pickCargo(house, fakeCtx, day, 1, signals)


out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '| ' .. tostring(day)
out[#out+1] = '| ' .. tostring(i)
.. ' || ' .. formatDate(ctx, day)
.. ' || ' .. t.merchant .. '<br><small>' .. t.house .. '</small>'
.. ' || ' .. cargo.name
.. ' || ' .. t.cargo
.. ' || ' .. route.from .. ' → ' .. route.to .. ' (' .. route.mode .. ')'
.. ' || ' .. t.from .. ' → ' .. t.to .. '<br><small>' .. tostring(t.km) .. ' km</small>'
.. ' || ' .. house.merchant .. ', ' .. house.house
.. ' || ' .. t.mode
.. ' || ' .. formatMoney(t.value)
.. ' || ' .. (t.onTime and "On time" or "Late")
.. ' || ' .. (t.incident or "—")
end
end


Line 1,042: Line 913:
end
end


function p.renderTenderBoard(frame)
local function renderNoticesFromBoard(board, limit)
local ctx = seasonArgs(frame)
local ctx = board.ctx
local board = simulateBoard(ctx)
limit = tonumber(limit) or 6
local day = ctx.day
limit = clamp(limit, 1, 10)
local tenders = board.tendersByDay[day] or {}


local limit = tonumber((frame and frame.args and frame.args.limit) or "") or 12
local noticeTypes = {
"claimed a sealed tender before sunrise",
"passed customs inspection without amendment",
"lost time to a ferry queue",
"secured an improved stable certificate",
"completed a difficult route under poor conditions",
"received a manifest challenge from port clerks",
"posted a clean ledger stamp",
"was seen bidding for a prestige contract",
"settled a late delivery at reduced value",
"gained favor among route clerks"
}


local out = {}
local out = {}
out[#out+1] = '{| class="wikitable sortable"'
out[#out+1] = '{| class="wikitable"'
out[#out+1] = '|+ Public tender board, ' .. formatDate(ctx, day)
out[#out+1] = '|+ League notices, ' .. formatDate(ctx, ctx.day)
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '! Rank !! Claimant !! Cargo !! Route !! Mode !! Quantity !! Posted value !! Result !! Incident'
out[#out+1] = '! Notice !! House !! Clerk note'
 
for i = 1, limit do
local rec = board.list[((i - 1) % #board.list) + 1]
local note = pick(noticeTypes, ctx.seasonKey .. "|notice|" .. ctx.day .. "|" .. rec.house.id .. "|" .. i)


for i = 1, math.min(limit, #tenders) do
local t = tenders[i]
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '| ' .. tostring(i)
out[#out+1] = '| ' .. tostring(i)
.. ' || ' .. t.merchant .. '<br><small>' .. t.house .. '</small>'
.. ' || ' .. rec.house.house
.. ' || ' .. t.cargo .. '<br><small>' .. t.company .. '</small>'
.. ' || ' .. rec.house.merchant .. ' ' .. note .. '.'
.. ' || ' .. t.from .. ' → ' .. t.to
.. ' || ' .. t.mode
.. ' || ' .. tostring(t.quantity) .. ' ' .. t.unit
.. ' || ' .. formatMoney(t.value)
.. ' || ' .. (t.onTime and "On time" or "Late")
.. ' || ' .. (t.incident or "—")
end
 
if #tenders == 0 then
out[#out+1] = '|-'
out[#out+1] = '| colspan="9" | No public tenders were reconciled for this day.'
end
end


Line 1,079: Line 951:
end
end


function p.renderTenderReport(frame)
------------------------------------------------------------------------
local args = (frame and frame.args) or {}
-- PUBLIC FUNCTIONS
------------------------------------------------------------------------
 
function p.renderSeasonInfo(frame)
local ctx = seasonArgs(frame)
local ctx = seasonArgs(frame)
local board = simulateBoard(ctx)
local board = buildBoard(ctx)
return renderSeasonInfoFromBoard(board)
end


local day = tonumber(args.day or args.d or "") or ctx.day
function p.renderStandings(frame)
day = clamp(day, 1, ctx.day)
local ctx = seasonArgs(frame)
local board = buildBoard(ctx)
return renderStandingsFromBoard(board)
end


local index = tonumber(args.tender or args.index or args[4] or "") or 1
p.standings = p.renderStandings
local tenders = board.tendersByDay[day] or {}
p.leaderboard = p.renderStandings
local t = tenders[index] or tenders[1]


if not t then
function p.renderTenderBoard(frame)
return "'''No tender report is available for " .. formatDate(ctx, day) .. ".'''"
local args = (frame and frame.args) or {}
end
local ctx = seasonArgs(frame)
 
local board = buildBoard(ctx)
local out = {}
return renderTenderBoardFromBoard(board, args.limit)
out[#out+1] = "'''Tender Report — " .. t.cargo .. " to " .. t.to .. "'''"
out[#out+1] = ""
out[#out+1] = t.merchant .. " of " .. t.house .. " claimed a " .. t.cargo .. " tender from " .. t.from .. " to " .. t.to .. ". The consignment moved by " .. t.mode .. " over approximately " .. tostring(round(t.km)) .. " km. The posted value was " .. formatMoney(t.value) .. ", with final settlement recorded at " .. formatMoney(t.paid) .. "."
out[#out+1] = ""
out[#out+1] = '{| class="wikitable"'
out[#out+1] = '|-'
out[#out+1] = '! Field !! Value'
out[#out+1] = '|-'
out[#out+1] = '| Date || ' .. formatDate(ctx, day)
out[#out+1] = '|-'
out[#out+1] = '| Merchant || ' .. t.merchant
out[#out+1] = '|-'
out[#out+1] = '| House || ' .. t.house
out[#out+1] = '|-'
out[#out+1] = '| Cargo || ' .. t.cargo
out[#out+1] = '|-'
out[#out+1] = '| Sector || ' .. t.sector
out[#out+1] = '|-'
out[#out+1] = '| Route || ' .. t.from .. ' → ' .. t.to
out[#out+1] = '|-'
out[#out+1] = '| Mode || ' .. t.mode
out[#out+1] = '|-'
out[#out+1] = '| Distance || ' .. tostring(round(t.km)) .. ' km'
out[#out+1] = '|-'
out[#out+1] = '| Quantity || ' .. tostring(t.quantity) .. ' ' .. t.unit
out[#out+1] = '|-'
out[#out+1] = '| Posted value || ' .. formatMoney(t.value)
out[#out+1] = '|-'
out[#out+1] = '| Settlement || ' .. formatMoney(t.paid)
out[#out+1] = '|-'
out[#out+1] = '| Result || ' .. (t.onTime and "On time" or "Late")
out[#out+1] = '|-'
out[#out+1] = '| Incident || ' .. (t.incident or "—")
out[#out+1] = '|-'
out[#out+1] = '| Sealed cargo || ' .. (t.sealed and "Yes" or "No")
out[#out+1] = '|-'
out[#out+1] = '| Ritual cargo || ' .. (t.ritual and "Yes" or "No")
out[#out+1] = '|}'
return table.concat(out, "\n")
end
end


function p.renderDailyNotices(frame)
function p.renderDailyNotices(frame)
local args = (frame and frame.args) or {}
local ctx = seasonArgs(frame)
local ctx = seasonArgs(frame)
local board = simulateBoard(ctx)
local board = buildBoard(ctx)
local args = (frame and frame.args) or {}
return renderNoticesFromBoard(board, args.limit)
local limit = tonumber(args.limit or "") or 10
 
local filtered = {}
for i = #board.notices, 1, -1 do
local n = board.notices[i]
if n.day <= ctx.day then
table.insert(filtered, n)
end
if #filtered >= limit then break end
end
 
local out = {}
out[#out+1] = '{| class="wikitable"'
out[#out+1] = '|+ League notices, ' .. formatSeason(ctx) .. ' through day ' .. tostring(ctx.day)
out[#out+1] = '|-'
out[#out+1] = '! Day !! Type !! Notice'
 
for _, n in ipairs(filtered) do
out[#out+1] = '|-'
out[#out+1] = '| ' .. tostring(n.day)
.. ' || ' .. n.type
.. ' || ' .. n.text
end
 
if #filtered == 0 then
out[#out+1] = '|-'
out[#out+1] = '| colspan="3" | No major league notices have been posted yet.'
end
 
out[#out+1] = '|}'
return table.concat(out, "\n")
end
end


Line 1,196: Line 1,006:
local args = (frame and frame.args) or {}
local args = (frame and frame.args) or {}
local ctx = seasonArgs(frame)
local ctx = seasonArgs(frame)
local board = simulateBoard(ctx)
local board = buildBoard(ctx)


local query = args.house or args.merchant or args[4] or args[1]
local query = args.house or args.merchant or args[1]
local h = findHouse(query)
local house = findHouse(query)


if not h then
if not house then
local options = {}
local options = {}
for _, x in ipairs(HOUSES) do
for _, h in ipairs(HOUSES) do
table.insert(options, x.house)
table.insert(options, h.house)
end
end
return "'''Unknown merchant house.''' Available houses: " .. table.concat(options, "; ")
return "'''Unknown merchant house.''' Available houses: " .. table.concat(options, "; ")
end
end


local rec = board.records[h.id]
local rec = board.byId[house.id]
if not rec then return "'''No ledger record found for " .. h.house .. ".'''" end


local out = {}
local out = {}
out[#out+1] = '{| class="wikitable"'
out[#out+1] = '{| class="wikitable"'
out[#out+1] = '|+ Ledger profile — ' .. h.house .. ', ' .. formatSeason(ctx)
out[#out+1] = '|+ Ledger profile — ' .. house.house .. ', ' .. formatSeason(ctx)
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '! Field !! Value'
out[#out+1] = '! Field !! Value'
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '| Merchant || ' .. h.merchant
out[#out+1] = '| Merchant || ' .. house.merchant
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '| House || ' .. h.house
out[#out+1] = '| House || ' .. house.house
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '| Origin || ' .. h.origin
out[#out+1] = '| Origin || ' .. house.origin
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '| Current location || ' .. rec.location
out[#out+1] = '| Profile || ' .. house.profile
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '| Profile || ' .. h.profile
out[#out+1] = '| Current rank || #' .. tostring(rec.rank)
out[#out+1] = '|-'
out[#out+1] = '| Rank || #' .. tostring(rec.rank or "—")
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '| Score || ' .. tostring(rec.score)
out[#out+1] = '| Score || ' .. tostring(rec.score)
Line 1,244: Line 1,051:
out[#out+1] = '| Stable papers || ' .. stableName(rec.stable)
out[#out+1] = '| Stable papers || ' .. stableName(rec.stable)
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '| Distance moved || ' .. tostring(round(rec.km)) .. ' km'
out[#out+1] = '| Usual route mode || ' .. rec.mode
out[#out+1] = '|-'
out[#out+1] = '| Distance credited || ' .. tostring(rec.km) .. ' km'
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '| Sealed tenders || ' .. tostring(rec.sealed)
out[#out+1] = '| Sealed tenders || ' .. tostring(rec.sealed)
Line 1,250: Line 1,059:
out[#out+1] = '| Ritual tenders || ' .. tostring(rec.ritual)
out[#out+1] = '| Ritual tenders || ' .. tostring(rec.ritual)
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '| Route modes || ' .. modeSummary(rec)
out[#out+1] = '| Top cargo || ' .. rec.topCargo
out[#out+1] = '|-'
out[#out+1] = '| Top cargo || ' .. topCargo(rec)
out[#out+1] = '|}'
out[#out+1] = '|}'
return table.concat(out, "\n")
return table.concat(out, "\n")
Line 1,259: Line 1,066:
p.house = p.renderHouseLedger
p.house = p.renderHouseLedger


function p.renderMarketSignals(frame)
function p.renderRivalHouses(frame)
local signals = marketSignals()
 
local out = {}
local out = {}
out[#out+1] = '{| class="wikitable sortable"'
out[#out+1] = '{| class="wikitable sortable"'
out[#out+1] = '|+ General Port market signals used by the Invitational Division'
out[#out+1] = '|+ Rival merchant houses of the Vaeringheim Merchant League - Invitational Division'
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '! Sector !! Signal !! Change !! League effect'
out[#out+1] = '! Merchant !! House !! Origin !! Usual profile'
 
for _, sector in ipairs({ "Agriculture", "Manufacturing", "Services" }) do
local s = signals[sector]
local effect = "Standard tender weighting"
if s.level == "High" then
effect = "Higher scarcity pressure; tenders receive higher value weighting"
elseif s.level == "Low" then
effect = "Lower scarcity pressure; tenders receive lower value weighting"
end


for _, h in ipairs(HOUSES) do
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '| ' .. (s.label or sector)
out[#out+1] = '| ' .. h.merchant
.. ' || ' .. (s.level or "Standard") .. ' ' .. (s.sym or "•")
.. ' || ' .. h.house
.. ' || ' .. string.format("%.2f%%", s.pct or 0)
.. ' || ' .. h.origin
.. ' || ' .. effect
.. ' || ' .. h.profile
end
end


Line 1,288: Line 1,085:
end
end


function p.renderAwards(frame)
function p.renderSchedule(frame)
local args = (frame and frame.args) or {}
local ctx = seasonArgs(frame)
local ctx = seasonArgs(frame)
local board = simulateBoard(ctx)
local tenders = board.tendersByDay[ctx.day] or {}


local bestValue, bestOnTime, worstLate
local startDay = tonumber(args.start or "") or 1
for _, t in ipairs(tenders) do
local endDay = tonumber(args["end"] or args.finish or "") or 12
if not bestValue or t.value > bestValue.value then bestValue = t end
 
if t.onTime and (not bestOnTime or t.paid > bestOnTime.paid) then bestOnTime = t end
startDay = clamp(startDay, 1, DAYS_IN_SEASON)
if t.late and (not worstLate or t.value > worstLate.value) then worstLate = t end
endDay = clamp(endDay, startDay, DAYS_IN_SEASON)
end


local out = {}
local out = {}
out[#out+1] = '{| class="wikitable"'
out[#out+1] = '{| class="wikitable sortable"'
out[#out+1] = '|+ Daily league distinctions, ' .. formatDate(ctx, ctx.day)
out[#out+1] = '|+ Featured tender schedule, ' .. formatSeason(ctx)
out[#out+1] = '|-'
out[#out+1] = '|-'
out[#out+1] = '! Distinction !! Recipient !! Basis'
out[#out+1] = '! Day !! Date !! Featured claimant !! Likely cargo !! Route'


out[#out+1] = '|-'
for day = startDay, endDay do
out[#out+1] = '| Seal of the Board || ' .. (bestValue and bestValue.merchant or "—") .. ' || ' .. (bestValue and (bestValue.cargo .. " worth " .. formatMoney(bestValue.value)) or "No tender")
local h = pick(HOUSES, ctx.seasonKey .. "|scheduleHouse|" .. tostring(day))
out[#out+1] = '|-'
local cargo = pick(h.cargo, ctx.seasonKey .. "|scheduleCargo|" .. tostring(day))
out[#out+1] = '| Fleet-Footed Ledger || ' .. (bestOnTime and bestOnTime.merchant or "—") .. ' || ' .. (bestOnTime and ("On-time delivery to " .. bestOnTime.to) or "No on-time tender")
local route = pick(ROUTES, ctx.seasonKey .. "|scheduleRoute|" .. tostring(day))
out[#out+1] = '|-'
 
out[#out+1] = '| Red Stamp of Delay || ' .. (worstLate and worstLate.merchant or "—") .. ' || ' .. (worstLate and ("Late " .. worstLate.cargo .. " tender") or "No late tender")
out[#out+1] = '|-'
out[#out+1] = '| ' .. tostring(day)
.. ' || ' .. formatDate(ctx, day)
.. ' || ' .. h.merchant .. '<br><small>' .. h.house .. '</small>'
.. ' || ' .. cargo
.. ' || ' .. route.from .. ' → ' .. route.to .. ' (' .. route.mode .. ')'
end


out[#out+1] = '|}'
out[#out+1] = '|}'
Line 1,317: Line 1,118:
end
end


p.awards = p.renderAwards
function p.renderFull(frame)
local args = (frame and frame.args) or {}
local ctx = seasonArgs(frame)
local board = buildBoard(ctx)


function p.renderFull(frame)
local out = {}
local out = {}
out[#out+1] = p.renderSeasonInfo(frame)
out[#out+1] = renderSeasonInfoFromBoard(board)
out[#out+1] = ""
out[#out+1] = ""
out[#out+1] = p.renderStandings(frame)
out[#out+1] = renderStandingsFromBoard(board)
out[#out+1] = ""
out[#out+1] = ""
out[#out+1] = p.renderAwards(frame)
out[#out+1] = renderTenderBoardFromBoard(board, args.limit or 8)
out[#out+1] = ""
out[#out+1] = ""
out[#out+1] = p.renderTenderBoard(frame)
out[#out+1] = renderNoticesFromBoard(board, args.noticeLimit or 6)
out[#out+1] = ""
out[#out+1] = p.renderDailyNotices(frame)
return table.concat(out, "\n")
return table.concat(out, "\n")
end
end


p.full = p.renderFull
p.full = p.renderFull
------------------------------------------------------------------------
-- ATLAS RENDER FUNCTIONS
-- These are intentionally not included in renderFull unless manually invoked.
------------------------------------------------------------------------
function p.renderCargoAtlas(frame)
local args = (frame and frame.args) or {}
local sectorFilter = mw.ustring.lower(tostring(args.sector or args[1] or ""))
local classFilter = mw.ustring.lower(tostring(args.class or args.kind or ""))
local limit = tonumber(args.limit or "") or 999
limit = clamp(limit, 1, 999)
local out = {}
local shown = 0
out[#out+1] = '{| class="wikitable sortable"'
out[#out+1] = '|+ Vaeringheim Merchant League cargo atlas'
out[#out+1] = '|-'
out[#out+1] = '! Cargo !! Sector !! Company !! Unit !! Class !! Handling risk'
for _, c in ipairs(CARGO) do
local sector = mw.ustring.lower(c.sector or "")
local class = mw.ustring.lower(c.class or "")
local sectorOK = sectorFilter == "" or sector:find(sectorFilter, 1, true)
local classOK = classFilter == "" or class:find(classFilter, 1, true)
if sectorOK and classOK then
shown = shown + 1
out[#out+1] = '|-'
out[#out+1] = '| ' .. c.name
.. ' || ' .. c.sector
.. ' || ' .. c.company
.. ' || ' .. c.unit
.. ' || ' .. c.class
.. ' || ' .. string.format("%.2f", c.risk or 0)
end
if shown >= limit then break end
end
if shown == 0 then
out[#out+1] = '|-'
out[#out+1] = '| colspan="6" | No cargo matched the requested filter.'
end
out[#out+1] = '|}'
return table.concat(out, "\n")
end
function p.renderRouteAtlas(frame)
local args = (frame and frame.args) or {}
local modeFilter = mw.ustring.lower(tostring(args.mode or args[1] or ""))
local cityFilter = mw.ustring.lower(tostring(args.city or args.from or args.to or ""))
local corridorFilter = mw.ustring.lower(tostring(args.corridor or ""))
local limit = tonumber(args.limit or "") or 999
limit = clamp(limit, 1, 999)
local out = {}
local shown = 0
out[#out+1] = '{| class="wikitable sortable"'
out[#out+1] = '|+ Vaeringheim Merchant League route atlas'
out[#out+1] = '|-'
out[#out+1] = '! From !! To !! Mode !! Distance !! Risk !! Corridor'
for _, r in ipairs(ROUTES) do
local mode = mw.ustring.lower(r.mode or "")
local from = mw.ustring.lower(r.from or "")
local to = mw.ustring.lower(r.to or "")
local corridor = mw.ustring.lower(r.corridor or "")
local modeOK = modeFilter == "" or mode:find(modeFilter, 1, true)
local cityOK = cityFilter == "" or from:find(cityFilter, 1, true) or to:find(cityFilter, 1, true)
local corridorOK = corridorFilter == "" or corridor:find(corridorFilter, 1, true)
if modeOK and cityOK and corridorOK then
shown = shown + 1
out[#out+1] = '|-'
out[#out+1] = '| ' .. r.from
.. ' || ' .. r.to
.. ' || ' .. r.mode
.. ' || ' .. tostring(r.km) .. ' km'
.. ' || ' .. string.format("%.2f", r.risk or 0)
.. ' || ' .. (r.corridor or "—")
end
if shown >= limit then break end
end
if shown == 0 then
out[#out+1] = '|-'
out[#out+1] = '| colspan="6" | No route matched the requested filter.'
end
out[#out+1] = '|}'
return table.concat(out, "\n")
end


function p.doc(frame)
function p.doc(frame)
return table.concat({
return table.concat({
"'''Vaeringheim Merchant League - Invitational Division module'''",
"'''Vaeringheim Merchant League - Invitational Division'''",
"",
"This is the lightweight version of the merchant league simulation module.",
"It calculates each house's season record directly instead of replaying every day of the season.",
"",
"",
"Common invocations:",
"Common invocations:",
Line 1,343: Line 1,247:
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderStandings}}</code>",
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderStandings}}</code>",
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderStandings|year=54|month=Thalassiel|day=61}}</code>",
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderStandings|year=54|month=Thalassiel|day=61}}</code>",
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderSchedule|year=54|month=Atosiel}}</code>",
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderTenderBoard|limit=6}}</code>",
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderTenderBoard|limit=20}}</code>",
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderDailyNotices|limit=5}}</code>",
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderTenderReport|day=14|tender=1}}</code>",
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderHouseLedger|house=Giid-Tally Yard}}</code>",
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderHouseLedger|house=Giid-Tally Yard}}</code>",
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderMarketSignals}}</code>",
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderSchedule|start=1|end=12}}</code>",
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderRivalHouses}}</code>",
"",
"",
"Seasons last 61 days and reset at the beginning of each PSSC month."
"Each season lasts 61 days and resets at the beginning of each PSSC month."
}, "\n")
}, "\n")
end
end


return p
return p

Latest revision as of 18:36, 9 July 2026

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

-- Module:VaeringheimMerchantLeagueInvitational
-- Lightweight version.
-- Simulates the Vaeringheim Merchant League - Invitational Division
-- without replaying every tender of every day.
--
-- Each season lasts one PSSC month: 61 days.
-- Seasons reset on Atosiel 1, Thalassiel 1, and Opsitheiel 1.

local p = {}

------------------------------------------------------------------------
-- BASIC CALENDAR
------------------------------------------------------------------------

local DAYS_IN_YEAR = 183
local DAYS_IN_SEASON = 61
local DEFAULT_YEAR = 53

local MONTHS = {
	[1] = "Atosiel",
	[2] = "Thalassiel",
	[3] = "Opsitheiel"
}

local MONTH_OF = {
	["atosiel"] = 1,
	["thalassiel"] = 2,
	["opsitheiel"] = 3,
	["1"] = 1,
	["2"] = 2,
	["3"] = 3
}

local okDate, date = pcall(require, "Module:BassaridianCalendar")

local function clamp(x, lo, hi)
	x = tonumber(x) or 0
	if x < lo then return lo end
	if x > hi then return hi end
	return x
end

local function round(x)
	return math.floor((tonumber(x) or 0) + 0.5)
end

local function currentPSSC()
	local s = nil

	if okDate and date then
		if type(date.getCurrentDateNoProverb) == "function" then
			s = date.getCurrentDateNoProverb()
		elseif type(date.getCurrentDate) == "function" then
			s = date.getCurrentDate()
		end
	end

	local dayOfYear = 1
	local year = DEFAULT_YEAR

	if s then
		dayOfYear = tonumber(tostring(s):match("^(%d+),")) or 1
		year = tonumber(tostring(s):match("(%d+)%s*PSSC")) or DEFAULT_YEAR
	else
		local startDate = os.time({ year = 1999, month = 8, day = 6 })
		local secondsInDay = 86400
		local currentDate = os.time()
		local totalDaysElapsed = math.floor((currentDate - startDate) / secondsInDay)
		local yearFraction = totalDaysElapsed / DAYS_IN_YEAR
		year = math.floor(yearFraction)
		dayOfYear = math.floor((yearFraction - year) * DAYS_IN_YEAR) + 1
	end

	dayOfYear = ((dayOfYear - 1) % DAYS_IN_YEAR) + 1

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

	return {
		year = year,
		dayOfYear = dayOfYear,
		monthIndex = monthIndex,
		month = MONTHS[monthIndex],
		day = dayInMonth
	}
end

local function monthIndexFrom(v)
	if v == nil or v == "" then return nil end
	local key = mw.ustring.lower(tostring(v))
	return MONTH_OF[key]
end

local function seasonArgs(frame)
	local args = (frame and frame.args) or {}
	local now = currentPSSC()

	local year = tonumber(args.year or args.season or "") or now.year
	local monthIndex = monthIndexFrom(args.month or args.m or "") or now.monthIndex

	local day = tonumber(args.day or args.d or "")

	if not day then
		if year == now.year and monthIndex == now.monthIndex then
			day = now.day
		else
			day = DAYS_IN_SEASON
		end
	end

	day = clamp(day, 1, DAYS_IN_SEASON)

	return {
		year = year,
		monthIndex = monthIndex,
		month = MONTHS[monthIndex],
		day = day,
		seasonKey = tostring(year) .. "|" .. tostring(monthIndex)
	}
end

local function formatDate(ctx, day)
	return tostring(day) .. "/" .. tostring(ctx.monthIndex) .. "/" .. tostring(ctx.year) .. " [[PSSC]]"
end

local function formatSeason(ctx)
	return ctx.month .. " " .. tostring(ctx.year) .. " [[PSSC]]"
end

------------------------------------------------------------------------
-- DETERMINISTIC RANDOM
------------------------------------------------------------------------

local function hashNum(key)
	key = tostring(key or "")
	local h = mw.hash.hashValue("md5", key)
	local n = 0
	for i = 1, 8 do
		local c = h:sub(i, i)
		n = n * 16 + (tonumber(c, 16) or 0)
	end
	return n
end

local function randKey(key)
	return (hashNum(key) % 1000000) / 1000000
end

local function pick(list, key)
	if not list or #list == 0 then return nil end
	local idx = math.floor(randKey(key) * #list) + 1
	if idx < 1 then idx = 1 end
	if idx > #list then idx = #list end
	return list[idx]
end

------------------------------------------------------------------------
-- MONEY
------------------------------------------------------------------------

-- Internal values are stored as nil-coins.
-- 1 poli = 200 nil-coins; 1 sik = 10 nil-coins.

local function formatMoney(nilValue)
	local n = math.max(0, round(nilValue or 0))
	local poli = math.floor(n / 200)
	local rem = n % 200
	local sik = math.floor(rem / 10)
	local nils = 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 nils > 0 then table.insert(parts, tostring(nils) .. "•") end
	if #parts == 0 then return "0•" end
	return table.concat(parts, " ")
end

------------------------------------------------------------------------
-- MERCHANT HOUSES
------------------------------------------------------------------------

local HOUSES = {
	{
		id = "qamar",
		merchant = "Asena Vel-Qamar",
		house = "House Qamar",
		origin = "Vaeringheim",
		profile = "Luxury shrine-road brokerage and ceremonial cargo.",
		cargo = { "luxury shrine linens", "ceremonial oil", "sealed relic cases", "festival incense" },
		mode = "road",
		pace = 0.94,
		reliability = 0.86,
		risk = 0.46,
		baseRep = 14,
		stable = 5
	},
	{
		id = "eikskald",
		merchant = "Jorunn Eikskald",
		house = "Eikskald Factors",
		origin = "Riddersborg",
		profile = "Cold-road wool, timber, and northern freight.",
		cargo = { "cold-road wool", "timber beams", "salted provisions", "northern ironwork" },
		mode = "rail",
		pace = 0.88,
		reliability = 0.90,
		risk = 0.38,
		baseRep = 12,
		stable = 4
	},
	{
		id = "bluequay",
		merchant = "Nami of the Blue Quay",
		house = "Blue Quay Papers",
		origin = "Mylecia",
		profile = "Water, medicine, ferry freight, and wetland routes.",
		cargo = { "clean water casks", "medicine crates", "ferry manifests", "wetland provisions" },
		mode = "ferry",
		pace = 0.91,
		reliability = 0.88,
		risk = 0.42,
		baseRep = 13,
		stable = 3
	},
	{
		id = "aureli",
		merchant = "Sadir al-Aureli",
		house = "Aurelian Grain Desk",
		origin = "Aurelia",
		profile = "Grain, lamp oil, staple food, and bulk provisions.",
		cargo = { "Agrithor Orys grain", "lamp oil", "staple flour", "bulk provisions" },
		mode = "road",
		pace = 1.02,
		reliability = 0.84,
		risk = 0.35,
		baseRep = 11,
		stable = 3
	},
	{
		id = "arshir",
		merchant = "Laleh Arshir",
		house = "Ardashir Import Rooms",
		origin = "Delphica",
		profile = "Euranidom imports, fine horse stock, and prestige contracts.",
		cargo = { "fine horse stock papers", "Euranidom imports", "perfumed leather", "prestige parcels" },
		mode = "road",
		pace = 0.86,
		reliability = 0.78,
		risk = 0.68,
		baseRep = 13,
		stable = 6
	},
	{
		id = "salval",
		merchant = "Brennan Salval",
		house = "Salt-Valed Ledger",
		origin = "Sufriya",
		profile = "Salt, fish, hard weather routes, and rough-margin tenders.",
		cargo = { "salt fish", "rough-weather provisions", "barrel salt", "coastal tar" },
		mode = "ferry",
		pace = 0.97,
		reliability = 0.80,
		risk = 0.72,
		baseRep = 9,
		stable = 3
	},
	{
		id = "thalmaen",
		merchant = "Orvida Thal-Maen",
		house = "Thal-Maen Water Desk",
		origin = "Thal Lenaia",
		profile = "Clean water, fish, mountain greens, and perishables.",
		cargo = { "clean water casks", "spring-pond trout", "mountain greens", "ice-box produce" },
		mode = "rail",
		pace = 0.90,
		reliability = 0.89,
		risk = 0.41,
		baseRep = 12,
		stable = 4
	},
	{
		id = "sanvei",
		merchant = "Yusaru Sanvei",
		house = "Sandveil Commissions",
		origin = "Somniumpolis",
		profile = "Dream-city luxuries, sealed consignments, and dusk tenders.",
		cargo = { "dream-city luxury parcels", "sealed dusk consignments", "Somniumpolis perfumes", "private commission packets" },
		mode = "ferry",
		pace = 0.89,
		reliability = 0.77,
		risk = 0.70,
		baseRep = 12,
		stable = 5
	},
	{
		id = "jorope",
		merchant = "Mikel Jorope",
		house = "Jogi Ropehouse",
		origin = "Jogi",
		profile = "Dock rope, reed mats, wet-road hauling, and marsh labor.",
		cargo = { "dock rope", "reed mats", "cargo padding", "cane-fiber dock twine" },
		mode = "road",
		pace = 1.00,
		reliability = 0.82,
		risk = 0.58,
		baseRep = 10,
		stable = 2
	},
	{
		id = "ourid",
		merchant = "Tavri of Ourid",
		house = "Ourid Shrine Accounts",
		origin = "Ourid",
		profile = "Pass-road courier work and shrine-route contracts.",
		cargo = { "courier packets", "shrine ledgers", "sealed relic permits", "pass-road offerings" },
		mode = "pass-road",
		pace = 0.92,
		reliability = 0.85,
		risk = 0.52,
		baseRep = 13,
		stable = 4
	},
	{
		id = "siva",
		merchant = "Nysa Siva",
		house = "Velai Glass & Scrip",
		origin = "Luminaria",
		profile = "Glassware, scrip, invoice brokerage, and social tenders.",
		cargo = { "glassware crates", "invoice scrip packets", "brokerage ledgers", "social tender seals" },
		mode = "road",
		pace = 0.93,
		reliability = 0.81,
		risk = 0.47,
		baseRep = 12,
		stable = 3
	},
	{
		id = "sinders",
		merchant = "Haldor bin Sinders",
		house = "Giid-Tally Yard",
		origin = "Acheron",
		profile = "Prestige caravan work, heavy hauling, and Giid-Lisea-drawn cargo.",
		cargo = { "heavy dock hardware", "timber loads", "prestige caravan freight", "Giid-Lisea harness gear" },
		mode = "road",
		pace = 0.84,
		reliability = 0.83,
		risk = 0.45,
		baseRep = 11,
		stable = 7
	}
}

local HOUSE_BY_ID = {}
for _, h in ipairs(HOUSES) do
	HOUSE_BY_ID[h.id] = h
end

------------------------------------------------------------------------
-- STABLE PRESTIGE
------------------------------------------------------------------------

local STABLES = {
	[1] = { name = "licensed porter crew", prestige = 10 },
	[2] = { name = "reed-cart pair", prestige = 22 },
	[3] = { name = "mule and wagon string", prestige = 36 },
	[4] = { name = "woolly horse caravan", prestige = 52 },
	[5] = { name = "sealed carriage account", prestige = 68 },
	[6] = { name = "prestige horse stock", prestige = 84 },
	[7] = { name = "Giid-Lisea heavy team", prestige = 105 }
}

local function stableName(idx)
	local s = STABLES[idx] or STABLES[1]
	return s.name
end

local function stablePrestige(idx)
	local s = STABLES[idx] or STABLES[1]
	return s.prestige or 0
end

------------------------------------------------------------------------
-- COMPREHENSIVE CARGO ATLAS
-- Static data only. This does not make the main standings calculation heavy.
------------------------------------------------------------------------

local CARGO = {
	-- Agriculture / food staples
	{ name = "Rice", sector = "Agriculture", company = "Gladeseed Farmers' Union", unit = "tons", class = "staple", risk = 0.08 },
	{ name = "Edible Algae", sector = "Agriculture", company = "Gladeseed Farmers' Union", unit = "tons", class = "staple", risk = 0.12 },
	{ name = "Shrimp", sector = "Agriculture", company = "Gladeseed Farmers' Union", unit = "tons", class = "perishable", risk = 0.22 },
	{ name = "Anterran Spicy Onion", sector = "Agriculture", company = "Gladeseed Farmers' Union", unit = "tons", class = "spice", risk = 0.14 },
	{ name = "Queen's Garlic", sector = "Agriculture", company = "Gladeseed Farmers' Union", unit = "tons", class = "spice", risk = 0.15 },
	{ name = "Wheat", sector = "Agriculture", company = "Plains of Jogi", unit = "tons", class = "grain", risk = 0.07 },
	{ name = "Oats", sector = "Agriculture", company = "Plains of Jogi", unit = "tons", class = "grain", risk = 0.06 },
	{ name = "Hay", sector = "Agriculture", company = "Plains of Jogi", unit = "bales", class = "fodder", risk = 0.06 },
	{ name = "Cereal", sector = "Agriculture", company = "Plains of Jogi", unit = "pallets", class = "processed food", risk = 0.07 },

	-- Livestock / animal products
	{ name = "Morovian Water Buffalo", sector = "Agriculture", company = "Atterian Cattle Drivers", unit = "head", class = "livestock", risk = 0.26 },
	{ name = "Morovian Water Buffalo Beef", sector = "Agriculture", company = "Atterian Cattle Drivers", unit = "tons", class = "meat", risk = 0.20 },
	{ name = "Morovian Water Buffalo Milk (raw)", sector = "Agriculture", company = "Atterian Cattle Drivers", unit = "barrels", class = "perishable", risk = 0.28 },
	{ name = "Morovian Water Buffalo Cheese (aged wheels)", sector = "Agriculture", company = "Atterian Cattle Drivers", unit = "wheels", class = "preserved food", risk = 0.12 },
	{ name = "Morovian Water Buffalo Hides (cured)", sector = "Agriculture", company = "Atterian Cattle Drivers", unit = "sheets", class = "hide", risk = 0.10 },
	{ name = "Camels", sector = "Agriculture", company = "Camel Herders of the Jangsong Province", unit = "head", class = "livestock", risk = 0.28 },
	{ name = "Camel Milk", sector = "Agriculture", company = "Camel Herders of the Jangsong Province", unit = "barrels", class = "perishable", risk = 0.26 },
	{ name = "Camel Hump Water", sector = "Agriculture", company = "Camel Herders of the Jangsong Province", unit = "barrels", class = "water", risk = 0.16 },

	-- Ritual / medicinal / plant goods
	{ name = "Sanctified Noctic Leaf (ritual-grade)", sector = "Agriculture", company = "Noctic Fleet", unit = "bundles", class = "ritual", risk = 0.34 },
	{ name = "Noctic-Rabrev Infused Incense", sector = "Agriculture", company = "Noctic Fleet", unit = "packs", class = "ritual", risk = 0.24 },
	{ name = "Noctic-Rabrev Anointing Balm", sector = "Agriculture", company = "Noctic Fleet", unit = "jars", class = "ritual", risk = 0.26 },
	{ name = "Noctic-Rabrev Ritual Ink", sector = "Agriculture", company = "Noctic Fleet", unit = "bottles", class = "ritual", risk = 0.25 },
	{ name = "Zoe Elm Resin (Refined)", sector = "Agriculture", company = "Keepers of the Grove of Zoe Elm", unit = "barrels", class = "resin", risk = 0.13 },
	{ name = "Zoe Elm Incense Resin (temple blend)", sector = "Agriculture", company = "Keepers of the Grove of Zoe Elm", unit = "packs", class = "ritual", risk = 0.18 },
	{ name = "Zoe Elm Pitch (waterproofing grade)", sector = "Agriculture", company = "Keepers of the Grove of Zoe Elm", unit = "barrels", class = "ship supply", risk = 0.12 },
	{ name = "Poppy Seeds (culinary)", sector = "Agriculture", company = "Merchant Guild of the Poppy Goddess", unit = "sacks", class = "culinary", risk = 0.18 },
	{ name = "Poppy Seed Oil (pressed)", sector = "Agriculture", company = "Merchant Guild of the Poppy Goddess", unit = "barrels", class = "oil", risk = 0.20 },
	{ name = "Poppy Incense Cones (temple blend)", sector = "Agriculture", company = "Merchant Guild of the Poppy Goddess", unit = "packs", class = "ritual", risk = 0.22 },

	-- Beverages / preserves
	{ name = "Manwine", sector = "Agriculture", company = "Jogiani Merchants of Manwine", unit = "barrels", class = "beverage", risk = 0.15 },
	{ name = "Milkwine", sector = "Agriculture", company = "Jogiani Merchants of Manwine", unit = "barrels", class = "beverage", risk = 0.18 },
	{ name = "Aged Manwine (oak-rested)", sector = "Agriculture", company = "Jogiani Merchants of Manwine", unit = "barrels", class = "luxury beverage", risk = 0.16 },
	{ name = "Manwine Vinegar (culinary)", sector = "Agriculture", company = "Jogiani Merchants of Manwine", unit = "barrels", class = "culinary", risk = 0.08 },
	{ name = "Sea Elm Flavored Mead", sector = "Agriculture", company = "Sea-Elm Southern Meadery", unit = "barrels", class = "beverage", risk = 0.14 },
	{ name = "Sea Elm Mead Vinegar (culinary)", sector = "Agriculture", company = "Sea-Elm Southern Meadery", unit = "barrels", class = "culinary", risk = 0.08 },
	{ name = "Honey Cakes (sealed tins)", sector = "Agriculture", company = "Grand Cave Bee Meadery", unit = "boxes", class = "sealed food", risk = 0.10 },
	{ name = "Cave Honey (strained)", sector = "Agriculture", company = "Grand Cave Bee Meadery", unit = "jars", class = "food", risk = 0.11 },
	{ name = "Beeswax Candles (standard)", sector = "Agriculture", company = "Grand Cave Bee Meadery", unit = "bundles", class = "ritual supply", risk = 0.07 },

	-- Coastal / shell / luxury goods
	{ name = "Pearls", sector = "Agriculture", company = "Anterran Imports and Services", unit = "sacks", class = "luxury", risk = 0.32 },
	{ name = "Abalone", sector = "Agriculture", company = "Anterran Imports and Services", unit = "crates", class = "perishable luxury", risk = 0.24 },
	{ name = "Mother-of-Pearl Buttons (sorted)", sector = "Agriculture", company = "Anterran Imports and Services", unit = "crates", class = "luxury component", risk = 0.18 },
	{ name = "Abalone Shell Inlay Blanks", sector = "Agriculture", company = "Anterran Imports and Services", unit = "crates", class = "craft luxury", risk = 0.16 },

	-- Textiles and fine materials
	{ name = "Silk", sector = "Agriculture", company = "Seamstresses of Rouge", unit = "rolls", class = "textile", risk = 0.18 },
	{ name = "Dyed Silk (Rouge crimson)", sector = "Agriculture", company = "Seamstresses of Rouge", unit = "rolls", class = "luxury textile", risk = 0.20 },
	{ name = "Patterned Silk Brocade", sector = "Agriculture", company = "Seamstresses of Rouge", unit = "rolls", class = "luxury textile", risk = 0.22 },
	{ name = "Woven Silk Sashes", sector = "Agriculture", company = "Seamstresses of Rouge", unit = "bundles", class = "festival textile", risk = 0.18 },
	{ name = "Velvet Worm Silk Thread (spools)", sector = "Agriculture", company = "The Velvet Company", unit = "spools", class = "textile component", risk = 0.18 },
	{ name = "Velvet Worm Silk Cloth (bolts)", sector = "Agriculture", company = "The Velvet Company", unit = "bolts", class = "luxury textile", risk = 0.22 },

	-- Stone, metal, construction
	{ name = "Purple Granite", sector = "Manufacturing", company = "Thalassian Temples Granite Export Cooperative", unit = "blocks", class = "stone", risk = 0.16 },
	{ name = "Purple Granite (Polished Slabs)", sector = "Manufacturing", company = "Thalassian Temples Granite Export Cooperative", unit = "slabs", class = "stone", risk = 0.18 },
	{ name = "Purple Granite Sealstone Tablets", sector = "Manufacturing", company = "Thalassian Temples Granite Export Cooperative", unit = "tablets", class = "ritual stone", risk = 0.20 },
	{ name = "Iron", sector = "Manufacturing", company = "East Keltian Iron Company", unit = "tons", class = "metal", risk = 0.14 },
	{ name = "Steel", sector = "Manufacturing", company = "East Keltian Iron Company", unit = "tons", class = "metal", risk = 0.15 },
	{ name = "Concrete", sector = "Manufacturing", company = "East Keltian Iron Company", unit = "tons", class = "construction", risk = 0.10 },
	{ name = "Timber beams", sector = "Manufacturing", company = "Eikskald Factors", unit = "loads", class = "construction", risk = 0.16 },
	{ name = "Heavy dock hardware", sector = "Manufacturing", company = "Giid-Tally Yard", unit = "crates", class = "heavy freight", risk = 0.24 },

	-- Energy / infrastructure services as cargo-equivalent contracts
	{ name = "Electricity (Peak Dispatch)", sector = "Manufacturing", company = "Roving Wind Farm Corporation", unit = "contracts", class = "energy contract", risk = 0.20 },
	{ name = "Grid Stabilization (Frequency Response)", sector = "Manufacturing", company = "Roving Wind Farm Corporation", unit = "contracts", class = "energy contract", risk = 0.22 },
	{ name = "Shore Power Hookups (Vessels)", sector = "Manufacturing", company = "Port of Qandra", unit = "connections", class = "port utility", risk = 0.18 },
	{ name = "Backup Generator Dispatch (Emergency)", sector = "Manufacturing", company = "Port of Qandra", unit = "dispatches", class = "emergency utility", risk = 0.30 },
	{ name = "High-Load Foundry Power Package", sector = "Manufacturing", company = "Bijarian Energy Corporation", unit = "contracts", class = "industrial utility", risk = 0.26 },

	-- Licensed security / controlled freight
	{ name = "Thorakitai Field Armor Composite-Plated", sector = "Manufacturing", company = "Baratar Corporation", unit = "kits", class = "regulated security", risk = 0.46 },
	{ name = "Cataphract Saddle Harness Reinforced", sector = "Manufacturing", company = "Baratar Corporation", unit = "sets", class = "regulated harness", risk = 0.30 },
	{ name = "Xyston Utility Pack 14L Pilgrim Model", sector = "Manufacturing", company = "Baratar Corporation", unit = "packs", class = "pilgrim equipment", risk = 0.14 },
	{ name = "Mycale Boots Knee-High Water-Wicking", sector = "Manufacturing", company = "Baratar Corporation", unit = "pairs", class = "field gear", risk = 0.12 },
	{ name = "Thymiari Signal Kit Reedflare Tricolor", sector = "Manufacturing", company = "Baratar Corporation", unit = "units", class = "signal equipment", risk = 0.28 },
	{ name = "Fanarion Torch Dual Beam Ritual Use", sector = "Manufacturing", company = "Baratar Corporation", unit = "units", class = "ritual equipment", risk = 0.20 },

	-- General Port / Hatch / customs services and salvage
	{ name = "Confiscated Trade Crates", sector = "Services", company = "Hatch Ministry", unit = "units", class = "salvage", risk = 0.42 },
	{ name = "Mixed Merchant Cargo", sector = "Services", company = "Hatch Ministry", unit = "tons", class = "salvage", risk = 0.38 },
	{ name = "Recovered Cargo Manifests", sector = "Services", company = "Hatch Ministry", unit = "scrolls", class = "documents", risk = 0.30 },
	{ name = "Stamped Silver Bars", sector = "Services", company = "Hatch Ministry", unit = "kg", class = "high value", risk = 0.48 },
	{ name = "High-Value Personal Effects", sector = "Services", company = "Hatch Ministry", unit = "items", class = "high value", risk = 0.46 },
	{ name = "Private Letters and Ship Logs", sector = "Services", company = "Hatch Ministry", unit = "volumes", class = "documents", risk = 0.32 },
	{ name = "Blocked Port Registry Books", sector = "Services", company = "Lake Morovia Blockade Fund - Hatch Ministry", unit = "ledgers", class = "documents", risk = 0.34 },
	{ name = "Confiscated Calibration Weights", sector = "Services", company = "Lake Morovia Blockade Fund - Hatch Ministry", unit = "sets", class = "port equipment", risk = 0.28 },

	-- Maskmakers / intelligence / temple / courier services
	{ name = "Ceremonial Masks (lacquered)", sector = "Services", company = "Maritime Guild of the Cult of Maskmakers", unit = "masks", class = "ritual craft", risk = 0.22 },
	{ name = "Crated Mask Lots (mixed export)", sector = "Services", company = "Maritime Guild of the Cult of Maskmakers", unit = "crates", class = "craft export", risk = 0.18 },
	{ name = "Background Dossier (merchant vetting)", sector = "Services", company = "Ergonian Spy Agency", unit = "reports", class = "intelligence", risk = 0.48 },
	{ name = "Cipher & Codebook Service (per batch)", sector = "Services", company = "Ergonian Spy Agency", unit = "batches", class = "intelligence", risk = 0.50 },
	{ name = "Dead-Drop Setup (secure route)", sector = "Services", company = "Ergonian Spy Agency", unit = "routes", class = "intelligence", risk = 0.54 },
	{ name = "Missionaries of Harmony Sanctum", sector = "Services", company = "Temple Bank of the Reformed Stripping Path", unit = "teams", class = "mission escort", risk = 0.34 },
	{ name = "Missionaries of the Azure Sentinel Sect", sector = "Services", company = "Temple Bank of the Reformed Stripping Path", unit = "teams", class = "mission escort", risk = 0.30 },
	{ name = "Missionaries of Reverie Nebulous", sector = "Services", company = "Temple Bank of the Reformed Stripping Path", unit = "teams", class = "mission escort", risk = 0.38 },
	{ name = "Missionaries of the Order of the Umbral Oracle", sector = "Services", company = "Temple Bank of the Reformed Stripping Path", unit = "teams", class = "mission escort", risk = 0.36 },

	-- Local Southern Strait / custom league goods
	{ name = "Agrithor Orys grain", sector = "Agriculture", company = "Wardens of the Fields of Callope", unit = "tons", class = "grain", risk = 0.08 },
	{ name = "Aureum lamp oil", sector = "Agriculture", company = "Wardens of the Fields of Callope", unit = "casks", class = "oil", risk = 0.14 },
	{ name = "Clean water casks", sector = "Agriculture", company = "Lenaian Water Company", unit = "casks", class = "water", risk = 0.12 },
	{ name = "Spring-fed watercress", sector = "Agriculture", company = "Lenaian Water Company", unit = "crates", class = "perishable greens", risk = 0.26 },
	{ name = "Spring-pond trout", sector = "Agriculture", company = "Lenaian Water Company", unit = "ice crates", class = "perishable fish", risk = 0.32 },
	{ name = "Cistern melons", sector = "Agriculture", company = "Pontic Esperance Homestead Association", unit = "hampers", class = "desert produce", risk = 0.24 },
	{ name = "Dryland cucumbers", sector = "Agriculture", company = "Pontic Esperance Homestead Association", unit = "crates", class = "desert produce", risk = 0.22 },
	{ name = "Ferry mats", sector = "Manufacturing", company = "Thyrsa Maena Harbor Cooperative", unit = "bundles", class = "dock supply", risk = 0.10 },
	{ name = "Cargo padding", sector = "Manufacturing", company = "Thyrsa Maena Harbor Cooperative", unit = "rolls", class = "dock supply", risk = 0.08 },
	{ name = "Cane-fiber dock twine", sector = "Manufacturing", company = "Thyrsa Maena Harbor Cooperative", unit = "spools", class = "dock supply", risk = 0.08 },
	{ name = "Reed screens", sector = "Manufacturing", company = "Thyrsa Maena Harbor Cooperative", unit = "panels", class = "dock supply", risk = 0.09 },
	{ name = "Glassware crates", sector = "Manufacturing", company = "Velai Glass & Scrip", unit = "crates", class = "fragile luxury", risk = 0.38 },
	{ name = "Invoice scrip packets", sector = "Services", company = "Velai Glass & Scrip", unit = "sealed packets", class = "documents", risk = 0.24 },
	{ name = "Luxury shrine linens", sector = "Services", company = "House Qamar", unit = "chests", class = "ritual luxury", risk = 0.30 },
	{ name = "Courier packets", sector = "Services", company = "Ourid Shrine Accounts", unit = "packets", class = "courier", risk = 0.22 },
	{ name = "Dream-city luxury parcels", sector = "Services", company = "Sandveil Commissions", unit = "parcels", class = "luxury", risk = 0.34 }
}

------------------------------------------------------------------------
-- ROUTES
------------------------------------------------------------------------

------------------------------------------------------------------------
-- COMPREHENSIVE ROUTE ATLAS
-- Distances are gameplay/wiki approximations for league scoring.
------------------------------------------------------------------------

local ROUTES = {
	-- Lake Morovia / General Port core
	{ from = "Vaeringheim", to = "Mylecia", mode = "skybridge", km = 1479, risk = 0.18, corridor = "Morovia-Haifa trunk" },
	{ from = "Vaeringheim", to = "Serena", mode = "ferry", km = 210, risk = 0.18, corridor = "Lake Morovia ferry" },
	{ from = "Vaeringheim", to = "Luminaria", mode = "road", km = 320, risk = 0.20, corridor = "Central Bassaridian road" },
	{ from = "Vaeringheim", to = "Delphica", mode = "road", km = 410, risk = 0.22, corridor = "Central Bassaridian road" },
	{ from = "Vaeringheim", to = "Somniumpolis", mode = "ferry", km = 520, risk = 0.30, corridor = "Lake Morovia night ferry" },
	{ from = "Vaeringheim", to = "Aurelia", mode = "road", km = 360, risk = 0.19, corridor = "Aurelian road" },
	{ from = "Vaeringheim", to = "Riddersborg", mode = "rail", km = 760, risk = 0.24, corridor = "Northern rail" },
	{ from = "Vaeringheim", to = "Pyralis", mode = "road", km = 390, risk = 0.23, corridor = "Temple road" },
	{ from = "Vaeringheim", to = "Sylvapolis", mode = "road", km = 440, risk = 0.25, corridor = "Verdant road" },
	{ from = "Vaeringheim", to = "Catonis Atrium", mode = "road", km = 275, risk = 0.17, corridor = "Inner market road" },
	{ from = "Vaeringheim", to = "Aetherium", mode = "skybridge", km = 620, risk = 0.16, corridor = "Skybridge feeder" },
	{ from = "Vaeringheim", to = "Saluria", mode = "ferry", km = 340, risk = 0.21, corridor = "Lake Morovia ferry" },

	-- Alpazkigz / inland commercial roads
	{ from = "Aurelia", to = "Erythros", mode = "road", km = 300, risk = 0.24, corridor = "Alpazkigz road" },
	{ from = "Erythros", to = "Acheron", mode = "road", km = 440, risk = 0.30, corridor = "Acheron road" },
	{ from = "Acheron", to = "Ourid", mode = "pass-road", km = 510, risk = 0.37, corridor = "Ourid pass road" },
	{ from = "Luminaria", to = "Ourid", mode = "pass-road", km = 480, risk = 0.34, corridor = "Shrine-pass road" },
	{ from = "Aurelia", to = "Koinonia", mode = "road", km = 280, risk = 0.21, corridor = "Aurelian civic road" },
	{ from = "Aurelia", to = "Ferrum Citadel", mode = "rail", km = 390, risk = 0.26, corridor = "Metal road" },
	{ from = "Ferrum Citadel", to = "Nexa", mode = "road", km = 240, risk = 0.25, corridor = "Iron road" },
	{ from = "Nexa", to = "Acheron", mode = "road", km = 360, risk = 0.31, corridor = "Eastern Alpazkigz road" },
	{ from = "Acheron", to = "Riddersborg", mode = "road", km = 620, risk = 0.32, corridor = "Northern hard-road" },

	-- Mylecia / Haifan Bassaridia / wetland and coastal links
	{ from = "Mylecia", to = "Jogi", mode = "road", km = 310, risk = 0.36, corridor = "Jogi wet-road" },
	{ from = "Mylecia", to = "Sufriya", mode = "ferry", km = 460, risk = 0.34, corridor = "Haifan coastal ferry" },
	{ from = "Mylecia", to = "Hayr Chaskal", mode = "rail", km = 880, risk = 0.30, corridor = "Southern Strait approach" },
	{ from = "Mylecia", to = "Nysara Velai", mode = "skybridge", km = 1480, risk = 0.24, corridor = "Southern Strait skybridge" },
	{ from = "Jogi", to = "Lykopolis", mode = "road", km = 240, risk = 0.38, corridor = "Wetland road" },
	{ from = "Jogi", to = "Sufriya", mode = "ferry", km = 330, risk = 0.35, corridor = "Haifan ferry" },
	{ from = "Sufriya", to = "Keybir-Aviv", mode = "ferry", km = 360, risk = 0.40, corridor = "Coastal ferry" },
	{ from = "Keybir-Aviv", to = "Lykopolis", mode = "road", km = 290, risk = 0.42, corridor = "Haifan coast road" },
	{ from = "Lykopolis", to = "Mylecia", mode = "road", km = 390, risk = 0.37, corridor = "Haifan return road" },

	-- Southern Strait Corridor
	{ from = "Hayr Chaskal", to = "Saruq Thyrsa", mode = "road", km = 430, risk = 0.33, corridor = "Southern Strait road" },
	{ from = "Saruq Thyrsa", to = "Thal Lenaia", mode = "rail", km = 520, risk = 0.28, corridor = "Lacaran Hodos rail" },
	{ from = "Thal Lenaia", to = "Mirovia", mode = "rail", km = 690, risk = 0.31, corridor = "Lacaran Hodos rail" },
	{ from = "Mirovia", to = "Nysara Velai", mode = "road", km = 570, risk = 0.35, corridor = "Esperance road" },
	{ from = "Nysara Velai", to = "Rumi-Kefaya", mode = "road", km = 420, risk = 0.36, corridor = "Southern Strait inland road" },
	{ from = "Rumi-Kefaya", to = "Mirovia", mode = "road", km = 390, risk = 0.34, corridor = "Mirovian inland road" },
	{ from = "Saruq Thyrsa", to = "Mirovia", mode = "rail", km = 960, risk = 0.32, corridor = "Long Southern Strait rail" },
	{ from = "Hayr Chaskal", to = "Thal Lenaia", mode = "road", km = 810, risk = 0.36, corridor = "Snowholme approach road" },
	{ from = "Mylecia", to = "Saruq Thyrsa", mode = "rail", km = 1180, risk = 0.32, corridor = "Southern Strait trunk" },
	{ from = "Mylecia", to = "Mirovia", mode = "rail", km = 1760, risk = 0.34, corridor = "Southern Strait trunk" },

	-- Northern / cold-road and timber routes
	{ from = "Riddersborg", to = "Dragevik", mode = "road", km = 410, risk = 0.33, corridor = "Cold-road" },
	{ from = "Dragevik", to = "Sårensby", mode = "road", km = 360, risk = 0.35, corridor = "Northern timber road" },
	{ from = "Sårensby", to = "Vaeringheim", mode = "rail", km = 890, risk = 0.29, corridor = "Northern return rail" },
	{ from = "Riddersborg", to = "Tonar", mode = "road", km = 540, risk = 0.36, corridor = "Steppe road" },
	{ from = "Tonar", to = "Aurelia", mode = "road", km = 650, risk = 0.33, corridor = "Steppe-to-market road" },

	-- Maritime / port-service routes
	{ from = "Vaeringheim", to = "Port of Qandra", mode = "ferry", km = 710, risk = 0.30, corridor = "Port utility ferry" },
	{ from = "Port of Qandra", to = "Serena", mode = "ferry", km = 460, risk = 0.26, corridor = "Utility ferry" },
	{ from = "Port of Qandra", to = "Mylecia", mode = "ferry", km = 940, risk = 0.36, corridor = "Long-haul ferry" },
	{ from = "Vaeringheim", to = "Pentiad Terminal", mode = "ferry", km = 190, risk = 0.14, corridor = "Terminal shuttle" },
	{ from = "Pentiad Terminal", to = "Mylecia", mode = "ferry", km = 780, risk = 0.31, corridor = "Container ferry" },
	{ from = "Pentiad Terminal", to = "Serena", mode = "ferry", km = 260, risk = 0.18, corridor = "Terminal ferry" },

	-- Odiferia / Somniumpolis / dream-city goods
	{ from = "Somniumpolis", to = "Odiferian Tribes", mode = "road", km = 360, risk = 0.39, corridor = "Odiferian meadow road" },
	{ from = "Somniumpolis", to = "Vaeringheim", mode = "ferry", km = 520, risk = 0.30, corridor = "Lake Morovia night ferry" },
	{ from = "Odiferian Tribes", to = "Luminaria", mode = "road", km = 640, risk = 0.38, corridor = "Odiferian caravan road" },
	{ from = "Odiferian Tribes", to = "Aurelia", mode = "road", km = 780, risk = 0.40, corridor = "Long meadow road" },

	-- Foreign / ally desk routes
	{ from = "Vaeringheim", to = "Imperial Federation Desk", mode = "ferry", km = 1320, risk = 0.36, corridor = "Foreign desk sea-lane" },
	{ from = "Vaeringheim", to = "Moorland Desk", mode = "ferry", km = 980, risk = 0.34, corridor = "Foreign desk sea-lane" },
	{ from = "Mylecia", to = "Imperial Federation Desk", mode = "ferry", km = 1420, risk = 0.38, corridor = "Foreign desk sea-lane" },
	{ from = "Vaeringheim", to = "Haifa Compliance Exchange", mode = "road", km = 60, risk = 0.08, corridor = "Administrative route" },
	{ from = "General Port Customs Yard", to = "Pentiad Terminal", mode = "road", km = 24, risk = 0.06, corridor = "Port shuttle" },
	{ from = "General Port Customs Yard", to = "Haifa Compliance Exchange", mode = "road", km = 18, risk = 0.05, corridor = "Administrative route" },

	-- High-risk special contracts
	{ from = "Jogi", to = "General Port Customs Yard", mode = "ferry", km = 620, risk = 0.42, corridor = "Customs recovery route" },
	{ from = "Sufriya", to = "Pentiad Terminal", mode = "ferry", km = 740, risk = 0.44, corridor = "Customs recovery route" },
	{ from = "Keybir-Aviv", to = "Haifa Compliance Exchange", mode = "road", km = 820, risk = 0.45, corridor = "Compliance route" },
	{ from = "Hayr Chaskal", to = "General Port Customs Yard", mode = "rail", km = 1640, risk = 0.37, corridor = "Inspection return rail" },
	{ from = "Mirovia", to = "Temple Bank Mission Yard", mode = "road", km = 44, risk = 0.12, corridor = "Mission-yard shuttle" },
	{ from = "Saruq Thyrsa", to = "Temple Bank Mission Yard", mode = "road", km = 52, risk = 0.13, corridor = "Mission-yard shuttle" }
}

local INCIDENTS = {
	"inspection delay",
	"weather hold",
	"ferry queue",
	"shrine-road crowding",
	"manifest challenge",
	"stable injury",
	"wet-road damage",
	"customs reweighing",
	"rumor panic",
	"route patrol diversion"
}

------------------------------------------------------------------------
-- LIGHTWEIGHT RECORD CALCULATION
------------------------------------------------------------------------

local function currentStable(house, ctx)
	local upgradeSeed = randKey(ctx.seasonKey .. "|stable|" .. house.id)
	local bonus = 0

	if ctx.day >= 21 and upgradeSeed > 0.78 then bonus = bonus + 1 end
	if ctx.day >= 44 and upgradeSeed > 0.92 then bonus = bonus + 1 end

	return clamp((house.stable or 1) + bonus, 1, #STABLES)
end

local function topCargo(house, ctx)
	return pick(house.cargo, ctx.seasonKey .. "|cargo|" .. house.id)
end

local function computeRecord(house, ctx)
	local day = ctx.day
	local seasonSeed = randKey(ctx.seasonKey .. "|house|" .. house.id)
	local formSeed = randKey(ctx.seasonKey .. "|form|" .. house.id)
	local riskSeed = randKey(ctx.seasonKey .. "|risk|" .. house.id)

	local form = 0.88 + formSeed * 0.24
	local pace = (house.pace or 0.9) * form

	local contracts = math.floor(day * pace + seasonSeed * 3)
	if contracts < 0 then contracts = 0 end

	local lateRate = 0.035
		+ (1 - (house.reliability or 0.8)) * 0.18
		+ (house.risk or 0.5) * 0.055
		+ riskSeed * 0.025

	local late = math.floor(contracts * lateRate + randKey(ctx.seasonKey .. "|late|" .. house.id) * 2.1)
	if late > contracts then late = contracts end

	local onTime = contracts - late
	local stable = currentStable(house, ctx)
	local prestige = stablePrestige(stable)

	local sealed = math.floor(contracts * (0.10 + (house.risk or 0.5) * 0.045) + randKey(ctx.seasonKey .. "|sealed|" .. house.id) * 2)
	local ritual = 0
	if house.id == "qamar" or house.id == "ourid" or house.id == "sanvei" then
		ritual = math.floor(contracts * 0.18 + randKey(ctx.seasonKey .. "|ritual|" .. house.id) * 2)
	elseif house.id == "bluequay" or house.id == "thalmaen" then
		ritual = math.floor(contracts * 0.06 + randKey(ctx.seasonKey .. "|ritual|" .. house.id) * 1)
	else
		ritual = math.floor(contracts * 0.03 + randKey(ctx.seasonKey .. "|ritual|" .. house.id))
	end

	local averageTender = 310
		+ prestige * 3.2
		+ (house.risk or 0.5) * 130
		+ (house.reliability or 0.8) * 60
		+ seasonSeed * 90

	local income = contracts * averageTender
	income = income - late * averageTender * 0.35
	income = income + sealed * 55 + ritual * 70

	local trade = income * (0.18 + randKey(ctx.seasonKey .. "|trade|" .. house.id) * 0.34)

	local avgKm = 260 + (house.risk or 0.5) * 350 + randKey(ctx.seasonKey .. "|km|" .. house.id) * 210
	if house.mode == "skybridge" then avgKm = avgKm + 520 end
	if house.mode == "rail" then avgKm = avgKm + 180 end
	if house.mode == "ferry" then avgKm = avgKm + 120 end
	if house.mode == "pass-road" then avgKm = avgKm + 160 end

	local km = contracts * avgKm

	local rep = (house.baseRep or 10)
		+ onTime * (0.085 + (house.reliability or 0.8) * 0.045)
		- late * 1.45
		+ sealed * 0.20
		+ ritual * 0.32
		+ prestige * 0.025

	rep = clamp(rep, 0, 40)

	local incidents = math.floor(contracts * ((house.risk or 0.5) * 0.045) + randKey(ctx.seasonKey .. "|incidents|" .. house.id) * 2)
	if incidents > contracts then incidents = contracts end

	local score =
		contracts * 62
		+ income * 0.31
		+ trade * 0.035
		+ rep * 24
		+ prestige * 0.70
		+ sealed * 24
		+ ritual * 28
		+ km * 0.018
		- late * 72
		- incidents * 18

	local rec = {
		house = house,
		contracts = contracts,
		onTime = onTime,
		late = late,
		earnings = round(income),
		trade = round(trade),
		rep = rep,
		incidents = incidents,
		sealed = sealed,
		ritual = ritual,
		km = round(km),
		stable = stable,
		stablePrestige = prestige,
		topCargo = topCargo(house, ctx),
		mode = house.mode,
		score = round(score)
	}

	return rec
end

local function buildBoard(ctx)
	local list = {}

	for _, house in ipairs(HOUSES) do
		table.insert(list, computeRecord(house, ctx))
	end

	table.sort(list, function(a, b)
		if a.score ~= b.score then return a.score > b.score end
		if a.earnings ~= b.earnings then return a.earnings > b.earnings end
		if a.contracts ~= b.contracts then return a.contracts > b.contracts end
		return a.house.merchant < b.house.merchant
	end)

	local byId = {}
	for i, rec in ipairs(list) do
		rec.rank = i
		byId[rec.house.id] = rec
	end

	return {
		ctx = ctx,
		list = list,
		byId = byId
	}
end

------------------------------------------------------------------------
-- DAILY TENDER FLAVOR
------------------------------------------------------------------------

local function generateTender(ctx, board, index)
	local rec = board.list[((index - 1) % #board.list) + 1]
	local house = rec.house

	local route = pick(ROUTES, ctx.seasonKey .. "|dailyRoute|" .. ctx.day .. "|" .. house.id .. "|" .. index)
	local cargo = pick(house.cargo, ctx.seasonKey .. "|dailyCargo|" .. ctx.day .. "|" .. house.id .. "|" .. index)
	local incident = nil

	local incidentChance = 0.05 + (house.risk or 0.5) * 0.08
	if randKey(ctx.seasonKey .. "|dailyIncident|" .. ctx.day .. "|" .. house.id .. "|" .. index) < incidentChance then
		incident = pick(INCIDENTS, ctx.seasonKey .. "|incidentPick|" .. ctx.day .. "|" .. house.id .. "|" .. index)
	end

	local onTimeChance = clamp(
		0.62
		+ (house.reliability or 0.8) * 0.28
		- (route.risk or 0.2) * 0.22
		- (incident and 0.16 or 0),
		0.32,
		0.96
	)

	local onTime = randKey(ctx.seasonKey .. "|dailyOnTime|" .. ctx.day .. "|" .. house.id .. "|" .. index) <= onTimeChance

	local value = 260
		+ (route.km or 0) * 0.26
		+ rec.stablePrestige * 2.4
		+ (house.risk or 0.5) * 95
		+ randKey(ctx.seasonKey .. "|dailyValue|" .. ctx.day .. "|" .. house.id .. "|" .. index) * 120

	return {
		merchant = house.merchant,
		house = house.house,
		cargo = cargo,
		from = route.from,
		to = route.to,
		mode = route.mode,
		km = route.km,
		value = round(value),
		onTime = onTime,
		incident = incident
	}
end

------------------------------------------------------------------------
-- INTERNAL RENDERERS
------------------------------------------------------------------------

local function renderSeasonInfoFromBoard(board)
	local ctx = board.ctx
	local leader = board.list[1]

	local out = {}
	out[#out+1] = '{| class="wikitable"'
	out[#out+1] = '|-'
	out[#out+1] = '! colspan="2" | Vaeringheim Merchant League - Invitational Division'
	out[#out+1] = '|-'
	out[#out+1] = '| Season || ' .. formatSeason(ctx)
	out[#out+1] = '|-'
	out[#out+1] = '| Season day || ' .. tostring(ctx.day) .. ' of 61'
	out[#out+1] = '|-'
	out[#out+1] = '| Board status || ' .. (ctx.day >= 61 and "Final table" or "Active competition")
	out[#out+1] = '|-'
	out[#out+1] = '| Current leader || ' .. leader.house.merchant .. ' (' .. leader.house.house .. ')'
	out[#out+1] = '|-'
	out[#out+1] = '| Reset rule || Board resets at the beginning of each PSSC month.'
	out[#out+1] = '|}'
	return table.concat(out, "\n")
end

local function renderStandingsFromBoard(board)
	local ctx = board.ctx
	local out = {}

	out[#out+1] = '{| class="wikitable sortable"'
	out[#out+1] = '|+ Vaeringheim Merchant League - Invitational Division standings, ' .. formatSeason(ctx) .. ' — day ' .. tostring(ctx.day) .. ' of 61'
	out[#out+1] = '|-'
	out[#out+1] = '! Pos !! Merchant !! House !! Origin !! Contracts !! Late !! Income !! Reputation !! Stable !! Top cargo !! Score'

	for _, rec in ipairs(board.list) do
		local style = ""
		if rec.rank == 1 then
			style = ' style="background-color:#fff2cc;"'
		elseif rec.rank <= 3 then
			style = ' style="background-color:#e6ffe6;"'
		elseif rec.rank >= 11 then
			style = ' style="background-color:#ffe6e6;"'
		end

		out[#out+1] = '|-' .. style
		out[#out+1] = '| ' .. tostring(rec.rank)
			.. ' || ' .. rec.house.merchant
			.. ' || ' .. rec.house.house
			.. ' || ' .. rec.house.origin
			.. ' || ' .. tostring(rec.contracts)
			.. ' || ' .. tostring(rec.late)
			.. ' || ' .. formatMoney(rec.earnings)
			.. ' || ' .. string.format("%.1f", rec.rep)
			.. ' || ' .. stableName(rec.stable)
			.. ' || ' .. rec.topCargo
			.. ' || ' .. tostring(rec.score)
	end

	out[#out+1] = '|}'
	return table.concat(out, "\n")
end

local function renderTenderBoardFromBoard(board, limit)
	local ctx = board.ctx
	limit = tonumber(limit) or 8
	limit = clamp(limit, 1, 12)

	local out = {}
	out[#out+1] = '{| class="wikitable sortable"'
	out[#out+1] = '|+ Public tender board, ' .. formatDate(ctx, ctx.day)
	out[#out+1] = '|-'
	out[#out+1] = '! Rank !! Claimant !! Cargo !! Route !! Mode !! Posted value !! Result !! Incident'

	for i = 1, limit do
		local t = generateTender(ctx, board, i)

		out[#out+1] = '|-'
		out[#out+1] = '| ' .. tostring(i)
			.. ' || ' .. t.merchant .. '<br><small>' .. t.house .. '</small>'
			.. ' || ' .. t.cargo
			.. ' || ' .. t.from .. ' → ' .. t.to .. '<br><small>' .. tostring(t.km) .. ' km</small>'
			.. ' || ' .. t.mode
			.. ' || ' .. formatMoney(t.value)
			.. ' || ' .. (t.onTime and "On time" or "Late")
			.. ' || ' .. (t.incident or "—")
	end

	out[#out+1] = '|}'
	return table.concat(out, "\n")
end

local function renderNoticesFromBoard(board, limit)
	local ctx = board.ctx
	limit = tonumber(limit) or 6
	limit = clamp(limit, 1, 10)

	local noticeTypes = {
		"claimed a sealed tender before sunrise",
		"passed customs inspection without amendment",
		"lost time to a ferry queue",
		"secured an improved stable certificate",
		"completed a difficult route under poor conditions",
		"received a manifest challenge from port clerks",
		"posted a clean ledger stamp",
		"was seen bidding for a prestige contract",
		"settled a late delivery at reduced value",
		"gained favor among route clerks"
	}

	local out = {}
	out[#out+1] = '{| class="wikitable"'
	out[#out+1] = '|+ League notices, ' .. formatDate(ctx, ctx.day)
	out[#out+1] = '|-'
	out[#out+1] = '! Notice !! House !! Clerk note'

	for i = 1, limit do
		local rec = board.list[((i - 1) % #board.list) + 1]
		local note = pick(noticeTypes, ctx.seasonKey .. "|notice|" .. ctx.day .. "|" .. rec.house.id .. "|" .. i)

		out[#out+1] = '|-'
		out[#out+1] = '| ' .. tostring(i)
			.. ' || ' .. rec.house.house
			.. ' || ' .. rec.house.merchant .. ' ' .. note .. '.'
	end

	out[#out+1] = '|}'
	return table.concat(out, "\n")
end

------------------------------------------------------------------------
-- PUBLIC FUNCTIONS
------------------------------------------------------------------------

function p.renderSeasonInfo(frame)
	local ctx = seasonArgs(frame)
	local board = buildBoard(ctx)
	return renderSeasonInfoFromBoard(board)
end

function p.renderStandings(frame)
	local ctx = seasonArgs(frame)
	local board = buildBoard(ctx)
	return renderStandingsFromBoard(board)
end

p.standings = p.renderStandings
p.leaderboard = p.renderStandings

function p.renderTenderBoard(frame)
	local args = (frame and frame.args) or {}
	local ctx = seasonArgs(frame)
	local board = buildBoard(ctx)
	return renderTenderBoardFromBoard(board, args.limit)
end

function p.renderDailyNotices(frame)
	local args = (frame and frame.args) or {}
	local ctx = seasonArgs(frame)
	local board = buildBoard(ctx)
	return renderNoticesFromBoard(board, args.limit)
end

p.notices = p.renderDailyNotices

local function findHouse(query)
	if not query or query == "" then return nil end
	query = mw.ustring.lower(tostring(query))

	for _, h in ipairs(HOUSES) do
		local id = mw.ustring.lower(h.id or "")
		local merchant = mw.ustring.lower(h.merchant or "")
		local house = mw.ustring.lower(h.house or "")

		if id == query or merchant:find(query, 1, true) or house:find(query, 1, true) then
			return h
		end
	end

	return nil
end

function p.renderHouseLedger(frame)
	local args = (frame and frame.args) or {}
	local ctx = seasonArgs(frame)
	local board = buildBoard(ctx)

	local query = args.house or args.merchant or args[1]
	local house = findHouse(query)

	if not house then
		local options = {}
		for _, h in ipairs(HOUSES) do
			table.insert(options, h.house)
		end
		return "'''Unknown merchant house.''' Available houses: " .. table.concat(options, "; ")
	end

	local rec = board.byId[house.id]

	local out = {}
	out[#out+1] = '{| class="wikitable"'
	out[#out+1] = '|+ Ledger profile — ' .. house.house .. ', ' .. formatSeason(ctx)
	out[#out+1] = '|-'
	out[#out+1] = '! Field !! Value'
	out[#out+1] = '|-'
	out[#out+1] = '| Merchant || ' .. house.merchant
	out[#out+1] = '|-'
	out[#out+1] = '| House || ' .. house.house
	out[#out+1] = '|-'
	out[#out+1] = '| Origin || ' .. house.origin
	out[#out+1] = '|-'
	out[#out+1] = '| Profile || ' .. house.profile
	out[#out+1] = '|-'
	out[#out+1] = '| Current rank || #' .. tostring(rec.rank)
	out[#out+1] = '|-'
	out[#out+1] = '| Score || ' .. tostring(rec.score)
	out[#out+1] = '|-'
	out[#out+1] = '| Contracts || ' .. tostring(rec.contracts)
	out[#out+1] = '|-'
	out[#out+1] = '| Late deliveries || ' .. tostring(rec.late)
	out[#out+1] = '|-'
	out[#out+1] = '| Income || ' .. formatMoney(rec.earnings)
	out[#out+1] = '|-'
	out[#out+1] = '| Trade turnover || ' .. formatMoney(rec.trade)
	out[#out+1] = '|-'
	out[#out+1] = '| Reputation || ' .. string.format("%.1f", rec.rep)
	out[#out+1] = '|-'
	out[#out+1] = '| Stable papers || ' .. stableName(rec.stable)
	out[#out+1] = '|-'
	out[#out+1] = '| Usual route mode || ' .. rec.mode
	out[#out+1] = '|-'
	out[#out+1] = '| Distance credited || ' .. tostring(rec.km) .. ' km'
	out[#out+1] = '|-'
	out[#out+1] = '| Sealed tenders || ' .. tostring(rec.sealed)
	out[#out+1] = '|-'
	out[#out+1] = '| Ritual tenders || ' .. tostring(rec.ritual)
	out[#out+1] = '|-'
	out[#out+1] = '| Top cargo || ' .. rec.topCargo
	out[#out+1] = '|}'
	return table.concat(out, "\n")
end

p.house = p.renderHouseLedger

function p.renderRivalHouses(frame)
	local out = {}
	out[#out+1] = '{| class="wikitable sortable"'
	out[#out+1] = '|+ Rival merchant houses of the Vaeringheim Merchant League - Invitational Division'
	out[#out+1] = '|-'
	out[#out+1] = '! Merchant !! House !! Origin !! Usual profile'

	for _, h in ipairs(HOUSES) do
		out[#out+1] = '|-'
		out[#out+1] = '| ' .. h.merchant
			.. ' || ' .. h.house
			.. ' || ' .. h.origin
			.. ' || ' .. h.profile
	end

	out[#out+1] = '|}'
	return table.concat(out, "\n")
end

function p.renderSchedule(frame)
	local args = (frame and frame.args) or {}
	local ctx = seasonArgs(frame)

	local startDay = tonumber(args.start or "") or 1
	local endDay = tonumber(args["end"] or args.finish or "") or 12

	startDay = clamp(startDay, 1, DAYS_IN_SEASON)
	endDay = clamp(endDay, startDay, DAYS_IN_SEASON)

	local out = {}
	out[#out+1] = '{| class="wikitable sortable"'
	out[#out+1] = '|+ Featured tender schedule, ' .. formatSeason(ctx)
	out[#out+1] = '|-'
	out[#out+1] = '! Day !! Date !! Featured claimant !! Likely cargo !! Route'

	for day = startDay, endDay do
		local h = pick(HOUSES, ctx.seasonKey .. "|scheduleHouse|" .. tostring(day))
		local cargo = pick(h.cargo, ctx.seasonKey .. "|scheduleCargo|" .. tostring(day))
		local route = pick(ROUTES, ctx.seasonKey .. "|scheduleRoute|" .. tostring(day))

		out[#out+1] = '|-'
		out[#out+1] = '| ' .. tostring(day)
			.. ' || ' .. formatDate(ctx, day)
			.. ' || ' .. h.merchant .. '<br><small>' .. h.house .. '</small>'
			.. ' || ' .. cargo
			.. ' || ' .. route.from .. ' → ' .. route.to .. ' (' .. route.mode .. ')'
	end

	out[#out+1] = '|}'
	return table.concat(out, "\n")
end

function p.renderFull(frame)
	local args = (frame and frame.args) or {}
	local ctx = seasonArgs(frame)
	local board = buildBoard(ctx)

	local out = {}
	out[#out+1] = renderSeasonInfoFromBoard(board)
	out[#out+1] = ""
	out[#out+1] = renderStandingsFromBoard(board)
	out[#out+1] = ""
	out[#out+1] = renderTenderBoardFromBoard(board, args.limit or 8)
	out[#out+1] = ""
	out[#out+1] = renderNoticesFromBoard(board, args.noticeLimit or 6)
	return table.concat(out, "\n")
end

p.full = p.renderFull


------------------------------------------------------------------------
-- ATLAS RENDER FUNCTIONS
-- These are intentionally not included in renderFull unless manually invoked.
------------------------------------------------------------------------

function p.renderCargoAtlas(frame)
	local args = (frame and frame.args) or {}
	local sectorFilter = mw.ustring.lower(tostring(args.sector or args[1] or ""))
	local classFilter = mw.ustring.lower(tostring(args.class or args.kind or ""))
	local limit = tonumber(args.limit or "") or 999
	limit = clamp(limit, 1, 999)

	local out = {}
	local shown = 0

	out[#out+1] = '{| class="wikitable sortable"'
	out[#out+1] = '|+ Vaeringheim Merchant League cargo atlas'
	out[#out+1] = '|-'
	out[#out+1] = '! Cargo !! Sector !! Company !! Unit !! Class !! Handling risk'

	for _, c in ipairs(CARGO) do
		local sector = mw.ustring.lower(c.sector or "")
		local class = mw.ustring.lower(c.class or "")

		local sectorOK = sectorFilter == "" or sector:find(sectorFilter, 1, true)
		local classOK = classFilter == "" or class:find(classFilter, 1, true)

		if sectorOK and classOK then
			shown = shown + 1
			out[#out+1] = '|-'
			out[#out+1] = '| ' .. c.name
				.. ' || ' .. c.sector
				.. ' || ' .. c.company
				.. ' || ' .. c.unit
				.. ' || ' .. c.class
				.. ' || ' .. string.format("%.2f", c.risk or 0)
		end

		if shown >= limit then break end
	end

	if shown == 0 then
		out[#out+1] = '|-'
		out[#out+1] = '| colspan="6" | No cargo matched the requested filter.'
	end

	out[#out+1] = '|}'
	return table.concat(out, "\n")
end

function p.renderRouteAtlas(frame)
	local args = (frame and frame.args) or {}
	local modeFilter = mw.ustring.lower(tostring(args.mode or args[1] or ""))
	local cityFilter = mw.ustring.lower(tostring(args.city or args.from or args.to or ""))
	local corridorFilter = mw.ustring.lower(tostring(args.corridor or ""))
	local limit = tonumber(args.limit or "") or 999
	limit = clamp(limit, 1, 999)

	local out = {}
	local shown = 0

	out[#out+1] = '{| class="wikitable sortable"'
	out[#out+1] = '|+ Vaeringheim Merchant League route atlas'
	out[#out+1] = '|-'
	out[#out+1] = '! From !! To !! Mode !! Distance !! Risk !! Corridor'

	for _, r in ipairs(ROUTES) do
		local mode = mw.ustring.lower(r.mode or "")
		local from = mw.ustring.lower(r.from or "")
		local to = mw.ustring.lower(r.to or "")
		local corridor = mw.ustring.lower(r.corridor or "")

		local modeOK = modeFilter == "" or mode:find(modeFilter, 1, true)
		local cityOK = cityFilter == "" or from:find(cityFilter, 1, true) or to:find(cityFilter, 1, true)
		local corridorOK = corridorFilter == "" or corridor:find(corridorFilter, 1, true)

		if modeOK and cityOK and corridorOK then
			shown = shown + 1
			out[#out+1] = '|-'
			out[#out+1] = '| ' .. r.from
				.. ' || ' .. r.to
				.. ' || ' .. r.mode
				.. ' || ' .. tostring(r.km) .. ' km'
				.. ' || ' .. string.format("%.2f", r.risk or 0)
				.. ' || ' .. (r.corridor or "—")
		end

		if shown >= limit then break end
	end

	if shown == 0 then
		out[#out+1] = '|-'
		out[#out+1] = '| colspan="6" | No route matched the requested filter.'
	end

	out[#out+1] = '|}'
	return table.concat(out, "\n")
end

function p.doc(frame)
	return table.concat({
		"'''Vaeringheim Merchant League - Invitational Division'''",
		"",
		"This is the lightweight version of the merchant league simulation module.",
		"It calculates each house's season record directly instead of replaying every day of the season.",
		"",
		"Common invocations:",
		"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderFull}}</code>",
		"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderStandings}}</code>",
		"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderStandings|year=54|month=Thalassiel|day=61}}</code>",
		"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderTenderBoard|limit=6}}</code>",
		"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderDailyNotices|limit=5}}</code>",
		"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderHouseLedger|house=Giid-Tally Yard}}</code>",
		"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderSchedule|start=1|end=12}}</code>",
		"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderRivalHouses}}</code>",
		"",
		"Each season lasts 61 days and resets at the beginning of each PSSC month."
	}, "\n")
end

return p