From 3e2622fbb781b40b95afc493db3a36ee3d5e37d8 Mon Sep 17 00:00:00 2001 From: ORelio Date: Sun, 23 Aug 2015 18:51:24 +0200 Subject: [PATCH] Various C# Script improvements Move handling code in a separate file Add caching ability for low-power devices (rpi..) Use a distinct API with MCC.MethodName() Stop script execution only on specific API calls --- MinecraftClient/CSharpRunner.cs | 372 ++++++++++++++++++ MinecraftClient/ChatBot.cs | 62 +-- MinecraftClient/ChatBots/Script.cs | 96 +---- MinecraftClient/MinecraftClient.csproj | 1 + MinecraftClient/Settings.cs | 11 +- .../config/sample-script-extended.cs | 12 +- .../config/sample-script-with-chatbot.cs | 2 +- MinecraftClient/config/sample-script.cs | 10 +- 8 files changed, 407 insertions(+), 159 deletions(-) create mode 100644 MinecraftClient/CSharpRunner.cs diff --git a/MinecraftClient/CSharpRunner.cs b/MinecraftClient/CSharpRunner.cs new file mode 100644 index 00000000..46add112 --- /dev/null +++ b/MinecraftClient/CSharpRunner.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.IO; +using Microsoft.CSharp; +using System.CodeDom.Compiler; +using System.Reflection; +using System.Threading; + +namespace MinecraftClient +{ + /// + /// C# Script runner - Compile on-the-fly and run C# scripts + /// + class CSharpRunner + { + private static readonly Dictionary CompileCache = new Dictionary(); + + /// + /// Run the specified C# script file + /// + /// ChatBot handler for accessing ChatBot API + /// Tick handler for waiting after some API calls + /// Lines of the script file to run + /// Arguments to pass to the script + /// Set to false to compile and cache the script without launching it + /// Thrown if an error occured + /// Result of the execution, returned by the script + public static object Run(ChatBot apiHandler, ManualResetEvent tickHandler, string[] lines, string[] args, bool run = true) + { + //Script compatibility check for handling future versions differently + if (lines.Length < 1 || lines[0] != "//MCCScript 1.0") + throw new CSharpException(CSErrorType.InvalidScript, + new InvalidDataException("The provided script does not have a valid MCCScript header")); + + //Script hash for determining if it was previously compiled + ulong scriptHash = QuickHash(lines); + Assembly assembly = null; + + //No need to compile two scripts at the same time + lock (CompileCache) + { + ///Process and compile script only if not already compiled + if (!Settings.CacheScripts || !CompileCache.ContainsKey(scriptHash)) + { + //Process different sections of the script file + bool scriptMain = true; + List script = new List(); + List extensions = new List(); + foreach (string line in lines) + { + if (line.StartsWith("//MCCScript")) + { + if (line.EndsWith("Extensions")) + scriptMain = false; + } + else if (scriptMain) + script.Add(line); + else extensions.Add(line); + } + + //Add return statement if missing + if (script.All(line => !line.StartsWith("return ") && !line.Contains(" return "))) + script.Add("return null;"); + + //Generate a class from the given script + string code = String.Join("\n", new string[] + { + "using System;", + "using System.Collections.Generic;", + "using System.Linq;", + "using System.Text;", + "using System.IO;", + "using System.Threading;", + "using MinecraftClient;", + "namespace ScriptLoader {", + "public class Script {", + "public CSharpAPI MCC;", + "public object __run(CSharpAPI __apiHandler, string[] args) {", + "this.MCC = __apiHandler;", + String.Join("\n", script), + "}", + String.Join("\n", extensions), + "}}", + }); + + //Compile the C# class in memory using all the currently loaded assemblies + CSharpCodeProvider compiler = new CSharpCodeProvider(); + CompilerParameters parameters = new CompilerParameters(); + parameters.ReferencedAssemblies + .AddRange(AppDomain.CurrentDomain + .GetAssemblies() + .Where(a => !a.IsDynamic) + .Select(a => a.Location).ToArray()); + parameters.CompilerOptions = "/t:library"; + parameters.GenerateInMemory = true; + CompilerResults result = compiler.CompileAssemblyFromSource(parameters, code); + + //Process compile warnings and errors + if (result.Errors.Count > 0) + throw new CSharpException(CSErrorType.LoadError, + new InvalidOperationException(result.Errors[0].ErrorText)); + + //Retrieve compiled assembly + assembly = result.CompiledAssembly; + if (Settings.CacheScripts) + CompileCache[scriptHash] = result.CompiledAssembly; + } + else if (Settings.CacheScripts) + assembly = CompileCache[scriptHash]; + } + + //Run the compiled assembly with exception handling + if (run) + { + try + { + object compiledScript + = CompileCache[scriptHash].CreateInstance("ScriptLoader.Script"); + return + compiledScript + .GetType() + .GetMethod("__run") + .Invoke(compiledScript, + new object[] { new CSharpAPI(apiHandler, tickHandler), args }); + } + catch (Exception e) { throw new CSharpException(CSErrorType.RuntimeError, e); } + } + else return null; + } + + /// + /// Quickly calculate a hash for the given script + /// + /// script lines + /// Quick hash as unsigned long + private static ulong QuickHash(string[] lines) + { + ulong hashedValue = 3074457345618258791ul; + for (int i = 0; i < lines.Length; i++) + { + for (int j = 0; j < lines[i].Length; j++) + { + hashedValue += lines[i][j]; + hashedValue *= 3074457345618258799ul; + } + hashedValue += '\n'; + hashedValue *= 3074457345618258799ul; + } + return hashedValue; + } + } + + /// + /// Describe a C# script error type + /// + public enum CSErrorType { FileReadError, InvalidScript, LoadError, RuntimeError }; + + /// + /// Describe a C# script error with associated error type + /// + public class CSharpException : Exception + { + private CSErrorType _type; + public CSErrorType ExceptionType { get { return _type; } } + public override string Message { get { return InnerException.Message; } } + public override string ToString() { return InnerException.ToString(); } + public CSharpException(CSErrorType type, Exception inner) + : base(inner != null ? inner.Message : "", inner) + { + _type = type; + } + } + + /// + /// Represents the C# API object accessible from C# Scripts + /// + public class CSharpAPI : ChatBot + { + /// + /// Thread blocking utility for stopping execution when making a ChatBot API call + /// + private ManualResetEvent tickHandler; + + /// + /// Create a new C# API Wrapper + /// + /// ChatBot API Handler + /// ChatBot tick handler + public CSharpAPI(ChatBot apiHandler, ManualResetEvent tickHandler) + { + SetMaster(apiHandler); + this.tickHandler = tickHandler; + } + + /* == Wrappers for ChatBot API with public visibility and call limit to one per tick for safety == */ + + /// + /// Write some text in the console. Nothing will be sent to the server. + /// + /// Log text to write + new public void LogToConsole(object text) + { + base.LogToConsole(text); + } + + /// + /// Send text to the server. Can be anything such as chat messages or commands + /// + /// Text to send to the server + /// True if the text was sent with no error + new public bool SendText(object text) + { + bool result = base.SendText(text is string ? (string)text : text.ToString()); + tickHandler.WaitOne(); + Thread.Sleep(1000); + return result; + } + + /// + /// Perform an internal MCC command (not a server command, use SendText() instead for that!) + /// + /// The command to process + /// TRUE if the command was indeed an internal MCC command + new public bool PerformInternalCommand(string command) + { + bool result = base.PerformInternalCommand(command); + tickHandler.WaitOne(); + return result; + } + + /// + /// Disconnect from the server and restart the program + /// It will unload and reload all the bots and then reconnect to the server + /// + /// If connection fails, the client will make X extra attempts + new public void ReconnectToTheServer(int extraAttempts = -999999) + { + if (extraAttempts == -999999) + base.ReconnectToTheServer(); + else base.ReconnectToTheServer(extraAttempts); + tickHandler.WaitOne(); + } + + /// + /// Disconnect from the server and exit the program + /// + new public void DisconnectAndExit() + { + base.DisconnectAndExit(); + tickHandler.WaitOne(); + } + + /// + /// Load the provided ChatBot object + /// + /// Bot to load + new public void LoadBot(ChatBot bot) + { + base.LoadBot(bot); + tickHandler.WaitOne(); + } + + /* == Additional Methods useful for Script API == */ + + /// + /// Get a global variable by name + /// + /// Name of the variable + /// Value of the variable or null if no variable + public object GetVar(string varName) + { + return Settings.GetVar(varName); + } + + /// + /// Get a global variable by name, as a string + /// + /// Name of the variable + /// Value of the variable as string, or null if no variable + public string GetVarAsString(string varName) + { + object val = GetVar(varName); + if (val != null) + return val.ToString(); + return null; + } + + /// + /// Get a global variable by name, as an integer + /// + /// Name of the variable + /// Value of the variable as int, or 0 if no variable or not a number + public int GetVarAsInt(string varName) + { + if (GetVar(varName) is int) + return (int)GetVar(varName); + int result; + if (int.TryParse(GetVarAsString(varName), out result)) + return result; + return 0; + } + + /// + /// Get a global variable by name, as a boolean + /// + /// Name of the variable + /// Value of the variable as bool, or false if no variable or not a boolean + public bool GetVarAsBool(string varName) + { + if (GetVar(varName) is bool) + return (bool)GetVar(varName); + bool result; + if (bool.TryParse(GetVarAsString(varName), out result)) + return result; + return false; + } + + /// + /// Set a global variable for further use in any other script + /// + /// Name of the variable + /// Value of the variable + public bool SetVar(string varName, object varValue) + { + return Settings.SetVar(varName, varValue); + } + + /// + /// Load login/password using an account alias and optionally reconnect to the server + /// + /// Account alias + /// Set to true to reconnecto to the server afterwards + /// True if the account was found and loaded + public bool SetAccount(string accountAlias, bool andReconnect = false) + { + bool result = Settings.SetAccount(accountAlias); + if (result && andReconnect) + ReconnectToTheServer(); + return result; + } + + /// + /// Load new server information and optionally reconnect to the server + /// + /// "serverip:port" couple or server alias + /// True if the server IP was valid and loaded, false otherwise + public bool SetServer(string server, bool andReconnect = false) + { + bool result = Settings.SetServerIP(server); + if (result && andReconnect) + ReconnectToTheServer(); + return result; + } + + /// + /// Synchronously call another script and retrieve the result + /// + /// Script to call + /// Arguments to pass to the script + /// An object returned by the script, or null + public object CallScript(string script, string[] args) + { + string[] lines = null; + ChatBots.Script.LookForScript(ref script); + try { lines = File.ReadAllLines(script); } + catch (Exception e) { throw new CSharpException(CSErrorType.FileReadError, e); } + return CSharpRunner.Run(this, tickHandler, lines, args); + } + } +} diff --git a/MinecraftClient/ChatBot.cs b/MinecraftClient/ChatBot.cs index b8c71706..4c0a5c09 100644 --- a/MinecraftClient/ChatBot.cs +++ b/MinecraftClient/ChatBot.cs @@ -85,12 +85,10 @@ namespace MinecraftClient /// Text to send to the server /// True if the text was sent with no error - protected bool SendText(object text) + protected bool SendText(string text) { LogToConsole("Sending '" + text + "'"); - bool result = Handler.SendText(text is string ? (string)text : text.ToString()); - Thread.Sleep(1000); - return result; + return Handler.SendText(text); } /// @@ -348,7 +346,7 @@ namespace MinecraftClient /// /// Log text to write - public void LogToConsole(object text) + protected void LogToConsole(object text) { ConsoleIO.WriteLogLine(String.Format("[{0}] {1}", this.GetType().Name, text)); string logfile = Settings.ExpandVars(Settings.chatbotLogFile); @@ -458,59 +456,5 @@ namespace MinecraftClient return new string[0]; } } - - /// - /// Set a custom %variable% which will be available through expandVars() - /// - /// Name of the variable - /// Value of the variable - /// True if the parameters were valid - - protected static bool SetVar(string varName, object varData) - { - return Settings.SetVar(varName, varData.ToString()); - } - - /// - /// Get a custom %variable% or null if the variable does not exist - /// - /// Variable name - /// The value or null if the variable does not exists - - protected static string GetVar(string varName) - { - return Settings.GetVar(varName); - } - - /// - /// Get a custom %variable% as an Integer or null if the variable does not exist - /// - /// Variable name - /// The value or null if the variable does not exists - - protected static int GetVarAsInt(string varName) - { - return Settings.str2int(Settings.GetVar(varName)); - } - - /// - /// Load login/password using an account alias - /// - /// True if the account was found and loaded - - protected static bool SetAccount(string accountAlias) - { - return Settings.SetAccount(accountAlias); - } - - /// - /// Load server information in ServerIP and ServerPort variables from a "serverip:port" couple or server alias - /// - /// True if the server IP was valid and loaded, false otherwise - - protected static bool SetServerIP(string server) - { - return Settings.SetServerIP(server); - } } } diff --git a/MinecraftClient/ChatBots/Script.cs b/MinecraftClient/ChatBots/Script.cs index 0e1894d9..4e464f1a 100644 --- a/MinecraftClient/ChatBots/Script.cs +++ b/MinecraftClient/ChatBots/Script.cs @@ -5,6 +5,7 @@ using System.Text; using System.Threading; using Microsoft.CSharp; using System.CodeDom.Compiler; +using System.Reflection; namespace MinecraftClient.ChatBots { @@ -143,8 +144,18 @@ namespace MinecraftClient.ChatBots tpause = new ManualResetEvent(false); thread = new Thread(() => { - if (!RunCSharpScript() && owner != null) - SendPrivateMessage(owner, "Script '" + file + "' failed to run."); + try + { + CSharpRunner.Run(this, tpause, lines, args); + } + catch (CSharpException e) + { + string errorMessage = "Script '" + file + "' failed to run (" + e.ExceptionType + ")."; + LogToConsole(errorMessage); + if (owner != null) + SendPrivateMessage(owner, errorMessage); + LogToConsole(e.InnerException); + } }); thread.Start(); } @@ -154,7 +165,7 @@ namespace MinecraftClient.ChatBots { tpause.Set(); tpause.Reset(); - if (thread.Join(100)) + if (!thread.IsAlive) UnloadBot(); } } @@ -205,84 +216,5 @@ namespace MinecraftClient.ChatBots } } } - - private bool RunCSharpScript() - { - //Script compatibility check for handling future versions differently - if (lines.Length < 1 || lines[0] != "//MCCScript 1.0") - { - LogToConsole("Script file '" + file + "' does not start with a valid //MCCScript identifier."); - return false; - } - - //Process different sections of the script file - bool scriptMain = true; - List script = new List(); - List extensions = new List(); - foreach (string line in lines) - { - if (line.StartsWith("//MCCScript")) - { - if (line.EndsWith("Extensions")) - scriptMain = false; - } - else if (scriptMain) - { - script.Add(line); - //Add breakpoints for step-by-step execution of the script - if (tpause != null && line.Trim().EndsWith(";")) - script.Add("tpause.WaitOne();"); - } - else extensions.Add(line); - } - - //Generate a ChatBot class, allowing access to the ChatBot API - string code = String.Join("\n", new string[] - { - "using System;", - "using System.IO;", - "using System.Threading;", - "using MinecraftClient;", - "namespace ScriptLoader {", - "public class Script : ChatBot {", - "public void __run(ChatBot master, ManualResetEvent tpause, string[] args) {", - "SetMaster(master);", - String.Join("\n", script), - "}", - String.Join("\n", extensions), - "}}", - }); - - //Compile the C# class in memory using all the currently loaded assemblies - CSharpCodeProvider compiler = new CSharpCodeProvider(); - CompilerParameters parameters = new CompilerParameters(); - parameters.ReferencedAssemblies - .AddRange(AppDomain.CurrentDomain - .GetAssemblies() - .Where(a => !a.IsDynamic) - .Select(a => a.Location).ToArray()); - parameters.CompilerOptions = "/t:library"; - parameters.GenerateInMemory = true; - CompilerResults result - = compiler.CompileAssemblyFromSource(parameters, code); - - //Process compile warnings and errors - if (result.Errors.Count > 0) - { - LogToConsole("Error loading '" + file + "':\n" + result.Errors[0].ErrorText); - return false; - } - - //Run the compiled script with exception handling - object compiledScript = result.CompiledAssembly.CreateInstance("ScriptLoader.Script"); - try { compiledScript.GetType().GetMethod("__run").Invoke(compiledScript, new object[] { this, tpause, args }); } - catch (Exception e) - { - LogToConsole("Runtime error for '" + file + "':\n" + e); - return false; - } - - return true; - } } } diff --git a/MinecraftClient/MinecraftClient.csproj b/MinecraftClient/MinecraftClient.csproj index bc276c9e..9e1fc0c7 100644 --- a/MinecraftClient/MinecraftClient.csproj +++ b/MinecraftClient/MinecraftClient.csproj @@ -113,6 +113,7 @@ + diff --git a/MinecraftClient/Settings.cs b/MinecraftClient/Settings.cs index 0985287e..40a51cb1 100644 --- a/MinecraftClient/Settings.cs +++ b/MinecraftClient/Settings.cs @@ -45,6 +45,7 @@ namespace MinecraftClient public static char internalCmdChar = '/'; public static bool playerHeadAsIcon = false; public static string chatbotLogFile = ""; + public static bool CacheScripts = true; //AntiAFK Settings public static bool AntiAFK_Enabled = false; @@ -94,7 +95,7 @@ namespace MinecraftClient public static string AutoRespond_Matches = "matches.ini"; //Custom app variables and Minecraft accounts - private static readonly Dictionary AppVars = new Dictionary(); + private static readonly Dictionary AppVars = new Dictionary(); private static readonly Dictionary> Accounts = new Dictionary>(); private static readonly Dictionary> Servers = new Dictionary>(); @@ -159,6 +160,7 @@ namespace MinecraftClient case "chatbotlogfile": chatbotLogFile = argValue; break; case "mcversion": ServerVersion = argValue; break; case "splitmessagedelay": splitMessageDelay = TimeSpan.FromSeconds(str2int(argValue)); break; + case "scriptcache": CacheScripts = str2bool(argValue); break; case "botowners": Bots_Owners.Clear(); @@ -366,6 +368,7 @@ namespace MinecraftClient + "serverlist=servers.txt\r\n" + "playerheadicon=true\r\n" + "exitonfailure=false\r\n" + + "scriptcache=true\r\n" + "timestamps=false\r\n" + "\r\n" + "[AppVars]\r\n" @@ -491,7 +494,7 @@ namespace MinecraftClient /// Value of the variable /// True if the parameters were valid - public static bool SetVar(string varName, string varData) + public static bool SetVar(string varName, object varData) { lock (AppVars) { @@ -511,7 +514,7 @@ namespace MinecraftClient /// Variable name /// The value or null if the variable does not exists - public static string GetVar(string varName) + public static object GetVar(string varName) { if (AppVars.ContainsKey(varName)) return AppVars[varName]; @@ -559,7 +562,7 @@ namespace MinecraftClient default: if (AppVars.ContainsKey(varname_lower)) { - result.Append(AppVars[varname_lower]); + result.Append(AppVars[varname_lower].ToString()); } else result.Append("%" + varname + '%'); break; diff --git a/MinecraftClient/config/sample-script-extended.cs b/MinecraftClient/config/sample-script-extended.cs index 68ff8cd1..b8751f0b 100644 --- a/MinecraftClient/config/sample-script-extended.cs +++ b/MinecraftClient/config/sample-script-extended.cs @@ -9,8 +9,8 @@ if (args.Length > 0) for (int i = 0; i < 5; i++) { - int count = GetVarAsInt("test") + 1; - SetVar("test", count); + int count = MCC.GetVarAsInt("test") + 1; + MCC.SetVar("test", count); SendHelloWorld(count, text); SleepBetweenSends(); } @@ -21,15 +21,11 @@ for (int i = 0; i < 5; i++) void SendHelloWorld(int count, string text) { - /* Warning: Do not make more than one server-related call into a method - * defined as a script extension eg SendText or switching servers, - * as execution flow is not managed in the Extensions section */ - - SendText("Hello World no. " + count + ": " + text); + MCC.SendText("Hello World no. " + count + ": " + text); } void SleepBetweenSends() { - LogToConsole("Sleeping for 5 seconds..."); + MCC.LogToConsole("Sleeping for 5 seconds..."); Thread.Sleep(5000); } \ No newline at end of file diff --git a/MinecraftClient/config/sample-script-with-chatbot.cs b/MinecraftClient/config/sample-script-with-chatbot.cs index 264711fa..25cb13eb 100644 --- a/MinecraftClient/config/sample-script-with-chatbot.cs +++ b/MinecraftClient/config/sample-script-with-chatbot.cs @@ -3,7 +3,7 @@ /* This is a sample script that will load a ChatBot into Minecraft Console Client * Simply execute the script once with /script or the script scheduler to load the bot */ -LoadBot(new ExampleBot()); +MCC.LoadBot(new ExampleBot()); //MCCScript Extensions diff --git a/MinecraftClient/config/sample-script.cs b/MinecraftClient/config/sample-script.cs index 3d61b25c..34d3354a 100644 --- a/MinecraftClient/config/sample-script.cs +++ b/MinecraftClient/config/sample-script.cs @@ -2,13 +2,13 @@ /* This is a sample script for Minecraft Console Client * The code provided in this file will be compiled at runtime and executed - * Allowed instructions: Any C# code AND all methods provided by the bot API */ + * Allowed instructions: Any C# code AND methods provided by the MCC API */ for (int i = 0; i < 5; i++) { - int count = GetVarAsInt("test") + 1; - SetVar("test", count); - SendText("Hello World no. " + count); - LogToConsole("Sleeping for 5 seconds..."); + int count = MCC.GetVarAsInt("test") + 1; + MCC.SetVar("test", count); + MCC.SendText("Hello World no. " + count); + MCC.LogToConsole("Sleeping for 5 seconds..."); Thread.Sleep(5000); } \ No newline at end of file