From ed1bb20e24809426df5e08508e409c9138ffb108 Mon Sep 17 00:00:00 2001 From: WillConker Date: Wed, 10 Jul 2024 12:40:42 +0100 Subject: [PATCH] Added datapacks and loot table functionality --- .../vanilla/loot_table/chests/testloot.json | 36 ++++ mods/CORE/vl_datapacks/init.lua | 54 ++++++ mods/CORE/vl_datapacks/resource_loader.lua | 155 ++++++++++++++++++ mods/CORE/vl_loot/engine.lua | 144 ++++++++++++++++ mods/CORE/vl_loot/init.lua | 59 +++++++ mods/CORE/vl_loot/item_modifier.lua | 80 +++++++++ mods/CORE/vl_loot/mod.conf | 1 + mods/CORE/vl_loot/number_provider.lua | 46 ++++++ mods/CORE/vl_loot/predicate.lua | 18 ++ 9 files changed, 593 insertions(+) create mode 100644 datapacks/vanilla/data/vanilla/loot_table/chests/testloot.json create mode 100644 mods/CORE/vl_datapacks/init.lua create mode 100644 mods/CORE/vl_datapacks/resource_loader.lua create mode 100644 mods/CORE/vl_loot/engine.lua create mode 100644 mods/CORE/vl_loot/init.lua create mode 100644 mods/CORE/vl_loot/item_modifier.lua create mode 100644 mods/CORE/vl_loot/mod.conf create mode 100644 mods/CORE/vl_loot/number_provider.lua create mode 100644 mods/CORE/vl_loot/predicate.lua diff --git a/datapacks/vanilla/data/vanilla/loot_table/chests/testloot.json b/datapacks/vanilla/data/vanilla/loot_table/chests/testloot.json new file mode 100644 index 000000000..579cc9274 --- /dev/null +++ b/datapacks/vanilla/data/vanilla/loot_table/chests/testloot.json @@ -0,0 +1,36 @@ +{ + "type": "chest", + "functions": [], + "pools": [ + { + "conditions": [], + "functions": [], + "rolls": { + "type": "uniform", + "min": 2, + "max": 4 + }, + "bonus_rolls": 1, + "entries": [ + { + "conditions": [], + "type": "item", + "weight": 1, + "quality": 0, + "functions": [ + { + "conditions": [], + "function": "set_count", + "count": { + "type": "binomial", + "n": 20, + "p": 0.5 + } + } + ], + "name": "mcl_core:diamond" + } + ] + } + ] +} diff --git a/mods/CORE/vl_datapacks/init.lua b/mods/CORE/vl_datapacks/init.lua new file mode 100644 index 000000000..1998dd69e --- /dev/null +++ b/mods/CORE/vl_datapacks/init.lua @@ -0,0 +1,54 @@ +local modpath = minetest.get_modpath(minetest.get_current_modname()) + +local default_datapack_path = modpath .. "/../../../datapacks/vanilla" + + +vl_datapacks = { + registries = {}, + loaded_datapacks = {}, + registry_specs = { + ["loot_table"] = "json", + } +} + +--[[ Format of `vl_datapacks.registries`: + { + : { + : { + : resource, + } + : { + : resource, + } + }, + : { + : { + : resource, + } + : { + : resource, + } + }, + } +]] + +local function split_resource_string(resource_string) + local match_start, _, namespace, path = string.find(resource_string, "([^%s]+)%:([^%s]+)") + if not match_start then + error("Invalid resource string: " .. resource_string) + end + return namespace, path +end + +function vl_datapacks.get_resource(registry, resource_string) + local namespace, path = split_resource_string(resource_string) + return vl_datapacks.registries[registry][namespace][path] +end + +for registry_name, _ in pairs(vl_datapacks.registry_specs) do + vl_datapacks.registries[registry_name] = {} +end + +dofile(modpath .. "/resource_loader.lua") + +vl_datapacks.load_datapack("vanilla", default_datapack_path) \ No newline at end of file diff --git a/mods/CORE/vl_datapacks/resource_loader.lua b/mods/CORE/vl_datapacks/resource_loader.lua new file mode 100644 index 000000000..5385c9ba3 --- /dev/null +++ b/mods/CORE/vl_datapacks/resource_loader.lua @@ -0,0 +1,155 @@ +--[[ -- Gets the last extension of a filename +-- Returns: stem, extension +local function split_fname(filename) + local matched, _, stem, extension = string.find(filename, "^(.*)%.(.*)$") + if not matched then + return filename, "" + else + return stem, extension + end +end + + + + +-- Loads a resource into `load_into` +-- key: filename with last extension stripped +-- value: preferred lua format for that resource +local function load_resource(path, filename, load_into, strict) + local stem, extension = split_fname(filename) + local filepath = path .. "/" .. filename + local filestring = read_file_string(filepath, strict) + if extension == "json" then + local parsed_json, err = minetest.parse_json(filestring) + if not parsed_json then + -- Not valid json + error("Error while reading json file " .. filepath .. ": " .. error) + end + load_into[stem] = parsed_json + end +end + +-- Recursively load resources from `path` +-- `load_into`: table to load into +-- `strict`: whether to error if resources not found +local function load_resources_internal(path, expected_resources, load_into, strict) + minetest.debug(path, dump(expected_resources)) + for subfile, subfile_expected in pairs(expected_resources) do + if type(subfile_expected) == "table" then + if not load_into[subfile] then + load_into[subfile] = {} + end + local subdir_load_into = load_into[subfile] + load_resources_internal(path .. "/" .. subfile, subfile_expected, subdir_load_into, strict) + else -- subfile is file, not dir + load_resource(path, subfile, load_into, strict) + end + end +end ]] + +--[[ -- Check if match_string starts with start_check +local function startswith(match_string, start_check) + return string.sub(match_string, 1, string.len(start_check)) == start_check +end ]] + +-- Get file subpath without suffix +-- Returns nil if invalid (e.g. file had wrong suffix) +local function get_stem(subpath, file_suffix) + local _, _, stem = string.find(subpath, "(.*)%." .. file_suffix .. "$") + return stem +end + +-- Read a file at `filepath` and return its contents as a string +-- Raises an error if file could not be opened +local function read_file_string(filepath) + local file, err = io.open(filepath) + if not file then + error("Error while loading resources: could not open file " .. filepath .. ": " .. err) + end + local filestring = file:read("*all") + file:close() + return filestring +end + +-- registry_path must end in a slash +local function load_single_resource(registry_path, subpath, namespace_table, file_suffix) + local absolute_path = registry_path .. subpath + local stem = get_stem(subpath, file_suffix) + if not stem then + error("Invalid filename in datapack at " .. absolute_path) + end + local raw_data = read_file_string(absolute_path) + local loaded_data, err + + minetest.debug("Loading resource at " .. subpath .. " in " .. registry_path) + + if file_suffix == "json" then + loaded_data, err = minetest.parse_json(raw_data) + if not loaded_data then + -- Not valid json + error("Error while reading json file " .. filepath .. ": " .. err) + end + else + error("Can't read file with format " .. file_suffix .. " at " .. absolute_path) + end + + namespace_table[stem] = loaded_data +end + +-- registry_path must end in a slash +local function load_resources_recursive(registry_path, subpath, namespace_table, file_suffix) + -- Put a / on the end of the subpath, unless we are in the main registry directory + if subpath ~= nil then subpath = subpath .. "/" + else subpath = "" end + + local absolute_path = registry_path .. subpath + + minetest.debug("Loading resources from " .. absolute_path) + -- Load files in this directory + for _, filename in pairs(minetest.get_dir_list(absolute_path, false)) do + minetest.debug("Loading resource " .. filename) + load_single_resource(registry_path, subpath .. filename, namespace_table, file_suffix) + end + + -- Load from subdirectories + for _, dirname in pairs(minetest.get_dir_list(absolute_path, true)) do + minetest.debug("Going into subdirectory " .. dirname) + load_resources_recursive(registry_path, subpath .. dirname, namespace_table, file_suffix) + end + minetest.debug("Finished loading resources from " .. absolute_path) +end + +local function load_into_registry(path, registry_name, namespace, file_suffix) + local registry = vl_datapacks.registries[registry_name] + if not registry[namespace] then registry[namespace] = {} end + local namespace_table = registry[namespace] + load_resources_recursive(path, nil, namespace_table, file_suffix) +end + + +local function load_into_namespace(namespace, path) + for registry_name, file_suffix in pairs(vl_datapacks.registry_specs) do + -- registry_path must end in a slash for internal functions + load_into_registry(path .. "/" .. registry_name .. "/", registry_name, namespace, file_suffix) + end +end + + +-- Loads a full datapack into memory, thereby enabling it and any overrides +local function load_datapack(name, path) + for _, other_name in pairs(vl_datapacks.loaded_datapacks) do + if name == other_name then + error("Datapack " .. name .. " is already loaded") + end + end + + + local data_path = path .. "/data" + + for _, namespace in pairs(minetest.get_dir_list(data_path, true)) do + load_into_namespace(namespace, data_path .. "/" .. namespace) + end + table.insert(vl_datapacks.loaded_datapacks, name) +end + +vl_datapacks.load_datapack = load_datapack \ No newline at end of file diff --git a/mods/CORE/vl_loot/engine.lua b/mods/CORE/vl_loot/engine.lua new file mode 100644 index 000000000..fbb54d826 --- /dev/null +++ b/mods/CORE/vl_loot/engine.lua @@ -0,0 +1,144 @@ +-- Main loot engine +vl_loot.engine = {} + +-- Adds all items from array `to_append` into old_table +-- Returns nil +local function append_table(old_table, to_append) + for _, item in ipairs(to_append) do + table.insert(old_table, item) + end +end + +-- Modifies each of the stacks in `loot_stacks` according to `modifier_table` (array) +-- Edits `loot_stacks` in-place +local function modify_stacks(modifier_table, loot_stacks, loot_context) + for i, itemstack in pairs(loot_stacks) do + loot_stacks[i] = vl_loot.modifier.apply_item_modifier(modifier_table, itemstack, loot_context) + end +end + +-- Conditionally returns an entry based on whether its conditions passed +-- For compound-type entries this can return multiple entries +-- Returns array of entries to add to pool +local function unpack_entry(entry_provider_table, loot_context) + -- ENTRY PROVIDER TYPES (compound have *) + -- item + -- tag + -- loot_table + -- dynamic + -- empty + -- group * + -- alternative * + -- sequence * + + if not vl_loot.predicate.check_predicates(entry_provider_table.conditions, loot_context) then return {} end + + -- TODO: Support other types + if entry_provider_table.type == "item" then + return {entry_provider_table} + end +end + +-- Should always receive a simple-type entry +-- Returns all (already modified) stacks to use as pool loot +local function get_entry_loot(entry_table, loot_context) + -- SIMPLE ENTRY TYPES + -- item + -- tag + -- loot_table + -- dynamic + -- empty + + -- Don't check conditions, these were already checked in unpack_entry + + local loot_stacks = {} + -- TODO: Support other types + if entry_table.type == "item" then + local itemstack = ItemStack(entry_table.name) + table.insert(loot_stacks, itemstack) + end + + -- Apply modifier + modify_stacks(entry_table.functions, loot_stacks, loot_context) + + return loot_stacks +end + +local function get_final_weight(entry_table, loot_context) + -- TODO: Add 'quality' based on luck + -- TODO: Do some floor stuff? + return entry_table.weight +end + +local function get_final_rolls(pool_table, loot_context) + -- TODO: bonus_rolls (also a number provider) + local rolls = vl_loot.number_provider.evaluate(pool_table.rolls, loot_context) + -- Must be an integer + return math.floor(rolls) +end + +-- returns list of itemstacks to add to loot +local function get_pool_loot(pool_table, loot_context) + if not vl_loot.predicate.check_predicates(pool_table.conditions, loot_context) then return {} end + local loot_stacks = {} + + -- Calculate how many rolls to do + local rolls = get_final_rolls(pool_table, loot_context) + + -- Unpack entries of a compound type into simple entries + local unpacked_entries = {} + for _, entry_provider_table in ipairs(pool_table.entries) do + local new_unpacked_entries = unpack_entry(entry_provider_table, loot_context) + append_table(unpacked_entries, new_unpacked_entries) + end + + for i=1,rolls do + -- The 'total weight' of all entries combined (= 1 probability) + local total_weight = 0 + -- Array of weights of individual entries + local entry_weights = {} + + -- Calculate weights and add to array and total_weight + for _, entry_table in ipairs(unpacked_entries) do + local current_entry_weight = get_final_weight(entry_table, loot_context) + table.insert(entry_weights, current_entry_weight) + total_weight = total_weight + current_entry_weight + end + + -- Get a random value + -- TODO: Make this deterministic per-loot table and world seed + local random_value = math.random(0, total_weight) + + -- Work out which entry was picked + local chosen_entry + for i, entry_weight in ipairs(entry_weights) do + random_value = random_value - entry_weight + -- If the value went <= 0 then this was the chosen entry + if random_value <= 0 then + chosen_entry = unpacked_entries[i] + break + end + end + + minetest.debug(dump(unpacked_entries), dump(entry_weights), total_weight, dump(chosen_entry)) + -- Get loot from the chosen entry + local current_roll_loot = get_entry_loot(chosen_entry, loot_context) + append_table(loot_stacks, current_roll_loot) + + end + modify_stacks(pool_table.functions, loot_stacks, loot_context) + minetest.debug("Pool's loot stacks:", dump(loot_stacks)) + return loot_stacks +end + +local function get_loot(loot_table, loot_context) + local loot_stacks = {} + for _, pool_table in ipairs(loot_table.pools) do + local pool_loot_stacks = get_pool_loot(pool_table, loot_context, loot_stacks) + append_table(loot_stacks, pool_loot_stacks) + end + modify_stacks(loot_table.functions, loot_stacks, loot_context) + return loot_stacks +end + +vl_loot.engine.get_loot = get_loot \ No newline at end of file diff --git a/mods/CORE/vl_loot/init.lua b/mods/CORE/vl_loot/init.lua new file mode 100644 index 000000000..534c71855 --- /dev/null +++ b/mods/CORE/vl_loot/init.lua @@ -0,0 +1,59 @@ +local modpath = minetest.get_modpath(minetest.get_current_modname()) + +vl_loot = { +} + + +dofile(modpath .. "/item_modifier.lua") +dofile(modpath .. "/predicate.lua") +dofile(modpath .. "/number_provider.lua") +dofile(modpath .. "/engine.lua") + +--[[ -- Load resources specified in `expected_resources` from `path` +-- `strict`: whether to error if resources not found +local function load_loot_tables(path, expected_resources, strict) + vl_loot.load_resources_internal(path, expected_resources, vl_loot.loot_tables, strict) +end ]] + + + +-- loot_table: chest loot table +-- pos: position of chest node +-- opener: objectref of entity that opened chest +local function get_chest_loot(loot_table, pos, opener) + -- TODO: Provide better context (there are implied fields) + local context = { + ["this"] = opener, + ["origin"] = pos, + } + return vl_loot.engine.get_loot(loot_table, context) +end + +minetest.register_chatcommand("testloot", { + func = function(name, param) + local player = minetest.get_player_by_name(name) + local pos = player:get_pos() + local loot_table = vl_datapacks.get_resource("loot_table", "vanilla:chests/testloot") + minetest.debug("testloot table:", dump(loot_table)) + local loot = get_chest_loot(loot_table, pos, player) + for _, stack in ipairs(loot) do + minetest.debug(stack:to_string()) + end + local player_inv = player:get_inventory() + local inv_list = player_inv:get_list("main") + for i, itemstack in ipairs(inv_list) do + if itemstack:is_empty() then + inv_list[i] = table.remove(loot, 1) + end + end + player_inv:set_list("main", inv_list) + if #loot > 0 then + minetest.debug("Too much loot for inventory!") + end + return true + end +}) + +--load_loot_tables(default_loot_path, expected_loot_tables, true) + +minetest.debug(dump(vl_loot)) \ No newline at end of file diff --git a/mods/CORE/vl_loot/item_modifier.lua b/mods/CORE/vl_loot/item_modifier.lua new file mode 100644 index 000000000..d7a7443a6 --- /dev/null +++ b/mods/CORE/vl_loot/item_modifier.lua @@ -0,0 +1,80 @@ +-- Item modifiers are lists of item functions +-- [ +-- +-- +-- ] +-- The item functions are applied in order to an itemstack when applying a modifier +vl_loot.modifier = {} + +local function apply_item_function(function_table, itemstack, loot_context) + -- Only apply function if predicates pass + if not vl_loot.predicate.check_predicates(function_table.conditions, loot_context) then + return itemstack + end + + -- TODO: Make this work + -- Item functions: + --[[ + apply_bonus + copy_components + copy_custom_data + copy_name + copy_state + enchant_randomly + enchant_with_levels + exploration_map + explosion_decay + fill_player_head + filtered + furnace_smelt + limit_count + enchanted_count_increase + modify_contents + reference + sequence + set_attributes + set_banner_pattern + set_book_cover + set_components + set_contents + set_count + set_custom_data + set_custom_model_data + set_damage + set_enchantments + set_fireworks + set_firework_explosion + set_instrument + set_item + set_loot_table + set_lore + set_name + set_potion + set_stew_effect + set_writable_book_pages + set_written_book_pages + toggle_tooltips + ]] + if function_table["function"] == "set_count" then + local count = vl_loot.number_provider.evaluate(function_table.count, loot_context) + if function_table.add then + count = count + itemstack:get_count() + end + itemstack:set_count(count) + return itemstack + end + + + +end + +-- Modify an itemstack based on an item modifier +-- Returns the resulting itemstack +local function apply_item_modifier(modifier_table, itemstack, loot_context) + for _, function_table in ipairs(modifier_table) do + itemstack = apply_item_function(function_table, itemstack, loot_context) + end + return itemstack +end + +vl_loot.modifier.apply_item_modifier = apply_item_modifier \ No newline at end of file diff --git a/mods/CORE/vl_loot/mod.conf b/mods/CORE/vl_loot/mod.conf new file mode 100644 index 000000000..597d48c54 --- /dev/null +++ b/mods/CORE/vl_loot/mod.conf @@ -0,0 +1 @@ +name=vl_loot \ No newline at end of file diff --git a/mods/CORE/vl_loot/number_provider.lua b/mods/CORE/vl_loot/number_provider.lua new file mode 100644 index 000000000..29aa961aa --- /dev/null +++ b/mods/CORE/vl_loot/number_provider.lua @@ -0,0 +1,46 @@ +-- Evaluates number providers in loot tables +vl_loot.number_provider = {} + +local function evaluate(number_provider, loot_context) + -- TODO: Support all types + -- TODO: Use deterministic random gen + -- Possible number provider types: + -- [implied constant] + -- [implied uniform] + -- constant: use .value (f) + -- uniform: between .min (f) and .max (f) + -- binomial: .n (f), .p (i) + -- score: TODO + -- storage: TODO + -- enchantment_level: TODO (not used for what you think it is) + + -- implied constant + if type(number_provider) == "number" then + return number_provider + -- otherwise we can assume it is a table + elseif number_provider.type == "constant" then + return number_provider.value + elseif number_provider.type == "uniform" then + return math.random(number_provider.min, number_provider.max) + elseif number_provider.type == "binomial" then + -- Sample binomial distribution + local total_value = 0 + local p = number_provider.p + for i=1,number_provider.n do + if math.random() >= p then + total_value = total_value + 1 + end + end + return total_value + --elseif number_provider.type == "score" then + --elseif number_provider.type == "storage" then + --elseif number_provider.type == "enchantment_level" then + elseif number_provider.min and number_provider.max then + return math.random(number_provider.min, number_provider.max) + else + minetest.log("error", "invalid number provider when calculating loot: ", dump(number_provider)) + return 0 + end +end + +vl_loot.number_provider.evaluate = evaluate \ No newline at end of file diff --git a/mods/CORE/vl_loot/predicate.lua b/mods/CORE/vl_loot/predicate.lua new file mode 100644 index 000000000..138709978 --- /dev/null +++ b/mods/CORE/vl_loot/predicate.lua @@ -0,0 +1,18 @@ +-- Evaluates predicates (conditions) in loot tables +vl_loot.predicate = {} + +local function check_predicate(predicate_table, loot_context) + -- TODO: Make this work + return true +end + +local function check_predicates(predicate_tables, loot_context) + for _, predicate_table in ipairs(predicate_tables) do + if not vl_loot.predicate.check_predicate(predicate_table, loot_context) then + return false + end + end + return true +end + +vl_loot.predicate.check_predicates = check_predicates \ No newline at end of file