Module:LootTable

From ARK Wiki
Jump to navigation Jump to search
Template-info.png Documentation

local p = {}

local Utility = require('Module:Utility')
local ItemList = require('Module:ItemList')

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 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="mw-collapsible mw-collapsed">'
				.. caption
				.. '<div class="mw-collapsible-content">'
					.. contents
				.. '</div>'
			 .. '</'..tag..'>'
	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({
        --'<p>',
        --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 '')),
        --'</p>',
    })
end


local function zipItemsForItemList(info, order)
    local elements = {}
    for _, item in ipairs(order) do
        if info[item] ~= nil then
            table.insert(elements, {item, info[item]})
        end
    end
    return 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 = string.format('%s\n<h3 class="with-separator">%s</h3>\n', out, packed.T('SPECIAL_DROPS_HEADING'))

        if #packed.uniqueOrder > 0 then
            out = string.format('%s%s\n%s\n',
                                out, packed.T('UNIQUE_ITEMS_INTRO'),
                                ItemList.create(zipItemsForItemList(packed.unique, packed.uniqueOrder), {
                                    extraStyle = 'column-width: 15em; list-style: none',
                                    asPerCent = true,
                                }))
        end
        
        -- display items with increased chances
        if #packed.bumpedOrder > 0 then
            out = string.format('%s%s\n%s',
                                out, packed.T('BUMPED_ITEMS_INTRO'),
                                ItemList.create(zipItemsForItemList(packed.bumped, packed.bumpedOrder), {
                                    extraStyle = 'column-width: 15em; list-style: none',
                                    asPerCent = true,
                                }))
        end
    end
    
    out = string.format('%s\n<h3 class="with-separator">%s</h3>\n%s\n',
                        out, packed.T('COMMON_DROPS_HEADING'), packed.T('COMMON_ITEMS_INTRO'))
    
    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, items in pairs(itemGroups) do
        	if #items > 0 then
        		if category == Strings.MISCELLANEOUS then
        			hasMiscellaneous = true
        		else
	            	table.insert(categoryNames, category)
	        	end
	        end
        end
        table.sort(categoryNames)
        
        if hasMiscellaneous then
        	table.insert(categoryNames, Strings.MISCELLANEOUS)
        end
        
        -- render
        out = out .. '<ul>'
        for _, category in ipairs(categoryNames) do
            local contents = itemGroups[category]
            local itemList = ItemList.create(zipItemsForItemList(packed.common, contents), {
                extraStyle = 'column-width: 15em; list-style: none',
                asPerCent = true,
            })

			if itemList ~= nil then
	            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("&nbsp;'''%s''' ''("..packed.T('CHANCE_OVERALL')..")''", category, chanceSum * 100)
            	if collapseCommonLoot then
            	    out = out .. '\n' .. makeCollapsibleSegment(label, itemList)
            	else
            		out = out .. '\n<p>' .. label .. '</p>' .. itemList
            	end
            end
        end
        out = out .. '</ul>'
        
    else
        out = out .. '\n' .. ItemList.create(zipItemsForItemList(packed.common, packed.commonOrder), {
            extraStyle = 'column-width: 15em; list-style: none',
            asPerCent = true,
        })
    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.<br/>
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<h3 class=\"with-separator\">'''%d item set(s):'''</h3>\n", #packed.sets)
    for _, lootSet in ipairs(packed.sets) do
        out = out .. '<div class="loottable-frame">\n'
                  .. string.format("; %.1f%%<nowiki>:</nowiki> {{HoverNote|%s|This is how the game developers decided "
                                .. "to call it and may not reflect the actual contents.}}\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 .. '<div class="loottable-frame">\n'
                      .. string.format("; %.1f%%<nowiki>:</nowiki> {{HoverNote|%s|This is how the game developers decided "
                                    .. "to call it and may not reflect the actual contents.}}\n"
                                    .. [===[
: Estimated [[Item Quality|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('<span style="font-size:95%%">%d item(s) in this sub-pool</span>', #entry.items),
            			'{{ItemList|columnWidth=15em|listtype=none|showQuantityAsPerCent=yes|'
                      .. table.concat(elements, '|')
                      .. '}}', 'div')

            out = out .. '</div>'
        end

        out = out .. '</div>'
    end

    -- TODO: remove this disclaimer once finalised
    out = [[
<p style="background: #ac205d;
          color: #fff;
          padding: 0.4em 1em;
          font-size: 17px;
          margin: 2px;
          font-weight: 500;">This view is still being worked on and may not provide data accuracy.</p>
]] .. out

    return out
end


function p.loottable( f )
    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] = Utility.removeDuplicatesFromList(allUniqueItems.order[tag])
        allItems.order[tag] = Utility.removeDuplicatesFromList(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 = '<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(
                '<tabber>'
                .. packed.T('OVERVIEW_TAB')..'='
                   .. generateGeneralView(packed, splitUnique, collapseCommonLoot, showItemGroups)
                .. '|-|'
                .. packed.T('DETAILS_TAB')..'='
                   .. generateDetailedView(packed)
                .. '</tabber>'
              )
    end
    tabber = tabber .. '</tabber>'

    return '<div class="loottable ' .. cssClasses .. '"'
           -- left bar color
           .. (barColor ~= '' and ' style="--ark-loottable-bar-color: ' .. barColor .. '"' or '')
           .. '>\n'
              -- left bar with an icon
              .. (icon ~= '' and '<div class="loottable-topbar">[[File:' .. icon .. '|24px|link=]]</div>' or '')
              -- right side
              .. f:preprocess(tabber)
           .. '</div>'
end

return p