From 0b757d3863e26e1bfbfee3c3c1421b8c13a94e65 Mon Sep 17 00:00:00 2001 From: Maruno17 Date: Sun, 25 Apr 2021 19:09:56 +0100 Subject: [PATCH] Added extension to Plugin Manager that supports scripts in a Plugin folder --- .../001_Technical/002_Files/001_FileTests.rb | 60 +++ .../001_Technical/002_RubyUtilities.rb | 7 + .../001_Technical/005_PluginManager.rb | 407 +++++++++++++++--- Data/Scripts/999_Main/999_Main.rb | 1 + 4 files changed, 415 insertions(+), 60 deletions(-) diff --git a/Data/Scripts/001_Technical/002_Files/001_FileTests.rb b/Data/Scripts/001_Technical/002_Files/001_FileTests.rb index db652e43a..ff0b7a796 100644 --- a/Data/Scripts/001_Technical/002_Files/001_FileTests.rb +++ b/Data/Scripts/001_Technical/002_Files/001_FileTests.rb @@ -1,3 +1,63 @@ +#=============================================================================== +# Reads files of certain format from a directory +#=============================================================================== +class Dir + #----------------------------------------------------------------------------- + # Reads all files in a directory + #----------------------------------------------------------------------------- + def self.get(dir, filters = "*", full = true) + files = [] + filters = [filters] if !filters.is_a?(Array) + self.chdir(dir) do + for filter in filters + self.glob(filter){ |f| files.push(full ? (dir + "/" + f) : f) } + end + end + return files.sort + end + #----------------------------------------------------------------------------- + # Generates entire file/folder tree from a certain directory + #----------------------------------------------------------------------------- + def self.all(dir, filters = "*", full = true) + # sets variables for starting + files = [] + for file in self.get(dir, filters, full) + # engages in recursion to read the entire file tree + files += self.safe?(file) ? self.get(file, filters, full) : [file] + end + # returns all found files + return files + end + #----------------------------------------------------------------------------- + # Checks for existing directory, gets around accents + #----------------------------------------------------------------------------- + def self.safe?(dir) + ret = false + self.chdir(dir) { ret = true } rescue nil + return ret + end + #----------------------------------------------------------------------------- +end + + + +#=============================================================================== +# extensions for file class +#=============================================================================== +class File + #----------------------------------------------------------------------------- + # Checks for existing file, gets around accents + #----------------------------------------------------------------------------- + def self.safe?(file) + ret = false + self.open(file, 'rb') { ret = true } rescue nil + return ret + end + #----------------------------------------------------------------------------- +end + + + #=============================================================================== # Checking for files and directories #=============================================================================== diff --git a/Data/Scripts/001_Technical/002_RubyUtilities.rb b/Data/Scripts/001_Technical/002_RubyUtilities.rb index c7f9bdca4..a6d567e82 100644 --- a/Data/Scripts/001_Technical/002_RubyUtilities.rb +++ b/Data/Scripts/001_Technical/002_RubyUtilities.rb @@ -76,6 +76,13 @@ class Array def ^(other) # xor of two arrays return (self|other) - (self&other) end + + def swap(val1, val2) + index1 = self.index(val1) + index2 = self.index(val2) + self[index1] = val2 + self[index2] = val1 + end end #=============================================================================== diff --git a/Data/Scripts/001_Technical/005_PluginManager.rb b/Data/Scripts/001_Technical/005_PluginManager.rb index db05ed46b..0c3aa8fcc 100644 --- a/Data/Scripts/001_Technical/005_PluginManager.rb +++ b/Data/Scripts/001_Technical/005_PluginManager.rb @@ -1,9 +1,14 @@ #==============================================================================# # Plugin Manager # -# by Marin # +# by Marin # +# support for external plugin scripts by Luka S.J. # +# tweaked by Maruno # #------------------------------------------------------------------------------# # Provides a simple interface that allows plugins to require dependencies # # at specific versions, and to specify incompatibilities between plugins. # +# # +# Supports external scripts that are in .rb files in folders within the # +# Plugins folder. # #------------------------------------------------------------------------------# # Usage: # # # @@ -21,14 +26,23 @@ # the error message if the PluginManager detects that this plugin needs to be # # updated. # # # -# A plugin's version is typically in the format X.Y.Z, but the number of # -# digits does not matter. You can also use Xa, Xb, Xc, Ya, etc. # +# A plugin's version should be in the format X.Y.Z, but the number of digits # +# you use does not matter. You can also use Xa, Xb, Xc, Ya, etc. # # What matters is that you use it consistently, so that it can be compared. # # # +# IF there are multiple people to credit, their names should be in an array. # +# If there is only one credit, it does not need an array: # +# # +# :credits => "Marin" # +# :credits => ["Marin", "Maruno"], # # # # # -# Now let's say we create a new plugin titled "Simple Extension", which # -# requires our previously created "Basic Plugin" to work. # +# # +# Dependency: # +# # +# A plugin can require another plugin to be installed in order to work. For # +# example, the "Simple Extension" plugin depends on the above "Basic Plugin" # +# like so: # # # # PluginManager.register({ # # :name => "Simple Extension", # @@ -38,48 +52,45 @@ # :dependencies => ["Basic Plugin"] # # }) # # # -# This plugin has two credits as an array, instead of one string. Furthermore, # -# this code will ensure that "Basic Plugin" is installed, ignoring its # -# version. If you have only one dependency, you can omit the array brackets # -# like so: # +# If there are multiple dependencies, they should be listed in an array. If # +# there is only one dependency, it does not need an array: # # # # :dependencies => "Basic Plugin" # # # -# # -# # # To require a minimum version of a dependency plugin, you should turn the # # dependency's name into an array which contains the name and the version # # (both as strings). For example, to require "Basic Plugin" version 1.2 or # # higher, you would write: # # # -# PluginManager.register({ # -# :name => "Simple Extension", # -# :version => "1.0", # -# :link => "https://reliccastle.com/link-to-the-plugin/", # -# :credits => "Marin", # -# :dependencies => [ # -# ["Basic Plugin", "1.2"] # -# ] # -# }) # -# # -# # +# :dependencies => [ # +# ["Basic Plugin", "1.2"] # +# ] # # # # To require a specific version (no higher and no lower) of a dependency # # plugin, you should add the :exact flag as the first thing in the array for # # that dependency: # # # -# PluginManager.register({ # -# :name => "Simple Extension", # -# :version => "1.0", # -# :link => "https://reliccastle.com/link-to-the-plugin/", # -# :credits => "Marin", # -# :dependencies => [ # -# [:exact, "Basic Plugin", "1.2"] # -# ] # -# }) # +# :dependencies => [ # +# [:exact, "Basic Plugin", "1.2"] # +# ] # +# # +# If your plugin can work without another plugin, but it is incompatible with # +# an old version of that other plugin, you should list it as an optional # +# dependency. If that other plugin is present in a game, then this optional # +# dependency will check whether it meets the minimum version required for your # +# plugin. Write it in the same way as any other dependency as described above, # +# but use the :optional flag instead. # +# # +# :dependencies => [ # +# [:optional, "QoL Improvements", "1.1"] # +# ] # +# # +# The :optional_exact flag is a combination of :optional and :exact. # # # # # # # +# Incompatibility: # +# # # If your plugin is known to be incompatible with another plugin, you should # # list that other plugin as such. Only one of the two plugins needs to list # # that it is incompatible with the other. # @@ -94,39 +105,70 @@ # ] # # }) # # # +#------------------------------------------------------------------------------# +# Plugin folder: # # # +# The Plugin folder is treated like the PBS folder, but for script files for # +# plugins. Each plugin has its own folder within the Plugin folder. Each # +# plugin must have a meta.txt file in its folder, which contains information # +# about that plugin. Folders without this meta.txt file are ignored. # # # -# If your plugin can work without another plugin, but is known to be # -# incompatible with an old version of that other plugin, you should list it as # -# an optional dependency. If that other plugin is present in a game, then this # -# optional dependency will ensure it meets the minimum version required for # -# your plugin. Write it in the same way as any other dependency as described # -# above, but use the :optional flag instead. # -# You do not need to list a plugin as an optional dependency at all if all # -# versions of that other plugin are compatible with your plugin. # +# Scripts must be in .rb files. You should not put any other files into a # +# plugin's folder except for script files and meta.txt. # # # -# PluginManager.register({ # -# :name => "Other Plugin", # -# :version => "1.0", # -# :link => "https://reliccastle.com/link-to-the-plugin/", # -# :credits => "Marin", # -# :dependencies => [ # -# [:optional, "QoL Improvements", "1.1"] # -# ] # -# }) # +# When the game is compiled, scripts in these folders are read and converted # +# into a usable format, and saved in the file Data/PluginScripts.rxdata. # +# Script files are loaded in order of their name and subfolder, so it is wise # +# to name script files "001_first script.rb", "002_second script.rb", etc. to # +# ensure they are loaded in the correct order. # +# # +# When the game is compressed for distribution, the Plugin folder and all its # +# contents should be deleted (like the PBS folder), because its contents will # +# be unused (they will have been compiled into the PluginScripts.rxdata file). # +# # +# The contents of meta.txt are as follows: # +# # +# Name = Simple Extension # +# Version = 1.0 # +# Requires = Basic Plugin # +# Requires = Useful Utilities,1.1 # +# Conflicts = Complex Extension # +# Conflicts = Extended Windows # +# Link = https://reliccastle.com/link-to-the-plugin/ # +# Credits = Luka S.J.,Maruno,Marin # +# # +# These lines are related to what is described above. You can have multiple # +# "Requires" and "Conflicts" lines, each listing a single other plugin that is # +# either a dependency or a conflict respectively. # +# # +# Examples of the "Requires" line: # +# # +# Requires = Basic Plugin # +# Requires = Basic Plugin,1.1 # +# Requires = Basic Plugin,1.1,exact # +# Requires = Basic Plugin,1.1,optional # +# Exact = Basic Plugin,1.1 # +# Optional = Basic Plugin,1.1 # +# # +# The "Exact" and "Optional" lines are equivalent to the "Requires" lines # +# that contain those keywords. # +# # +# There is also a "Scripts" line, which lists one or more script files that # +# should be loaded first. You can have multiple "Scripts" lines. However, you # +# can achieve the same effect by simply naming your script files in # +# alphanumeric order to make them load in a particular order, so the "Scripts" # +# line should not be necessary. # # # -# The :optional_exact flag is a combination of :optional and :exact. # #------------------------------------------------------------------------------# # Please give credit when using this. # #==============================================================================# module PluginManager - # Win32API MessageBox function for custom errors. - # MBOX = Win32API.new('user32', 'MessageBox', ['I','P','P','I'], 'I') # Holds all registered plugin data. @@Plugins = {} - + #----------------------------------------------------------------------------- # Registers a plugin and tests its dependencies and incompatibilities. + #----------------------------------------------------------------------------- def self.register(options) name = nil version = nil @@ -302,12 +344,12 @@ module PluginManager :credits => credits } end - + #----------------------------------------------------------------------------- # Throws a pure error message without stack trace or any other useless info. + #----------------------------------------------------------------------------- def self.error(msg) Graphics.update t = Thread.new do - #MBOX.call(Win32API.pbFindRgssWindow, msg, "Plugin Error", 0x10) p "Plugin Error:\n#{msg}" Thread.exit end @@ -316,10 +358,11 @@ module PluginManager end Kernel.exit! true end - + #----------------------------------------------------------------------------- # Returns true if the specified plugin is installed. # If the version is specified, this version is taken into account. # If mustequal is true, the version must be a match with the specified version. + #----------------------------------------------------------------------------- def self.installed?(plugin_name, plugin_version = nil, mustequal = false) plugin = @@Plugins[plugin_name] return false if plugin.nil? @@ -328,36 +371,41 @@ module PluginManager return true if !mustequal && comparison >= 0 return true if mustequal && comparison == 0 end - + #----------------------------------------------------------------------------- # Returns the string names of all installed plugins. + #----------------------------------------------------------------------------- def self.plugins return @@Plugins.keys end - + #----------------------------------------------------------------------------- # Returns the installed version of the specified plugin. + #----------------------------------------------------------------------------- def self.version(plugin_name) return if !installed?(plugin_name) return @@Plugins[plugin_name][:version] end - + #----------------------------------------------------------------------------- # Returns the link of the specified plugin. + #----------------------------------------------------------------------------- def self.link(plugin_name) return if !installed?(plugin_name) return @@Plugins[plugin_name][:link] end - + #----------------------------------------------------------------------------- # Returns the credits of the specified plugin. + #----------------------------------------------------------------------------- def self.credits(plugin_name) return if !installed?(plugin_name) return @@Plugins[plugin_name][:credits] end - + #----------------------------------------------------------------------------- # Compares two versions given in string form. v1 should be the plugin version # you actually have, and v2 should be the minimum/desired plugin version. # Return values: # 1 if v1 is higher than v2 # 0 if v1 is equal to v2 # -1 if v1 is lower than v2 + #----------------------------------------------------------------------------- def self.compare_versions(v1, v2) d1 = v1.split("") d1.insert(0, "0") if d1[0] == "." # Turn ".123" into "0.123" @@ -378,4 +426,243 @@ module PluginManager end return 0 end + #----------------------------------------------------------------------------- + # Used to read the metadata file + #----------------------------------------------------------------------------- + def self.readMeta(dir, file) + filename = "#{dir}/#{file}" + meta = {} + # read file + Compiler.pbCompilerEachPreppedLine(filename) { |line, line_no| + # split line up into property name and values + if !line[/^\s*(\w+)\s*=\s*(.*)$/] + raise _INTL("Bad line syntax (expected syntax like XXX=YYY)\r\n{1}", FileLineData.linereport) + end + property = $~[1].upcase + data = $~[2].split(',') + data.each_with_index { |value, i| data[i] = value.strip } + # begin formatting data hash + case property + when 'REQUIRES' + meta[:dependencies] = [] if !meta[:dependencies] + if data.length < 2 # No version given, just push name of plugin dependency + meta[:dependencies].push(data[0]) + next + elsif data.length == 2 # Push name and version of plugin dependency + meta[:dependencies].push([data[0], data[1]]) + else # Push dependency type, name and version of plugin dependency + meta[:dependencies].push([data[2].downcase.to_sym, data[0], data[1]]) + end + when "EXACT" + next if data.length < 2 # Exact dependencies must have a version given; ignore if not + meta[:dependencies] = [] if !meta[:dependencies] + meta[:dependencies].push([:exact, data[0], data[1]]) + when "OPTIONAL" + next if data.length < 2 # Optional dependencies must have a version given; ignore if not + meta[:dependencies] = [] if !meta[:dependencies] + meta[:dependencies].push([:optional, data[0], data[1]]) + when 'CONFLICTS' + meta[:incompatibilities] = [] if !meta[:incompatibilities] + data.each { |value| meta[:incompatibilities].push(value) if value && !value.empty? } + when "SCRIPTS" + meta[:scripts] = [] if !meta[:scripts] + data.each { |scr| meta[:scripts].push(scr) } + when "CREDITS" + meta[:credits] = data + when "LINK", "WEBSITE" + meta[:link] = data[0] + else + meta[property.downcase.to_sym] = data[0] + end + } + # generate a list of all script files to be loaded, in the order they are to + # be loaded (files listed in the meta file are loaded first) + meta[:scripts] = [] if !meta[:scripts] + # get all script files from plugin Dir + for fl in Dir.all(dir) + next if !File.extname(fl).include?(".rb") + meta[:scripts].push(fl.gsub("#{dir}/", "")) + end + # ensure no duplicate script files are queued + meta[:scripts].uniq! + # return meta hash + return meta + end + #----------------------------------------------------------------------------- + # Get a list of all the plugin directories to inspect + #----------------------------------------------------------------------------- + def self.listAll + return [] if !$DEBUG || safeExists?("Game.rgssad") + # get a list of all directories in the `Plugins/` folder + dirs = [] + Dir.get("Plugins").each { |d| dirs.push(d) if Dir.safe?(d) } + # return all plugins + return dirs + end + #----------------------------------------------------------------------------- + # Catch any potential loop with dependencies and raise an error + #----------------------------------------------------------------------------- + def self.validateDependencies(name, meta, og = nil) + # exit if no registered dependency + return nil if !meta[name] || !meta[name][:dependencies] + # go through all dependencies + for dname in meta[name][:dependencies] + # clean the name to a simple string + dname = dname[0] if dname.is_a?(Array) && dname.length == 2 + dname = dname[1] if dname.is_a?(Array) && dname.length == 3 + # catch looping dependecy issue + self.error("Plugin '#{og}' has looping dependencies which cannot be resolved automatically.") if !og.nil? && og == dname + self.validateDependencies(dname, meta, name) + end + return name + end + #----------------------------------------------------------------------------- + # Sort load order based on dependencies (this ends up in reverse order) + #----------------------------------------------------------------------------- + def self.sortLoadOrder(order, plugins) + # go through the load order + for o in order + next if !plugins[o] || !plugins[o][:dependencies] + # go through all dependencies + for dname in plugins[o][:dependencies] + # clean the name to a simple string + dname = dname[0] if dname.is_a?(Array) && dname.length == 2 + dname = dname[1] if dname.is_a?(Array) && dname.length == 3 + # skip if already sorted + next if order.index(dname) > order.index(o) + # catch looping dependency issue + order.swap(o, dname) + order = self.sortLoadOrder(order, plugins) + end + end + return order + end + #----------------------------------------------------------------------------- + # Get the order in which to load plugins + #----------------------------------------------------------------------------- + def self.getPluginOrder + plugins = {} + order = [] + # Find all plugin folders that have a meta.txt and add them to the list of + # plugins. + for dir in self.listAll + # skip if there is no meta file + next if !safeExists?(dir + "/meta.txt") + ndx = order.length + meta = self.readMeta(dir, "meta.txt") + meta[:dir] = dir + # raise error if no name defined for plugin + self.error("No 'Name' metadata defined for plugin located at '#{dir}'.") if !meta[:name] + # raise error if no script defined for plugin + self.error("No 'Scripts' metadata defined for plugin located at '#{dir}'.") if !meta[:scripts] + plugins[meta[:name]] = meta + # raise error if a plugin with the same name already exists + self.error("A plugin called '#{meta[:name]}' already exists in the load order.") if order.include?(meta[:name]) + order.insert(ndx, meta[:name]) + end + # validate all dependencies + order.each { |o| self.validateDependencies(o, plugins) } + # sort the load order + return self.sortLoadOrder(order, plugins).reverse, plugins + end + #----------------------------------------------------------------------------- + # Check if plugins need compiling + #----------------------------------------------------------------------------- + def self.needCompiling?(order, plugins) + # fixed actions + return false if !$DEBUG || safeExists?("Game.rgssad") + return true if !safeExists?("Data/PluginScripts.rxdata") + return true if Input.press?(Input::CTRL) + ret = false + # analyze whether or not to push recompile + mtime = File.mtime("Data/PluginScripts.rxdata") + for o in order + # go through all the registered plugin scripts + scr = plugins[o][:scripts] + dir = plugins[o][:dir] + for sc in scr + ret = true if File.mtime("#{dir}/#{sc}") > mtime + end + ret = true if File.mtime("#{dir}/meta.txt") > mtime + end + # return result + return ret + end + #----------------------------------------------------------------------------- + # Check if plugins need compiling + #----------------------------------------------------------------------------- + def self.compilePlugins(order, plugins) + scripts = [] + # go through the entire order one by one + for o in order + # save name, metadata and scripts array + meta = plugins[o].clone + meta.delete(:scripts) + meta.delete(:dir) + dat = [o, meta, []] + # iterate through each file to deflate + for file in plugins[o][:scripts] + File.open("#{plugins[o][:dir]}/#{file}", 'rb') { |f| dat[2].push(Zlib::Deflate.deflate(f.read)) } + end + # push to the main scripts array + scripts.push(dat) + end + # save to main `PluginScripts.rxdata` file + File.open("Data/PluginScripts.rxdata", 'wb') { |f| Marshal.dump(scripts, f) } + # collect garbage + GC.start + end + #----------------------------------------------------------------------------- + # Check if plugins need compiling + #----------------------------------------------------------------------------- + def self.runPlugins + # get the order of plugins to interpret + order, plugins = self.getPluginOrder + # compile if necessary + self.compilePlugins(order, plugins) if self.needCompiling?(order, plugins) + # run the plugins from compiled archive + # load plugins + scripts = load_data("Data/PluginScripts.rxdata") + for plugin in scripts + # get the required data + name, meta, script = plugin + # register plugin + self.register(meta) + # go through each script and interpret + for scr in script + # turn code into plaintext + code = Zlib::Inflate.inflate(scr) + # get rid of tabs + code.gsub!("\t", " ") + # construct filename + fname = "[Plugin] " + name + # try to run the code + begin + eval(code, TOPLEVEL_BINDING, fname) + rescue Exception # format error message to display + msg = "[Pokémon Essentials v#{Essentials::VERSION}] #{Essentials::ERROR_TEXT}\r\n\r\n" + msg += "#{$raise_msg}\r\n-------------------------------\r\n" if $raise_msg + msg += "Error in Plugin [#{name}]:\r\n" + msg += "#{$!.class} occurred.\r\n" + for line in $!.message.split("\r\n") + next if !line || line == "" + n = line[/\d+/] + err = line.split(":")[-1].strip + lms = line.split(":")[0].strip + err.gsub!(n, "") if n + err = err.capitalize if err.is_a?(String) && !err.empty? + linum = n ? "Line #{n}: " : "" + msg += "#{linum}#{err}: #{lms}\r\n" + end + msg += "\r\nFull trace can be found below:\r\n" + for bck in $!.backtrace + msg += "#{bck}\r\n" + end + msg += "\r\nEnd of Error." + $raise_msg = nil + raise msg + end + end + end + end end diff --git a/Data/Scripts/999_Main/999_Main.rb b/Data/Scripts/999_Main/999_Main.rb index 3ba30a299..516de92ea 100644 --- a/Data/Scripts/999_Main/999_Main.rb +++ b/Data/Scripts/999_Main/999_Main.rb @@ -24,6 +24,7 @@ end def mainFunctionDebug begin + PluginManager.runPlugins Compiler.main Game.initialize Game.set_up_system