Module:Dice: Difference between revisions

From Caves of Qud Wiki
Jump to navigation Jump to search
(permit spaces everywhere (purely cosmetic))
(2.0.0: https://github.com/TrashMonks/Dice/releases/tag/v2.0.0)
 
(6 intermediate revisions by the same user not shown)
Line 1: Line 1:
--[[
-- The implementation of this module makes use of a class metaphor. All a class
    Dice.lua
-- really is is a table meant to be used as a metatable.


    The core repository for this Lua module can be found at:
-- # Helper Functions
    https://bitbucket.org/HeladoDeBrownie/dice/


--]]
-- Instantiate the given class, passing the given arguments to its 'initialize'
local Dice = {}
-- method.
Dice.__index = Dice
local function new(class, ...)
    local result = setmetatable({}, {__index = class})
    result:initialize(...)
    return result
end
 
-- Derive the given class, adding a 'new' method to the child class.
local function derive(class)
    return setmetatable({new = new}, {__index = class})
end
 
-- Return a function usable as a method that always gives an error.
local function unimplemented(method_name)
    assert(type(method_name) == 'string')
 
    return function ()
        error('unimplemented: ' .. method_name)
    end
end
 
-- Return a function usable as a method that calls the given method.
local function alias(method_name)
    return function (self, ...)
        return self[method_name](self, ...)
    end
end
 
-- Is the given value an integer?
local function is_integer(value)
    return type(value) == 'number' and value == math.floor(value)
end
 
-- Is the given value an integer at least a certain amount?
local function is_integer_at_least(lower_bound, value)
    return is_integer(value) and value >= lower_bound
end


--# Interface
-- Is the given value a natural number, i.e., an integer at least 0?
local function is_natural(value)
    return is_integer_at_least(0, value)
end


--[[
-- Does the given value represent an expression in the dice DSL?
    Get a dice distribution from a dice string, which is in the form of a list
-- NOTE: This function exists mostly to mark intent. It doesn't actually check
     of terms separated by + and -. Each term is either:
-- if the given table has all the methods expected of an expression.
local function is_expression(value)
     return type(value) == 'table'
end


    - a dice roll expression of the form XdY, where X and Y are integers;
-- Every distribution we care about is symmetric, so its mean is the mean of
     - or a constant integer X, which is interpreted as Xd1.
-- its minimum and maximum.
local function mean(expression)
     return (expression:maximum() + expression:minimum()) / 2
end


    The terms are added and subtracted, as specified by + and - respectively,
-- The range of a distribution is the distance between its minimum and maximum.
    to form a compound dice distribution.
local function range(expression)
    return expression:maximum() - expression:minimum()
end


    For example, Dice.fromString'5d3+1d2-1' gets the distribution that's
-- The standard deviation of a distribution is the square root of its variance.
    represented by rolling 5 3-sided dice, rolling a 2-sided die, and
local function standard_deviation(expression)
    subtracting 1 from their sum.
    return math.sqrt(expression:variance())
--]]
end
function Dice.fromString(diceString)
    local diceTable = {}


    for term in diceString:gsub('%s', ''):gmatch'[+-]?[^+-]+' do
-- Given two dice expressions, signal which is "better", as judged by the
        local quantity, size = term:match'^([+-]?%d+)d(%d+)$'
-- following metrics:
-- - greater mean
-- - smaller range
-- - less variance
-- The first return value will be 1 if the first argument is better, -1 if the
-- second is, or 0 if they're indistinguishable. The second return value is a
-- string describing the metric by which the better one won.
function compare(dice_a, dice_b)
    local mean_a, mean_b = dice_a:mean(), dice_b:mean()


         if quantity ~= nil and size ~= nil then
    if mean_a > mean_b then
            diceTable[#diceTable + 1] = {
         return 1, 'greater mean'
                quantity = tonumber(quantity, 10),
    elseif mean_b > mean_a then
                size = tonumber(size, 10),
        return -1, 'greater mean'
             }
    else
        local range_a, range_b = dice_a:range(), dice_b:range()
 
        if range_a < range_b then
            return 1, 'smaller range'
        elseif range_b < range_a then
             return -1, 'smaller range'
         else
         else
             local quantity = term:match'^([+-]?%d+)$'
             local variance_a, variance_b =
                dice_a:variance(), dice_b:variance()


             if quantity ~= nil then
             if variance_a < variance_b then
                 diceTable[#diceTable + 1] = {
                 return 1, 'less variance'
                    quantity = tonumber(quantity, 10),
            elseif variance_b < variance_a then
                    size = 1,
                return -1, 'less variance'
                }
             else
             else
                 error([[
                 return 0, 'no difference'
 
I couldn't make sense of this as a dice string: ]] .. diceString .. [[
 
In particular, this part doesn't look like a term I understand: ]] .. term)
             end
             end
         end
         end
     end
     end
end
-- # Dice Expression DSL
-- ## BaseExpression
local BaseExpression = {
    -- These definitions don't need to be overridden by derived classes.
    mean = mean,
    range = range,
    standard_deviation = standard_deviation,
    compare = compare,
    -- Derived classes must provide definitions for these.
    initialize = unimplemented('initialize'),
    minimum = unimplemented('minimum'),
    maximum = unimplemented('maximum'),
    variance = unimplemented('variance'),
    roll = unimplemented('roll'),
    -- Methods can be called by various names.
    average = alias('mean'),
    ev = alias('mean'),
    expected_value = alias('mean'),
    sd = alias('standard_deviation'),
    min = alias('minimum'),
    max = alias('maximum'),
    sample = alias('roll'),
}
-- ## Constant
local Constant = derive(BaseExpression)
function Constant:initialize(constant)
    assert(is_integer(constant))
    self.constant = constant
end
function Constant:minimum()
    return self.constant
end
function Constant:maximum()
    return self.constant
end


     return setmetatable({diceTable = diceTable}, Dice)
function Constant:variance()
     return 0
end
end


-- Get the minimum possible value of the distribution.
function Constant:roll()
function Dice:minimum()
    return self.constant
end
 
-- ## DiceRoll
 
local DiceRoll = derive(BaseExpression)
 
function DiceRoll:initialize(quantity, sides)
    assert(is_natural(quantity))
    assert(is_natural(sides) and sides >= 1)
    self.quantity = quantity
    self.sides = sides
end
 
function DiceRoll:minimum()
    return math.min(self.quantity * 1, self.quantity * self.sides)
end
 
function DiceRoll:maximum()
    return math.max(self.quantity * 1, self.quantity * self.sides)
end
 
function DiceRoll:variance()
    local single_die_mean = (1 + self.sides) / 2
     local sum = 0
     local sum = 0


     for _, subdice in ipairs(self.diceTable) do
     for n = 1, self.sides do
         if subdice.quantity >= 0 then
         sum = sum + (n - single_die_mean) ^ 2
            sum = sum + subdice.quantity * 1
        else
            sum = sum + subdice.quantity * subdice.size
        end
     end
     end


     return sum
     return math.abs(self.quantity) * sum / self.sides
end
end


--[[
function DiceRoll:roll()
    Get the mean value of the distribution, defined as the mean of all the
    possible values the distribution can take on weighted by their probability.
--]]
function Dice:mean()
     local sum = 0
     local sum = 0


     for _, subdice in ipairs(self.diceTable) do
     for n = 1, math.abs(self.quantity) do
         sum = sum + subdice.quantity * (1 + subdice.size) / 2
         sum = sum + math.random(1, self.sides)
    end
 
    if self.quantity >= 0 then
        return sum
    else
        return -sum
     end
     end
end
-- ## Addition
local Addition = derive(BaseExpression)
function Addition:initialize(operand_a, operand_b)
    assert(is_expression(operand_a))
    assert(is_expression(operand_b))
    self.operand_a = operand_a
    self.operand_b = operand_b
end
function Addition:minimum()
    return self.operand_a:minimum() + self.operand_b:minimum()
end
function Addition:maximum()
    return self.operand_a:maximum() + self.operand_b:maximum()
end
function Addition:variance()
    return self.operand_a:variance() + self.operand_b:variance()
end


     return sum
function Addition:roll()
     return self.operand_a:roll() + self.operand_b:roll()
end
end


-- Get the maximum possible value of the distribution.
-- ## Negation
function Dice:maximum()
 
     local sum = 0
local Negation = derive(BaseExpression)
 
function Negation:initialize(operand)
    assert(is_expression(operand))
    self.operand = operand
end
 
function Negation:minimum()
    return -self.operand:maximum()
end
 
function Negation:maximum()
    return -self.operand:minimum()
end
 
function Negation:variance()
    return self.operand:variance()
end
 
function Negation:roll()
     return -self.operand:roll()
end
 
-- ## Multiplication
 
local Multiplication = derive(BaseExpression)
 
function Multiplication:initialize(expression, constant)
    assert(is_expression(expression))
    assert(is_natural(constant))
    self.expression = expression
    self.constant = constant
end
 
function Multiplication:minimum()
    if self.constant >= 0 then
        return self.expression:minimum() * self.constant
    else
        return self.expression:maximum() * self.constant
    end
end


    for _, subdice in ipairs(self.diceTable) do
function Multiplication:maximum()
        if subdice.quantity >= 0 then
    if self.constant >= 0 then
            sum = sum + subdice.quantity * subdice.size
        return self.expression:maximum() * self.constant
        else
    else
            sum = sum + subdice.quantity * 1
        return self.expression:minimum() * self.constant
        end
     end
     end
end
function Multiplication:variance()
    return self.expression:variance() * self.constant * self.constant
end
function Multiplication:roll()
    return self.expression:roll() * self.constant
end
-- # Parsing
local Dice = {}


     return sum
local function try_integer(input)
     return input ~= nil and tonumber(input:match'^[+-]?%d+$') or nil
end
end


--# Export
local function try_natural(input)
    return input ~= nil and tonumber(input:match'^%d+$') or nil
end
 
function Dice.parse(input)
    input = input:gsub('%s', '')
    local x, y
 
    -- addition
    x, y = input:match'^([^+]+)+(.+)$'
    if x ~= nil then
        return Addition:new(Dice.parse(x), Dice.parse(y))
    end
 
    -- subtraction
    x, y = input:match'^([^-]+)-(.+)$'
    if x ~= nil and input:match'd' then
        return Addition:new(Dice.parse(x), Negation:new(Dice.parse(y)))
    end
 
    -- multiplication
    x, y = input:match'^([^x]+)x(.+)$'
    y = try_integer(y)
    if x ~= nil and y ~= nil then
        return Multiplication:new(Dice.parse(x), y)
    end
 
    -- range
    x, y = input:match'^([^-]+)-(.+)$'
    x, y = try_natural(x), try_natural(y)
    if x ~= nil and y ~= nil then
        return Addition:new(DiceRoll:new(1, y - x + 1), Constant:new(x - 1))
    end
 
    -- dice
    x, y = input:match'^([^d]+)d(.+)$'
    x, y = try_integer(x), try_natural(y)
    if x ~= nil and y ~= nil then
        return DiceRoll:new(x, y)
    end
 
    -- constant
    x = try_integer(input)
    if x ~= nil then
        return Constant:new(x)
    end
 
    error(([[
 
I couldn't understand this as part of an expression:
%s]]):format(input))
end


return Dice
return Dice

Latest revision as of 06:16, 25 August 2023

This module is maintained and documented on GitHub.


-- The implementation of this module makes use of a class metaphor. All a class
-- really is is a table meant to be used as a metatable.

-- # Helper Functions

-- Instantiate the given class, passing the given arguments to its 'initialize'
-- method.
local function new(class, ...)
    local result = setmetatable({}, {__index = class})
    result:initialize(...)
    return result
end

-- Derive the given class, adding a 'new' method to the child class.
local function derive(class)
    return setmetatable({new = new}, {__index = class})
end

-- Return a function usable as a method that always gives an error.
local function unimplemented(method_name)
    assert(type(method_name) == 'string')

    return function ()
        error('unimplemented: ' .. method_name)
    end
end

-- Return a function usable as a method that calls the given method.
local function alias(method_name)
    return function (self, ...)
        return self[method_name](self, ...)
    end
end

-- Is the given value an integer?
local function is_integer(value)
    return type(value) == 'number' and value == math.floor(value)
end

-- Is the given value an integer at least a certain amount?
local function is_integer_at_least(lower_bound, value)
    return is_integer(value) and value >= lower_bound
end

-- Is the given value a natural number, i.e., an integer at least 0?
local function is_natural(value)
    return is_integer_at_least(0, value)
end

-- Does the given value represent an expression in the dice DSL?
-- NOTE: This function exists mostly to mark intent. It doesn't actually check
-- if the given table has all the methods expected of an expression.
local function is_expression(value)
    return type(value) == 'table'
end

-- Every distribution we care about is symmetric, so its mean is the mean of
-- its minimum and maximum.
local function mean(expression)
    return (expression:maximum() + expression:minimum()) / 2
end

-- The range of a distribution is the distance between its minimum and maximum.
local function range(expression)
    return expression:maximum() - expression:minimum()
end

-- The standard deviation of a distribution is the square root of its variance.
local function standard_deviation(expression)
    return math.sqrt(expression:variance())
end

-- Given two dice expressions, signal which is "better", as judged by the
-- following metrics:
-- - greater mean
-- - smaller range
-- - less variance
-- The first return value will be 1 if the first argument is better, -1 if the
-- second is, or 0 if they're indistinguishable. The second return value is a
-- string describing the metric by which the better one won.
function compare(dice_a, dice_b)
    local mean_a, mean_b = dice_a:mean(), dice_b:mean()

    if mean_a > mean_b then
        return 1, 'greater mean'
    elseif mean_b > mean_a then
        return -1, 'greater mean'
    else
        local range_a, range_b = dice_a:range(), dice_b:range()

        if range_a < range_b then
            return 1, 'smaller range'
        elseif range_b < range_a then
            return -1, 'smaller range'
        else
            local variance_a, variance_b =
                dice_a:variance(), dice_b:variance()

            if variance_a < variance_b then
                return 1, 'less variance'
            elseif variance_b < variance_a then
                return -1, 'less variance'
            else
                return 0, 'no difference'
            end
        end
    end
end

-- # Dice Expression DSL

-- ## BaseExpression

local BaseExpression = {
    -- These definitions don't need to be overridden by derived classes.
    mean = mean,
    range = range,
    standard_deviation = standard_deviation,
    compare = compare,

    -- Derived classes must provide definitions for these.
    initialize = unimplemented('initialize'),
    minimum = unimplemented('minimum'),
    maximum = unimplemented('maximum'),
    variance = unimplemented('variance'),
    roll = unimplemented('roll'),

    -- Methods can be called by various names.
    average = alias('mean'),
    ev = alias('mean'),
    expected_value = alias('mean'),
    sd = alias('standard_deviation'),
    min = alias('minimum'),
    max = alias('maximum'),
    sample = alias('roll'),
}

-- ## Constant

local Constant = derive(BaseExpression)

function Constant:initialize(constant)
    assert(is_integer(constant))
    self.constant = constant
end

function Constant:minimum()
    return self.constant
end

function Constant:maximum()
    return self.constant
end

function Constant:variance()
    return 0
end

function Constant:roll()
    return self.constant
end

-- ## DiceRoll

local DiceRoll = derive(BaseExpression)

function DiceRoll:initialize(quantity, sides)
    assert(is_natural(quantity))
    assert(is_natural(sides) and sides >= 1)
    self.quantity = quantity
    self.sides = sides
end

function DiceRoll:minimum()
    return math.min(self.quantity * 1, self.quantity * self.sides)
end

function DiceRoll:maximum()
    return math.max(self.quantity * 1, self.quantity * self.sides)
end

function DiceRoll:variance()
    local single_die_mean = (1 + self.sides) / 2
    local sum = 0

    for n = 1, self.sides do
        sum = sum + (n - single_die_mean) ^ 2
    end

    return math.abs(self.quantity) * sum / self.sides
end

function DiceRoll:roll()
    local sum = 0

    for n = 1, math.abs(self.quantity) do
        sum = sum + math.random(1, self.sides)
    end

    if self.quantity >= 0 then
        return sum
    else
        return -sum
    end
end

-- ## Addition

local Addition = derive(BaseExpression)

function Addition:initialize(operand_a, operand_b)
    assert(is_expression(operand_a))
    assert(is_expression(operand_b))
    self.operand_a = operand_a
    self.operand_b = operand_b
end

function Addition:minimum()
    return self.operand_a:minimum() + self.operand_b:minimum()
end

function Addition:maximum()
    return self.operand_a:maximum() + self.operand_b:maximum()
end

function Addition:variance()
    return self.operand_a:variance() + self.operand_b:variance()
end

function Addition:roll()
    return self.operand_a:roll() + self.operand_b:roll()
end

-- ## Negation

local Negation = derive(BaseExpression)

function Negation:initialize(operand)
    assert(is_expression(operand))
    self.operand = operand
end

function Negation:minimum()
    return -self.operand:maximum()
end

function Negation:maximum()
    return -self.operand:minimum()
end

function Negation:variance()
    return self.operand:variance()
end

function Negation:roll()
    return -self.operand:roll()
end

-- ## Multiplication

local Multiplication = derive(BaseExpression)

function Multiplication:initialize(expression, constant)
    assert(is_expression(expression))
    assert(is_natural(constant))
    self.expression = expression
    self.constant = constant
end

function Multiplication:minimum()
    if self.constant >= 0 then
        return self.expression:minimum() * self.constant
    else
        return self.expression:maximum() * self.constant
    end
end

function Multiplication:maximum()
    if self.constant >= 0 then
        return self.expression:maximum() * self.constant
    else
        return self.expression:minimum() * self.constant
    end
end

function Multiplication:variance()
    return self.expression:variance() * self.constant * self.constant
end

function Multiplication:roll()
    return self.expression:roll() * self.constant
end

-- # Parsing

local Dice = {}

local function try_integer(input)
    return input ~= nil and tonumber(input:match'^[+-]?%d+$') or nil
end

local function try_natural(input)
    return input ~= nil and tonumber(input:match'^%d+$') or nil
end

function Dice.parse(input)
    input = input:gsub('%s', '')
    local x, y

    -- addition
    x, y = input:match'^([^+]+)+(.+)$'
    if x ~= nil then
        return Addition:new(Dice.parse(x), Dice.parse(y))
    end

    -- subtraction
    x, y = input:match'^([^-]+)-(.+)$'
    if x ~= nil and input:match'd' then
        return Addition:new(Dice.parse(x), Negation:new(Dice.parse(y)))
    end

    -- multiplication
    x, y = input:match'^([^x]+)x(.+)$'
    y = try_integer(y)
    if x ~= nil and y ~= nil then
        return Multiplication:new(Dice.parse(x), y)
    end

    -- range
    x, y = input:match'^([^-]+)-(.+)$'
    x, y = try_natural(x), try_natural(y)
    if x ~= nil and y ~= nil then
        return Addition:new(DiceRoll:new(1, y - x + 1), Constant:new(x - 1))
    end

    -- dice
    x, y = input:match'^([^d]+)d(.+)$'
    x, y = try_integer(x), try_natural(y)
    if x ~= nil and y ~= nil then
        return DiceRoll:new(x, y)
    end

    -- constant
    x = try_integer(input)
    if x ~= nil then
        return Constant:new(x)
    end

    error(([[

I couldn't understand this as part of an expression:
%s]]):format(input))
end

return Dice