Module:VaeringheimMerchantLeagueInvitational: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
NewZimiaGov (talk | contribs)
No edit summary
Line 279: 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",
profile = "Clean water, fish, mountain greens, and perishables.",
profile = "Clean water, fish, mountain greens, and perishables.",
cargo = { "clean water casks", "spring-pond trout", "mountain greens", "ice-box produce" },
cargo = { "clean water casks", "spring-pond trout", "mountain greens", "ice-box produce" },
Line 408: Line 408:
{ from = "Saruq Thyrsa", to = "Thal Qencha", mode = "rail", km = 520, risk = 0.28 },
{ 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 = "Thal Qencha", to = "Mirovia", mode = "rail", km = 690, risk = 0.31 },
{ from = "Mirovia", to = "Ayllu Nysara", mode = "road", km = 570, risk = 0.35 },
{ from = "Mirovia", to = "Nysara Velai", mode = "road", km = 570, risk = 0.35 },
{ from = "Ayllu Nysara", to = "Mylecia", mode = "skybridge", km = 1480, risk = 0.24 },
{ from = "Nysara Velai", to = "Mylecia", mode = "skybridge", km = 1480, risk = 0.24 },
{ from = "Acheron", to = "Ourid", mode = "pass-road", km = 510, risk = 0.37 },
{ 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 = "Luminaria", to = "Ourid", mode = "pass-road", km = 480, risk = 0.34 },

Revision as of 18:22, 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

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

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 = "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 = "Nysara Velai", mode = "road", km = 570, risk = 0.35 },
	{ from = "Nysara Velai", to = "Mylecia", mode = "skybridge", km = 1480, risk = 0.24 },
	{ 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 }
}

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

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