- initial beta version
This commit is contained in:
Leslie Krause 2020-03-15 13:08:00 -04:00
commit 672479116f
3 changed files with 582 additions and 0 deletions

65
README.txt Normal file
View File

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

513
init.lua Normal file
View File

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

4
mod.conf Normal file
View File

@ -0,0 +1,4 @@
name = plugins
title = Pluggable Helpers
author = sorcerykid
license = MIT