Module:VaeringheimMerchantLeagueInvitational

From MicrasWiki
Revision as of 18:10, 9 July 2026 by 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...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigationJump to search

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

-- 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_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 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)
	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 args[1] or "") or now.year
	local monthIndex = monthIndexFrom(args.month or args.m or args[2]) or now.monthIndex

	local day = tonumber(args.day or args.d or args[3] 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

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

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

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

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

-- Internal simulation stores contract income in 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

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

local HOUSES = {
	{
		id = "qamar",
		merchant = "Asena Vel-Qamar",
		house = "House Qamar",
		origin = "Vaeringheim",
		location = "Vaeringheim",
		style = "Luxury shrine-road brokerage and ceremonial cargo.",
		profile = "A polished house known for shrine consignments, sealed ledgers, and expensive clients.",
		skill = 0.88,
		reliability = 0.86,
		risk = 0.46,
		rep = 14,
		stable = 5,
		tags = { luxury = 4, ritual = 4, service = 2, sealed = 3 }
	},
	{
		id = "eikskald",
		merchant = "Jorunn Eikskald",
		house = "Eikskald Factors",
		origin = "Riddersborg",
		location = "Riddersborg",
		style = "Cold-road wool, timber, and northern freight.",
		profile = "A hard-weather ledger with reliable northern carriers and a reputation for endurance.",
		skill = 0.82,
		reliability = 0.90,
		risk = 0.38,
		rep = 12,
		stable = 4,
		tags = { wool = 4, timber = 4, bulk = 3, cold = 4 }
	},
	{
		id = "bluequay",
		merchant = "Nami of the Blue Quay",
		house = "Blue Quay Papers",
		origin = "Mylecia",
		location = "Mylecia",
		style = "Water, medicine, ferry freight, and wetland routes.",
		profile = "A careful wet-road broker whose clerks are trusted with water, medicine, and ferry claims.",
		skill = 0.84,
		reliability = 0.88,
		risk = 0.42,
		rep = 13,
		stable = 3,
		tags = { water = 4, medicine = 3, ferry = 4, perishable = 2 }
	},
	{
		id = "aureli",
		merchant = "Sadir al-Aureli",
		house = "Aurelian Grain Desk",
		origin = "Aurelia",
		location = "Aurelia",
		style = "Grain, lamp oil, staple food, and bulk provisions.",
		profile = "A high-volume desk that wins by moving ordinary staples with unusual punctuality.",
		skill = 0.81,
		reliability = 0.84,
		risk = 0.35,
		rep = 11,
		stable = 3,
		tags = { grain = 5, oil = 3, staple = 5, bulk = 4 }
	},
	{
		id = "arshir",
		merchant = "Laleh Arshir",
		house = "Ardashir Import Rooms",
		origin = "Delphica",
		location = "Delphica",
		style = "Euranidom imports, fine horse stock, and prestige contracts.",
		profile = "An import house with elegant accounts, costly preferences, and a taste for risky prestige tenders.",
		skill = 0.86,
		reliability = 0.78,
		risk = 0.68,
		rep = 13,
		stable = 6,
		tags = { luxury = 4, animal = 4, import = 4, prestige = 5 }
	},
	{
		id = "salval",
		merchant = "Brennan Salval",
		house = "Salt-Valed Ledger",
		origin = "Sufriya",
		location = "Sufriya",
		style = "Salt, fish, hard weather routes, and rough-margin tenders.",
		profile = "A lean house that accepts poor weather, thin margins, and cargo other merchants avoid.",
		skill = 0.79,
		reliability = 0.80,
		risk = 0.72,
		rep = 9,
		stable = 3,
		tags = { salt = 5, fish = 4, cold = 3, rough = 5, ferry = 2 }
	},
	{
		id = "thalmaen",
		merchant = "Orvida Thal-Maen",
		house = "Thal-Maen Water Desk",
		origin = "Thal Qencha",
		location = "Thal Qencha",
		style = "Clean water, fish, mountain greens, and perishables.",
		profile = "A mountain-water desk respected for inspection discipline and clean cask seals.",
		skill = 0.83,
		reliability = 0.89,
		risk = 0.41,
		rep = 12,
		stable = 4,
		tags = { water = 5, fish = 3, greens = 4, perishable = 5 }
	},
	{
		id = "sanvei",
		merchant = "Yusaru Sanvei",
		house = "Sandveil Commissions",
		origin = "Somniumpolis",
		location = "Somniumpolis",
		style = "Dream-city luxuries, sealed consignments, and dusk tenders.",
		profile = "A theatrical and sometimes uncanny commission house with a talent for dusk deliveries.",
		skill = 0.85,
		reliability = 0.77,
		risk = 0.70,
		rep = 12,
		stable = 5,
		tags = { luxury = 4, sealed = 5, ritual = 2, service = 3, night = 5 }
	},
	{
		id = "jorope",
		merchant = "Mikel Jorope",
		house = "Jogi Ropehouse",
		origin = "Jogi",
		location = "Jogi",
		style = "Dock rope, reed mats, wet-road hauling, and marsh labor.",
		profile = "A dockside house built on rope, reed, rough labor, and an intimate knowledge of marsh delay.",
		skill = 0.78,
		reliability = 0.82,
		risk = 0.58,
		rep = 10,
		stable = 2,
		tags = { rope = 5, reed = 5, ferry = 3, rough = 3, bulk = 3 }
	},
	{
		id = "ourid",
		merchant = "Tavri of Ourid",
		house = "Ourid Shrine Accounts",
		origin = "Ourid",
		location = "Ourid",
		style = "Pass-road courier work and shrine-route contracts.",
		profile = "A shrine-route courier house that treats stamps, relic boxes, and pass ledgers as sacred matter.",
		skill = 0.87,
		reliability = 0.85,
		risk = 0.52,
		rep = 13,
		stable = 4,
		tags = { ritual = 5, courier = 5, sealed = 3, pass = 4 }
	},
	{
		id = "siva",
		merchant = "Nysa Siva",
		house = "Velai Glass & Scrip",
		origin = "Luminaria",
		location = "Luminaria",
		style = "Glassware, scrip, invoice brokerage, and social tenders.",
		profile = "A nimble house whose glass consignments and scrip brokerage thrive on reputation.",
		skill = 0.84,
		reliability = 0.81,
		risk = 0.47,
		rep = 12,
		stable = 3,
		tags = { glass = 5, scrip = 5, service = 4, fragile = 4 }
	},
	{
		id = "sinders",
		merchant = "Haldor bin Sinders",
		house = "Giid-Tally Yard",
		origin = "Acheron",
		location = "Acheron",
		style = "Prestige caravan work, heavy hauling, and Giid-Lisea-drawn cargo.",
		profile = "The heaviest yard in the division, feared for slow starts but massive hauling capacity.",
		skill = 0.80,
		reliability = 0.83,
		risk = 0.45,
		rep = 11,
		stable = 7,
		tags = { heavy = 5, prestige = 4, bulk = 5, animal = 2, timber = 3 }
	}
}

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

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

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

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

local function stableCapacity(idx)
	local s = STABLES[idx] or STABLES[1]
	return s.capacity or 1
end

------------------------------------------------------------------------
-- 5) CARGO POOL
------------------------------------------------------------------------

local CARGO = {
	{ name = "Rice", sector = "Agriculture", base = 9.00, unit = "tons", company = "Gladeseed Farmers' Union", tags = { "grain", "staple", "bulk" }, risk = 0.08 },
	{ 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 = "Lamp oil", sector = "Agriculture", base = 18.00, unit = "casks", company = "Wardens of the Fields of Callope", tags = { "oil", "ritual", "staple" }, risk = 0.18 },
	{ name = "Clean water casks", sector = "Agriculture", base = 16.00, unit = "casks", company = "Thal-Maen Water Desk", tags = { "water", "staple", "perishable" }, risk = 0.12 },
	{ name = "Mountain greens", sector = "Agriculture", base = 22.00, unit = "crates", company = "Lenaian Water Company", tags = { "greens", "perishable", "water" }, risk = 0.25 },
	{ name = "Spring-pond trout", sector = "Agriculture", base = 26.00, unit = "ice crates", company = "Lenaian Water Company", tags = { "fish", "perishable", "water" }, risk = 0.30 },
	{ name = "Salt fish", sector = "Agriculture", base = 14.00, unit = "barrels", company = "Salt-Valed Ledger", tags = { "fish", "salt", "rough" }, risk = 0.16 },
	{ name = "Cistern melons", sector = "Agriculture", base = 20.00, unit = "hampers", company = "Pontic Esperance Homestead Association", tags = { "perishable", "staple", "desert" }, risk = 0.24 },
	{ name = "Ferry mats", sector = "Manufacturing", base = 13.00, unit = "bundles", company = "Thyrsa Maena Harbor Cooperative", tags = { "reed", "rope", "ferry", "bulk" }, risk = 0.10 },
	{ name = "Cargo padding", sector = "Manufacturing", base = 11.00, unit = "rolls", company = "Thyrsa Maena Harbor Cooperative", tags = { "reed", "rope", "bulk" }, risk = 0.08 },
	{ 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)
	if module and type(module.getAverageData) == "function" then
		local ok, data = pcall(module.getAverageData)
		if ok and type(data) == "table" then
			return {
				label = data.label or label,
				level = data.avgLevel or "Standard",
				pct = tonumber(data.pctChange) or 0,
				sym = data.avgSym or "•"
			}
		end
	end
	return { label = label, level = "Standard", pct = 0, sym = "•" }
end

local function marketSignals()
	return {
		Agriculture = marketAverage(okAg and agPrices or nil, "Agriculture"),
		Manufacturing = marketAverage(okManf and manfPrices or nil, "Mining / Manufacturing / Energy"),
		Services = marketAverage(okServ and servPrices or nil, "Services")
	}
end

local function sectorPulse(sector, signals)
	local s = signals and signals[sector]
	if not s then return 1.0 end

	-- In the General Port price ledgers, "High" means pressure/scarcity;
	-- this increases tender value and demand.
	if s.level == "High" then return 1.14 end
	if s.level == "Low" then return 0.94 end
	return 1.0
end

------------------------------------------------------------------------
-- 7) ROUTE NETWORK
------------------------------------------------------------------------

local ROUTES = {
	{ from = "Vaeringheim", to = "Mylecia", mode = "skybridge", km = 1479, risk = 0.18 },
	{ from = "Vaeringheim", to = "Luminaria", mode = "road", km = 320, risk = 0.20 },
	{ from = "Vaeringheim", to = "Serena", mode = "ferry", km = 210, risk = 0.18 },
	{ from = "Vaeringheim", to = "Delphica", mode = "road", km = 410, risk = 0.22 },
	{ from = "Vaeringheim", to = "Somniumpolis", mode = "ferry", km = 520, risk = 0.30 },
	{ from = "Mylecia", to = "Jogi", mode = "road", km = 310, risk = 0.36 },
	{ from = "Mylecia", to = "Sufriya", mode = "ferry", km = 460, risk = 0.34 },
	{ from = "Mylecia", to = "Hayr Chaskal", mode = "rail", km = 880, risk = 0.30 },
	{ 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 = {}

local function addRoute(r)
	ROUTES_FROM[r.from] = ROUTES_FROM[r.from] or {}
	table.insert(ROUTES_FROM[r.from], r)
end

for _, r in ipairs(ROUTES) do
	addRoute(r)
	addRoute({
		from = r.to,
		to = r.from,
		mode = r.mode,
		km = r.km,
		risk = r.risk + 0.01
	})
end

local function routesFrom(city)
	return ROUTES_FROM[city] or {}
end

local function pickRoute(rec, house, ctx, day, idx)
	local opts = routesFrom(rec.location or house.location or house.origin)
	if not opts or #opts == 0 then opts = ROUTES end

	return chooseWeighted(opts, ctx.seasonKey .. "|route|" .. house.id .. "|" .. day .. "|" .. idx, function(r)
		local w = 1.0
		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
------------------------------------------------------------------------

local function hasTag(cargo, tag)
	for _, t in ipairs(cargo.tags or {}) do
		if t == tag then return true end
	end
	return false
end

local function cargoWeightForHouse(house, cargo, signals)
	local w = 1.0

	for _, tag in ipairs(cargo.tags or {}) do
		if house.tags[tag] then
			w = w + house.tags[tag] * 0.70
		end
	end

	w = w * sectorPulse(cargo.sector, signals)

	if cargo.risk and house.risk then
		if cargo.risk > 0.35 and house.risk > 0.60 then
			w = w + 1.4
		elseif cargo.risk > 0.35 and house.risk < 0.45 then
			w = w * 0.65
		end
	end

	return w
end

local function pickCargo(house, ctx, day, idx, signals)
	return chooseWeighted(CARGO, ctx.seasonKey .. "|cargo|" .. house.id .. "|" .. day .. "|" .. idx, function(c)
		return cargoWeightForHouse(house, c, signals)
	end)
end

------------------------------------------------------------------------
-- 9) SIMULATION
------------------------------------------------------------------------

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"
}

local function emptyRecord(house)
	return {
		house = house,
		location = house.location or house.origin,
		contracts = 0,
		late = 0,
		earnings = 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)
	local r = randKey(ctx.seasonKey .. "|tenders|" .. house.id .. "|" .. day)
	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
	if r < 0.76 then return 1 end
	if r < 0.95 then return 2 end
	return 3
end

local function routePayMultiplier(mode)
	if mode == "skybridge" then return 1.26 end
	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

local function routeSpeed(mode)
	if mode == "skybridge" then return 140 end
	if mode == "rail" then return 70 end
	if mode == "ferry" then return 28 end
	if mode == "pass-road" then return 18 end
	return 24
end

local function simulateTender(rec, house, ctx, day, idx, signals)
	local route = pickRoute(rec, house, ctx, day, idx)
	local cargo = pickCargo(house, ctx, day, idx, signals)

	local capacity = stableCapacity(rec.stable or house.stable or 1)
	local qtyPulse = 0.65 + randKey(ctx.seasonKey .. "|qty|" .. house.id .. "|" .. day .. "|" .. idx) * 1.10
	local quantity = math.max(1, round(qtyPulse * capacity * 4))

	local basePay = 64
		+ (tonumber(route.km) or 0) * 0.115
		+ (tonumber(cargo.base) or 10) * 3.4
		+ (tonumber(route.risk) or 0.2) * 190
		+ (tonumber(cargo.risk) or 0.1) * 120
		+ quantity * 12

	basePay = basePay * routePayMultiplier(route.mode)
	basePay = basePay * sectorPulse(cargo.sector, signals)

	local sealed = hasTag(cargo, "sealed") or randKey(ctx.seasonKey .. "|sealed|" .. house.id .. "|" .. day .. "|" .. idx) < 0.16
	local ritual = hasTag(cargo, "ritual")

	if sealed then basePay = basePay * 1.08 end
	if ritual then basePay = basePay * 1.10 end

	local riskLoad = (route.risk or 0.2) * 0.55 + (cargo.risk or 0.1) * 0.45
	local onTimeChance =
		(house.skill or 0.8) * 0.48
		+ (house.reliability or 0.8) * 0.40
		+ (stableCapacity(rec.stable or house.stable or 1) - 1.0) * 0.055
		- riskLoad * 0.24

	if route.mode == "skybridge" then onTimeChance = onTimeChance + 0.05 end
	if route.mode == "ferry" then onTimeChance = onTimeChance - 0.02 end
	if hasTag(cargo, "perishable") then onTimeChance = onTimeChance - 0.04 end
	if hasTag(cargo, "fragile") then onTimeChance = onTimeChance - 0.04 end

	onTimeChance = clamp(onTimeChance, 0.35, 0.96)

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

	local incidentChance = clamp(
		(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
	if randKey(ctx.seasonKey .. "|incident|" .. house.id .. "|" .. day .. "|" .. idx) < incidentChance then
		incident = pick(INCIDENTS, ctx.seasonKey .. "|incidentPick|" .. house.id .. "|" .. day .. "|" .. idx)
	end

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

	local pay = round(basePay)
	local paid = onTime and pay or round(pay * 0.62)

	local travelHours = (route.km or 0) / routeSpeed(route.mode)
	if not onTime then travelHours = travelHours * 1.25 end
	if incident then travelHours = travelHours * 1.12 end

	local tender = {
		day = day,
		date = formatDate(ctx, day),
		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,
		late = not onTime,
		incident = incident,
		sealed = sealed,
		ritual = ritual,
		travelHours = travelHours,
		risk = riskLoad
	}

	rec.contracts = rec.contracts + 1
	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

local function maybeStableUpgrade(rec, house, ctx, day, notices)
	local r = randKey(ctx.seasonKey .. "|stable|" .. house.id .. "|" .. day)
	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
		records[house.id] = emptyRecord(house)
	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

	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)

	for i, rec in ipairs(list) do
		rec.rank = i
	end

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

------------------------------------------------------------------------
-- 10) RENDER HELPERS
------------------------------------------------------------------------

local function topCargo(rec)
	local bestName, bestCount = "—", 0
	for name, count in pairs(rec.cargoMap or {}) do
		if count > bestCount or (count == bestCount and name < bestName) then
			bestName = name
			bestCount = count
		end
	end
	if bestCount <= 0 then return "—" end
	return bestName .. " (" .. tostring(bestCount) .. ")"
end

local function modeSummary(rec)
	local parts = {}
	local order = { "road", "rail", "ferry", "skybridge", "pass-road" }
	for _, mode in ipairs(order) do
		local n = rec.modeMap and rec.modeMap[mode] or 0
		if n > 0 then table.insert(parts, mode .. " " .. tostring(n)) end
	end
	if #parts == 0 then return "—" end
	return table.concat(parts, "<br>")
end

local function renderSignalLine(signals)
	local parts = {}
	for _, sector in ipairs({ "Agriculture", "Manufacturing", "Services" }) do
		local s = signals[sector]
		if s then
			table.insert(parts, (s.label or sector) .. ": " .. (s.level or "Standard") .. " " .. string.format("(%.2f%%)", s.pct or 0))
		end
	end
	return table.concat(parts, " · ")
end

------------------------------------------------------------------------
-- 11) PUBLIC RENDER FUNCTIONS
------------------------------------------------------------------------

function p.renderSeasonInfo(frame)
	local ctx = seasonArgs(frame)
	local board = simulateBoard(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 and (leader.house.merchant .. " (" .. leader.house.house .. ")") or "—")
	out[#out+1] = '|-'
	out[#out+1] = '| Market signals || ' .. renderSignalLine(board.signals)
	out[#out+1] = '|}'
	return table.concat(out, "\n")
end

function p.renderStandings(frame)
	local ctx = seasonArgs(frame)
	local board = simulateBoard(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)
			.. ' || ' .. topCargo(rec)
			.. ' || ' .. tostring(rec.score)
	end

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

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

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 DAYS_IN_SEASON

	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] = '|+ Daily featured tender schedule, ' .. formatSeason(ctx)
	out[#out+1] = '|-'
	out[#out+1] = '! Day !! Date !! Featured board notice !! Likely route !! Leading claimant'

	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)
		local rec = emptyRecord(house)
		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] = '| ' .. tostring(day)
			.. ' || ' .. formatDate(ctx, day)
			.. ' || ' .. cargo.name
			.. ' || ' .. route.from .. ' → ' .. route.to .. ' (' .. route.mode .. ')'
			.. ' || ' .. house.merchant .. ', ' .. house.house
	end

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

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

	local limit = tonumber((frame and frame.args and frame.args.limit) or "") or 12

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

	for i = 1, math.min(limit, #tenders) do
		local t = tenders[i]
		out[#out+1] = '|-'
		out[#out+1] = '| ' .. tostring(i)
			.. ' || ' .. t.merchant .. '<br><small>' .. t.house .. '</small>'
			.. ' || ' .. t.cargo .. '<br><small>' .. t.company .. '</small>'
			.. ' || ' .. 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

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

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

	local day = tonumber(args.day or args.d or "") or ctx.day
	day = clamp(day, 1, ctx.day)

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

	if not t then
		return "'''No tender report is available for " .. formatDate(ctx, day) .. ".'''"
	end

	local out = {}
	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

function p.renderDailyNotices(frame)
	local ctx = seasonArgs(frame)
	local board = simulateBoard(ctx)
	local args = (frame and frame.args) or {}
	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

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 = simulateBoard(ctx)

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

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

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

	local out = {}
	out[#out+1] = '{| class="wikitable"'
	out[#out+1] = '|+ Ledger profile — ' .. h.house .. ', ' .. formatSeason(ctx)
	out[#out+1] = '|-'
	out[#out+1] = '! Field !! Value'
	out[#out+1] = '|-'
	out[#out+1] = '| Merchant || ' .. h.merchant
	out[#out+1] = '|-'
	out[#out+1] = '| House || ' .. h.house
	out[#out+1] = '|-'
	out[#out+1] = '| Origin || ' .. h.origin
	out[#out+1] = '|-'
	out[#out+1] = '| Current location || ' .. rec.location
	out[#out+1] = '|-'
	out[#out+1] = '| Profile || ' .. h.profile
	out[#out+1] = '|-'
	out[#out+1] = '| Rank || #' .. tostring(rec.rank or "—")
	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] = '| Distance moved || ' .. tostring(round(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] = '| Route modes || ' .. modeSummary(rec)
	out[#out+1] = '|-'
	out[#out+1] = '| Top cargo || ' .. topCargo(rec)
	out[#out+1] = '|}'
	return table.concat(out, "\n")
end

p.house = p.renderHouseLedger

function p.renderMarketSignals(frame)
	local signals = marketSignals()

	local out = {}
	out[#out+1] = '{| class="wikitable sortable"'
	out[#out+1] = '|+ General Port market signals used by the Invitational Division'
	out[#out+1] = '|-'
	out[#out+1] = '! Sector !! Signal !! Change !! League effect'

	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

		out[#out+1] = '|-'
		out[#out+1] = '| ' .. (s.label or sector)
			.. ' || ' .. (s.level or "Standard") .. ' ' .. (s.sym or "•")
			.. ' || ' .. string.format("%.2f%%", s.pct or 0)
			.. ' || ' .. effect
	end

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

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

	local bestValue, bestOnTime, worstLate
	for _, t in ipairs(tenders) do
		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
		if t.late and (not worstLate or t.value > worstLate.value) then worstLate = t end
	end

	local out = {}
	out[#out+1] = '{| class="wikitable"'
	out[#out+1] = '|+ Daily league distinctions, ' .. formatDate(ctx, ctx.day)
	out[#out+1] = '|-'
	out[#out+1] = '! Distinction !! Recipient !! Basis'

	out[#out+1] = '|-'
	out[#out+1] = '| Seal of the Board || ' .. (bestValue and bestValue.merchant or "—") .. ' || ' .. (bestValue and (bestValue.cargo .. " worth " .. formatMoney(bestValue.value)) or "No tender")
	out[#out+1] = '|-'
	out[#out+1] = '| Fleet-Footed Ledger || ' .. (bestOnTime and bestOnTime.merchant or "—") .. ' || ' .. (bestOnTime and ("On-time delivery to " .. bestOnTime.to) or "No on-time tender")
	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] = '|}'
	return table.concat(out, "\n")
end

p.awards = p.renderAwards

function p.renderFull(frame)
	local out = {}
	out[#out+1] = p.renderSeasonInfo(frame)
	out[#out+1] = ""
	out[#out+1] = p.renderStandings(frame)
	out[#out+1] = ""
	out[#out+1] = p.renderAwards(frame)
	out[#out+1] = ""
	out[#out+1] = p.renderTenderBoard(frame)
	out[#out+1] = ""
	out[#out+1] = p.renderDailyNotices(frame)
	return table.concat(out, "\n")
end

p.full = p.renderFull

function p.doc(frame)
	return table.concat({
		"'''Vaeringheim Merchant League - Invitational Division module'''",
		"",
		"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|renderSchedule|year=54|month=Atosiel}}</code>",
		"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderTenderBoard|limit=20}}</code>",
		"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderTenderReport|day=14|tender=1}}</code>",
		"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderHouseLedger|house=Giid-Tally Yard}}</code>",
		"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderMarketSignals}}</code>",
		"",
		"Seasons last 61 days and reset at the beginning of each PSSC month."
	}, "\n")
end

return p