Module:Bestiary

From RuneRealm Wiki

This is the current revision of this page, as edited by Alex (talk | contribs) at 18:20, 15 October 2024 (Created page with "require('strict') local p = {} local yesno = require('Module:Yesno') local pt = require('Module:Paramtest') local arr = require('Module:Array') local pagelisttools = require('Module:PageListTools') local pageswithcats = pagelisttools.pageswithcats local smwstats = {} -- Formats a table header function p.header(tbl, verbose) local tr = tbl:tag('tr') :tag('th'):attr('colspan', '2'):wikitext('Monster'):done() :tag('th'):wikitext('Members'):done()..."). The present address (URL) is a permanent link to this version.

(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search
Module documentation
This documentation is transcluded from Template:No documentation/doc. [edit] [history] [purge]
This module does not have any documentation. Please consider adding documentation at Module:Bestiary/doc. [edit]
Module:Bestiary's function main is invoked by Template:Bestiarytable.
Module:Bestiary requires Module:Array.
Module:Bestiary requires Module:PageListTools.
Module:Bestiary requires Module:Paramtest.
Module:Bestiary requires Module:Yesno.
Module:Bestiary requires strict.

require('strict')

local p = {}

local yesno = require('Module:Yesno')
local pt = require('Module:Paramtest')
local arr = require('Module:Array')
local pagelisttools = require('Module:PageListTools')
local pageswithcats = pagelisttools.pageswithcats

local smwstats = {}

-- Formats a table header
function p.header(tbl, verbose)
    local tr = tbl:tag('tr')
        :tag('th'):attr('colspan', '2'):wikitext('Monster'):done()
        :tag('th'):wikitext('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:White dagger.png|link=|Stab defence]][[File:Defence icon.png|link=|alt=]]'):done()
        :tag('th'):wikitext('[[File:White scimitar.png|link=|Slash defence]][[File:Defence icon.png|link=|alt=]]'):done()
        :tag('th'):wikitext('[[File:White warhammer.png|link=|Crush defence]][[File:Defence icon.png|link=|alt=]]'):done()
        :tag('th'):wikitext('[[File:Magic icon.png|link=|Magic defence]][[File:Defence icon.png|link=|alt=]]'):done()
        :tag('th'):wikitext('[[File:Steel dart.png|link=|Light Ranged defence]][[File:Defence icon.png|link=|alt=]]'):done()
        :tag('th'):wikitext('[[File:Steel arrow.png|link=|Standard Ranged defence]][[File:Defence icon.png|link=|alt=]]'):done()
        :tag('th'):wikitext('[[File:Steel bolts.png|link=|Heavy Ranged defence]][[File:Defence icon.png|link=|alt=]]'):done()
        
    if verbose then
        tr:tag('th'):wikitext('[[File:Slayer icon.png|link=|Slayer level]]'):done()
          :tag('th'):wikitext('<abbr 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 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 arr.contains(mambo, mw.text.trim(args['mems'])) then
        error('mems parameter has invalid value')
    end

    if pt.has_content(args['assignedby']) and not arr.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'])
	mw.logObject(fulldata)
    local data = p.filterData(fulldata, ba['disco'], ba['quest'], ba['dmm'], ba['cache'])
    mw.logObject(data)
    local fulldata = nil
	
    if ba['levelsort'] then
		pcall(function()
        	table.sort(data, function(a, b)
        		return a['level'] < b['level']
        		-- example fix in case you were directed to the line above by an error:
				-- [[Special:Diff/14325110]]
        	end)
        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')

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

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

    -- Render rows
    
    local function makeRow(entry)
        local name = mw.text.split(entry['name'], '#', true)
        if name[2] then
        	name[2] = name[2]:gsub('_', ' ')
        end
        local tr = mw.html.create('tr')
            :tag('td'):css('height', '64px'):wikitext(entry['image'] and string.format('[[%s|link=|64x64px|%s%s]]', entry['image'], name[1], (name[2] and ' - ' .. name[2]) or '') or '' ):done()
            :tag('td'):wikitext( string.format('[[%s|%s]]%s', entry['name'], name[1], name[2] and '<br/><i>' .. name[2] .. '</i>' or '') ):done()
            :tag('td'):wikitext(entry['members'] and '[[File:Member icon.png|center|link=Members|alt=Members]]' or '[[File:Free-to-play icon.png|center|link=Free-to-play|alt=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(entry['stab defence']):done()
            :tag('td'):wikitext(entry['slash defence']):done()
            :tag('td'):wikitext(entry['crush defence']):done()
            :tag('td'):wikitext(entry['magic defence']):done()
            :tag('td'):wikitext(entry['light ranged defence']):done()
            :tag('td'):wikitext(entry['standard ranged defence']):done()
            :tag('td'):wikitext(entry['heavy ranged defence']):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')
            local slaymtbl = {}
            for a, ab in ipairs(entry['assignedby']) do
                if arr.contains(slayer_masters, ab) then
                    table.insert(slaymtbl, ab)
                else
                    mw.log(string.format('Unknown Slayer master: %s', ab))
                end
            end
            td:wikitext(mw.text.listToText( slaymtbl, ', ', ', and ' ))
        end
        return tr
	end
    
    local errors = {}
    for e, entry in ipairs(data) do
    	local isnoterr, ret = pcall(makeRow, entry)
    	if isnoterr then
    		tbl:node(ret)
    	else
    		table.insert(errors, 'Error message: '..ret..'<br>Affected entry: <code>'..mw.text.nowiki(mw.text.jsonEncode(entry))..'</code>')
    	end
    end
    if #errors > 0 then
    	local errtag = div:tag('div'):addClass('error'):wikitext('Errors with this query shown below:[[Category:Pages with script errors]]')
    	for i,v in ipairs(errors) do
    		errtag:newline():wikitext('* ',v)
    	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(arr.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 arr.contains(pages_excl, entry['variantof']) or arr.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',
        ['Stab defence bonus']      = 'stab defence',
        ['Slash defence bonus']     = 'slash defence',
        ['Crush defence bonus']     = 'crush defence',
        ['Magic defence bonus']     = 'magic defence',
        ['Light range defence bonus']    = 'light ranged defence',
        ['Standard range defence bonus']    = 'standard ranged defence',
        ['Heavy range defence bonus']    = 'heavy ranged defence',
        ['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 1000 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