Module:Drop rate calculator

From WIDEVERSE Wiki
Jump to navigation Jump to search

Documentation for this module may be created at Module:Drop rate calculator/doc

-- <nowiki>
local yesno = require( 'Module:Yesno' )
local chart = require( 'Module:Chart data' )
local array = require( 'Module:Array' )
local addCommas = require( 'Module:Addcommas' )._add
local prob = require( 'Module:Probability' )
local binomialPMF = prob.binomialPMF
local binomialCDF = prob.binomialCDF
local min = math.min
local max = math.max
local log10 = math.log10
local floor = math.floor
local ceil = math.ceil
local abs = math.abs
local inf = 1/0

local p = {}

local function formatDecimal( num )
    num = max( min( num, 100 ), 0 )
    local x = num

    if num > 99 then
        x = abs( 100 - num )
    end

    local mag = min( max( -floor( math.log10( x ) ) + 2, 2 ), 10 )
    local res = string.format( '%.' .. mag .. 'f', num  ):gsub( '0+$', '' ):gsub(  '%.$', '.0' ) .. '%'

    if num > 100 - 1e-10 then
        res = '~' .. res
    end

    if num < 2^-1074 then
        res = res .. string.format( ' (<span class="hover-text" title="Exact value can\'t be show as it is smaller than the lowest value that can be represented by a 64 bit floating point">Less than</span> %.2e%%)', 2^-1074 )
    elseif num < 1e-4 then
        res = res .. string.format( ' (%.2e%%)', num )
    end

    return res
end

local function round( num, dp )
    dp = dp or 0
    return floor( num * 10^dp + 0.5 ) / 10^dp
end

local function processDroprate( droprate )
    local d = tonumber( mw.ext.ParserFunctions.expr( droprate ) ) or 1

    if d > 1 then
        return 1 / d
    end

    return d
end

-- Chart.js doesn't like very small values with logarithmic axis so change those to zero instead
local function fixedPointToZero( t, removeZeroValues )
    for i = 1, #t do
        if t[i].y < 2^-1022 then
            t[i].y = 0
        end
    end

    if removeZeroValues then
        for i = #t, 1, -1 do
            if t[i].y == 0 then
                table.remove( t, i )
            end
        end
    end

    return t
end

local function plural( val )
    return val == 1 and '' or 's'
end

local function genKcArr( kills, viewOptions )
    local lowerBound = viewOptions.lowerBound
    local upperBound = viewOptions.upperBound
    local useCustomViewRange = viewOptions.useCustomViewRange and viewOptions.customViewRangeType == 'Kill count'
    local stepSize = max( floor( (useCustomViewRange and (upperBound - lowerBound) or kills) / 250 ), 1 )
    local kcArr

    if useCustomViewRange then
        kcArr = array.range( lowerBound, upperBound, stepSize )
    else
        kcArr = array.range( 0, 2 * kills, stepSize )
        if stepSize > 1 then
            table.insert( kcArr, 2, 1 )
        end
    end

    kcArr.stepSize = stepSize

    return kcArr
end

local function calcChanceVsKcArrays( kcArr, kills, droprate, dropCount, threshold, viewOptions )
    local atLeastProbArrThreshold = {}
    local atLeastProbArrNoThreshold = {}
    local exactProbArrNoThreshold = {}

    local mean = (1 / droprate) * dropCount
    local needsMorePrecision = (viewOptions.scaleType == 'logarithmic')
        or (viewOptions.useCustomViewRange == false and kills * 2 < mean)
        or (viewOptions.useCustomViewRange == true and (mean < viewOptions.lowerBound or viewOptions.upperBound < mean))

    for _, kc in ipairs( kcArr ) do
        if threshold then
            local atLeastProbNoThreshold = prob.noThreshold( droprate, kc ) * 100
            table.insert( atLeastProbArrNoThreshold, { x=kc, y=atLeastProbNoThreshold } )

            local probThreshold = prob.withThreshold( droprate, threshold, 0, kc ) * 100
            table.insert( atLeastProbArrThreshold, { x=kc, y=probThreshold } )
        else
            local exactProbNoThreshold = binomialPMF( droprate, kc, dropCount )
            table.insert( exactProbArrNoThreshold, { x=kc, y=100 * exactProbNoThreshold } )

            local atLeastProbNoThreshold = 1 - binomialCDF( droprate, kc, 0, dropCount - 1 )
            if atLeastProbNoThreshold < 1e-9 and (abs( kc - kills ) <= kcArr.stepSize or needsMorePrecision) then
                atLeastProbNoThreshold = binomialCDF( droprate, kc, dropCount, inf )
            end
            table.insert( atLeastProbArrNoThreshold, { x=kc, y=math.max( 100 * atLeastProbNoThreshold, 0 ) } )
        end
    end

    return atLeastProbArrNoThreshold, exactProbArrNoThreshold, atLeastProbArrThreshold
end

local function setYAxisTickFormat( plot, axisType, ... )
    local findMin = function( arr )
        local val = array.max_by( arr, function(val) return val.y == 0 and -inf or -val.y end )
        return val and val.y or inf
    end
    local findMax = function( arr )
        local val = array.max_by( arr, function(val) return val.y end )
        return val and val.y or -inf
    end
    local minVal = inf
    local maxVal = -inf
    local setToScientific = false

    if axisType == 'logarithmic' then
        for _, arr in pairs{ ... } do
            minVal = min( minVal, findMin( arr ) )
            if minVal < 1e-6 then
                setToScientific = true
                break
            end
        end
    else
        for _, arr in pairs{ ... } do
            maxVal = max( maxVal, findMax( arr ) )
        end
        if maxVal < 1e-3 then
            setToScientific = true
        end
    end

    if setToScientific then
        plot:setOptions{
            scales = {
                y = {
                    ticks = {
                        format = {
                            notation = 'scientific',
                            maximumSignificantDigits = 3
                        }
                    }
                },
            }
        }
    end
end

local function genChanceVsKcPlot( atLeastProbArrNoThreshold, exactProbArrNoThreshold, atLeastProbArrThreshold, kills,
                                  dropCount, hasThreshold, atLeastDropCountProb, exactDropCountProb, viewOptions )
    local plot = chart.newChart{ type='scatter' }
        :setTitle( 'Chance vs kill count' )
        :setDimensions( '60vw', '55vh' )
        :setXLabel( 'Kill count' )
        :setYLabel( 'Chance [%]' )
        :setYAxisType( viewOptions.scaleType )

    if dropCount == 0 then
        plot:setTitle( 'Chance to get no drop' )
            :showLegend( false )
    else
        plot.options.colorPallet = {
            chart.colorPallets.qualitative[1],
            chart.colorPallets.qualitative[3],
            chart.colorPallets.qualitative[2],
        }
    end

    setYAxisTickFormat( plot, viewOptions.scaleType, atLeastProbArrNoThreshold, exactProbArrNoThreshold, atLeastProbArrThreshold )

    if dropCount > 0 then
        plot:newDataSet{
            data = atLeastProbArrNoThreshold,
            pointRadius = 0,
            label = hasThreshold and 'Without threshold' or ('At least ' .. dropCount .. ' drop' .. plural( dropCount ))
        }
    end

    if hasThreshold then
        plot:newDataSet{
            data = atLeastProbArrThreshold,
            pointRadius = 0,
            label = 'With threshold',
        }
    else
        plot:newDataSet{
            data = exactProbArrNoThreshold,
            pointRadius = 0,
            label = 'Exactly ' .. dropCount .. ' drop' .. plural( dropCount )
        }
    end

    if viewOptions.useCustomViewRange and viewOptions.customViewRangeType == 'Kill count' then
        plot:setXLimits( viewOptions.lowerBound, viewOptions.upperBound,
            10^round( log10( viewOptions.upperBound - viewOptions.lowerBound ) - 1 ) )
    else
        local y = atLeastDropCountProb
        if dropCount == 0 then
            y = exactDropCountProb
        end
        plot:newDataSet{
            data = fixedPointToZero{ {x=0, y=y}, {x=kills, y=y}, {x=kills, y=0} },
            pointRadius = 0,
            borderDash = {10, 10},
            borderWidth = 1,
            lineTension = 0,
            label = '- - -'
        }
    end

    return plot
end

local function calcChanceVsDropCount( droprate, kc, viewOptions )
    local sd = math.sqrt( kc * droprate * (1 - droprate) )
    local mean = kc * droprate
    local lowerBound = max( 0, mean - 5 * sd )
    local upperBound = max( 5, mean + 5 * sd )

    if viewOptions.scaleType == 'logarithmic' then
        lowerBound = max( 0, mean - 7.5 * sd )
        upperBound = max( 5, mean + 7.5 * sd )
    end

    if viewOptions.useCustomViewRange and viewOptions.customViewRangeType == 'Drop count' then
        lowerBound = viewOptions.lowerBound
        upperBound = viewOptions.upperBound
    end

    local stepSize = max( floor( (upperBound - lowerBound) / 250 ), 1 )
    local chanceArr = prob.binomialPMFArr( droprate, kc, lowerBound, upperBound, stepSize )

    for i = 1, #chanceArr do
        chanceArr[i].y = chanceArr[i].y * 100
    end

    return chanceArr
end

local function genChanceVsDropCountPlot( chanceArr, kc, viewOptions )
    local plot = chart.newChart{ type='scatter' }
        :setTitle( 'Chance to get n drops in ' .. addCommas( kc ) .. ' kill' .. plural( kc ) )
        :setDimensions( '60vw', '50vh' )
        :setXLabel( 'Drop count' )
        :setYLabel( 'Chance [%]' )
        :setYAxisType( viewOptions.scaleType )
        :showLegend( false )

    setYAxisTickFormat( plot, viewOptions.scaleType, chanceArr )

    if viewOptions.useCustomViewRange and viewOptions.customViewRangeType == 'Drop count' then
        plot:setXLimits( viewOptions.lowerBound, viewOptions.upperBound,
            10^round( log10( viewOptions.upperBound - viewOptions.lowerBound ) - 1 ) )
    end

    local set = plot:newDataSet{
        data = chanceArr,
        pointRadius = 4,
        backgroundAlpha = 1,
        hoverRadius = 6
    }

    if #chanceArr > 50 then
        set.pointRadius = 3
        set.hoverRadius = 5
    end
    if #chanceArr > 100 then
        set.pointRadius = 0
        set.hoverRadius = 0
    end

    return plot
end

function p.main( frame )
    return p._main( frame:getParent().args )
end

function p._main( args )
    local kills = tonumber( args.kills ) or 1
    local droprate = processDroprate( args.droprate )
    local hasThreshold = yesno( args.hasThreshold ) or false
    local threshold = hasThreshold and tonumber( args.threshold )
    local dropCount = not hasThreshold and tonumber( args.dropCount ) or 1
    local viewOptions = {
        scaleType = args.scaleType or 'linear',
        useCustomViewRange = yesno( args.useCustomViewRange ) or false,
        customViewRangeType = args.customViewRangeType,
        lowerBound = tonumber( args.lowerBound ) or 0,
        upperBound = tonumber( args.upperBound ) or 1
    }

    if droprate == nil then
        return 'Error in processing droprate'
    end
    if droprate <= 0 or droprate >= 1 then
        return 'Droprate must be between 0 and 1'
    end
    if viewOptions.lowerBound >= viewOptions.upperBound then
        return 'View range lower bound must be smaller than the upper bound'
    end

    local kcArr = genKcArr( kills, viewOptions )
    local atLeastProbArrNoThreshold, exactProbArrNoThreshold, atLeastProbArrThreshold =
        calcChanceVsKcArrays( kcArr, kills, droprate, dropCount, threshold, viewOptions )

    local averageKC = round( prob.calcAverageKc( droprate, threshold ), 1 )
    local dropRateAtKillCount = prob.dropRateAtKillCount( droprate, threshold, kills )
    local killCountAt50 = prob.inverseBinomialCDF( 0.50, droprate, max( dropCount, 1 ), threshold )
    local killCountAt90 = prob.inverseBinomialCDF( 0.90, droprate, max( dropCount, 1 ), threshold )
    local killCountAt99 = prob.inverseBinomialCDF( 0.99, droprate, max( dropCount, 1 ), threshold )

    local atLeastDropCountProb = 0
    local exactDropCountProb = 0
    local lessThanOrEqualDropCountProb = 0
    local noDropProb
    local chanceVsDropCountArr = {}

    if hasThreshold then
        atLeastDropCountProb, noDropProb = prob.withThreshold( droprate, threshold, 0, kills )
        atLeastDropCountProb = atLeastDropCountProb * 100
        noDropProb = noDropProb * 100
    else
        atLeastDropCountProb = prob.binomialCDF( droprate, kills, max( dropCount, 1 ), inf ) * 100
        exactDropCountProb = prob.binomialPMF( droprate, kills, dropCount ) * 100
        lessThanOrEqualDropCountProb = 100 - atLeastDropCountProb + exactDropCountProb
        if lessThanOrEqualDropCountProb < 1e-6 then
            lessThanOrEqualDropCountProb = binomialCDF( droprate, kills, 0, dropCount ) * 100
        end
        chanceVsDropCountArr = calcChanceVsDropCount( droprate, kills, viewOptions )
    end

    fixedPointToZero( atLeastProbArrThreshold, viewOptions.scaleType == 'logarithmic' )
    fixedPointToZero( atLeastProbArrNoThreshold, viewOptions.scaleType == 'logarithmic' )
    fixedPointToZero( exactProbArrNoThreshold, viewOptions.scaleType == 'logarithmic' )
    fixedPointToZero( chanceVsDropCountArr, viewOptions.scaleType == 'logarithmic' )

    local chanceVsKcPlot = genChanceVsKcPlot( atLeastProbArrNoThreshold, exactProbArrNoThreshold, atLeastProbArrThreshold,
        kills, dropCount, hasThreshold, atLeastDropCountProb, exactDropCountProb, viewOptions )
    local chanceVsDropCountPlot = hasThreshold and '' or genChanceVsDropCountPlot( chanceVsDropCountArr, kills, viewOptions )

    local res = ''

    if hasThreshold == true then
        res = res .. string.format( 'Drop rate at %s kill%s: 1/%s or %s',
            addCommas( kills ),
            plural( kills ),
            string.format( '%.3f', 1 / dropRateAtKillCount ):gsub( '%.?0+$', '' ),
            formatDecimal( dropRateAtKillCount * 100 )
        )
    else
        res = res .. string.format( 'Drop rate: 1/%s or %s',
            string.format( '%.3f', 1 / dropRateAtKillCount ):gsub( '%.?0+$', '' ),
            formatDecimal( dropRateAtKillCount * 100 )
        )
    end

    res = res .. string.format( '\n* %s chance to get at least %s drop%s',
        formatDecimal( atLeastDropCountProb ),
        addCommas( max( dropCount, 1 ) ),
        plural( max( dropCount, 1 ) )
    )
    .. string.format( string.rep( '\n** %s at %s kill%s', 3 ),
        '50%', addCommas( killCountAt50 ), plural( killCountAt50 ),
        '90%', addCommas( killCountAt90 ), plural( killCountAt90 ),
        '99%', addCommas( killCountAt99 ), plural( killCountAt99 )
    )

    if hasThreshold == true or dropCount == 0 then
        res = res .. string.format( '\n* %s chance to get no drop', formatDecimal( noDropProb or exactDropCountProb ) )
    else
        res = res .. string.format( '\n* %s chance to get exactly %s',
            formatDecimal( exactDropCountProb ),
            addCommas( dropCount )
        )
        .. string.format( '\n* %s chance to get %s or less',
            formatDecimal( lessThanOrEqualDropCountProb ),
            addCommas( dropCount )
        )
    end

    if dropCount > 0 then
        res = res .. '\nAverage kill count: ' .. addCommas( averageKC * dropCount )
    end

    res = res .. tostring( chanceVsKcPlot ) .. tostring( chanceVsDropCountPlot )

    return res
end

return p
-- </nowiki>