-- liblevelup mod for Minetest -- Copyright © 2017-2021 Alex Yst -- 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 -- . ----------------------------------------------------------------------- ----------------- 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