Module:LootTable
Jump to navigation
Jump to search
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(" '''%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