liblevelup/API.lua

1403 lines
60 KiB
Lua

-- liblevelup mod for Minetest
-- Copyright © 2017-2021 Alex Yst <mailto:copyright@y.st>
-- This program is free software; you can redistribute it and/or
-- modify it under the terms of the GNU Lesser General Public
-- License as published by the Free Software Foundation; either
-- version 2.1 of the License, or (at your option) any later version.
-- This software is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
-- Lesser General Public License for more details.
-- You should have received a copy of the GNU Lesser General Public
-- License along with this program. If not, see
-- <https://www.gnu.org./licenses/>.
-----------------------------------------------------------------------
----------------- Deprecated mod name database import -----------------
-----------------------------------------------------------------------
-- liblevelup used to be called "minestats". If the older database
-- under the deprecated name still exists, we should import that to the
-- new database. Usually, I keep all code related to supporting
-- deprecated features at the end of the file, but this database import
-- has to come before everything else. Otherwise, when liblevelup
-- requests its storage object, the data the storage object contains
-- will be incorrect.
--
-- Legacy support in this mod is only enabled when overall Minetest
-- legacy support is enabled in minetest.conf. By default, legacy
-- support is enabled for release versions but not development
-- versions.
--
-- Deprecated on 2020-02-29; DO NOT REMOVE FOR AT LEAST TWO YEARS.
if minetest.settings:get("deprecated_lua_api_handling") ~= "error" then
local liblevelup_database = io.open(minetest.get_worldpath().."/mod_storage/liblevelup", "r")
if liblevelup_database then
liblevelup_database:close()
else
local minestats_database = io.open(minetest.get_worldpath().."/mod_storage/minestats", "r")
if minestats_database then
local liblevelup_database = io.open(minetest.get_worldpath().."/mod_storage/liblevelup", "w")
liblevelup_database:write(minestats_database:read())
liblevelup_database:close()
minestats_database:close()
end
end
end
-----------------------------------------------------------------------
------------------------- Internal Variables: -------------------------
-----------------------------------------------------------------------
-- Anything in this table is a countable node/drop pair. Anything else
-- is, well, not.
--
-- Third-party mod developers need not worry about the structure of
-- this table because the table's not made public. However, if you're
-- editing this file's code, the structure is:
--
-- registered_countables[node_name][drop_key(drop_list)] == true
--
-- A single countable node might be able to drop multiple countable
-- drops, so this table structure allows us to account for that fact.
local registered_countables = {}
-- We need a way to store our data, so let's try this:
local storage = minetest.get_mod_storage()
-- This table is used internally to keep track of the alphabetical
-- order of all registered drops.
local sorted_drops = {}
-- This is the maximum number of items that can be in a Minetest
-- ItemStack (16 bits, unsigned). I know the value off the top of my
-- head because I use it so often, to be honest, but assigning it a
-- variable name makes it more clear to readers of the code why that
-- value is even there. Like, where did 65535 come from? Well, here you
-- go. It's used in a few places due to being the most items that can
-- ever fit into a stack of items in Minetest.
local max_stack_size = 65535
-- This table contains all the functions registered to run once
-- liblevelup has been initialised.
local startup_functions = {}
-- This table contains all the functions to be called whenever a stat
-- is updated.
local update_functions = {}
-- This table contains all the functions to be called whenever a
-- level-up occurs.
local levelup_functions = {}
-- A third-party mod may tell liblevelup that a particular node drop is
-- countable. The functions registered by mods to check each drop are
-- stored in this table.
local is_countable_callbacks = {}
-- When the determining proficiency level of a player with a given
-- material, that material's own levelling curve needs to be accounted
-- for. In the majority of cases, a material's levelling exponent is
-- 0.414335358827271738046960081192082725465297698974609375. Yes, the
-- exponent is *that* finely tuned. That aside though, not all
-- materials have to have this specific levelling curve, so this table
-- keeps track of the levelling curves of counted materials. It gets
-- populated just before the game begins.
local levelling_exponent = {}
-- The reverse levelling exponent is used in determining how many
-- stacks of a material are needed to get to a specific level, instead
-- of determining what level you are at given a specific number of
-- stacks. It's used to build "next level at" counters.
local reverse_levelling_exponent = {}
-- Like the table above, this table deals with levelling mechanics and
-- is populated just before the game begins. The the maximum stack size
-- for each material is usually 99, but again, we shouldn't assume that
-- limit and use this table so we don't have to.
local drop_max_stack_size = {}
-- Level calculation's more costly than I'd like it to be. Let's just
-- cache the most expensive-to-compute parts. But also, we want to be
-- able to compare new values to old values to see if there was a
-- change, and the cache lets us do that.
local unscaled_level_cache = {}
-- The scaled levels are pretty costly too, and the full set of scaled
-- levels have to be calculated every single time the player collects a
-- drop from a node even if that node only dropped something related
-- to one of the levels. We might as well cache the levels at that time
-- to avoid recalculating them when other mods query these levels.
local scaled_level_cache = {}
-- If a craft-predicting function returns an empty ItemStack, Minetest
-- now disables the craft. This means we can prevent items from being
-- crafted based on arbitrary criteria. The keys in this table
-- represent items that the player isn't allowed to craft unless
-- they've met certain level requirements, and the values are tables of
-- level requirements with the drop the player must have levels in
-- being the keys and the level in that particular drop being the
-- values.
local craft_locks = {}
-- In implementing craft locks, we send messages to players that
-- haven't met the unlocking requirements, so we should probably be
-- sending translated strings.
local S = minetest.get_translator("liblevelup")
-----------------------------------------------------------------------
------------------------- Internal functions: -------------------------
-----------------------------------------------------------------------
-- This method normalises drops so we can better handle them.
-- Sometimes, a node will drop multiple stacks of the same item or will
-- drop unnormalised stacks, such as in the form of legacy item
-- strings. This function lets us properly handle these cases.
local function normalise_drops(drops)
local by_name = {}
local normalised = {}
for _, item in next, drops do
local stack = ItemStack(item)
if not stack:is_empty() then
local count = stack:get_count()
stack:set_count(1)
local name = stack:to_string()
if by_name[name] then
by_name[name] = by_name[name] + count
else
by_name[name] = count
end
end
end
-- We need to return drops in a consistent order for comparison later
-- in the script.
local sorted = {}
for name, _ in next, by_name do
sorted[#sorted+1] = name
end
table.sort(sorted)
for _, name in next, sorted do
local count = by_name[name]
if ItemStack(name):get_definition().type == "tool" then
-- The engine doesn't allow stacking multiple tools, even via code in a
-- mod. Tools just simply cannot be stacked. Ever. If we try to stack
-- the tools, we'll end up deleting all but one of them.
while count > 0 do
normalised[#normalised+1] = name
count = count - 1
end
else
-- A node might drop so many items that we can't store them all in a
-- stack. In practice, this should never happen, but we'd be fools to
-- fail to account for this possibility.
while count > max_stack_size do
normalised[#normalised+1] = ItemStack(name.." "..max_stack_size):to_string()
count = count - max_stack_size
end
end
-- Who knows how many bugs converting to an ItemStack and back fixes?
-- The need for this process was discovered because of the common
-- possibility that an item drop contains a stack of a single item.
-- In the original code, we simply append the size of the stack to the
-- ID of the item. In cases of one item, that's incorrect behaviour and
-- results in unnormalised item strings. We could account for
-- single-item stacks manually, but there's a chance I messed something
-- else up along the way too. Converting to an ItemStack and back
-- ensures that the item string is in the canonically-normalised form
-- as defined by the engine.
--
-- I'm almost certain another bug is present somewhere, too. Flint and
-- cotton seeds from Minetest Game weren't displaying in the stats
-- menu, along with potentially other items. Converting to an ItemStack
-- and back mysteriously fixed that though.
normalised[#normalised+1] = ItemStack(name.." "..count):to_string()
end
return normalised
end
-- Checking to see if a given list of items would be counted as a drop
-- requires putting the entire drop list into a single string to check
-- against a table.
local function drop_key(drop_list)
-- Multiple stacks in the same drop list might be the same type of
-- item, which should be treated the same as if the same quantity of
-- items in the drop list were present but as a single stack. Stack
-- order also shouldn't influence how drop lists are treated, so we
-- need to sort the drop list. Normalisation of the drop list takes
-- care of both.
local drops = normalise_drops(drop_list)
-- Te list could be empty. If so, the list isn't counted. Return an
-- empty string anyway instead of nil to avoid errors caused by
-- assuming the return value of this function is a string. If not,
-- build the table key needed to check.
if drops[1] then
local key = drops[1]
if drops[2] then
for i = 2, #drops do
key = key..";"..drops[i]
end
end
return key
else
return ""
end
end
-- Need to know if something's counted? Look no further! Keep in mind
-- that drops are counted based on context, so the full drop list needs
-- to be passed to this function.
local function is_counted(node_name, drop_list)
local key = drop_key(drop_list)
if registered_countables[node_name]
and registered_countables[node_name][key] then
return true
else
return false
end
end
-- This method simply gets a string from the database and casts it to
-- an integer. All other functions that get data from the database
-- aren't interacting with the database directly, but are merely
-- calling this function and using the return value in some way.
local function get_stat(key)
-- Sometimes tonumber() returns nil instead of a number.
return tonumber(storage:get_string(key)) or 0
end
-- This method returns a stat in terms of how many of an item have been
-- harvested, regardless of number dropped in a single dig and
-- regardless of what node dropped it.
local function get_drop_stat(player_name, drop)
return get_stat(player_name..";"..drop)
end
-- This method returns the number of stacks of the drop a player has
-- mined. Fractional values are returned, to allow for levels to
-- account for everything a player has mined in a unified way.
local function get_stacks_mined(player_name, drop_name)
return get_drop_stat(player_name, drop_name) / drop_max_stack_size[drop_name]
end
-- We'll overwrite these functions later in the script (see "External
-- API"), so we need to create local copies of the old versions to call
-- from our new versions.
local builtin_get_node_drops = minetest.get_node_drops
-- This second function needs to be copied after the game begins
-- though, not now. That makes it more likely our version is the
-- outermost version of the function. If ours is not the outermost one,
-- we can't even make sure our version is called at all. Specifically,
-- Minetest Game's creative mod overwrites the node drop handler (iff
-- creative mode is enabled) and doesn't call the previous version of
-- it. If we don't wait to put our own version of the function in
-- place, liblevelup will be incompatible with the Minetest Game
-- creative mod.
local builtin_handle_node_drops
-- Actually solving this problem is incredibly difficult, and I don't
-- know how to do it yet. For now, this function is dummied out.
--
-- Eventually, this function should verify that there could exist a
-- string that matches all patterns and doesn't match any
-- "antipatterns".
local function vague_tool_is_consistent(drop_possibility)
return true
end
-- The next two functions actually both need to be able to call one
-- another, so the second function's variable needs to be declared
-- local before the first function is defined. Otherwise, when calling
-- the second function from within the first, Lua will try and fail to
-- find the function using the global variable name.
local handle_affirmative_drop_state
-- Recursion seems sort of like overkill for the problem at hand, but
-- I can't seem to figure out what else can do the job. Anyway, this
-- function takes a drop table from a node definition and spits out a
-- table of tables, with each inner table representing a list of items
-- that the node could drop if the node is dug. All possibilities are
-- enumerated, but their exact probabilities are not calculated.
local function drop_possibilities(node_drop_table, palette_index, return_table, current_state)
if type(node_drop_table) == "string" then
return_table[1] = {node_drop_table}
elseif not node_drop_table.items then
return_table[1] = {}
else
if current_state.drop_index > #node_drop_table.items
or current_state.number_drops == node_drop_table.max_items then
if current_state.tool
or vague_tool_is_consistent(node_drop_table) then
return_table[#return_table+1] = current_state.drops
end
else
-- This value may be updated before this variable is checked.
local might_not_drop = false
local dropped_state = table.copy(current_state)
dropped_state.drop_index = dropped_state.drop_index + 1
local undropped_state = table.copy(dropped_state)
if node_drop_table.items[current_state.drop_index].tools then
-- If the item definitely drops with the right tool, we have to handle
-- it here. Otherwise, if there's a chance the item wouldn't drop even
-- *with* the right tool, no special treatment is needed. We'll just
-- consider the chance that luck wasn't on the player's side.
if not node_drop_table.items[current_state.drop_index].rarity
or node_drop_table.items[current_state.drop_index].rarity <= 1 then
might_not_drop = true
-- We're holding a specific tool. Compare the tool to the list of tool
-- names and tool name patterns that allow the drop to occur.
if undropped_state.tool then
for _, disallowed_tool in next, node_drop_table.items[current_state.drop_index].tools do
if disallowed_tool:sub(1, 1) == "~" then
if undropped_state.tool:find(disallowed_tool:sub(2)) ~= nil then
might_not_drop = false
end
else
if disallowed_tool == undropped_state.tool then
might_not_drop = false
end
end
end
-- We have limited information on the tool we're holding. Check to see
-- if there are any direct conflicts that would prevent us from missing
-- this drop.
else
-- See if we can miss the drop.
for _, pattern in next, node_drop_table.items[current_state.drop_index].tools do
if undropped_state.toolname_patterns[pattern] then
might_not_drop = false
end
end
-- If we can, add the list of tool patterns that would cause the drop
-- to the list of patterns that don't match the tool we're holding.
if not might_not_drop then
for _, pattern in next, node_drop_table.items[current_state.drop_index].tools do
undropped_state.toolname_antipatterns[pattern] = true
end
end
end
end
end
-- If the item is dropped based on probability, we might not get the
-- drop. It's as simple as that.
if node_drop_table.items[current_state.drop_index].rarity
and node_drop_table.items[current_state.drop_index].rarity > 1 then
might_not_drop = true
end
-- We fork the state here if there's a chance the next set of items
-- might not be dropped.
if might_not_drop then
drop_possibilities(node_drop_table, palette_index, return_table, undropped_state)
end
-- Done forking, let's continue. This branch accounts for if the set of
-- items did drop after all.
dropped_state.number_drops = dropped_state.number_drops + 1
-- First of all, *can* the item drop after all? If we're holding the
-- wrong tool, maybe not.
if node_drop_table.items[current_state.drop_index].tools then
-- If we know what tool we're holding, check it against the list of
-- tool patterns that trigger the drop.
if dropped_state.tool then
local might_drop = false
for _, pattern in next, node_drop_table.items[current_state.drop_index].tools do
if pattern:sub(1, 1) == "~" then
if dropped_state.tool:find(pattern:sub(2)) ~= nil then
might_drop = true
end
else
if pattern == dropped_state.tool then
might_drop = true
end
end
end
if might_drop then
handle_affirmative_drop_state(node_drop_table, palette_index, return_table, dropped_state)
end
-- If we don't know what tool we're holding, compare what would cause
-- the drop with what we know for a fact we don't have.
else
for _, pattern in next, node_drop_table.items[current_state.drop_index].tools do
-- If the pattern is a pattern and not a full tool name, we still don't
-- know what we're holding. Check to see if the pattern directly
-- conflicts with something we know we don't have. If it doesn't, fork,
-- then add it to the list of pattern we know we know our tool matches.
if pattern:sub(1, 1) == "~" then
local might_drop = true
for tool, _ in next, dropped_state.toolname_antipatterns do
if pattern == tool then
might_drop = false
end
end
if might_drop then
local forked_state = table.copy(dropped_state)
forked_state.toolname_patterns[pattern] = true
handle_affirmative_drop_state(node_drop_table, palette_index, return_table, forked_state)
end
-- Finally!! At this point in the script, we don't know for sure what
-- tool we have, but we may or may not have a list of patterns our tool
-- matches against and/or a list of patterns our tool does not match
-- against. Now, we've finally gotten a concrete tool name to match
-- against. Check the tool name against the lists. If the tool name is
-- compatible, fork here, set the tool name, and remove the
-- now-redundant lists.
else
local matches_pattens = true
local does_not_match_antipaterns = true
for tool, _ in next, dropped_state.toolname_patterns do
if pattern:find(tool:sub(2)) == nil then
matches_pattens = false
end
end
for tool, _ in next, dropped_state.toolname_antipatterns do
if tool:sub(1, 1) == "~" then
if pattern:find(tool:sub(2)) ~= nil then
does_not_match_antipaterns = false
end
else
if pattern == tool then
does_not_match_antipaterns = false
end
end
end
if matches_pattens
and does_not_match_antipaterns then
local forked_state = table.copy(dropped_state)
forked_state.toolname_patterns = nil
forked_state.toolname_antipatterns = nil
forked_state.tool = pattern
handle_affirmative_drop_state(node_drop_table, palette_index, return_table, forked_state)
end
end
end
end
else
-- If there are no tool requirements, there's a definite possibility
-- that we can get the drop.
handle_affirmative_drop_state(node_drop_table, palette_index, return_table, dropped_state)
end
end
end
end
-- The function above has two branches that require this logic, and I'd
-- rather not code this same thing twice in case a later Minetest
-- update requires me to modify it, so I made it a separate function.
-- Being separate, I added this function call to a third branch as well
-- that otherwise would have hooked into an existing branch using a set
-- variable.
--
-- This function's variable name was already declared local before. If
-- we declare it local a second time, the previous function somehow
-- can't access this function. I think Lua somehow sets up a second
-- local variable with the same name or something, presenting the
-- former variable to functions that bound to the variable before this
-- point and the newer variable to functions that bind to the variable
-- after this point. Very unintuitive. In any case, the easy solution
-- is to just not declare the function name local here, then write a
-- note here in the comments explaining that not declaring the function
-- local was intentional.
function handle_affirmative_drop_state(node_drop_table, palette_index, return_table, dropped_state)
if node_drop_table.items[dropped_state.drop_index-1].inherit_color
and palette_index then
for _, item in next, node_drop_table.items[dropped_state.drop_index-1].items do
local stack = ItemStack(item)
stack:get_meta():set_int("palette_index", palette_index)
dropped_state.drops[#dropped_state.drops+1] = stack:to_string()
end
else
for _, item in next, node_drop_table.items[dropped_state.drop_index-1].items do
dropped_state.drops[#dropped_state.drops+1] = item
end
end
drop_possibilities(node_drop_table, palette_index, return_table, dropped_state)
end
-- Adding a wrapper to that last function will make our API cleaner.
local function node_drop_possibilities(node_name, palette_index)
-- The above function modifies tables passed into it to make recursion
-- a bit more efficient. Instead of building a bunch of tables and
-- having to combine their data, we can just put all the data into a
-- single table to begin with.
--
-- Likewise, the current_state argument is only used internally, so
-- we'll set that as well.
local return_table = {}
local current_state = {
drop_index = 1,
number_drops = 0,
drops = {},
toolname_patterns = {},
toolname_antipatterns = {},
}
drop_possibilities(minetest.registered_nodes[node_name].drop, palette_index, return_table, current_state)
return return_table
end
-- This function calls any functions registered by mods to be called
-- any time a player's stats are updated. This could be used, for
-- example, to build an automatically-updating inventory page for
-- players.
local function call_update_functions(player, drops)
for modname, funct in next, update_functions do
funct(player, drops)
end
end
-- Calculating uses exponents, which is a bit more
-- computationally-expensive than I'd like it to be. As such,
-- get_unscaled_partial_level() just returns data from the cache. When
-- we want to actually recalculate the data, we need to use
-- calculate_unscaled_partial_level() instead.
local function calculate_unscaled_partial_level(player_name, material)
if levelling_exponent[material] then
return math.min(get_stacks_mined(player_name, material)^levelling_exponent[material], max_stack_size)
else
return 0
end
end
-- This just conveniently adds up the unscaled levels, which needs to
-- be done in order to calculate the scaled level or calculate the size
-- of the level progress bar.
local function get_unscaled_floating_point_level(player_name)
local total_level = 0
for material, partial_level in next, unscaled_level_cache[player_name] do
total_level = total_level + partial_level
end
return total_level
end
-- Given a player name and a material, this function returns a sort of
-- "level" representing that player's experience mining or farming that
-- material. Rather than an outright number of stacks mined, like
-- get_stacks_mined() returns, this function calculates on a curve,
-- requiring players to mine/farm more and more of the material to
-- reach the next level.
local function get_unscaled_partial_level(player_name, material)
-- We've got a cache, so we should use it.
if unscaled_level_cache[player_name] then
if unscaled_level_cache[player_name][material] then
return unscaled_level_cache[player_name][material]
else
-- if the player is in the cache but the level isn't, that material
-- must not be associated with a stat, and thus not associated with a
-- level. Return zero.
return 0
end
else
-- If the player has been online this server session, their data will
-- be in the cache, but if their data isn't in the cache, we still want
-- this function to return the correct answer. We'll calculate the
-- level and return it.
return calculate_unscaled_partial_level(player_name, material)
end
end
-- The first time that a player's level is queried, this function needs
-- to be called to calculate all of their levels. Each time the player
-- gets a drop, this function needs to be called again to calculate all
-- the levels and see if the player levelled up and the level-up
-- callbacks need to be called.
local function calculate_scaled_partial_levels(player_name)
local raw_value = {}
for _, drop in next, sorted_drops do
raw_value[drop] = math.sqrt(
get_unscaled_partial_level(player_name, drop) * (get_unscaled_floating_point_level(player_name) / #sorted_drops)
)
end
rawset(scaled_level_cache, player_name, raw_value)
end
-- Calculating a player's full level all at once is more expensive than
-- I'd like it to be, not to mention that with the new on-level-up
-- callback feature, a player's level has to be recalculated *every
-- time* one of their stats is raised so as to see if the level has
-- changed. We greatly alleviate the problem by using a cache to store
-- the current proficiency levels, which we can initialise the first
-- time the data is needed or the first time the data changes,
-- whichever happens first. From there, we only have to recalculate the
-- parts of the player's level referring to individual stats that
-- changed and compare them to the previous values for those
-- components. If there's a change, we can use the new values along
-- with even more data from the cache to calculate the new total level
-- and call any registered on-level-up callbacks.
--
-- Previously, the generation of cached data was done when a player
-- logged in. However, I found that sometimes, a player's level might
-- get checked even if that player hasn't logged in since the last
-- restart. One example of this is if a sfinv page displays a player's
-- level. In one case, I found that sfinv was generated before the
-- registered on_join function from this mod was run, causing the game
-- to crash because the sfinv page was trying to access data from
-- liblevelup that wasn't yet available but would be a fraction of a
-- second later when the on_join function from this mod would get run.
-- This is only one example though. A mod should be able to check the
-- level of absolutely any player without fear of crashing the game and
-- without getting a zeroed-out result. To handle that, the
-- cache-generation functionality was moved to this metatable. The
-- first time that the data is needed, it'll be generated.
setmetatable(unscaled_level_cache, {
__index = function(unscaled_level_cache, player_name)
local raw_value = rawget(unscaled_level_cache, player_name)
if not raw_value then
raw_value = {}
for _, material in next, sorted_drops do
raw_value[material] = calculate_unscaled_partial_level(player_name, material)
end
rawset(unscaled_level_cache, player_name, raw_value)
end
return raw_value
end
})
-- Same logic as above. If the data is queried but hasn't been
-- calculated, let's calculate it.
setmetatable(scaled_level_cache, {
__index = function(scaled_level_cache, player_name)
local raw_value = rawget(scaled_level_cache, player_name)
if not raw_value then
calculate_scaled_partial_levels(player_name)
raw_value = rawget(scaled_level_cache, player_name)
end
return raw_value
end
})
-- Mods can register update functions to be alerted when a player's
-- stats change.
local function register_update_function(funct)
update_functions[minetest.get_current_modname()] = funct
end
-- Mods can also remove the update functions of other mods, if need be.
local function unregister_update_function(modname)
update_functions[modname] = nil
end
-- Mods can register update functions to be alerted when a player's
-- level changes.
local function register_levelup_function(funct)
levelup_functions[minetest.get_current_modname()] = funct
end
-- Mods can also remove the update functions of other mods, if need be.
local function unregister_levelup_function(modname)
levelup_functions[modname] = nil
end
-- Functions may be registered by other mods for telling liblevelup to
-- count more types of drops using this function.
local function register_is_countable(funct)
is_countable_callbacks[minetest.get_current_modname()] = funct
end
-- Functions may be unregistered by other mods so their behaviour can
-- be overridden.
local function unregister_is_countable(modname)
is_countable_callbacks[modname] = nil
end
-- Mods can register craft locks, which prevent items from being
-- crafted if level requirements have not been met. Aliases, item
-- groups, and item quantities are not handles here. Only specific
-- known item names should be registered. If multiple locks on the same
-- item are registered - for example, by different mods - they'll be
-- merged and the level requirements for all registered locks will need
-- to be met in order to craft the item.
local function register_craft_lock(name, requirements)
craft_locks[name] = craft_locks[name] or {}
for drop, level in next, requirements do
-- We attach "or level" here to the existing value because a value
-- might not yet be registered, at which point the level will simply be
-- compared to itself by math.max().
craft_locks[name][drop] = math.max(craft_locks[name][drop] or level, level)
end
end
-- All requirements for unlocking the craft will be removed, and the
-- player will have that craft available to them from the start.
local function remove_craft_lock(name)
craft_locks[name] = nil
end
-- If even one registered function says a drop is valid, this function
-- will reply that the drop is valid.
local function is_countable(node_name, drop_list, drop_opts, drop_key)
drop_list = normalise_drops(drop_list)
for mod_name, funct in next, is_countable_callbacks do
if funct(node_name, drop_list, drop_opts, drop_key) then
return true
end
end
return false
end
-- Mods developers should really think about whether they actually need
-- per-material player levels before making use of this feature. In
-- most cases, a unified level would be a much cleaner experience for
-- players. In fact, I originally planned not to provide a per-material
-- level-retrieval function specifically so it wouldn't be misused.
-- However, there are some cases in which per-material levels make
-- sense. I'm not saying not to use this function, I'm just saying you
-- should consider unified levels first and make sure you've ruled them
-- out for a good reason before resorting to this feature.
--
-- To make this function much more balanced, it heavily takes into
-- account a the average drop counts across all possible drops. As a
-- result, players with a low overall level will always have low
-- per-material levels even if they've maxed out the amount of that
-- material that will affect levels. While this doesn't fully alleviate
-- the concerns expressed in the paragraph above, it does greatly
-- reduce them.
local function get_scaled_per_material_player_level(player_name, drop)
local level = scaled_level_cache[player_name][drop] or 0
local cap = drop_max_stack_size[drop] or 0
return math.min(cap, math.floor(level))
end
-- For mods that use liblevelup to add a sense of progression to the
-- game, it can be useful to know a player's level.
local function get_scaled_player_level(player_name)
local level = 0
for drop_name, drop_level in next, scaled_level_cache[player_name] do
level = level + math.min(drop_max_stack_size[drop_name], math.floor(drop_level))
end
return level
end
-- I was running into a lot of bugs in the code related to next level
-- meters, and I wasn't sure how much of the issue is caused by
-- mistaken inconsistency in the code, so I just dumped all the
-- relevant code into this function. The other two functions can call
-- this one as need be, and I can be sure that they're both doing the
-- same thing as one another. It turned out inconsistency had nothing
-- to do with the issue, but this still makes the code easier to debug.
local function get_per_level_material_scaled_level_at(player_name, material, target_level)
-- Due to precision errors, the exact starting point isn't getting
-- accurately determined. When at level zero, this can cause this
-- extrapolation algorithm to predict that the current level would have
-- been reached a drop before or after it really would be. I have no
-- idea what to do about that; there's no way to have infinite
-- precision, so we can't eliminate precision errors. I think most of
-- the time, the effect is unnoticeable, especially because of the
-- ever-shifting denominators that result from the complexities of the
-- levelling system. Importantly, this rounding issue affects only
-- level predictions (and not even the goal state, but the extrapolated
-- starting state), and not the actual level of the player. However,
-- there's a corner case: level zero. There are two odd behaviours that
-- are *highly* noticeable for players at level zero. The most obvious
-- error is when -nan is is returned instead of the valid starting
-- point of zero drops. This seems to happen when precision errors set
-- the extrapolated starting point to be below zero. The second is when
-- the extrapolated starting point is instead estimated to be one
-- instead of zero. This sets causes progress pages, for example, to
-- say the progress toward the next level - level 1 - is one less than
-- the total number of drops obtained. It makes absolutely no sense to
-- the player and only serves to confuse them. I'm utterly confused as
-- to even one semantically correct thing I can try in attempting to
-- resolve the issue.
--
-- Out of desperation, I'm just checking for level zero, and if level
-- zero isn't the case, having liblevelup actually do the maths. This
-- results in correct return values for players at level zero and
-- almost-correct values for everyone else.
if target_level == 0 then
return 0
elseif reverse_levelling_exponent[material] then
local current_material_level = get_unscaled_partial_level(player_name, material)
if current_material_level == drop_max_stack_size[material] then
return math.huge
else
local target = target_level^2
local average_material_level = get_unscaled_floating_point_level(player_name) / #sorted_drops
-- BEGIN COMPLEX, UNVERIFIED MATHS
--
-- This part's a bit above me. Basically, the intent is that this
-- equality be true:
--
-- (current_material_level + X) * (average_material_level + X / #sorted_drops) == target
--
-- We then figure out how many of the drop is needed to get to level
-- (current_material_level + X) and call it a day. I couldn't figure
-- out where to go after I'd expressed my intent in that form though. I
-- ended up asking an algebra solver hoping that I could walk through
-- the steps it provided an arrive at the same solution it did, but it
-- solved it using quadratic equations and at the moment, I'm far too
-- stressed out by other things in my life to keep the variables
-- straight in my head enough to work with quadratic equations. When
-- I'm in a better place mentally and emotionally, I need to come back
-- and verify that this equation actually does what it's supposed to.
-- Until then, I'm just going to trust that the computerised algebra
-- solver knows what it's doing and that this equation does actually
-- solve for X so I can continue development.
local X = (
math.sqrt(
4 * #sorted_drops * target
- 2 * current_material_level * average_material_level * #sorted_drops
+ average_material_level^2 * (#sorted_drops)^2
+ current_material_level^2
)
- current_material_level
- average_material_level * #sorted_drops
) / 2
-- END COMPLEX, UNVERIFIED MATHS
local target_material_level = current_material_level + X
return math.ceil(target_material_level^reverse_levelling_exponent[material] * drop_max_stack_size[material])
end
else
return math.huge
end
end
-- Given a player name and a material, this function returns the number
-- of the material left the player needs to mine or farm to reach the
-- next per-material level. If there are no more levels to gain, this
-- function returns infinity. Consider using next_scaled_level_at()
-- instead, when reasonable.
local function next_level_at(player_name, material)
return get_per_level_material_scaled_level_at(player_name, material, math.floor(scaled_level_cache[player_name][material] or 0) + 1)
- get_drop_stat(player_name, material)
end
-- This helper function determines the length of a player's progress
-- progress bar for things such as level-up progress bars. In
-- combination with the next_level_at(), it can be used to determine
-- what a user's progress bar percentage and progress bar text should
-- be set to.
--
-- progress_percent == (length - next_scaled_level_at) / length
local function player_material_progress_bar_length(player_name, material)
local current_level = math.floor(scaled_level_cache[player_name][material] or 0)
return get_per_level_material_scaled_level_at(player_name, material, current_level + 1)
- get_per_level_material_scaled_level_at(player_name, material, current_level)
end
-- Incrementing is handled differently depending on a setting that is
-- read only when the game starts.
local increment
-- If unlimited counting is enabled, we count on and on without end and
-- without overflow. In this case, we work with the number fifteen
-- digits at a time, which allows us to work not at the integer
-- precision limit, but instead at the RAM limit.
--
-- If we weren't using the default Minetest implementation by default,
-- and thus didn't need the default Minetest implementation to be
-- compatible with our own implementation, we could save space by using
-- hexadecimal instead of decimal. The default Minetest implementation
-- uses decimal though, so we must use decimal too. It doesn't actually
-- matter though. You shouldn't need to even turn on our
-- implementation, so the default Minetest implementation is the one
-- that matters here.
if Settings(minetest.get_worldpath().."/world.conf"):get_bool("liblevelup.enable_infinite_counter", false) or true then
function increment(key, quantity)
local count = storage:get_string(key)
local continue = true
local offset = 0
while continue do
local prefix = count:sub(1, offset - 16)
-- I'm not sure how to correctly set the suffix without a bizarre
-- exception for the first working segment. I guess for now, a bizarre
-- exception is going to have to do.
--
-- To sum up the situation simply, the problem seems to be that I have
-- no idea what input would yield an empty string when no suffix is
-- available.
local suffix = ""
if offset ~= 0 then
suffix = count:sub(offset)
end
local working_segment = count:sub(offset - 15, offset - 1)
-- An empty segment can't appear in the middle of a string, so if the
-- segment is empty, we can ignore the prefix, which must also be
-- empty. We also need to treat the empty segment as zero, but there's
-- no need to set it to zero and increment, because incrementing zero
-- will always yield the value we incremented by. So we skip right to
-- using the value we're adding as the final value, and append the
-- suffix to it. Here, we terminate the loop, as we've finished the
-- incrementing.
if working_segment == "" then
count = quantity .. suffix
continue = false
-- if the segment isn't empty, we need to add the value to it.
else
working_segment = working_segment + quantity
-- If there's no prefix, we're working with the most-significant
-- segment and can just write it as-is. Overflow, if any, into the next
-- segment can just be dealt with by writing this segment longer than
-- it should be. As a side note, a more-robust check would be needed if
-- arbitrarily-large values could be added, but in fact, the value
-- added will never be more than the maximum stack size of 65535, so
-- the value to carry to the next segment, if any, will always be
-- exactly one. No need for a special check.
if prefix == "" then
count = prefix .. string.format("%d", working_segment) .. suffix
continue = false
-- If the segment is maxed out, we need to set it back to all zeros and
-- increment the next segment by one. Here, we change the offset, but
-- the incrementing hasn't completed yet, so the loop continues.
elseif working_segment > 999999999999999 then
quantity = 1
count = prefix .. string.format("%015d", working_segment - 1000000000000000) .. suffix
offset = offset - 15
-- If it's not the most-significant segment, we need to pad the segment
-- out with zeros.
else
count = prefix .. string.format("%015d", working_segment) .. suffix
continue = false
end
end
end
-- Finally, we save the incremented value.
storage:set_string(key, count)
end
-- If unlimited counting is disabled though, we just add the quantity
-- to the current value, without doing anything special. The Minetest
-- mod storage API will automatically overflow for us at the 32-bit
-- signed integer limit. Realistically, no one will hit this limit, so
-- this is just fine for normal gameplay.
else
function increment(key, quantity)
storage:set_int(key, storage:get_int(key) + quantity)
end
end
-- This function calls any functions registered by mods to be called
-- any time a player levels up. This could be used, for example, to
-- reward the player in some way or update their abilities.
local function call_levelup_functions(player)
-- First, we'll add up the level, then we'll call the functions and
-- pass that level to them.
local level = get_scaled_player_level(player:get_player_name())
for modname, funct in next, levelup_functions do
funct(player, level)
end
end
-- We want this function to be the last to override
-- minetest.handle_node_drops(). We can define our implementation now,
-- but we can't put it in place until later.
function liblevelup_handle_node_drops(pos, original_drops, digger)
local update_stats = false
local update_level = false
-- If we have information on what node was dug, let's use it.
-- Otherwise, this function must have been called not by a dig, but by
-- some other mod.
if original_drops.node_dug then
-- Is this drop even counted?
if is_counted(original_drops.node_dug, original_drops) then
update_stats = true
local player_name = digger:get_player_name()
for _, item in next, original_drops do
local stack = ItemStack(item)
local count = stack:get_count()
stack:set_count(1)
local material_name = stack:to_string()
increment(player_name..";"..material_name, count)
-- We need to check the player's level now, before updating it, so we
-- can compare it to the new value.
local previous_level = get_scaled_player_level(player_name)
-- Now we can recalculate the player's level.
unscaled_level_cache[player_name][material_name] = calculate_unscaled_partial_level(player_name, material_name)
calculate_scaled_partial_levels(player_name)
-- With both the new and old level available, we can check to see if
-- there was an increase.
if previous_level ~= get_scaled_player_level(player_name, material_name) then
update_level = true
end
end
end
end
-- Whether called by a dig or by another mod, we should call the
-- default implementation to finish up.
builtin_handle_node_drops(pos, original_drops, digger)
-- Is stats have actually changed, we should notify mods that have
-- requested to be notified.
if update_stats then
-- First, notify mods about changed stats.
call_update_functions(digger, original_drops)
-- Then, if the level has changed as well, notify mods about that as
-- too.
if update_level then
call_levelup_functions(digger)
end
end
end
-- Again, this function returns an empty table if called at load time.
-- Wait until run time to call it.
local function get_all_drops()
return table.copy(sorted_drops)
end
-- Sometimes, a mod may need code to run when the game begins, but
-- after liblevelup has initialised. Using minetest.after() is no
-- guarantee unless you pass it a time longer than zero seconds.
-- Instead of registering a function with minetest.after, you can
-- register one with this function and it will run as soon as the
-- liblevelup API is fully functional.
local function register_startup_function(funct)
startup_functions[minetest.get_current_modname()] = funct
end
-- A "node definition" might actually be several node definitions
-- rolled into one.
local function node_forms(node_name)
local palette = minetest.registered_nodes[node_name].palette
local paramtype2 = minetest.registered_nodes[node_name].paramtype2
local increment
if paramtype2 == "color" then
increment = 1
elseif paramtype2 == "colorwallmounted" then
increment = 8
elseif paramtype2 == "colorfacedir" then
increment = 32
end
if palette and increment then
local array = {}
local index = 0
while index < 256 do
array[#array+1] = {string=node_name..' 1 0 "\\0001palette_index\\0002'..index..'\\0003"', palette_index=index}
index = index + increment
end
return array
else
return {{string=node_name}}
end
end
-----------------------------------------------------------------------
---------------------------- External API: ----------------------------
-----------------------------------------------------------------------
-- To grant drop points to the player, we need to know which player
-- dug the node, what node was dug, and what item was dropped. The
-- Minetest API doesn't give us all that information at once. Instead,
-- it tells us what node was dug when we need to find the drops, then
-- tells us the drops and tells us what player they're for. In order to
-- get all the information we need, we need to sneak the dug node's
-- name into the drop table.
function minetest.get_node_drops(node, tool_name)
local drops = builtin_get_node_drops(node, tool_name)
-- When a player digs, we get a chance to remove this item from the
-- drop list before passing it to Minetest's handlers. When drops are
-- gotten for some other reason, we don't. If we add the information on
-- what node was dug by simply setting this value to the name of the
-- node, it causes item duplication in some cases (for example, when
-- attached nodes fall). However, if we store this value in a meta
-- table, we can retrieve the value later without Minetest doing
-- anything silly with it in the mean time.
local paramtype2 = ItemStack(node.name):get_definition().paramtype2
local palette_index = minetest.strip_param2_color(node.param2, paramtype2)
if palette_index then
setmetatable(drops, {__index = {node_dug = node.name..' 1 0 "\\0001palette_index\\0002'..palette_index..'\\0003"'}})
else
setmetatable(drops, {__index = {node_dug = node.name}})
end
return drops
end
minetest.register_on_mods_loaded(function()
-- We'll need to iterate over a list of the drops later on so we can
-- build the sorted_drops table, so we should build that list as we go.
-- Once this function completes though, the list will no longer be
-- needed in this form and can be discarded. From there, we'll only
-- need the sorted_drops table to get this information if we need to
-- later iterate over it again.
local registered_drops = {}
-- After all mods have loaded, we need to go through the node table to
-- figure out which countable node/drop pairs have been defined in the
-- game.
for node_name, def in next, minetest.registered_nodes do
-- No drop property means the node drops itself. It's not countable.
if def.drop
-- If the node's not pointable, how are we supposed to mine it?
and minetest.registered_nodes[node_name].pointable
-- If the node is not diggable, we shouldn't be tormenting players with
-- a visible stat in their menus that they'll never be able to raise.
and def.diggable
-- If the can_dig() method is defined, the node either can never be dug
-- (so again, we shouldn't torment players) of the node is likely some
-- unnatural node such as a furnace or chest. We shouldn't be keeping
-- stats for these kinds of nodes.
and not def.can_dig then
-- For now, node forms just refers to the different versions of
-- coloured nodes. If some other type of node variant needs to be
-- accounted for later, that can be added in as well.
for _, form in next, node_forms(node_name) do
local possibilities = node_drop_possibilities(node_name, form.palette_index)
local blacklisted = {}
-- If one of the recipes matches one of the drops, that drop is
-- basically just the node dropping itself in another form. Nodes that
-- do this include stone, which drops cobble that can be smelted back
-- into stone, and clay blocks, which drop four clay lumps that can be
-- crafted back into one clay block.
local recipes = minetest.get_all_craft_recipes(node_name)
if recipes then
for _, recipe in next, recipes do
blacklisted[drop_key(recipe.items)] = true
end
end
for _, possibility in next, possibilities do
local key = drop_key(possibility)
if not blacklisted[key] and form.string ~= key and key ~= ""
and is_countable(form.string, possibility, possibilities, key) then
if not registered_countables[form.string] then
registered_countables[form.string] = {}
end
registered_countables[form.string][key] = true
for _, item in next, possibility do
local stack = ItemStack(item)
if not stack:is_empty() then
stack:set_count(1)
registered_drops[stack:to_string()] = stack:get_name()
end
end
end
end
end
end
end
-- Here, we set up several important tables. It's quicker to work with
-- them all at once instead of building them separately, as the server
-- doesn't have to cycle through the drop materials as many times.
-- First, we have the sorted_drops drops table, of which a copy of is
-- given to any mod that requests it. While we're building this table,
-- we also take the opportunity to build the levelling_exponent table,
-- used to provide a levelling mechanic in which reaching the next
-- level requires exponentially higher numbers of drops. We also build
-- the drop_max_stack_size table here, used in the levelling equation
-- of each drop stat.
local denominator = math.log(max_stack_size)
for drop_string, drop_name in next, registered_drops do
sorted_drops[#sorted_drops+1] = drop_string
if minetest.registered_items[drop_name] then
drop_max_stack_size[drop_string] = minetest.registered_items[drop_name].stack_max
else
drop_max_stack_size[drop_string] = minetest.registered_items.unknown.stack_max
end
local numerator = math.log(drop_max_stack_size[drop_string])
levelling_exponent[drop_string] = numerator/denominator
reverse_levelling_exponent[drop_string] = denominator/numerator
end
table.sort(sorted_drops)
-- It's time! Let's put our implementation of the drop handler in
-- place.
builtin_handle_node_drops = minetest.handle_node_drops
minetest.handle_node_drops = liblevelup_handle_node_drops
-- It's too late now to register startup functions. These should be
-- registered at load time, not run time, as it'd be too late for them
-- to get called if registered later. The same applies to the
-- is_countable functions. However, it's not even just these two
-- registrations that don't work after the game starts. Due to the way
-- registration is implemented, no registration will be successful once
-- the game starts. We might as well remove the whole registration
-- function table, as none of it is useful at this point in execution.
liblevelup.register = nil
-- We should remove the unregistration table as well.
liblevelup.unregister = nil
-- We're now completely done finding drop possibilities to count for
-- stats. We can delete the registered handlers while we're at it, as
-- we're done with them.
is_countable_callbacks = nil
-- The liblevelup API is now fully functional. Let's run the functions
-- other mods have requested we run.
for modname, funct in next, startup_functions do
funct()
end
-- We've already run the startup functions. We can now remove them.
startup_functions = nil
end)
-- To lock a craft, we simply return an empty ItemStack() if the
-- unlocking requirements have not been met after reporting to the
-- player what the unmet requirements are.
minetest.register_craft_predict(function(itemstack, player, old_craft_grid, craft_inv)
local lock = craft_locks[itemstack:get_name()]
if lock then
local disallowed = false
local name = player:get_player_name()
for drop, level in next, lock do
if get_scaled_per_material_player_level(name, drop) < level then
disallowed = true
minetest.chat_send_player(name, S("@1 level @2 is required for this craft.", ItemStack(drop):get_description(), level))
end
end
if disallowed then
return ItemStack("")
end
end
end)
-- Make the API visible to other mods:
liblevelup = {
-- Both for this mod's main purpose and for other purposes, it can be
-- useful to register callbacks that will be used when certain events
-- occur. If you register multiple callbacks of the same type from the
-- same mod, only the last one registered will be called by liblevelup.
-- If you need multiple things to happen, put them all in one function
-- or register a callback that calls all the functions you need called.
register = {
is_countable = register_is_countable ,
levelup_function = register_levelup_function,
startup_function = register_startup_function,
update_function = register_update_function ,
craft_lock = register_craft_lock ,
},
-- Sometimes though, you want to override the functionality of a mod,
-- so you don't want the old mod's callbacks called. These functions
-- let you unregister most callbacks given by other mods. The argument
-- to pass to these functions is always the name of the mod you wish to
-- unregister a callback provided by, and it doesn't prevent that mod
-- from later registering a callback. What that means is that you'll
-- want to depend on (either a hard dependency or a soft dependency)
-- the mod you want to restrict from having a registered callback, so
-- you can wait for it to register the callback and you can
-- successfully unregister that callback.
unregister = {
is_countable = unregister_is_countable ,
levelup_function = unregister_levelup_function,
update_function = unregister_update_function ,
craft_lock = remove_craft_lock ,
},
-- These methods use data queried form the database, process it, and
-- return it to the caller. liblevelup.get.pair_stat() is the only one
-- that returns data directly form the database unprocessed. All other
-- functions in this sub-table are built up from this one.
--
-- For any method taking a player name, the player name argument comes
-- first. Node names are always specified before drop names, if
-- specified at all. Part of the API overhaul was to ensure not only
-- organisation, but also consistency, so all future functions will
-- follow this convention as well.
get = {
stat = get_drop_stat ,
next_level_at = next_level_at ,
player_level = get_scaled_player_level ,
player_material_level = get_scaled_per_material_player_level,
player_material_progress_bar_length = player_material_progress_bar_length ,
},
-- These methods return metadata about how liblevelup is interacting
-- with the current subgame.
meta = {
drops_list = get_all_drops,
is_counted = is_counted ,
},
}
-----------------------------------------------------------------------
-------------------------- Deprecated Stuff: --------------------------
-----------------------------------------------------------------------
-- Legacy support in this mod is only enabled when overall Minetest
-- legacy support is enabled in minetest.conf. By default, legacy
-- support is enabled for release versions but not development
-- versions.
if minetest.settings:get("deprecated_lua_api_handling") ~= "error" then
-- I deprecated the entire API. It was a bit of a mess, and the mod's
-- name wasn't very fitting of its purpose either, so I renamed the mod
-- and rebuilt the API from the ground up with consistency and
-- organisation in mind. This is the old API table, kept for backwards
-- compatibility for at least two years, as is the usual time frame for
-- keeping deprecated code around in this mod.
--
-- Deprecated on 2020-02-29; DO NOT REMOVE FOR AT LEAST TWO YEARS.
__minestats__ = {
get_all_drops = get_all_drops ,
get_all_pairs = function()
return {}
end,
get_drop_stat = get_drop_stat ,
get_pair_stat = get_drop_stat ,
get_total_level = get_scaled_player_level ,
is_counted = is_counted ,
next_level_at = get_scaled_player_level ,
register_is_countable = register_is_countable ,
register_levelup_function = register_levelup_function ,
register_startup_function = register_startup_function ,
register_update_function = register_update_function ,
unregister_is_countable = unregister_is_countable ,
unregister_levelup_function = unregister_levelup_function,
unregister_update_function = unregister_update_function ,
get_proficiency_level = function(player_name, material)
return math.floor(get_unscaled_partial_level(player_name, material))
end,
get_drop_limit_multiplier = function()
return 1
end,
get_engine_stacks_mined = function(player_name, drop_name)
if minetest.registered_items[drop_name].type == "tool" then
return get_drop_stat(player_name, drop_name)
else
local stat = get_drop_stat(player_name, drop_name)
return math.floor(stat / max_stack_size)
end
end,
get_stacks_mined = function(player_name, drop_name)
if drop_max_stack_size[drop_name] then
return math.floor(get_stacks_mined(player_name, drop_name))
else
return 0
end
end,
}
-- Unscaled levels are no longer queriable by other mods, as scaled
-- levels should be used instead in every case so that players who
-- engage in a variety of activities are rewarded more than players
-- that learn to perform one activity endlessly but quickly.
--
-- Also, full player levels are now only earned by earning a level in
-- a specific material. That is, you can't earn half a level in one
-- material and half in another to earn a full player level. This
-- feature was removed to make player-visible level displays more
-- intuitive to players, but also provides an easy way to deal with the
-- fact that progress bars using partial levels along with the new
-- scaled level system require complex mathematics that I was never
-- able to figure out.
--
-- Deprecated on 2020-12-16; DO NOT REMOVE FOR AT LEAST TWO YEARS.
liblevelup.get.scaled_player_level = get_scaled_player_level
liblevelup.get.next_material_level_at = next_level_at
-- Node data is no longer recorded, as it's not useful to the purpose
-- of liblevelup and merely inflates the database size.
--
-- Deprecated on 2021-09-30; DO NOT REMOVE FOR AT LEAST TWO YEARS.
liblevelup.get.pair_stat = liblevelup.get.stat
liblevelup.get.drop_stat = liblevelup.get.stat
function liblevelup.meta.pairs_list()
return {}
end
startup_functions["*DEPRECATED 2021-09-30*"] = function()
local updated = false
local new_fields = {}
local fields = storage:to_table().fields
for key, value in next, fields do
local parts = string.split(key, ";")
if #parts == 2 then
new_fields[key] = (new_fields[key] or 0) + value
else
local stack = ItemStack(parts[#parts])
local count = stack:get_count()
stack:set_count(1)
local new_key = parts[1]..";"..stack:to_string()
new_fields[new_key] = (new_fields[new_key] or 0) + value * count
updated = true
end
end
if updated then
storage:from_table({
fields = new_fields,
})
end
end
end