Module:LootTable

local p = {}

local _stringsModule = require('Module:LootTable/i18n') local Strings = _stringsModule.Strings local GroupRemaps = _stringsModule.GroupRemaps

local QUALITY_MIN_ERROR_MARGIN = 0.35 local CLAMP_QUALITY_MAX = 600

local function trim( s ) return (s:gsub( '^[\t\r\n\f ]+',  ):gsub( '[\t\r\n\f ]+$',  )) end

local function removeDuplicates(t) -- borrowed from: https://www.mediawiki.org/w/index.php?title=Module:TableTools if not t then return {} end local ret, exists = {}, {} for i, v in ipairs(t) do       if not exists[v] then ret[#ret + 1] = v           exists[v] = true end end return ret end

local function parseRange(s) local v = mw.text.split(s, '..', true) if #v == 2 then return tonumber(v[1]), tonumber(v[2]) end v = tonumber(s) return v, v end

-- Parses custom attribute strings and retrieves information, based on existing -- data inside the results table. local function parseNamedAttributeString( params, out, start ) for paramIndex, paramValue in ipairs(params) do       if paramIndex > start and paramValue and #paramValue > 0 then local pPair = mw.text.split(paramValue, ':', true) local pName = trim(pPair[1]) local pValue = pPair[2] local field = out[pName] if field == nil then return string.format(Strings.Errors.ATTRSTR_UNKNOWN_PARAMETER, pName) end if type(field) == 'table' and #field == 2 then if type(field[1]) == 'number' then field[1], field[2] = parseRange(pValue) else return string.format(Strings.Errors.ATTRSTR_UNSUPPORTED_RANGE, type(field[1]), pName) end elseif type(field) == 'string' then field = trim(pValue) elseif type(field) == 'number' then field = tonumber(pValue) elseif type(field) == 'boolean' then field = true else return string.format(Strings.Errors.ATTRSTR_UNSUPPORTED_TYPE, pName) end out[pName] = field end end end

-- Appends an item onto a table found under a key in a specific bidimensional table. local function tablePushSafe(t, key, value) if t[key] == nil then t[key] = {value} else table.insert(t[key], value) end end

-- Retrieves a string from the string table based on context. local function T(context, key) return Strings[context] and Strings[context][key] or Strings[key] end

-- Queries Cargo for items and their categories. local function queryItemCategories local out = {} local results = mw.ext.cargo.query(       'Items', '_pageName, Category', {            where = 'Category IS NOT NULL',            limit = 9999, -- needed or we'll be reading only 50 records        }    )

for index = 1, #results do       local result = results[index] local pageName = result._pageName local category = result.Category if GroupRemaps[category] ~= nil then category = GroupRemaps[category] end out[string.upper(pageName)] = category end

return out end

-- Find category name for an item using information from Cargo tables. local function findItemGroup(item, cargoResults) local DEFAULT_GROUP = Strings.MISCELLANEOUS local OVERRIDES = { } local itemName = string.upper(item) if OVERRIDES[itemName] ~= nil then return OVERRIDES[itemName] end if cargoResults[itemName] ~= nil then return cargoResults[itemName] end return DEFAULT_GROUP end

-- Groups items up into categories. local function groupItemsUp(items, isWeighedList, cargoItemData) local results = {} for _, item in ipairs(items) do       local name = item if isWeighedList then name = item[2] end local category = findItemGroup(name, cargoItemData) tablePushSafe(results, category, item) end return results end

local function comparePairs(a, b)   if a[1] ~= b[1] then return a[1] > b[1] else return a[2] < b[2] end end

local function compareBiTable(hashMap) return function(a,b) return comparePairs({hashMap[a], a}, {hashMap[b], b}) end end

local function makeCollapsibleSegment(caption, contents, tag) if tag == nil then tag = 'li' end local out = '<'..tag..' class="toccolours mw-collapsible mw-collapsed" style="background: none; border: none">' .. caption .. ' '					.. contents .. ' '			 .. '' return out end

local function generateIntroText(packed) -- { messageContext, unique, uniqueOrder, common, commonOrder, settings } local itemCount = #packed.uniqueOrder + #packed.commonOrder -- TODO: move this to the strings table... somehow? --      maybe make this entire thing a string-generating function... return table.concat({       --' ',        --string.format('Upon completion the players are rewarded with %d out of %d possible items%s ' --          .. 'from the following pool(s):', packed.settings.rolls, itemCount, --             (packed.settings.noReplacements and ' (with no duplicates)' or '')),        --' ',    }) end

local function concatItemsWithOrder(info, order) local elements = {} for _, item in ipairs(order) do       if info[item] ~= nil then table.insert(elements, item) table.insert(elements, string.format("%.4f", info[item])) end end return table.concat(elements, '|') end

local function generateGeneralView(packed, -- { messageContext, unique, uniqueOrder, common, commonOrder, settings }                                       splitUnique, collapseCommonLoot, groupItems) local out = generateIntroText(packed)

-- split unique items from general pool if splitUnique and (#packed.uniqueOrder > 0 or #packed.bumpedOrder > 0) then out = out .. '\n'..packed.T('SPECIAL_DROPS_HEADING')..' \n'

if #packed.uniqueOrder > 0 then out = out .. packed.T('UNIQUE_ITEMS_INTRO') .. '\n\n' end -- display items with increased chances if #packed.bumpedOrder > 0 then out = out .. packed.T('BUMPED_ITEMS_INTRO') .. '\n' end end out = out .. '\n'..packed.T('COMMON_DROPS_HEADING')..' \n' .. packed.T('COMMON_ITEMS_INTRO') .. '\n' if groupItems and #packed.commonOrder >= 12 then local cargoItemData = queryItemCategories local itemGroups = groupItemsUp(packed.commonOrder, false, cargoItemData) -- sort names, as key tables are unstable local categoryNames = {} local hasMiscellaneous = false for category, _ in pairs(itemGroups) do       	if category == Strings.MISCELLANEOUS then hasMiscellaneous = true else table.insert(categoryNames, category) end end table.sort(categoryNames) if hasMiscellaneous then table.insert(categoryNames, Strings.MISCELLANEOUS) end -- render out = out .. '' for _, category in ipairs(categoryNames) do           local contents = itemGroups[category] local itemList = ''

local chanceSum = 0 for _, item in ipairs(contents) do               if packed.common[item] ~= nil then chanceSum = chanceSum + packed.common[item] end end -- TODO: export into separate module local label = string.format(" %s ("..packed.T('CHANCE_OVERALL')..")", category, chanceSum * 100) if collapseCommonLoot then out = out .. '\n' .. makeCollapsibleSegment(label, itemList) else out = out .. '\n ' .. label .. ' ' .. itemList end end out = out .. '' else out = out .. '\n' end return out end

local function generateDetailedView(packed, -- { messageContext, unique, uniqueOrder, common, commonOrder, settings, sets }                                   splitUnique, collapseCommonLoot, groupItems) -- TODO: extract strings for translation and context-awareness -- TODO: actually calculate how many items are possible. local out = string.format( %s set(s) are rolled; %s. In total there are %d items in the pool.,    (packed.settings.rolls[1] ~= packed.settings.rolls[2] and string.format("%d-%d", packed.settings.rolls[1], packed.settings.rolls[2]) or tostring(packed.settings.rolls[1])),       (packed.settings.noSetReplacements and 'each set can be chosen only once (no replacements)' or 'a set may be chosen more than once'),		#packed.uniqueOrder + #packed.commonOrder    )

out = out .. string.format("\n%d item set(s): \n", #packed.sets) for _, lootSet in ipairs(packed.sets) do       out = out .. ' \n' .. string.format("; %.1f%% : \n"                               .. [[
 * %s.

]],                                lootSet.chance*100, lootSet.name, (lootSet.noEntryReplacements and 'Each entry may be chosen only once (no replacements)' or 'An entry may be chosen more than once'))

out = out .. string.format("\n%d item group(s):", #lootSet.entries) for _, entry in ipairs(lootSet.entries) do           out = out .. ' \n' .. string.format("; %.1f%% : \n"                                   .. [===[
 * Estimated item quality range: %.1f%% - %.1f%%

]===],                                entry.chance*100, entry.name, packed.settings.quality[1] * entry.quality[1] * (1-QUALITY_MIN_ERROR_MARGIN) * 100, math.min(packed.settings.quality[2] * entry.quality[2] * 100, CLAMP_QUALITY_MAX))

local elements = {} for _, itemInfo in ipairs(entry.items) do               table.insert(elements, itemInfo[2]) table.insert(elements, string.format("%.4f", entry.itemChances[itemInfo[2]])) end out = out .. makeCollapsibleSegment(string.format('%d item(s) in this sub-pool', #entry.items),           			'', 'div')

out = out .. ' '       end

out = out .. ' '   end

-- TODO: remove this disclaimer once finalised out = This view is still being worked on and may not provide data accuracy.  .. out

return out end

function p.loottable( f ) local dlclink = require('Module:DLCLink').link local args = f:getParent.args local caption = trim(args.name or '') local messageContext = string.upper(trim(args.type or '')) local nonRepeating = args.nonRepeating == 'yes' local showQuality = args.showQuality == 'yes' local splitUnique = args.splitUnique == 'yes' local collapseCommonLoot = args.collapseCommonLoot == 'yes' local showItemGroups = args.showItemGroups == 'yes'

local barColor = trim(args.color or '') local icon = trim(args.icon or '') local cssClasses = args.class or ''

-- Ensure the message context parameter is either supported or unset. assert(messageContext == '' or ({ MISSION = 1, SUPPLYDROP = 1 })[messageContext] == 1) AGGREGATION -- This is going to store all the tags. local tags = {} -- This is going to store all loot sets by tag (likely difficulty). local allLootSets = {} -- This is going to store all unique items that can be retrieved only in this -- mission, and the tags they can be found in. local allUniqueItems = { order = {}, info = {} } -- This is going to store all items that can be found in both pools. local allBumpedItems = { order = {}, info = {} } -- This is going to store all possible items and the tags they can be found in. local allItems = { order = {}, info = {} } --   local lootSetSettings = {} --- LOOT SET READING FROM PARAMETERS local setWeightSum = {} local currentSet = nil local currentEntry = nil local currentSectionProps = {} local isEntryUnique = false local tag = '' for setIndex, v in ipairs(args) do        if setIndex >= 1 and #v > 0 then local params = mw.text.split(v, ', ', true) local opname = trim(params[1]) if opname == 'section' then tag = trim(params[2]) table.insert(tags, tag) currentSectionProps = { -- properties rolls = {1, 1}, quality = {1, 1}, noSetReplacements = false, -- TODO: need to keep until tables updated :(                   noReplacements = false,                }                -- parse set info                local err = parseNamedAttributeString(params, currentSectionProps, 2)                lootSetSettings[tag] = currentSectionProps                -- TODO: handle error properly                -- initialize containers                setWeightSum[tag] = 0                allLootSets[tag] = {}                allItems.info[tag] = {}                allItems.order[tag] = {}                allUniqueItems.info[tag] = {}                allUniqueItems.order[tag] = {}            elseif opname == 'set' then                -- initialize set info                currentSet = {                    -- properties                    name = trim(params[2]), weight = 1,                    entryRolls = {1, 1}, unique = false, noEntryReplacements = false, -- TODO: need to keep until tables updated :(                   quality = {0, 0}, quantity = {0, 0},                    -- internal                    entryWeightSum = 0, entries = {}, chance = 0,                }                -- parse set info                local err = parseNamedAttributeString(params, currentSet, 2)                if err then                    error('set #' .. setIndex .. ': ' .. err)               end                -- update state                currentSet.unique = splitUnique and currentSet.unique                isEntryUnique = currentSet.unique                -- push to total list                tablePushSafe(allLootSets, tag, currentSet)

-- update set weight sum setWeightSum[tag] = setWeightSum[tag] + currentSet.weight elseif (opname == 'entry' or opname == 'group') and currentSet ~= nil then -- parse entry info currentEntry = { -- properties name = trim(params[2]), weight = 1, quantity = {1, 1}, quality = {0, 0}, -- internal items = {}, itemWeightSum = 0, itemChances = {}, }               local err = parseNamedAttributeString(params, currentEntry, 2) if err then return 'group #' .. setIndex .. ': ' .. err end -- push this entry onto the set table.insert(currentSet.entries, currentEntry)

-- update weight sum on set currentSet.entryWeightSum = currentSet.entryWeightSum + currentEntry.weight elseif #params >= 2 and tonumber(opname) ~= nil and currentEntry ~= nil then -- insert item local weight = tonumber(opname) local item = trim(params[2]) if currentEntry.items[item] == nil then currentEntry.items[item] = 0 end currentEntry.items[item] = currentEntry.items[item] + weight

-- insert item to total collections if isEntryUnique then allUniqueItems.info[tag][item] = 0 table.insert(allUniqueItems.order[tag], item) else allItems.info[tag][item] = 0 table.insert(allItems.order[tag], item) end

-- update weight sum on set currentEntry.itemWeightSum = currentEntry.itemWeightSum + weight else error(string.format(Strings.Errors.INVALID_PARAMETER_CODE, setIndex, opname)) end end end -- convert deduplicated associative item info to a table of pairs for _, tag in ipairs(tags) do       for _, set in ipairs(allLootSets[tag]) do            for _, entry in ipairs(set.entries) do            	local itemPairs = {} for item, weight in pairs(entry.items) do           		itemPairs[#itemPairs+1] = {weight,item} end entry.items = itemPairs end end end -- calculate chances for each item for _, tag in ipairs(tags) do       local rolls = lootSetSettings[tag].rolls local rollsMidPoint = (rolls[1] + rolls[2]) / 2 for _, set in ipairs(allLootSets[tag]) do           local sub0 = set.weight / setWeightSum[tag] set.chance = sub0

local info = nil if set.unique then info = allUniqueItems.info[tag] else info = allItems.info[tag] end

for _, entry in ipairs(set.entries) do               local sub1 = entry.weight / set.entryWeightSum entry.chance = sub1

for _, itemInfo in ipairs(entry.items) do                   local sub2 = itemInfo[1] / entry.itemWeightSum local chance = sub2 * sub1 * sub0 * rollsMidPoint entry.itemChances[itemInfo[2]] = (entry.itemChances[itemInfo[2]] or 0) + chance info[itemInfo[2]] = info[itemInfo[2]] + chance end end end end -- sort items in entries by weight & name for _, tag in ipairs(tags) do       for _, set in ipairs(allLootSets[tag]) do            for _, entry in ipairs(set.entries) do				table.sort(entry.items, comparePairs) end end end -- sort order lists for _, tag in ipairs(tags) do       allUniqueItems.order[tag] = removeDuplicates(allUniqueItems.order[tag]) allItems.order[tag] = removeDuplicates(allItems.order[tag]) table.sort(allUniqueItems.order[tag], compareBiTable(allUniqueItems.info[tag])) table.sort(allItems.order[tag], compareBiTable(allItems.info[tag])) end

-- extract overlapping pools if splitUnique then for _, tag in ipairs(tags) do           allBumpedItems.info[tag] = {} allBumpedItems.order[tag] = {}

local uniqueInfo = allUniqueItems.info[tag] local commonInfo = allItems.info[tag]

local clearedCount = 0

for _, item in ipairs(allUniqueItems.order[tag]) do               if commonInfo[item] ~= nil and uniqueInfo[item] ~= nil then table.insert(allBumpedItems.order[tag], item) allBumpedItems.info[tag][item] = commonInfo[item] + uniqueInfo[item]

-- null out the chances, so this item gets skipped commonInfo[item] = nil uniqueInfo[item] = nil

-- bump the counter clearedCount = clearedCount + 1 end end

-- delete unique item tables if all of the elements have been moved into bumped if clearedCount == #allUniqueItems.order[tag] then allUniqueItems.info[tag] = {} allUniqueItems.order[tag] = {} end end end -- generate tabber local tabber = ' ' for index, tag in ipairs(tags) do       local packed = { messageContext = messageContext, unique = allUniqueItems.info[tag], uniqueOrder = allUniqueItems.order[tag], bumped = allBumpedItems.info[tag], bumpedOrder = allBumpedItems.order[tag], common = allItems.info[tag], commonOrder = allItems.order[tag], settings = lootSetSettings[tag], sets = allLootSets[tag], T = function(key) return T(messageContext, key) end }

if index > 1 then tabber = tabber .. '\n|-|' end tabber = tabber .. tag .. '='             .. f:preprocess(                ' '                .. packed.T('OVERVIEW_TAB')..'='                   .. generateGeneralView(packed, splitUnique, collapseCommonLoot, showItemGroups)                .. '|-|'                .. packed.T('DETAILS_TAB')..'='                   .. generateDetailedView(packed)                .. ' '              ) end tabber = tabber .. ' '

return '\n' -- left bar with an icon .. (icon ~=  and ' ' or ) -- right side .. ' \n' .. (caption ~=  and '; ' .. caption .. '\n' or ) .. f:preprocess(tabber) .. ' '          .. ' ' end

return p