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 |
||
| Line 1: | Line 1: | ||
-- Module:VaeringheimMerchantLeagueInvitational | -- Module:VaeringheimMerchantLeagueInvitational | ||
-- Simulates the Vaeringheim Merchant League - Invitational Division. | -- Lightweight version. | ||
-- Each season lasts one | -- 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: | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- BASIC CALENDAR | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
| Line 30: | Line 33: | ||
local okDate, date = pcall(require, "Module:BassaridianCalendar") | local okDate, date = pcall(require, "Module:BassaridianCalendar") | ||
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 | local year = tonumber(args.year or args.season or "") or now.year | ||
local monthIndex = monthIndexFrom(args.month or args.m or | local monthIndex = monthIndexFrom(args.month or args.m or "") or now.monthIndex | ||
local day = tonumber(args.day or args.d | local day = tonumber(args.day or args.d or "") | ||
if not day then | if not day then | ||
| Line 139: | Line 138: | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- 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 | end | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- MONEY | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- Internal | -- 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: | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- MERCHANT HOUSES | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
| Line 224: | Line 196: | ||
house = "House Qamar", | house = "House Qamar", | ||
origin = "Vaeringheim", | 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, | reliability = 0.86, | ||
risk = 0.46, | risk = 0.46, | ||
baseRep = 14, | |||
stable = 5 | stable = 5 | ||
}, | }, | ||
{ | { | ||
| Line 239: | Line 210: | ||
house = "Eikskald Factors", | house = "Eikskald Factors", | ||
origin = "Riddersborg", | 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, | reliability = 0.90, | ||
risk = 0.38, | risk = 0.38, | ||
baseRep = 12, | |||
stable = 4 | stable = 4 | ||
}, | }, | ||
{ | { | ||
| Line 254: | Line 224: | ||
house = "Blue Quay Papers", | house = "Blue Quay Papers", | ||
origin = "Mylecia", | 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, | reliability = 0.88, | ||
risk = 0.42, | risk = 0.42, | ||
baseRep = 13, | |||
stable = 3 | stable = 3 | ||
}, | }, | ||
{ | { | ||
| Line 269: | Line 238: | ||
house = "Aurelian Grain Desk", | house = "Aurelian Grain Desk", | ||
origin = "Aurelia", | 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, | reliability = 0.84, | ||
risk = 0.35, | risk = 0.35, | ||
baseRep = 11, | |||
stable = 3 | stable = 3 | ||
}, | }, | ||
{ | { | ||
| Line 284: | Line 252: | ||
house = "Ardashir Import Rooms", | house = "Ardashir Import Rooms", | ||
origin = "Delphica", | 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, | reliability = 0.78, | ||
risk = 0.68, | risk = 0.68, | ||
baseRep = 13, | |||
stable = 6 | stable = 6 | ||
}, | }, | ||
{ | { | ||
| Line 299: | Line 266: | ||
house = "Salt-Valed Ledger", | house = "Salt-Valed Ledger", | ||
origin = "Sufriya", | 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, | reliability = 0.80, | ||
risk = 0.72, | risk = 0.72, | ||
baseRep = 9, | |||
stable = 3 | stable = 3 | ||
}, | }, | ||
{ | { | ||
| Line 314: | Line 280: | ||
house = "Thal-Maen Water Desk", | house = "Thal-Maen Water Desk", | ||
origin = "Thal Qencha", | origin = "Thal Qencha", | ||
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, | reliability = 0.89, | ||
risk = 0.41, | risk = 0.41, | ||
baseRep = 12, | |||
stable = 4 | stable = 4 | ||
}, | }, | ||
{ | { | ||
| Line 329: | Line 294: | ||
house = "Sandveil Commissions", | house = "Sandveil Commissions", | ||
origin = "Somniumpolis", | 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, | reliability = 0.77, | ||
risk = 0.70, | risk = 0.70, | ||
baseRep = 12, | |||
stable = 5 | stable = 5 | ||
}, | }, | ||
{ | { | ||
| Line 344: | Line 308: | ||
house = "Jogi Ropehouse", | house = "Jogi Ropehouse", | ||
origin = "Jogi", | 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, | reliability = 0.82, | ||
risk = 0.58, | risk = 0.58, | ||
baseRep = 10, | |||
stable = 2 | stable = 2 | ||
}, | }, | ||
{ | { | ||
| Line 359: | Line 322: | ||
house = "Ourid Shrine Accounts", | house = "Ourid Shrine Accounts", | ||
origin = "Ourid", | 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, | reliability = 0.85, | ||
risk = 0.52, | risk = 0.52, | ||
baseRep = 13, | |||
stable = 4 | stable = 4 | ||
}, | }, | ||
{ | { | ||
| Line 374: | Line 336: | ||
house = "Velai Glass & Scrip", | house = "Velai Glass & Scrip", | ||
origin = "Luminaria", | 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, | reliability = 0.81, | ||
risk = 0.47, | risk = 0.47, | ||
baseRep = 12, | |||
stable = 3 | stable = 3 | ||
}, | }, | ||
{ | { | ||
| Line 389: | Line 350: | ||
house = "Giid-Tally Yard", | house = "Giid-Tally Yard", | ||
origin = "Acheron", | 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, | reliability = 0.83, | ||
risk = 0.45, | risk = 0.45, | ||
baseRep = 11, | |||
stable = 7 | stable = 7 | ||
} | } | ||
} | } | ||
| Line 407: | Line 367: | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- STABLE PRESTIGE | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
local STABLES = { | local STABLES = { | ||
[1] = { name = "licensed porter crew", prestige = 10 | [1] = { name = "licensed porter crew", prestige = 10 }, | ||
[2] = { name = "reed-cart pair", prestige = 22 | [2] = { name = "reed-cart pair", prestige = 22 }, | ||
[3] = { name = "mule and wagon string", prestige = 36 | [3] = { name = "mule and wagon string", prestige = 36 }, | ||
[4] = { name = "woolly horse caravan", prestige = 52 | [4] = { name = "woolly horse caravan", prestige = 52 }, | ||
[5] = { name = "sealed carriage account", prestige = 68 | [5] = { name = "sealed carriage account", prestige = 68 }, | ||
[6] = { name = "prestige horse stock", prestige = 84 | [6] = { name = "prestige horse stock", prestige = 84 }, | ||
[7] = { name = "Giid-Lisea heavy team", prestige = 105 | [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 | end | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- ROUTES | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
| Line 518: | Line 405: | ||
{ from = "Jogi", to = "Lykopolis", mode = "road", km = 240, risk = 0.38 }, | { from = "Jogi", to = "Lykopolis", mode = "road", km = 240, risk = 0.38 }, | ||
{ from = "Sufriya", to = "Keybir-Aviv", mode = "ferry", km = 360, risk = 0.40 }, | { 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 = "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 = "Saruq Thyrsa", to = "Thal Qencha", mode = "rail", km = 520, risk = 0.28 }, | ||
| Line 524: | Line 410: | ||
{ from = "Mirovia", to = "Ayllu Nysara", mode = "road", km = 570, risk = 0.35 }, | { 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 = "Ayllu Nysara", 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 }, | ||
{ from = "Riddersborg", to = "Vaeringheim", mode = "rail", km = 760, risk = 0.24 | { from = "Riddersborg", to = "Vaeringheim", mode = "rail", km = 760, risk = 0.24 } | ||
} | } | ||
local INCIDENTS = { | local INCIDENTS = { | ||
| Line 629: | Line 428: | ||
} | } | ||
------------------------------------------------------------------------ | |||
-- LIGHTWEIGHT RECORD CALCULATION | |||
------------------------------------------------------------------------ | |||
local function | local function currentStable(house, ctx) | ||
local | local upgradeSeed = randKey(ctx.seasonKey .. "|stable|" .. house.id) | ||
local | local bonus = 0 | ||
if | if ctx.day >= 21 and upgradeSeed > 0.78 then bonus = bonus + 1 end | ||
if | if ctx.day >= 44 and upgradeSeed > 0.92 then bonus = bonus + 1 end | ||
end | |||
return clamp((house.stable or 1) + bonus, 1, #STABLES) | |||
end | end | ||
local function | local function topCargo(house, ctx) | ||
return pick(house.cargo, ctx.seasonKey .. "|cargo|" .. house.id) | |||
end | end | ||
local function | local function computeRecord(house, ctx) | ||
local | local day = ctx.day | ||
local | 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 | local form = 0.88 + formSeed * 0.24 | ||
local | local pace = (house.pace or 0.9) * form | ||
local | 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 | 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 | local sealed = math.floor(contracts * (0.10 + (house.risk or 0.5) * 0.045) + randKey(ctx.seasonKey .. "|sealed|" .. house.id) * 2) | ||
local | 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 | local trade = income * (0.18 + randKey(ctx.seasonKey .. "|trade|" .. house.id) * 0.34) | ||
local | 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 | 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 | 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 | 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 | local rec = { | ||
house = house, | |||
contracts = contracts, | |||
house = house | |||
onTime = onTime, | onTime = onTime, | ||
late = | late = late, | ||
earnings = round(income), | |||
trade = round(trade), | |||
rep = rep, | |||
incidents = incidents, | |||
sealed = sealed, | sealed = sealed, | ||
ritual = ritual, | ritual = ritual, | ||
km = round(km), | |||
stable = stable, | |||
stablePrestige = prestige, | |||
topCargo = topCargo(house, ctx), | |||
mode = house.mode, | |||
score = round(score) | |||
} | } | ||
rec | return rec | ||
end | end | ||
local function | local function buildBoard(ctx) | ||
local | local list = {} | ||
for _, house in ipairs(HOUSES) do | for _, house in ipairs(HOUSES) do | ||
table.insert(list, computeRecord(house, ctx)) | |||
end | end | ||
| Line 879: | Line 560: | ||
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, | ||
list = list, | list = list, | ||
byId = byId | |||
} | } | ||
end | end | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- DAILY TENDER FLAVOR | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
local function | local function generateTender(ctx, board, index) | ||
local | 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 | end | ||
local | local onTimeChance = clamp( | ||
0.62 | |||
local | + (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 | end | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- INTERNAL RENDERERS | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
function | local function renderSeasonInfoFromBoard(board) | ||
local ctx = | local ctx = board.ctx | ||
local leader = board.list[1] | local leader = board.list[1] | ||
| Line 951: | Line 640: | ||
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 || ' .. | out[#out+1] = '| Current leader || ' .. leader.house.merchant .. ' (' .. leader.house.house .. ')' | ||
out[#out+1] = '|-' | out[#out+1] = '|-' | ||
out[#out+1] = '| | 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 | local function renderStandingsFromBoard(board) | ||
local ctx = | local ctx = board.ctx | ||
local | 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 676: | ||
.. ' || ' .. string.format("%.1f", rec.rep) | .. ' || ' .. string.format("%.1f", rec.rep) | ||
.. ' || ' .. stableName(rec.stable) | .. ' || ' .. stableName(rec.stable) | ||
.. ' || ' .. topCargo | .. ' || ' .. rec.topCargo | ||
.. ' || ' .. tostring(rec.score) | .. ' || ' .. tostring(rec.score) | ||
end | end | ||
| Line 996: | Line 684: | ||
end | end | ||
local function renderTenderBoardFromBoard(board, limit) | |||
local ctx = board.ctx | |||
limit = tonumber(limit) or 8 | |||
function | limit = clamp(limit, 1, 12) | ||
local | |||
local out = {} | local out = {} | ||
out[#out+1] = '{| class="wikitable sortable"' | out[#out+1] = '{| class="wikitable sortable"' | ||
out[#out+1] = '|+ | out[#out+1] = '|+ Public tender board, ' .. formatDate(ctx, ctx.day) | ||
out[#out+1] = '|-' | out[#out+1] = '|-' | ||
out[#out+1] = '! | out[#out+1] = '! Rank !! Claimant !! Cargo !! Route !! Mode !! Posted value !! Result !! Incident' | ||
for | for i = 1, limit do | ||
local | local t = generateTender(ctx, board, i) | ||
out[#out+1] = '|-' | out[#out+1] = '|-' | ||
out[#out+1] = '| ' .. tostring( | out[#out+1] = '| ' .. tostring(i) | ||
.. ' || ' .. | .. ' || ' .. t.merchant .. '<br><small>' .. t.house .. '</small>' | ||
.. ' || ' .. cargo | .. ' || ' .. 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 | end | ||
| Line 1,042: | Line 713: | ||
end | end | ||
function | local function renderNoticesFromBoard(board, limit) | ||
local ctx = | local ctx = board.ctx | ||
limit = tonumber(limit) or 6 | |||
limit = clamp(limit, 1, 10) | |||
local | 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 | out[#out+1] = '{| class="wikitable"' | ||
out[#out+1] = '|+ | out[#out+1] = '|+ League notices, ' .. formatDate(ctx, ctx.day) | ||
out[#out+1] = '|-' | out[#out+1] = '|-' | ||
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] = '|-' | ||
out[#out+1] = '| ' .. tostring(i) | out[#out+1] = '| ' .. tostring(i) | ||
.. ' || ' .. | .. ' || ' .. rec.house.house | ||
.. ' || ' .. | .. ' || ' .. rec.house.merchant .. ' ' .. note .. '.' | ||
end | end | ||
| Line 1,079: | Line 751: | ||
end | end | ||
function p. | ------------------------------------------------------------------------ | ||
-- PUBLIC FUNCTIONS | |||
------------------------------------------------------------------------ | |||
function p.renderSeasonInfo(frame) | |||
local ctx = seasonArgs(frame) | local ctx = seasonArgs(frame) | ||
local board = | local board = buildBoard(ctx) | ||
return renderSeasonInfoFromBoard(board) | |||
end | |||
local | 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) | |||
local | return renderTenderBoardFromBoard(board, args.limit) | ||
return | |||
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 = | local board = buildBoard(ctx) | ||
return renderNoticesFromBoard(board, args.limit) | |||
end | end | ||
| Line 1,196: | Line 806: | ||
local args = (frame and frame.args) or {} | local args = (frame and frame.args) or {} | ||
local ctx = seasonArgs(frame) | local ctx = seasonArgs(frame) | ||
local board = | local board = buildBoard(ctx) | ||
local query = args.house or args.merchant | local query = args.house or args.merchant or args[1] | ||
local | local house = findHouse(query) | ||
if not | if not house then | ||
local options = {} | local options = {} | ||
for _, | for _, h in ipairs(HOUSES) do | ||
table.insert(options, | 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. | local rec = board.byId[house.id] | ||
local out = {} | local out = {} | ||
out[#out+1] = '{| class="wikitable"' | out[#out+1] = '{| class="wikitable"' | ||
out[#out+1] = '|+ Ledger profile — ' .. | 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 || ' .. | out[#out+1] = '| Merchant || ' .. house.merchant | ||
out[#out+1] = '|-' | out[#out+1] = '|-' | ||
out[#out+1] = '| House || ' .. | out[#out+1] = '| House || ' .. house.house | ||
out[#out+1] = '|-' | out[#out+1] = '|-' | ||
out[#out+1] = '| Origin || ' .. | out[#out+1] = '| Origin || ' .. house.origin | ||
out[#out+1] = '|-' | out[#out+1] = '|-' | ||
out[#out+1] = '| | out[#out+1] = '| Profile || ' .. house.profile | ||
out[#out+1] = '|-' | out[#out+1] = '|-' | ||
out[#out+1] = '| | out[#out+1] = '| Current rank || #' .. tostring(rec.rank) | ||
out[#out+1] = '|-' | out[#out+1] = '|-' | ||
out[#out+1] = '| Score || ' .. tostring(rec.score) | out[#out+1] = '| Score || ' .. tostring(rec.score) | ||
| Line 1,244: | Line 851: | ||
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 | 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 859: | ||
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] = '| | out[#out+1] = '| Top cargo || ' .. rec.topCargo | ||
out[#out+1] = '|}' | out[#out+1] = '|}' | ||
return table.concat(out, "\n") | return table.concat(out, "\n") | ||
| Line 1,259: | Line 866: | ||
p.house = p.renderHouseLedger | p.house = p.renderHouseLedger | ||
function p. | function p.renderRivalHouses(frame) | ||
local out = {} | local out = {} | ||
out[#out+1] = '{| class="wikitable sortable"' | out[#out+1] = '{| class="wikitable sortable"' | ||
out[#out+1] = '|+ | out[#out+1] = '|+ Rival merchant houses of the Vaeringheim Merchant League - Invitational Division' | ||
out[#out+1] = '|-' | out[#out+1] = '|-' | ||
out[#out+1] = '! | out[#out+1] = '! Merchant !! House !! Origin !! Usual profile' | ||
for _, h in ipairs(HOUSES) do | |||
out[#out+1] = '|-' | out[#out+1] = '|-' | ||
out[#out+1] = '| ' .. | out[#out+1] = '| ' .. h.merchant | ||
.. ' || ' .. | .. ' || ' .. h.house | ||
.. ' || ' .. | .. ' || ' .. h.origin | ||
.. ' || ' .. | .. ' || ' .. h.profile | ||
end | end | ||
| Line 1,288: | Line 885: | ||
end | end | ||
function p. | function p.renderSchedule(frame) | ||
local args = (frame and frame.args) or {} | |||
local ctx = seasonArgs(frame) | local ctx = seasonArgs(frame) | ||
local | 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 = {} | local out = {} | ||
out[#out+1] = '{| class="wikitable"' | out[#out+1] = '{| class="wikitable sortable"' | ||
out[#out+1] = '|+ | out[#out+1] = '|+ Featured tender schedule, ' .. formatSeason(ctx) | ||
out[#out+1] = '|-' | out[#out+1] = '|-' | ||
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] = '|}' | out[#out+1] = '|}' | ||
| Line 1,317: | Line 918: | ||
end | end | ||
p. | function p.renderFull(frame) | ||
local args = (frame and frame.args) or {} | |||
local ctx = seasonArgs(frame) | |||
local board = buildBoard(ctx) | |||
local out = {} | local out = {} | ||
out[#out+1] = | out[#out+1] = renderSeasonInfoFromBoard(board) | ||
out[#out+1] = "" | out[#out+1] = "" | ||
out[#out+1] = | out[#out+1] = renderStandingsFromBoard(board) | ||
out[#out+1] = "" | out[#out+1] = "" | ||
out[#out+1] = | out[#out+1] = renderTenderBoardFromBoard(board, args.limit or 8) | ||
out[#out+1] = "" | out[#out+1] = "" | ||
out[#out+1] = | out[#out+1] = renderNoticesFromBoard(board, args.noticeLimit or 6) | ||
return table.concat(out, "\n") | return table.concat(out, "\n") | ||
end | end | ||
| Line 1,337: | Line 938: | ||
function p.doc(frame) | function p.doc(frame) | ||
return table.concat({ | return table.concat({ | ||
"'''Vaeringheim Merchant League - Invitational Division | "'''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 947: | ||
"* <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|renderTenderBoard|limit=6}}</code>", | |||
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderTenderBoard|limit= | "* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderDailyNotices|limit=5}}</code>", | ||
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational| | |||
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderHouseLedger|house=Giid-Tally Yard}}</code>", | "* <code>{{#invoke:VaeringheimMerchantLeagueInvitational|renderHouseLedger|house=Giid-Tally Yard}}</code>", | ||
"* <code>{{#invoke:VaeringheimMerchantLeagueInvitational| | "* <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") | }, "\n") | ||
end | end | ||
return p | return p | ||
Revision as of 18:18, 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 Qencha",
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 = "Ayllu Nysara", mode = "road", km = 570, risk = 0.35 },
{ from = "Ayllu Nysara", 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