Module:Dice: Difference between revisions

From Caves of Qud Wiki
Jump to navigation Jump to search
(update to v1.0.0)
(2.0.0: https://github.com/TrashMonks/Dice/releases/tag/v2.0.0)
 
(One intermediate revision by the same user not shown)
Line 1: Line 1:
local Dice = {}
-- The implementation of this module makes use of a class metaphor. All a class
local dice_metatable = {__index = Dice}
-- really is is a table meant to be used as a metatable.
local private = setmetatable({}, {__mode = 'k'})
 
-- # 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


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


I couldn't make sense of this as a dice string: %s
-- Is the given value an integer at least a certain amount?
In particular, this part doesn't look like a term I understand: %s]]
local function is_integer_at_least(lower_bound, value)
    return is_integer(value) and value >= lower_bound
end


local RANGE_STRING_PARSE_ERROR_FORMAT = [[
-- 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


I couldn't make sense of this as a range string: %s]]
-- 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


local DICE_TERM_PATTERN = '[+-]?[^+-]+'
-- Every distribution we care about is symmetric, so its mean is the mean of
local XDY_PATTERN = '^([+-]?%d+)d(%d+)$'
-- its minimum and maximum.
local CONSTANT_PATTERN = '^([+-]?%d+)$'
local function mean(expression)
    return (expression:maximum() + expression:minimum()) / 2
end


local RANGE_PATTERN = '^(%d+)-(%d+)$'
-- The range of a distribution is the distance between its minimum and maximum.
local function range(expression)
    return expression:maximum() - expression:minimum()
end


local function new_dice(dice_list)
-- The standard deviation of a distribution is the square root of its variance.
    local result = {}
local function standard_deviation(expression)
    private[result] = {dice_list = dice_list}
     return math.sqrt(expression:variance())
     return setmetatable(result, dice_metatable)
end
end


-- Return a dice-string-compatible version of the given function.
-- Given two dice expressions, signal which is "better", as judged by the
local function dice_method(method)
-- following metrics:
     return function (dice)
-- - greater mean
         if type(dice) == 'string' then
-- - smaller range
             return method(Dice.from_dice_string(dice))
-- - 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
         else
             return method(dice)
             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
     end
end
end


local function map_sum_dice(dice, mapper)
-- # Dice Expression DSL
     local sum = 0
 
-- ## 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


    for _, subdice in ipairs(private[dice].dice_list) do
local Constant = derive(BaseExpression)
        sum = sum + mapper(subdice.quantity, subdice.size)
    end


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


--# Exports
function Constant:minimum()
    return self.constant
end


--## Computing the statistics of a roll
function Constant:maximum()
    return self.constant
end


--### Dice.average
function Constant:variance()
    return 0
end


function Dice:average()
function Constant:roll()
     return map_sum_dice(self, function (quantity, size)
     return self.constant
        return quantity * ((1 + size) / 2)
    end)
end
end


Dice.average = dice_method(Dice.average)
-- ## DiceRoll
Dice.ev = Dice.average
Dice.expected_value = Dice.average
Dice.mean = Dice.average


--### Dice.maximum
local DiceRoll = derive(BaseExpression)


function Dice:maximum()
function DiceRoll:initialize(quantity, sides)
     return map_sum_dice(self, function (quantity, size)
     assert(is_natural(quantity))
        return math.max(quantity * 1, quantity * size)
    assert(is_natural(sides) and sides >= 1)
     end)
    self.quantity = quantity
     self.sides = sides
end
end


Dice.maximum = dice_method(Dice.maximum)
function DiceRoll:minimum()
Dice.max = Dice.maximum
    return math.min(self.quantity * 1, self.quantity * self.sides)
end


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


function Dice:minimum()
function DiceRoll:variance()
     return map_sum_dice(self, function (quantity, size)
     local single_die_mean = (1 + self.sides) / 2
        return math.min(quantity * 1, quantity * size)
    local sum = 0
    end)
 
    for n = 1, self.sides do
        sum = sum + (n - single_die_mean) ^ 2
    end
 
    return math.abs(self.quantity) * sum / self.sides
end
end


Dice.minimum = dice_method(Dice.minimum)
function DiceRoll:roll()
Dice.min = Dice.minimum
    local sum = 0


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


function Dice:range()
    if self.quantity >= 0 then
     return math.abs(self:maximum() - self:minimum() + 1)
        return sum
     else
        return -sum
    end
end
end


Dice.range = dice_method(Dice.range)
-- ## Addition


--### Dice.variance
local Addition = derive(BaseExpression)


function Dice:variance()
function Addition:initialize(operand_a, operand_b)
     return map_sum_dice(self, function (quantity, size)
     assert(is_expression(operand_a))
        local single_die_average = (1 + size) / 2
    assert(is_expression(operand_b))
        local sum = 0
    self.operand_a = operand_a
    self.operand_b = operand_b
end
 
function Addition:minimum()
    return self.operand_a:minimum() + self.operand_b:minimum()
end


        for n = 1, size do
function Addition:maximum()
            sum = sum + (n - single_die_average) ^ 2 / size
    return self.operand_a:maximum() + self.operand_b:maximum()
        end
end
 
function Addition:variance()
    return self.operand_a:variance() + self.operand_b:variance()
end


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


Dice.variance = dice_method(Dice.variance)
-- ## Negation
 
local Negation = derive(BaseExpression)


--### Dice.roll
function Negation:initialize(operand)
    assert(is_expression(operand))
    self.operand = operand
end


function Dice:roll()
function Negation:minimum()
     return map_sum_dice(self, function (quantity, size)
     return -self.operand:maximum()
        local sum = 0
end


        for n = 1, math.abs(quantity) do
function Negation:maximum()
            sum = sum + math.random(1, size)
    return -self.operand:minimum()
        end
end
 
function Negation:variance()
    return self.operand:variance()
end


        if quantity >= 0 then
function Negation:roll()
            return sum
    return -self.operand:roll()
        else
            return -sum
        end
    end)
end
end


Dice.roll = dice_method(Dice.roll)
-- ## Multiplication
Dice.sample = Dice.roll


--### Dice.compare
local Multiplication = derive(BaseExpression)


function Dice.compare(dice_a, dice_b)
function Multiplication:initialize(expression, constant)
     local average_a, average_b = Dice.average(dice_a), Dice.average(dice_b)
     assert(is_expression(expression))
    assert(is_natural(constant))
    self.expression = expression
    self.constant = constant
end


     if average_a > average_b then
function Multiplication:minimum()
        return 1, 'greater average'
     if self.constant >= 0 then
    elseif average_b > average_a then
         return self.expression:minimum() * self.constant
         return -1, 'greater average'
     else
     else
         local range_a, range_b = Dice.range(dice_a), Dice.range(dice_b)
         return self.expression:maximum() * self.constant
    end
end


        if range_a < range_b then
function Multiplication:maximum()
            return 1, 'smaller range'
    if self.constant >= 0 then
         elseif range_b < range_a then
         return self.expression:maximum() * self.constant
            return -1, 'smaller range'
    else
        else
        return self.expression:minimum() * self.constant
            local variance_a, variance_b =
    end
                Dice.variance(dice_a), Dice.variance(dice_b)
end


            if variance_a < variance_b then
function Multiplication:variance()
                return 1, 'less variance'
    return self.expression:variance() * self.constant * self.constant
            elseif variance_b < variance_a then
end
                return -1, 'less variance'
            else
                return 0, 'no difference'
            end


            return 0, 'inconclusive'
function Multiplication:roll()
        end
     return self.expression:roll() * self.constant
     end
end
end


--## Parsing a roll from a string
-- # Parsing


--### Dice.from_dice_string
local Dice = {}


function Dice.from_dice_string(dice_string)
local function try_integer(input)
     local dice_list = {}
     return input ~= nil and tonumber(input:match'^[+-]?%d+$') or nil
end


     for term in dice_string:gsub('%s', ''):gmatch(DICE_TERM_PATTERN) do
local function try_natural(input)
        local quantity, size = term:match(XDY_PATTERN)
     return input ~= nil and tonumber(input:match'^%d+$') or nil
end


        if quantity ~= nil and size ~= nil then
function Dice.parse(input)
            table.insert(dice_list, {
    input = input:gsub('%s', '')
                quantity = tonumber(quantity, 10),
    local x, y
                size = tonumber(size, 10),
            })
        else
            local quantity = term:match(CONSTANT_PATTERN)


            if quantity ~= nil then
    -- addition
                table.insert(dice_list, {
    x, y = input:match'^([^+]+)+(.+)$'
                    quantity = tonumber(quantity, 10),
    if x ~= nil then
                    size = 1,
        return Addition:new(Dice.parse(x), Dice.parse(y))
                })
            else
                error(DICE_STRING_PARSE_ERROR_FORMAT:format(dice_string, term))
            end
        end
     end
     end


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


Dice.from_string = Dice.from_dice_string
    -- 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


--### Dice.from_range_string
    -- 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


function Dice.from_range_string(range_string)
    -- dice
     local minimum, maximum = range_string:gsub('%s', ''):match(RANGE_PATTERN)
    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


     if minimum ~= nil and maximum ~= nil then
    -- constant
         return new_dice{
    x = try_integer(input)
            {
     if x ~= nil then
                quantity = 1,
         return Constant:new(x)
                size = maximum - minimum + 1,
            },
            {
                quantity = minimum - 1,
                size = 1,
            },
        }
    else
        error(RANGE_STRING_PARSE_ERROR_FORMAT:format(range_string, term))
     end
     end
    error(([[
I couldn't understand this as part of an expression:
%s]]):format(input))
end
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