Module:Bestiary/Sandbox

From RuneRealm Wiki
Jump to navigation Jump to search

Documentation for this module may be created at Module:Bestiary/Sandbox/doc

local p = {}

local yesno = require('Module:Yesno')
local pt = require('Module:Paramtest')
local enum = require('Module:Enum')
local pagelisttools = require('Module:PageListTools')
local avg = require('Module:Average_drop_value/Sandbox')
local curr = require('Module:Currency')._amount
local pageswithcats = pagelisttools.pageswithcats

local smwstats = {}

-- Formats a table header
function p.header(tbl, verbose)
    local tr = tbl:tag('tr')
        :tag('th'):addClass('unsortable'):node(''):done()
        :tag('th'):css('text-align', 'left'):wikitext('Monster'):done()
        :tag('th'):wikitext('[[File:Member icon.png|link=|Members]]'):done()
        :tag('th'):wikitext('[[File:Attack style icon.png|link=|Combat level]]'):done()
        :tag('th'):wikitext('[[File:Hitpoints icon.png|link=|Hitpoints]]'):done()
        :tag('th'):wikitext('[[File:Attack icon.png|link=|Attack level]]'):done()
        :tag('th'):wikitext('[[File:Defence icon.png|link=|Defence level]]'):done()
        :tag('th'):wikitext('[[File:Magic icon.png|link=|Magic level]]'):done()
        :tag('th'):wikitext('[[File:Ranged icon.png|link=|Ranged level]]'):done()
        :tag('th'):wikitext('[[File:Coins 10000.png|link=|Average drop value]]'):done()

    if verbose then
        tr:tag('th'):wikitext('[[File:Slayer icon.png|link=|Slayer level]]'):done()
          :tag('th'):wikitext('<span title="Slayer experience">XP</span>'):done()
          :tag('th'):css('text-align', 'left'):wikitext('Slayer categories'):done()
          :tag('th'):css('text-align', 'left'):wikitext('Assigned by'):done()
    end
end

-- Main entrypoint
function p.main(frame)
    local args = frame:getParent().args
    return p._main(args)
end

function p._main(args)
	local frame = mw.getCurrentFrame()
    local mambo = {'Members', 'F2P', 'All'}
    local slayer_masters = { 'Spria', 'Turael', 'Krystilia', 'Mazchna', 'Vannaka', 'Chaeldar', 'Konar quo Maten', 'Nieve', 'Steve', 'Duradel' }
    local pattern_alpha = '^%a$'

    -- Fetch and validate all input parameters
    local fromlevel = tonumber(pt.default_to(args['fromlevel'], '1'))
    local tolevel = tonumber(pt.default_to(args['tolevel'], '1'))
    local fromletter = string.upper(pt.default_to(args['fromletter'], 'A'))
    local toletter = string.upper(pt.default_to(args['toletter'], 'A'))
    local mems = pt.default_to(args['mems'], 'All')
    local assignedby = pt.default_to(args['assignedby'], '')
    local onlycat = pt.default_to(args['onlycat'], '')
    local attribute = pt.default_to(args['attribute'], '')

    local ba = {
        levelselect = true,
        levelsort   = true,
        slayeronly  = false,
        verbose     = false,
        showstats   = false,
        disco       = false,
        quest       = true,
        dmm         = true,
        cache       = false
    }

    for k, b in pairs(ba) do
        if pt.has_content(args[k]) then
            ba[k] = yesno(args[k])
        end
    end

    if pt.has_content(args['mems']) and not enum.contains(mambo, mw.text.trim(args['mems'])) then
        error('mems parameter has invalid value')
    end

    if pt.has_content(args['assignedby']) and not enum.contains(slayer_masters, mw.text.trim(args['assignedby'])) then
        error('assignedby parameter has invalid value')
    end

    if ba['levelselect'] then
        if pt.is_empty(args['fromlevel']) or not fromlevel then
            error('parameter fromlevel is invalid')
        end

        if pt.is_empty(args['tolevel']) or not tolevel then
            error('parameter tolevel is invalid')
        end

        assert(tolevel >= fromlevel, 'parameter tolevel must be greater than or equal to fromlevel')
    else
        if pt.is_empty(args['fromletter']) or not fromletter:match(pattern_alpha) then
            error('parameter fromletter is invalid')
        end

        if pt.is_empty(args['toletter']) or not toletter:match(pattern_alpha) then
            error('parameter toletter is invalid')
        end

        assert(toletter >= fromletter, 'parameter toletter must be greater than or equal to fromletter')
    end

    -- Gather the data
    local fulldata = p.loadData(fromlevel, tolevel, fromletter, toletter, ba['levelselect'], mems, ba['slayeronly'], onlycat, attribute, assignedby, ba['verbose'])

    local data = p.filterData(fulldata, ba['disco'], ba['quest'], ba['dmm'], ba['cache'])
    local fulldata = nil

    if ba['levelsort'] then
        table.sort(data, function(a, b) return a['level'] < b['level'] end)
    end

    -- Format the output page
    local div = mw.html.create('div')

    if #data == 0 then
        div:wikitext('Search yielded no results.')
        return div
    elseif ba['showstats'] then
        div:wikitext(string.format('Search yielded %i results.', #data))
        if smwstats['found'] == smwstats['limit'] then
            div:wikitext(string.format(' Your search might have been too large and have been truncated as a result.'))
        end
    end

    local tbl = div:tag('table'):addClass('wikitable sortable')
        :addClass('align-center-1 align-left-2 align-left-3 align-center-4 align-center-5')
        :addClass('align-center-6 align-center-7 align-center-8 align-center-9 align-center-10')

    if ba['verbose'] then
        tbl:addClass('align-center-11 align-center-12 align-left-13 align-left-14')
    end

    p.header(tbl, ba['verbose'])

    -- Render rows
    local storedavgdata = {}
    for e, entry in ipairs(data) do
        local name = mw.text.split(entry['name'], '#', true)
        --we save up smw queries for performance, e.g. all barbarians in generic barbarian drop table get the same results so they are computed once.
        local avgdata = storedavgdata[name[1]] or avg.getDropData({entry['name']})
        --fallback case for average drop value
        if (not avgdata) or avgdata == {} or avgdata == '' then
        	avgdata = avg.getDropData({name[1]}) or {}
        	storedavgdata[name[1]] = avgdata
        end
        if name[2] then
        	name[2] = name[2]:gsub('_', ' ')
        else name[2] = '&nbsp;'
        end
        local tr = tbl:tag('tr')
            :tag('td'):css('height', '64px'):wikitext(entry['image'] and string.format('[[%s|link=|64x64px|%s]]', entry['image'], entry['name']) or ''):done()
            :tag('td'):wikitext(string.format('[[%s|%s]]<br/>\'\'%s\'\'', entry['name'], name[1], name[2])):done()
            :tag('td'):wikitext(entry['members'] and '[[File:Member icon.png|link=|Members]]' or '[[File:Free-to-play icon.png|link=|Free-to-play]]'):done()
            :tag('td'):wikitext(entry['level']):done()
            :tag('td'):wikitext(entry['hitpoints']):done()
            :tag('td'):wikitext(entry['attack']):done()
            :tag('td'):wikitext(entry['defence']):done()
            :tag('td'):wikitext(entry['magic']):done()
            :tag('td'):wikitext(entry['ranged']):done()
            :tag('td'):wikitext(curr(avg.totalvalfromdata(avgdata), 'coins')):done()
            
        if ba['verbose'] then
            tr:tag('td'):wikitext(entry['slaylvl'] or '1'):done()
              :tag('td'):wikitext(entry['slayxp']):done()
              :tag('td'):wikitext(entry['slaycat']):done()

            local td = tr:tag('td')
            for a, ab in ipairs(entry['assignedby']) do
                if enum.contains(slayer_masters, ab) then
                    td:wikitext(string.format('[[File:%s chathead.png|48x64px|link=%s]]', ab, ab))
                else
                    mw.log(string.format('unknown slayer master: %s', ab))
                end
            end
        end
    end

    return div
end

-- Estimate the storage size of a serialized table of data
-- This function assumes somewhat consistent sizes of the elements
-- like for instance typical SMW data tables
function p.est_serial_size(tbl, datapoints)
    assert(type(tbl) == 'table')
    assert(type(datapoints) == 'number')

    local dp = {}

    for p = 1, datapoints do
        local pos = math.floor(#tbl * p / datapoints)
        local txt = mw.text.jsonEncode(tbl[pos])
        table.insert(dp, txt:len())
    end

    local avgsize = math.floor(enum.sum(dp) / datapoints)

    return #tbl * avgsize
end

-- Filter data with exclusion lists
function p.filterData(indata, disco, quest, dmm, cache)
    -- Fetch exclusion list
    local exlist = {}

    if not disco then
        table.insert(exlist, '[[Category:Monsters]] [[Category:Discontinued content]]')
    end
    if not quest then
        table.insert(exlist, '[[Category:Quest monsters]]')
    end
    if not dmm then
        table.insert(exlist, '[[Category:Monsters]] [[Category:Deadman Mode]]')
    end
    if not cache then
        table.insert(exlist, '[[Category:Monsters]] [[Category:Pages using information from game APIs or cache]]')
    end

    local pages_excl = pageswithcats(exlist)

    -- Post-process the data
    local data = {}

    for _, entry in ipairs(indata) do
        local process = true

        if enum.contains(pages_excl, entry['variantof']) or enum.contains(pages_excl, entry['name']) then
            process = false
        end

        if process then
            table.insert(data, entry)
        else
            --mw.log(string.format('Removed: %s', entry[1]))
        end
    end

    -- Statistics
    mw.log(string.format('Filter: exclusion list size: %i, start size: %i, end size: %i, removed %i.', 
        #pages_excl, #indata, #data, #indata - #data))

    return data
end

-- Sends a query to SMW and returns the data
function p.loadData(fromlevel, tolevel, fromletter, toletter, levelselect, mems, slayeronly, onlycat, attribute, assignedby, verbose)
    local props = {
        ['Image']             = 'image',
        ['Is members only']   = 'members',
        ['Combat level']      = 'level',
        ['Hitpoints']         = 'hitpoints',
        ['Attack level']      = 'attack',
        ['Defence level']     = 'defence',
        ['Magic level']       = 'magic',
        ['Ranged level']      = 'ranged',
        ['Is variant of']     = 'variantof'
    }

    local extprops = {
        ['Slayer level']      = 'slaylvl',
        ['Slayer experience'] = 'slayxp',
        ['Slayer category']   = 'slaycat',
        ['Assigned by']       = 'assignedby'
    }

    local query = {}

    -- Conditions part of query
    local condition = { '[[Uses infobox::Monster]]' }
    if mems == 'Members' then
        table.insert(condition, ' [[Is members only::true]]')
    elseif mems == 'F2P' then
        table.insert(condition, ' [[Is members only::false]]')
    end
    if onlycat:len() > 0 or slayeronly then
        if onlycat:len() > 0 then
            table.insert(condition, string.format(' [[Slayer category::%s]]', onlycat))
        else
            table.insert(condition, ' [[Slayer category::+]]')
        end
    end
    if attribute:len() > 0 then
        table.insert(condition, string.format(' [[Monster attribute::%s]]', attribute))
    end
    if assignedby:len() > 0 then
        table.insert(condition, string.format(' [[Assigned by::%s]]', assignedby))
    end
    if levelselect then
        table.insert(condition, string.format(' [[Combat level::≥%i]] [[Combat level::≤%i]]', fromlevel, tolevel))
    else
        if (toletter == 'Z') then
            table.insert(condition, string.format(' [[Name::≥%s]]', fromletter))
        else
            table.insert(condition, string.format(' [[Name::≥%s]] [[Name::<<%s]]', fromletter, string.char(toletter:byte() + 1)))
        end
    end
    table.insert(query, table.concat(condition))

    -- Printouts part of query
    table.insert(query, '?=#-')
    for k, pr in pairs(props) do
        table.insert(query, string.format('?%s #- = %s', k, pr))
    end
    if verbose then
        for k, pr in pairs(extprops) do
            table.insert(query, string.format('?%s #- = %s', k, pr))
        end
    end

    -- Parameters part of query
    query.offset = 0
    query.limit = verbose and 500 or 580

    -- Fetch the data
    -- mw.logObject(query)
    local t1 = os.clock()
    local smw = mw.smw.ask(query)
    local t2 = os.clock()
    if not smw or #smw == 0 then
        smw = {}
    end

    -- Post-process the data
    local data = {}

    for _, entry in ipairs(smw) do
        local dataline = entry

        dataline['name'] = dataline[1]
        dataline[1] = nil
        if type(dataline['image']) == 'table' then
            dataline['image'] = dataline['image'][1]
        end
        if type(dataline['slaycat']) == 'table' then
            dataline['slaycat'] = table.concat(entry['slaycat'], ', ')
        end
        if type(dataline['assignedby']) == 'string' then
            dataline['assignedby'] = { entry['assignedby'] }
        elseif type(dataline['assignedby']) == 'nil' then
            dataline['assignedby'] = { }
        end

        table.insert(data, dataline)
    end

    -- Statistics
    smwstats = {
        offset  = query.offset,
        limit   = query.limit,
        found   = #smw,
        time    = (t2 - t1) * 1000,
        size    = p.est_serial_size(smw, 5)
    }

    mw.log(string.format('SMW: Found %i, offset %i, limit %i, time elapsed %.3f ms, est. serial size: %.0f KiB.', 
        smwstats.found, smwstats.offset, smwstats.limit, smwstats.time, smwstats.size / 1024))

    return data
end    

--[[ DEBUG COPYPASTA
mw.logObject( p.loadData('1', '1', 'A', 'A', true, 'All', false, '', '', '', false) )
mw.logObject( p.loadData('1', '1', 'A', 'A', false, 'All', false, '', '', '', false) )
= p._main({fromlevel='1', tolevel='1', verbose='yes'})
= p._main({fromletter='A', toletter='A', levelselect='no', levelsort='no', verbose='yes'})
--]]

return p