From 672479116f417bb7981641d32d11b9afb4b63236 Mon Sep 17 00:00:00 2001 From: Leslie Krause Date: Sun, 15 Mar 2020 13:08:00 -0400 Subject: [PATCH] Build 01 - initial beta version --- README.txt | 65 +++++++ init.lua | 513 +++++++++++++++++++++++++++++++++++++++++++++++++++++ mod.conf | 4 + 3 files changed, 582 insertions(+) create mode 100644 README.txt create mode 100644 init.lua create mode 100644 mod.conf diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..c7b7aa0 --- /dev/null +++ b/README.txt @@ -0,0 +1,65 @@ +Pluggable Helpers Mod v1.0 +By Leslie E. Krause + +Pluggable Helpers provides an API to fully automatate the process of downloading and +installing Lua helper methods, classes, and libraries within Minetest. + +Modularity is extremely important when it comes to maintaining large scale code-bases +such as games and mods in Minetest. Helper classes and methods and even libraries serve +this purpose. But so often they are re-implemented over-and-over again since nobody wants +to rely on external dependencies in their mods and games. Eventually some helpers may be +integrated into the engine, but even that is often a lengthy review process. + +This is where Pluggable Helpers comes into the picture. + + "The core philosophy of Pluggable Helpers is to empower the community to create an + evolving game-development API through the use of a jointly maintained repository of + helper classes, methods, and libraries that can be downloaded and installed on-the-fly + with no intervention required by the end-user." + +Although Pluggable Helpers has no dependencies in and of itself, it does need perform HTTP +requests. Therefore it must be added to the list of "secure_http_mods" in minetest.conf. + + +Repository +---------------------- + +Browse source code... + https://bitbucket.org/sorcerykid/plugins + +Download archive... + https://bitbucket.org/sorcerykid/plugins/get/master.zip + https://bitbucket.org/sorcerykid/plugins/get/master.tar.gz + +Installation +---------------------- + + 1) Unzip the archive into the mods directory of your game + 2) Rename the plugins-master directory to "plugins" + 3) Add "plugins" as a dependency to any mods using the API + +License of source code +---------------------------------------------------------- + +The MIT License (MIT) + +Copyright (c) 2020, Leslie Krause (leslie@searstower.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +For more details: +https://opensource.org/licenses/MIT diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..fe1b9b1 --- /dev/null +++ b/init.lua @@ -0,0 +1,513 @@ +-------------------------------------------------------- +-- Minetest :: Pluggable Helpers Mod (plugins) +-- +-- See README.txt for licensing and other information. +-- Copyright (c) 2020, Leslie E. Krause +-- +-- ./games/minetest_game/mods/plugins/init.lua +-------------------------------------------------------- + +plugins = { } + +local http_req = minetest.request_http_api( ) +local mod_path = minetest.get_modpath( "plugins" ) +local config = { + remote_host = "plugins.mytuner.net", + remote_path = "/source", +} + +assert( http_req ~= nil, "Failed to construct HTTP request object" ) + +local registered_helpers = { } +local downloaded_helpers = { } +local registered_classes = { } +local helper_stats = { registered = 0, downloaded = 0 } + +local globals = { + "next", + "pairs", + "ipairs", + "assert", + "print", + "error", + "dofile", + "loadfile", + "loadstring", + "getmetatable", + "setmetatable", + "pcall", + "rawequal", + "rawget", + "rawset", + "select", + "tonumber", + "tostring", + "type", + "unpack", + "dump", +} + +local license_defs = { + ["AGPLv2"] = true, + ["AGPLv3"] = true, + ["Apache 2.0"] = true, + ["BSD 2-Clause"] = true, + ["BSD 3-Clause"] = true, + ["CC0"] = true, + ["CC BY 3.0"] = true, + ["CC BY 4.0"] = true, + ["CC BY-NC-SA 3.0"] = true, + ["CC BY-SA 3.0"] = true, + ["CC BY-SA 4.0"] = true, + ["EUPLv1.2"] = true, + ["GPLv2"] = true, + ["GPLv3"] = true, + ["ISC"] = true, + ["LGPLv2.1"] = true, + ["LGPLv3"] = true, + ["MIT"] = true, +} + +local _ = { } + +local function is_match( text, glob ) + -- use array for captures + _ = { string.match( text, glob ) } + return #_ > 0 and _ or nil +end + +local function from_version( version ) + return version[ 1 ] .. "." .. version[ 2 ] +end + +local function to_version( val ) + if is_match( val, "^([0-9]+).([0-9]+)([ab]?)$" ) then + return { tonumber( _[ 1 ] ), tonumber( _[ 2 ] ), _[ 3 ] } + end + return nil +end + +local function to_depends( val ) + local res = { } + for _, cur_id in ipairs( val ) do + local data = string.split( cur_id, "/" ) + + table.insert( res, { data[ 1 ], to_version( data[ 2 ] or "1.0" ) } ) + end + return res +end + +local function split_id( id ) + if is_match( id, "^([a-z]+)%.([a-z][a-z0-9_]+)$" ) or is_match( id, "^([A-Z][A-Za-z0-9]+)$" ) then + return _[ 1 ], _[ 2 ] + end + return nil +end + +local function split_extra_id( extra_id ) + local data = type( extra_id ) == "table" and extra_id or string.split( extra_id, "/" ) + + if #data == 1 or #data == 2 then + return data[ 1 ], to_version( data[ 2 ] or "1.0" ) + end + return nil +end + +local function validate_extra_id( id ) + return string.find( id, "^[a-z]+%.[a-z][a-z0-9_]+/[0-9]+%.[0-9]+$" ) ~= nil or string.find( id, "^[A-Z][A-Za-z0-9]+/[0-9]+%.[0-9]+$" ) ~= nil +end + +local function validate_id( id ) + return string.find( id, "^[a-z]+%.[a-z][a-z0-9_]+$" ) ~= nil or string.find( id, "^[A-Z][A-Za-z0-9]+$" ) ~= nil +end + +local function check_version( cur_version, min_version ) + if cur_version[ 1 ] < min_version[ 1 ] or cur_version[ 1 ] == min_version[ 1 ] and cur_version[ 2 ] < min_version[ 2 ] then + return false + end + return true +end + +local function create_sandbox( func, imports ) + local env = { } + + for _, name in ipairs( globals ) do + env[ name ] = _G[ name ] + end + + for _, name in ipairs( imports ) do + local class, method = split_id( name ) + + if not class or not registered_classes[ class ] or method and not registered_classes[ class ][ method ] then + error( "create_sandbox(): Attempt to import unknown class or method" ) + elseif method then + env[ method ] = registered_classes[ class ][ method ] + else + env[ class ] = registered_classes[ class ] + end + end + + setfenv( func, env ) + setmetatable( env, { __index = registered_classes } ) +end + +local function convert_record( def ) + if not def or def == "" then + minetest.log( "error", "No helper definition found, aborting" ) + return nil + end + + local record = { } + + setfenv( def, record ) + local status, func = pcall( def ) + + if type( func ) ~= "function" then + minetest.log( "error", "Missing function in helper definition, aborting" ) + return nil + elseif record.prototype ~= nil and type( record.prototype ) ~= "table" then + minetest.log( "error", "Invalid prototype in helper definition, aborting" ) + return nil + elseif type( record.version ) ~= "string" or not string.find( record.version, "^[0-9]+%.[0-9]+$" ) then + minetest.log( "error", "Invalid or missing 'version' field in helper definition, aborting" ) + return nil + elseif type( record.author ) ~= "string" or not string.find( record.author, "^[a-zA-Z0-9_-]+$" ) then + minetest.log( "error", "Invalid or missing 'author' field in helper definition, aborting" ) + return nil + elseif type( record.license ) ~= "string" or not license_defs[ record.license ] then + minetest.log( "error", "Invalid or missing 'license' field in helper definition, aborting" ) + return nil + elseif type( record.depends ) ~= "table" then + minetest.log( "error", "Missing 'depends' field in helper definition, aborting" ) + return nil + elseif type( record.imports ) ~= "table" then + minetest.log( "error", "Missing 'imports' field in helper definition, aborting" ) + return nil + end + + for _, cur_id in ipairs( record.depends ) do + if not validate_id( cur_id ) and not validate_extra_id( cur_id ) then + minetest.log( "error", "Invalid 'depends' field in helper definition, aborting" ) + return nil + end + end + + for _, cur_id in ipairs( record.imports ) do + if not validate_id( cur_id ) then + minetest.log( "error", "Invalid 'imports' field in helper definition, aborting" ) + return nil + end + end + + return { + id = record.id, + author = record.author, + license = record.license, + version = to_version( record.version ), + depends = to_depends( record.depends ), + imports = record.imports, + is_required = false, + func = func, + this = record.prototype, + } +end + +local function load_repository( ) + minetest.log( "action", "Loading helper definitions from local repository" ) + + for _, id in ipairs( minetest.get_dir_list( mod_path .. "/source", false ) ) do + local record = convert_record( loadfile( mod_path .. "/source/" .. id ) ) + + if not record then + error( "load_repository(): Malformed helper definition" ) + end + + local class, method = split_id( id ) + + if not class then + error( "load_repository(): Malformed helper ID" ) + elseif method and ( not registered_classes[ class ] or type( registered_classes[ class ] ) ~= "table" ) then + error( "load_repository(): Unrecognized helper class" ) + end + + registered_helpers[ id ] = record + helper_stats.registered = helper_stats.registered + 1 + end +end + +local function simple_http_request( uri, timeout ) + local status = http_req.fetch_async( { url = uri, timeout = timeout, user_agent = "Pluggable Helpers/1.0" } ) + local result + + -- sleep until request completed + while true do + local t = os.clock( ) + while os.clock( ) - t <= 0.1 do end + + local result = http_req.fetch_async_get( status ) + + if result.completed then + return result + end + end +end + +local function request( id, version ) + minetest.log( "action", "Requesting helper '" .. id .. "' from remote repository '" .. config.remote_host .. "'" ) + + local results = simple_http_request( string.format( "http://%s/%s", config.remote_host .. config.remote_path, id ), 2.0 ) + + if results.timeout then + minetest.log( "error", "Failed to request helper '" .. id .. "', aborting" ) + error( "request(): Connection timed out." ) + + elseif not results.succeeded then + minetest.log( "error", "Failed to request helper '" .. id .. "', aborting" ) + if results.code == 404 then + error( "request(): Resource not found (status 404)" ) + elseif results.code == 403 then + error( "request(): Permission denied (status 403)" ) + elseif results.code == 418 then + error( "request(): I'm a teapot, short and stout (status 418)" ) + elseif results.code == 500 then + error( "request(): Internal server error (status 500)" ) + elseif results.code == 504 then + error( "request(): Gateway timed out (status 504)" ) + else + error( "request(): Unhandled exception" ) + end + end + + local record = convert_record( loadstring( results.data ) ) + + if not record then + minetest.log( "error", "Failed to request helper '" .. id .. "', aborting" ) + error( "request(): Malformed helper definition, '" .. results.data .. "'" ) + elseif not check_version( record.version, version ) then + minetest.log( "error", "Failed to request helper '" .. id .. "', aborting" ) + error( "request(): Insufficient helper version, '" .. from_version( record.version ) .. "'" ) + end + + if #record.depends > 0 then + minetest.log( "action", "Resolving dependencies for helper '" .. id .. "'" ) + end + + -- recursively request dependencies + for idx, elem in ipairs( record.depends ) do + local cur_id = elem[ 1 ] + local cur_version = elem[ 2 ] + + -- sanity check for dependency loop + if cur_id == id or downloaded_helpers[ cur_id ] then + minetest.log( "error", "Failed to request helper '" .. id .. "', aborting" ) + error( "request(): Unexpected dependency loop" ) + end + + -- if helper is not registered or helper is inadequate version, then download + if not registered_helpers[ cur_id ] or not check_version( registered_helpers[ cur_id ].version, cur_version ) then + request( cur_id, cur_version ) + end + end + + registered_helpers[ id ] = record + downloaded_helpers[ id ] = results.data + helper_stats.downloaded = helper_stats.downloaded + 1 + + return record +end + +local function install_from_queue( ) + for id, source in pairs( downloaded_helpers ) do + local helper = registered_helpers[ id ] + local date_spec = os.date( "%c" ) + local host_spec = config.remote_host + local path_spec = config.remote_path + + minetest.log( "action", "Installing required helper '" .. id .. "' to local repository" ) + + local file = io.open( mod_path .. "/source/" .. id, "w" ) + if not file then + minetest.log( "error", "Failed to install helper '" .. id .. "', aborting" ) + error( "install_from_queue(): Unable to write repository" ) + end + + file:write( "----------------------------------------------------\n" ) + file:write( "-- Installed on " .. date_spec .. "\n" ) + file:write( "-- \n" ) + file:write( "-- http://" .. host_spec .. path_spec .. "/" .. id .. "\n" ) + file:write( "----------------------------------------------------\n\n" ) + file:write( source ) + file:close( ) + end + + downloaded_helpers = { } +end + +local function uninstall_orphans( ) + -- uninstall orphaned helpers automatically + for id, helper in pairs( registered_helpers ) do + if not helper.is_required then + minetest.log( "action", "Uninstalling orphaned helper '" .. id .. "' from local repository" ) + + if not os.remove( mod_path .. "/source/" .. id ) then + minetest.log( "error", "Failed to uninstall helper '" .. id .. "', aborting" ) + error( "uninstall_orphans(): Unable to write repository" ) + end + end + end +end + +local function require( helper, class, method ) + if helper.is_required then + return helper.this or helper.func -- it's already been required, so skip processing + end + + helper.is_required = true + + -- recursively require dependencies + for _, elem in ipairs( helper.depends ) do + local cur_id = elem[ 1 ] + local cur_helper = registered_helpers[ cur_id ] + local cur_class, cur_method = split_id( cur_id ) + + if not cur_helper or not cur_class then + minetest.log( "error", "Failed to require helper '" .. cur_id .. "', aborting" ) + error( "require(): Missing dependency" ) + end + + require( cur_helper, cur_class, cur_method ) + end + + -- prepare sandbox with globals and imports + create_sandbox( helper.func, helper.imports ) + + if method then + _G[ class ][ method ] = helper.func + elseif not helper.this then + _G[ class ] = helper.func + registered_classes[ class ] = helper.func -- add class to sandbox + else + helper.func( helper.this ) + end + + return helper.this or helper.func +end + +-------------------- +-- Public Methods -- +-------------------- + +plugins.include = function ( extra_id ) + local id, version = split_extra_id( extra_id ) + + assert( id, "plugins.include(): Malformed helper ID" ) + + local class, method = split_id( id ) + local helper = registered_helpers[ id ] + + assert( class, "plugins.include(): Malformed helper ID" ) + assert( not method, "plugins.include()" ) + + if helper then + if not check_version( helper.version, version ) then + minetest.log( "warning", "Required helper '" .. id .. "' found, but insufficient version" ) + helper = request( id, version ) + install_from_queue( ) + return require( helper, class ) + else + return require( helper, class ) + end + else + minetest.log( "warning", "Required helper '" .. id .. "' not found" ) + helper = request( id, version ) + install_from_queue( ) + return require( helper, class ) + end +end + +plugins.require = function ( extra_id ) + local id, version = split_extra_id( extra_id ) + + assert( id, "plugins.require(): Malformed helper ID" ) + + local class, method = split_id( id ) + local helper = registered_helpers[ id ] + + assert( class, "plugins.require(): Malformed helper ID" ) + assert( not method or registered_classes[ class ], "plugins.require(): Unrecognized helper class" ) + + if helper then + if not check_version( helper.version, version ) then + minetest.log( "warning", "Required helper '" .. id .. "' found, but insufficient version" ) + helper = request( id, version ) + install_from_queue( ) + return require( helper, class, method ) + else + return require( helper, class, method ) + end + elseif class and method then + if not _G[ class ][ method ] then + minetest.log( "warning", "Required helper '" .. id .. "' not found" ) + helper = request( id, version ) + install_from_queue( ) + return require( helper, class, method ) + end + elseif class then + if not _G[ class ] then + minetest.log( "warning", "Required helper '" .. id .. "' not found" ) + helper = request( id, version ) + install_from_queue( ) + return require( helper, class ) + end + end +end + +plugins.register_class = function ( name, ref ) + if type( ref ) == "function" and not string.find( name, "^[A-Z][A-Za-z0-9]+$" ) or type( ref ) == "table" and not string.find( name, "^[a-z0-9_]+$" ) then + error( "register_class: Improperly formatted class name, '" .. name .. "'" ) + elseif not type( ref ) == "function" and not type( ref ) == "table" then + error( "register_class: Unsupported class type, " .. type( ref ) ) + end + registered_classes[ name ] = ref +end + +plugins.register_class( "minetest", minetest ) +plugins.register_class( "string", string ) +plugins.register_class( "math", math ) +plugins.register_class( "table", table ) +plugins.register_class( "os", table ) +plugins.register_class( "is", table ) +plugins.register_class( "debug", debug ) +plugins.register_class( "PerlinNoise", PerlinNoise ) +plugins.register_class( "PerlinNoiseMap", PerlinNoiseMap ) +plugins.register_class( "VoxelManip", VoxelManip ) +plugins.register_class( "VoxelArea", VoxelArea.new ) + +load_repository( ) + +minetest.after( 0.0, function ( ) + uninstall_orphans( ) + + plugins.require = function ( ) + error( "plugins.require(): Delayed invocation not permitted, aborting" ) + end + plugins.include = function ( ) + error( "plugins.include(): Delayed invocation not permitted, aborting" ) + end +end ) + +------------------------------ +-- Registered Chat Commands -- +------------------------------ + +minetest.register_chatcommand( "plugins", { + description = "List all plugins installed in local registry", + privs = { server = true }, + func = function( player_name, param ) + local res = "" + +-- for i, v in pairs( registered_helpers ) +-- table.insert( res, v.id ) +-- end + end +} ) diff --git a/mod.conf b/mod.conf new file mode 100644 index 0000000..28f0d97 --- /dev/null +++ b/mod.conf @@ -0,0 +1,4 @@ +name = plugins +title = Pluggable Helpers +author = sorcerykid +license = MIT