Added datapacks and loot table functionality

This commit is contained in:
WillConker 2024-07-10 12:40:42 +01:00
parent 026ea5940c
commit ed1bb20e24
9 changed files with 593 additions and 0 deletions

View File

@ -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"
}
]
}
]
}

View File

@ -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`:
{
<registry name>: {
<namespace>: {
<path string>: resource,
}
<namespace>: {
<path string>: resource,
}
},
<registry name>: {
<namespace>: {
<path string>: resource,
}
<namespace>: {
<path string>: 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)

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -0,0 +1,80 @@
-- Item modifiers are lists of item functions
-- [
-- <item function 1>
-- <item function 2>
-- ]
-- 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

View File

@ -0,0 +1 @@
name=vl_loot

View File

@ -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

View File

@ -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