Thread: [C#] From scratch: Revision agnostic scripting engine part 1

Results 1 to 10 of 10
  1. #1 [C#] From scratch: Revision agnostic scripting engine part 1 
    Registered Member
    Join Date
    May 2017
    Posts
    62
    Thanks given
    12
    Thanks received
    34
    Rep Power
    31
    Foreword
    I didn't know where to put this. It's kind of a tutorial, kind of a show-off, kind of a compilation of snippets. I settled on tutorials as I'm providing full source code that works with my server (which was originally based off a public C# source posted on rune-server). This source can very easily be adapted to any server under any revision (and even other games, I'm using a slightly edited version of this for a Unity game I'm developing).
    • This thread assumes that you have basic to intermediate programming knowledge already.
    • If you want to reproduce this for your copy paste sources in Java, this shouldn't be too hard as C# is a similar enough language.
    • Code is commented for the purpose of this thread.
    • You could theoretically take the 2 scripting C# projects and load them in to Java. I haven't tried this, but it should be possible.


    Introduction
    Hi guys, I've been working on a private server with a lad I've been teaching for a while now. Progress has been slow as my free time is limited, but one of the obstacles I've been working through is how to make a modular, expandable scripting engine for our rsps. The aim for this engine is to be able to easily script NPCs, object interactions, quests, shops and other content down the line. For the purpose of making this relatively brief, we'll be looking specifically at NPC scripts.

    Because scripts can grow to be complicated and the files quite large, it's important that we stick to the basic principle of separation of responsibility. This means that when we're done we'll have one class responsible for one NPC (technically multiple NPCs in some cases, as NPCs with different IDs can share the same dialogue). It's worth noting that the current project does not adhere to this at all, the original base wasn't written by me. The action button packet handlers decide which dialogue method to activate and where along the dialogue to begin. Here's a snippet of this in action:

    Code:
    if (player.getTemporaryAttribute("barrowTunnel") != null)
    {
        Barrows.verifyEnterTunnel(player);
        return;
    }
    else if (dialogueStatus == 1005)
    {
        Slayer.doDialogue(player, 1006);
        break; //These breaks actually do nothing...
    }
    else if (dialogueStatus == 1009)
    {
        Slayer.doDialogue(player, 1010);
        break;
    }
    else if (dialogueStatus == 1002)
    {
        Slayer.doDialogue(player, 1013);
        break;
    }
    To further adhere to this principle I'll be creating a separate C# project to house any scripts. This will compile to a Dynamic Link Library (.dll) file, which will be loaded in to memory at runtime by the main project. More on how this works later.

    Before beginning any actual programming, lets summarise our requirements for the engine. In no particular order, the engine MUST:
    1. House scripts separately to the main project.
    2. Be capable of interacting with the player object and other objects in the main server project.
    3. Have some mechanism to map scripts to unique IDs, in the case of this tutorial these are NPC IDs.
    4. Be revision agnostic in order to be completely future-proof (that is to say what works in 1 revision will work in every revision). The best way to achieve this is to separate out the scripting engine entirely and provide what is essentially a layer of middleware to bridge the gap between scripting engine and main server project. This way only the seam will need to be changed across revisions.
    5. Have an API which is easily understandable. I have up to date industry experience to help with this.

    It would be nice if the engine could:
    1. Be easily accessible to those with little to no programming knowledge, so that it can be easily accesible to as many people as possible.
    2. Reload scripts at run-time without needing to restart the server.

    I've made a simple project outside of the main server project by right clicking the solution, selecting "Add -> New Project" and picking "Class Library (.NET Framework)". We're targeting .NET 4.6.1 for now, in the future we may upgrade to 4.7.2 to make use of some of the newest features. This .dll file will be used as an API between the main server and a separate scripting project, which I've created in a new solution.
    By using the built .dll file as a reference, we can build our script files which are then compiled down to another dll file. This is then loaded in to the main server project and executed at run time. More on this later.

    Attributes
    In C# we call these attributes. I believe in Java these are called annotations and they function almost identically.
    We'll be writing some custom attributes to go with our script classes. We'll start by creating a static class Attributes which will house all of our script related attributes. A couple of simple examples are an author and an Npc attribute.

    Code:
    using System;
    using System.Collections.Generic;
    
    namespace RS2Server.Scripts
    {
        public static class Attributes
        {
            // This attribute is only allowed to be put on a class, anything else is invalid.
            [AttributeUsage(AttributeTargets.Class)]
            public class AuthorAttribute : Attribute // We have to inherit from the Attribute class so that our compiler can tell it's an attribute.
            {
                // Store the author name as a property on the attribute when constructed.
                // Author name should be set in stone once constructed, hence no { set; }
                // These { get; } and { set; } accessors are just a better way of writing the
                // get and set boilerplate that are commonly seen in Java.
                public string AuthorName { get; }
    
                public AuthorAttribute(string authorName)
                {
                    AuthorName = authorName;
                }
            }
    
            [AttributeUsage(AttributeTargets.Class)]
            public class NpcAttribute : Attribute
            {
                // IReadOnlyCollection is useful for when we don't want anything to be able
                // to change the collection after the instance has been constructed.
                // We use a collection here as multiple NPC IDs may share the same dialogue
                // e.g bankers in Runescape have many IDs but have the same dialogue. I don't want
                // to have to rewrite the same script just to change the npc id.
                public IReadOnlyCollection<int> Ids { get; }
    
                // Rather than just passing in an integer array, or better
                // yet an IEnumerable of integers, an attribute won't accept
                // the syntax new int[] { x, y, z } because it's not a compile time constant.
                // So instead we pass in "params", which will allow us to pass in IDs as (x, y, z)
                // If that doesn't make sense, google it.
                public NpcAttribute(params int[] ids)
                {
                    Ids = ids;
                }
            }
        }
    }
    Testing the attributes out
    The output from our RS2Server.Scripts project is to be consumed by a separate project, which has been created under the separate namespace RS2Scripts.
    We'll create an empty class and add these attributes to it just for the purpose of testing.

    Code:
    using RS2Server.Scripts;
    using static RS2Server.Scripts.Attributes;
    
    namespace RS2Scripts
    {
        [Author("Testicles")]
        [Npc(2305)]
        public sealed class Vanessa
        {
    
        }
    }
    Note that the naming convention for the class is down to personal choice. I'm naming this class after the NPC it represents, this may change as the engine grows. The important thing is to be consistent with naming conventions to keep things neat and tidy.

    Abstract classes
    Most of the coding that will happen in this c# project will be done in the form of abstract classes. We'll be creating what is essentially a set of building blocks which we can add to as needed in order to add features to our scripting engine. This helps to adhere to the separation of responsibility concept that we talked about earlier. We'll be using the Object Oriented Programming (OOP) pattern of inheritance throughout our abstract classes, which stops us from having to duplicate code between script types.

    We're going to start with an abstract Script class, which contains a single void method Execute. We do this because the need to execute will be common across every single script we write.

    Code:
    namespace RS2Server.Scripts
    {
        public abstract class Script
        {
            public abstract void Execute();
        }
    }
    We need to provide our scripts with some way to access the player. The abstract class will be passed along to the scripts, which won't have any understanding of what the player object looks like or how it functions. All it knows is that it can call methods and properties to access information. This is where the revision agnostic concept comes in to play. These scripts will never have to change between revisions (or sources, or even games) as long as they have a layer that provides the functionality for these methods. Our abstract script layer is going to be called an AbstractScriptCharacter.

    Code:
    namespace RS2Server.Scripts
    {
        public abstract class AbstractScriptCharacter
        {
            // Property which we can call to get the name of the player
            public abstract string Name { get; }
            // 
            public abstract void AddItem(int itemId, int quantity);
    
            public abstract void RemoveItem(int itemId, int quantity);
        }
    }
    This script character can be now be used by any script which needs access to the player. If we write an abstract class for Npcs, we can utilise an instance of this AbstractCharacterScript.
    Code:
    using System;
    
    namespace RS2Server.Scripts
    {
        public abstract class NpcScript : Script
        {
            public AbstractScriptCharacter Character { get; set; }
    
            // Although you can't see it, this script has access to the execute method
            // because it inherits from the abstract class Script
        }
    }
    Providing methods to the scripts
    First things first, lets provide some implementation for the methods that we've given our AbstractScriptCharacter. In our main server project we'll make a class called ScriptCharacter, which implements AbstractScriptCharacter.
    Code:
    using System;
    using RS2Server.Player;
    using RS2Server.Scripts;
    
    namespace RS2Server.Data
    {
        internal sealed class ScriptCharacter : AbstractScriptCharacter
        {
            // This will mostly be used for debugging purposes
            public string ScriptName { get; }
            // https://msdn.microsoft.com/en-us/library/gg712738(v=vs.110).aspx
            private WeakReference<Player> PlayerReference { get; }
    
            public ScriptCharacter(Player player, string scriptName)
            {
                PlayerReference = new WeakReference<Player>(player);
                ScriptName = scriptName;
            }
    
            // This is private so that our methods that provide actual functionality can access it,
            // but it's inaccessible to the script itself so it can't modify the player class at will
            private Player Player => PlayerReference.TryGetTarget(out var ret) ? ret : null;
            
            // Not sure how this relates to Override in Java? Probably works the same way.
            public override string Name => Player.LoginDetails.Username;
    
            public override void AddItem(int itemId, int quantity) => Player.Inventory.AddItem(itemId, quantity);
    
            public override void RemoveItem(int itemId, int quantity) => Player.Inventory.DeleteItem(itemId, quantity);
        }
    }
    Loading the scripts in to the main server

    Now that we have a project which is compiled down to a dll file, we need to leverage the reflection namespace and load the scripts in to memory so that they can be activated when needed.

    Code:
    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.Reflection;
    using RS2Server.Scripts;
    
    using static RS2Server.Scripts.Attributes;
    
    namespace RS2Server.Data
    {
        internal static class ScriptLoader
        {
            // Use a dictionary as the time to look up by ID is always the same regardless of collection size
            // and we're guaranteed to have unique IDs unless we make a mistake. This speed is referred to
            // as O(1). This is a good read on how dictionaries work in C#:
            // https://softwareengineering.stackexchange.com/questions/264766/efficiency-of-c-dictionaries
            public static Dictionary<int, Type> NpcScripts;
    
            // We could make this more generic in the future, calling it LoadScripts instead
            // and sorting each type of script in to different dictionaries, but for now since we
            // only have 1 type of script, it's only loading npc scripts and so this name makes more sense
            public static int LoadNpcScripts()
            {
                // Initialize new dictionary so we have an empty collection
                NpcScripts = new Dictionary<int, Type>();
                // The scripting project is set to output the build result to the same folder as this project
                const string scriptFile = @".\RS2Scripts.dll";
    
                // This could maybe be improved by throwing an exception if we can't find the script file
                // No problems are caused as long as this method has been called though, so return 0 for now
                if (!File.Exists(scriptFile)) return 0;
    
                // Read the contents of the file to memory
                var bytes = File.ReadAllBytes(scriptFile);
    
                // Use the reflection namespace to load the read bytes and get all public types
                var scriptAssembly = Assembly.Load(bytes);
                var types = scriptAssembly.GetExportedTypes();
    
               
                foreach (var t in types)
                {
                    // Does the class we're looking at inherit from NpcScript?
                    if (t.IsSubclassOf(typeof(NpcScript)))
                    {
                        // Get the NPC IDs from the custom attribute.
                        var ids = t.GetCustomAttribute<NpcAttribute>(true).Ids;
    
                        // To pack our scripts in to our dictionary, we create 1 record for each ID, pointing that ID to the same type.
                        foreach(var id in ids)
                        {
                            NpcScripts.Add(id, t);
                        }
                    }
                }
    
                // Return the amount of scripts loaded, mostly so we can print this to console
                return NpcScripts.Count;
            }
        }
    }
    Now to call this method when the server starts, simple addition to the entry point of the application.

    Code:
    public static void Main(string[] args)
    {
        var scriptCount = DataProvider.LoadNpcScripts();
        Console.WriteLine($"Loaded {scriptCount} scripts from the script engine.");
        // Load the other shit etc etc
    }
    Script activator
    Now that we've loaded all of our types in to memory, we need something to create instances of them when we need them. This is where our script activator comes in to play.

    Code:
    namespace RS2Server.Data
    {
        internal static class ScriptActivator
        {
            public static Script CreateScriptInstance(Type npcType, string scriptName, Player player)
            {
                // Create an instance of given class with default constructor, see
                // https://msdn.microsoft.com/en-us/library/wccyzw83(v=vs.110).aspx
                // If resulting object can't be cast to script, it doesn't inherit from our base class
                // and therefore should not be returned
                if (!(Activator.CreateInstance(npcType) is Script instance))
                {
                    Console.WriteLine($"Type {npcType} could not be loaded as a script");
                    return null;
                }
    
                // If our script can be cast to an NpcScript, set up reference to an instance of ScriptCharacter
                if (instance is NpcScript nInstance)
                {
                    nInstance.Character = new ScriptCharacter(player, scriptName);
                }
    
                return instance;
            }
        }
    }
    Writing the engine in the main project
    Now that we've loaded them all and can instantiate our scripts, it's time that we wrote an engine to tie it all together. In our main server project we'll write an NpcEngine class to make use of our script activator. This will also be a good place to provide the implementation for some of the methods that our NpcScripts can call.

    Code:
    using RS2Server.player;
    using System;
    using RS2Server.Definitions;
    using RS2Server.Scripts;
    
    namespace RS2Server.Data
    {
        internal sealed class NpcEngine : IDisposable
        {
            private Player PlayerReference;
            private readonly int NpcId;
            public NpcScript ScriptInstance { get; set; }
    
            private NpcEngine(Player player, int npcId)
            {
                PlayerReference = player;
                NpcId = npcId;
    
                // For a given ID, we attempt to get the value out of the NPC scripts dictionary
                // if it exists, create an instance of the class and store a reference to it so we can use it later
                if (DataProvider.NpcScripts.TryGetValue(NpcId, out var npcT) && npcT != null)
                {
                    ScriptInstance = ScriptActivator.CreateScriptInstance(npcT, npcT.ToString(), player) as NpcScript;
                    // This is only null if we've somehow tried to create a script that isn't an npc script.
                    if (ScriptInstance == null)
                    {
                        Console.WriteLine($"Failed to load NPC script {npcT} for NPC {npcId}");
                        return;
                    }
                }
                // If we couldn't find a script with the given ID, display a generic missing script chat box
                else
                {
                    PlayerReference.Packets.sendNPCHead(npcId, 241, 2);
                    PlayerReference.Packets.modifyText(NpcDataManager.forId(npcId).Name, 241, 3);
                    PlayerReference.Packets.modifyText("I'm missing a script, and that makes me sad.", 241, 4);
                    PlayerReference.Packets.animateInterface(9768, 241, 2);
                    PlayerReference.Packets.sendChatboxInterface2(241);
                }
            }
    
            private void ExecuteScriptForNpc()
            {
                if (ScriptInstance == null)
                    return;
    
                // We utilise a try catch block so that we can log errors in the form of
                // a chat box which users can see.
                try
                {
                    ScriptInstance.Execute();
                }
                catch (Exception e)
                {
                    Console.WriteLine($"Script for {NpcId} broke.);
                    Console.WriteLine(e);
                    PlayerReference.Packets.sendNPCHead(NpcId, 241, 2);
                    PlayerReference.Packets.modifyText(NpcDataManager.forId(NpcId).Name, 241, 3);
                    PlayerReference.Packets.modifyText($"Something broke! My Id is {NpcId}. Please fix me!", 241, 4);
                    PlayerReference.Packets.animateInterface(9768, 241, 2);
                    PlayerReference.Packets.sendChatboxInterface2(241);
                }
            }
    
            // Instantiate copy of NpcEngine and pass reference to player
            // then execute the script
            public static void OpenNpc(int npcId, Player player)
            {
                var npcEngine = new NpcEngine(player, npcId);
                player.NpcEngine = npcEngine;
    
                if (player.NpcEngine.ScriptInstance == null)
                    return;
    
                player.NpcEngine.ExecuteScriptForNpc();
            }
    
            // Delete player's reference to NpcEngine instance when disposed either by garbage collector or manually
            public void Dispose()
            {
                if (PlayerReference.NpcEngine == this)
                    PlayerReference.NpcEngine = null;
    
                PlayerReference = null;
                ScriptInstance = null;
            }
        }
    }
    Writing functionality and testing
    Now that we have a skeleton for creating and loading scripts, it's time to add some methods which the script can call to actually send packets across. For now, we'll focus on a simple example which sends 1 line of text with the continue button available.
    First things first, we need to make some modifications to our NpcScript class which our scripts can call.
    Code:
    namespace RS2Server.Scripts
    {
        public abstract class NpcScript : Script
        {
            public AbstractScriptCharacter Character { get; set; }
    
            // These actions are methods which can be invoked with the given parameters.
            // The types in the angle brackets (sometimes referred to as chevrons) define the parameters of the function
            // This requires a string, then an integer, then an integer in that order
            public Action<string, int, int> ModifyText;
            public Action<int, int, int> AnimateInterface;
            public Action<int, int> SendNpcChatHead;
            public Action<int, int> SendPlayerHead;
            // These 2 actions below require a single integer
            public Action<int> SendChatBoxInterface;
            public Action<int> SendChatBoxInterface2;
            // An action that would require no parameters would just be "public Action ActionName;"
        }
    }
    Now that we have some methods which can be called, lets change Vanessa's script to make use of these methods.

    Code:
    using RS2Server.Scripts;
    using static RS2Server.Scripts.Attributes;
    
    namespace RS2Scripts
    {
        [Author("Testicles")]
        [Npc(2305)]
        public sealed class Vanessa : NpcScript
        {
            public override void Execute()
            {
                SendPlayerHead(64, 2);
                ModifyText(Character.Name, 64, 3);
                ModifyText("Oi cunt", 64, 4);
                AnimateInterface(9764, 64, 2);
                SendChatBoxInterface2(64);
            }
        }
    }
    Right now, these methods don't actually do anything, because our instance of our Npc script hasn't been told what these methods are for.
    Going back to our NpcEngine class, we can hook these methods up to our script on creation.

    Code:
    private void SendPlayerHead(int interfaceId, int childId)
    {
        PlayerReference.Packets.sendPlayerHead(interfaceId, childId);
    }
    
    private void ModifyText(string text, int interfaceId, int childId)
    {
        PlayerReference.Packets.modifyText(text, interfaceId, childId);
    }
    
    private void SendChatBoxInterface(int childId)
    {
        PlayerReference.Packets.sendChatboxInterface(childId);
    }
    
    private void SendNpcChatHead(int interfaceId, int childId)
    {
        PlayerReference.Packets.sendNPCHead(NpcId, interfaceId, childId);
    }
    
    private void AnimateInterface(int animationId, int interfaceId, int childId)
    {
        PlayerReference.Packets.animateInterface(animationId, interfaceId, childId);
    }
    
    private void SendChatBoxInterface2(int childId)
    {
        PlayerReference.Packets.sendChatboxInterface2(childId);
    }
    These are all very simple and just call the equivalent packet sending methods.
    Now to hook these up when we create an instance of the NpcEngine.

    Code:
    private NpcEngine(Player player, int npcId)
    {
        PlayerReference = player;
        NpcId = npcId;
    
        if (DataProvider.NpcScripts.TryGetValue(NpcId, out var npcT) && npcT != null)
        {
            ScriptInstance = ScriptActivator.CreateScriptInstance(npcT, npcT.ToString(), player) as NpcScript;
            if (ScriptInstance == null)
            {
                Console.WriteLine($"Failed to load NPC script {npcT} for NPC {npcId}");
                return;
            }
    
            ScriptInstance.ModifyText = ModifyText;
            ScriptInstance.SendChatBoxInterface = SendChatBoxInterface;
            ScriptInstance.SendNpcChatHead = SendNpcChatHead;
            ScriptInstance.SendPlayerHead = SendPlayerHead;
            ScriptInstance.AnimateInterface = AnimateInterface;
            ScriptInstance.SendChatBoxInterface2 = SendChatBoxInterface2;
        }
        else
        {
            // Default "that makes me sad" behaviour that we saw earlier
        }
    }
    The only thing left to do before we can test out our NPC scripts is to make some changes to the packet handler.
    In our NpcInteract packet handler we have a method called "HandleSecondClickNPC".

    This iterates over a lot of different IDs in a big switch, which will eventually be replaced. For now, to make sure we don't break anything we'll be adding a default case to this switch statement so that our NpcEngine is called if there isn't already existing behaviour.

    Code:
    switch (npc.Id)
    {
        //Bob, Aubury, Ali Morisanne, slayer master etc above here
        default:
            player.Packets.softCloseInterfaces();
            player.setEntityFocus(npc.ClientIndex);
            // Prepare the code that should be executed when player is 1 square in any direction from the NPC
            var interactWithNpc = new AreaEvent(player, npc.Location.X - 1, npc.Location.Y - 1, npc.Location.X + 1, npc.Location.Y + 1);
            interactWithNpc.setAction(() =>
            {
                npc.setFaceLocation(player.Location);
                player.setFaceLocation(npc.Location);
                player.setEntityFocus(65535);
                NpcEngine.OpenNpc(npc.Id, player);
            });
            Server.RegisterCoordinateEvent(interactWithNpc);
            break;
    }
    Now if we hop in to the game, we can see that if I speak to an NPC which has no script:
    Attached image

    If I go to speak to Vanessa and force an error to appear:
    Attached image

    If I let the script execute properly:
    Attached image

    Finite state machines
    A finite state machine is the concept of a given entity being capable of existing in exactly one of many predefined states at any one given time. This concept isn't unique to programming, but can be observed in engineering, mathematics and many other fields. A car's gear stick, for example can only be in 1 of the many gears, reverse or neutral at any given time. Traffic lights can be in 1 state, green yellow or red at any time. The code base that I've started with made an attempt to adopt the finite state machine pattern, but didn't quite get it right as the single variable which tracks NPC state is shared amongst all scripts.

    Code:
    if (player.getTemporaryAttribute("barrowTunnel") != null)
    {
        Barrows.verifyEnterTunnel(player);
        return;
    }
    else if (dialogueStatus == 1005)
    {
        Slayer.doDialogue(player, 1006);
        break;
    }
    else if (dialogueStatus == 1009)
    {
        Slayer.doDialogue(player, 1010);
        break;
    }
    else if (dialogueStatus == 1002)
    {
        Slayer.doDialogue(player, 1013);
        break; // These breaks also do nothing
    }
    This results in a big long switch statement which is triggered by the second integer passed in to doDialogue:

    Code:
    switch (dialogueStatus)
    {
        case 1000:
            p.Packets.sendNPCHead((int)SLAYER_MASTERS[index][0], 241, 2);
            p.Packets.modifyText((string)SLAYER_MASTERS[index][2], 241, 3);
            p.Packets.modifyText("Hello, what can i help you with?", 241, 4);
            p.Packets.animateInterface(9827, 241, 2);
            p.Packets.sendChatboxInterface2(241);
            newStatus = 1001;
            break;
    
        case 1001:
            p.Packets.modifyText("I need another Slayer assignment.", 238, 1);
            p.Packets.modifyText("Could i see your supplies?", 238, 2);
            p.Packets.modifyText("I'd like to discuss Slayer points.", 238, 3);
            p.Packets.modifyText("I'd like a Slayer Skillcape.", 238, 4);
            p.Packets.modifyText("Er...nothing...", 238, 5);
            p.Packets.sendChatboxInterface2(238);
            newStatus = 1002;
            break;
    
        case 1002: // New assignment
            p.Packets.sendPlayerHead(64, 2);
            p.Packets.modifyText(p.LoginDetails.getUsername(), 64, 3);
            p.Packets.modifyText("I need a new assignment.", 64, 4);
            p.Packets.animateInterface(9827, 64, 2);
            p.Packets.sendChatboxInterface2(64);
            newStatus = 1003;
            break;
    
        // etc etc
    }
    Our scripts will look very similar to this towards the end, but will be responsible for tracking their own state. Currently the dialogueStatus variable is pulled from a list of "temporary attributes". I'd like this system to die off eventually, as it's a bit flaky and feels like extremely lazy programming. The system will live for now, but this single temporary attribute will die very soon. Here's the snippet that retrieves the dialogue status:
    Code:
    var dialogueStatus = player.getTemporaryAttribute("dialogue") == null ? -1 : (int)player.getTemporaryAttribute("dialogue"); // Dialogue status
    Simply adding the following code to our NpcScript class assures we start off at state 0. The script can then decide what to do from there.
    We're also going to add a LastInterface variable. This will check to see that the interface that contained the clicked action button was the last interface we showed the player.
    Code:
    public int LastInterface = -1;
    public int State = 0;
    In order to move from 1 state to the next, our NpcEngine will contain a method which will be called by our action button packet handler.
    For now it will only handle the "continue" button, since one liners are all the functionality our scripts have right now.

    Code:
    public void HandleSelection(int interfaceChildId)
    {
        // Continue
        if (interfaceChildId == 5)
        {
            ScriptInstance.State++;
        }
    
        // TODO: Investigate each dialogue interface to see which
        // children have which IDs and code interface specific behaviour
        
        // Execute the script again
        ScriptInstance.Execute();
    }
    Note that while the above behaviour works for now, later on when there are multiple interfaces being worked with this behaviour won't suffice.
    For now I'm happy with leaving a TODO here and coming back to handle interface specific behaviour later on.

    In the action button packet handler, the "continue" button triggers the method handleActionButton3.
    I don't really know the difference between the action button methods, possibly has something to do with how many layers deep the child is in the interface?
    Code:
    private void handleActionButton3(Player player, Packet packet)
    {
        int id = packet.readUShort();
        int interfaceId = packet.readUShort();
        int junk = packet.readLEShort();
    
        Console.WriteLine($"ACTIONBUTTON-3 {id}, interface {interfaceId}");
    
        // For information on the null conditional (?) operator, see https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/operators/null-conditional-operators
        // Probably better to just call HandleSelection and let it handle the interface id checks in there, but this will do for now
        if (player.NpcEngine?.ScriptInstance?.LastInterface == interfaceId)
        {
            player.NpcEngine.HandleSelection(id);
            return;
        }
        // etc etc
    }
    Now that we have the concept of state and our scripts can navigate between them, we should give the scripts a way of ending the chat dialogue.
    To do this, we add a method to NpcEngine and NpcScript called EndChat, the exact same way we added the other methods before. This method is quite simple:

    Code:
    private void EndChat()
    {
        p.Packets.softCloseInterfaces();
    }
    We can now make Vanessa have multiple dialogue states:

    Code:
    using RS2Server.Scripts;
    using static RS2Server.Scripts.Attributes;
    
    namespace RS2Scripts
    {
        [Author("Testicles")]
        [Npc(2305)]
        public sealed class Vanessa : NpcScript
        {
            public override void Execute()
            {
                switch (State)
                {
                    case 0:
                        SendPlayerHead(64, 2);
                        ModifyText(Character.Name, 64, 3);
                        ModifyText("Oi cunt", 64, 4);
                        AnimateInterface(9764, 64, 2);
                        SendChatBoxInterface2(64);
                        LastInterface = 64;
                        break;
                    case 1:
                        SendNpcChatHead(64, 2);
                        ModifyText("Vanessa", 64, 3);
                        ModifyText("U wot m8?", 64, 4);
                        AnimateInterface(9764, 64, 2);
                        SendChatBoxInterface2(64);
                        LastInterface = 64;
                        break;
                    default:
                        EndChat();
                        break;
                }
            }
        }
    }
    Using EndChat in our default case ends the chat if we ever end up in a state that we have no dialogue for. This should be obvious enough during testing that there's a problem with the script.
    It also allows us to set the state to something like -1 if we want to end the chat easily.

    Here's what this looks like in game:
    Attached image

    The final thing left to do for part 1 is to be able to load scripts on the fly.
    If I make a change to an NPC script, I don't want to have to restart the server, I want to reload the NPC scripts from scratch so that I can see if the bug is fixed immediately.

    To do this, we'll make a simple command script that requires level 2 rights to use.

    Code:
    using RS2Server.Data;
    using RS2Server.player;
    
    namespace RS2Server.PacketHandlers.Commands
    {
        internal sealed class ReloadScripts : ICommand
        {
            public void execute(Player player, string[] arguments)
            {
                var count = DataProvider.LoadNpcScripts();
                player.Packets.sendMessage($"Loaded {count} scripts!");
            }
    
            public int minimumRightsNeeded()
            {
                return 2;
            }
        }
    }
    Part 2 will be coming soon(ish) and will focus on
    • Simplifying the scripts to reduce redundant code
    • Removing hard coded integers, such as face animation IDs
    • Extending the functionality of the engine to include multiple option dialogue interfaces etc
    Reply With Quote  
     


  2. #2  
    Registered Member

    Join Date
    Dec 2012
    Posts
    2,999
    Thanks given
    894
    Thanks received
    921
    Rep Power
    2555
    im happy for you bro but c# conventions are trash
    Attached image
    Reply With Quote  
     

  3. Thankful users:


  4. #3  
    Registered Member
    Join Date
    May 2017
    Posts
    62
    Thanks given
    12
    Thanks received
    34
    Rep Power
    31
    Quote Originally Posted by Kaleem View Post
    im happy for you bro but c# conventions are trash
    This feels like a low quality bait post, but I'll bite. The only C# conventions in here vs how this would be written in Java is that the amount of shit boiler plate is cut down tremendously. The only other "conventions" are universal design patterns and are to do with writing clean, easily testable and expandable code. Not sure why I keep seeing Java programmers that love writing a ton of boiler plate to get the same functionality they could have without it, it's weird seeing people stuck in the past and not adapting.
    Reply With Quote  
     

  5. Thankful user:


  6. #4  
    Registered Member

    Join Date
    Feb 2009
    Age
    27
    Posts
    2,861
    Thanks given
    127
    Thanks received
    226
    Rep Power
    700
    Nice use of attributes, and a very thorough guide. You could probably wrap most of Vanessa's code into two functions though:

    SendPlayerText(Player player, String message);
    SendNpcText(Npc npc, String message);

    Quote Originally Posted by Kaleem View Post
    im happy for you bro but c# conventions are trash
    what does that even mean
    Reply With Quote  
     

  7. #5  
    Registered Member
    Join Date
    May 2017
    Posts
    62
    Thanks given
    12
    Thanks received
    34
    Rep Power
    31
    Quote Originally Posted by jameskmonger View Post
    Nice use of attributes, and a very thorough guide. You could probably wrap most of Vanessa's code into two functions though:

    SendPlayerText(Player player, String message);
    SendNpcText(Npc npc, String message);
    Thanks for the kind words and feedback I was intending for that to be the final section "Simplifying and extending the framework", but I thought this thread was getting too long so I've left it for part 2. It also felt a bit easier to follow and understand if I stuck with the 1:1 conversion of what most people are used to seeing for NPC dialogue for the first example. I'm just trying to settle on good method names, working on some factories for the more complicated dialogue options and I'll begin the writeup for part 2.

    Right now Vanessa looks like this:
    Code:
    using RS2Server.Scripts;
    using static RS2Server.Scripts.Attributes;
    
    namespace RS2Scripts
    {
        [Author("Testicles")]
        [Npc(2305)]
        public sealed class Vanessa : NpcScript
        {
            public override void Execute()
            {
                switch (State)
                {
                    case 0:
                        SendPlayerOneLine("I'm sad", ChatHeadAnimation.Sad); // ChatHeadAnimation being an enum of all the relevant animation IDs
                        break;
                    case 1:
                        SendNpcOneLine("I'm angry!", ChatHeadAnimation.Angry);
                        break;
                    default:
                        EndChat();
                        break;
                }
            }
        }
    }
    There are some extra things to take in to account, such as NPC dialogues which trigger dialogue windows with other NPCs, such as Cap'n Izzy No-Beard and his Parrot. This just means SendNpcOneLine will require an NPC Id instead of just pulling it directly from the script instance.

    It's also important to note that this framework doesn't (and shouldn't) pass around references to any objects from the main server project. This is what makes it version agnostic, all scripts can be adapted to any revision, source and even game just by providing implementation for SendPlayerOneLine and SendNpcOneLine etc etc.
    Reply With Quote  
     

  8. Thankful user:


  9. #6  
    Registered Member

    Join Date
    Dec 2012
    Posts
    2,999
    Thanks given
    894
    Thanks received
    921
    Rep Power
    2555
    Quote Originally Posted by testicles View Post
    This feels like a low quality bait post, but I'll bite. The only C# conventions in here vs how this would be written in Java is that the amount of shit boiler plate is cut down tremendously. The only other "conventions" are universal design patterns and are to do with writing clean, easily testable and expandable code. Not sure why I keep seeing Java programmers that love writing a ton of boiler plate to get the same functionality they could have without it, it's weird seeing people stuck in the past and not adapting.
    you wrote all of that when i was on about the upper camel case lol

    even then its inconsistent
    Attached image
    Reply With Quote  
     

  10. Thankful user:


  11. #7  
    Registered Member
    Join Date
    May 2017
    Posts
    62
    Thanks given
    12
    Thanks received
    34
    Rep Power
    31
    Quote Originally Posted by Kaleem View Post
    you wrote all of that when i was on about the upper camel case lol

    even then its inconsistent
    It's inconsistent right now because some of the source was converted 1:1 from Java and there are some things I haven't edited. I'm fixing convention as I go.
    There's really not that much to it
    • Class names upper camel case
    • Properties upper camel case
    • Methods upper camel case (ICommand's execute was just the way it was when I started working on this source, should be Execute)
    • Members lower camel case, or starting with underscores depending on who you ask
    • Local variables and parameters lower camel case


    I get that you're just shitposting as always, of course if you give some shit vague response I'm going to have to make assumptions about what you mean.
    ¯\_(ツ)_/¯
    Reply With Quote  
     

  12. Thankful user:


  13. #8  
    Registered Member

    Join Date
    Dec 2009
    Posts
    774
    Thanks given
    367
    Thanks received
    455
    Rep Power
    927
    Be careful when using Activator, that thing can be really slow.
    link removed
    Reply With Quote  
     

  14. Thankful user:


  15. #9  
    Registered Member
    Join Date
    May 2017
    Posts
    62
    Thanks given
    12
    Thanks received
    34
    Rep Power
    31
    Quote Originally Posted by Admiral Slee View Post
    Be careful when using Activator, that thing can be really slow.
    I've had a read online as I didn't know activator was a slow performer. I'm currently running some benchmarks and I'll post results when I'm done

    EDIT:
    After looking in to the alternatives I have available, it looks like I'm heavily limited because most alternatives require knowing the type of the object I want to construct at compile time.
    I've run one alternative which claims to be quicker using compiled lambda expressions (which could maybe be simplified, looks horrendous right now) and the time was unexpected.

    Code:
    using System;
    using System.Diagnostics;
    using System.Linq.Expressions;
    using NUnit.Framework; // Testing library, probably similar enough to JUnit
    using RS2Server.Data;
    using RS2Server.player;
    using RS2Server.Scripts;
    
    using static Rhino.Mocks.MockRepository; // Great proxy library for tests :)
    
    namespace RS2Server.Tests.Unit
    {
        internal sealed class InstanceCreationTest
        {
            // 100 runs, 112ms
            // 1k runs, 127ms
            // 10k runs, 279ms
            // 1 million runs, 18,288 ms
            [Test]
            public void ActivatorBenchmark()
            {
                DataProvider.LoadNpcScripts();
                DataProvider.NpcScripts.TryGetValue(2305, out var npcT);
                var stopwatch = new Stopwatch();
                stopwatch.Start();
                for (var i = 0; i < 10000; i++) 
                {
                    CreateScriptInstanceWithActivator(npcT, "test", GenerateStub<Player>(), -1);
                }
                stopwatch.Stop();
                // Hacky way to fail the test so that I can see the output in NUnit's UI
                // Also catches any mistakes I might have made when writing this, as this should never pass
                Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(1));
            }
    
            // 100 runs, 53ms
            // 1k runs, 156ms
            // 10k runs, 1377ms
            // 1 million runs, 116,254ms??
            [Test]
            public void LambdaBenchmark() 
            {
                DataProvider.LoadNpcScripts();
                DataProvider.NpcScripts.TryGetValue(2305, out var npcT);
                var stopwatch = new Stopwatch();
                stopwatch.Start();
                for (var i = 0; i < 10000; i++)
                {
                    CreateScriptInstanceWithLambda(npcT, "test", GenerateStub<Player>(), -1);
                }
                stopwatch.Stop();
                Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(1));
            }
    
            private static Script CreateScriptInstanceWithLambda(Type npcType, string scriptName, Player player, int npcId)
            {
                var thing = Expression.Lambda<Func<Script>>(Expression.New(npcType)).Compile();
                if (!(thing() is Script instance))
                {
                    Console.WriteLine($"Type {npcType} could not be loaded as a script");
                    return null;
                }
    
                if (instance is NpcScript nInstance)
                {
                    nInstance.Character = new ScriptCharacter(player, scriptName);
                    // This wasn't in the original post, I'm currently on the next branch
                    // Not happy about the circular reference, will fix later
                    nInstance.Dialogue = new DialogueProvider(player, npcId, nInstance);
                }
    
                return instance;
            }
    
            private static Script CreateScriptInstanceWithActivator(Type npcType, string scriptName, Player player, int npcId)
            {
                if (!(Activator.CreateInstance(npcType) is Script instance))
                {
                    Console.WriteLine($"Type {npcType} could not be loaded as a script");
                    return null;
                }
    
                if (instance is NpcScript nInstance)
                {
                    nInstance.Character = new ScriptCharacter(player, scriptName);
                    nInstance.Dialogue = new DialogueProvider(player, npcId, nInstance);
                }
    
                return instance;
            }
        }
    }
    The sudden jump from faster to much slower with lambda expressions is surprising, but right now both of the solutions run quickly enough that it shouldn't matter which I pick.
    I'll come and revisit this once I've nailed down the rest of the engine.
    Reply With Quote  
     

  16. #10  
    Registered Member
    Join Date
    Sep 2018
    Posts
    61
    Thanks given
    8
    Thanks received
    10
    Rep Power
    41

    Sounds Interesting, but definitely way to hard to code for me. Lol...
    Last edited by Zenarith; 10-14-2018 at 03:37 PM. Reason: Different Font
    High Ambitions in Scripting a 718 Server as Soon as I'm done Learning More in Java
    Reply With Quote  
     


Thread Information
Users Browsing this Thread

There are currently 1 users browsing this thread. (0 members and 1 guests)


User Tag List

Similar Threads

  1. Starting level 3 from scratch | Part II
    By joshismyname in forum Old School RS (2007)
    Replies: 1
    Last Post: 01-13-2017, 07:51 AM
  2. Replies: 11
    Last Post: 12-20-2013, 02:49 PM
  3. QuarterX Base (Made From Scratch)
    By Mrquarter in forum Downloads
    Replies: 74
    Last Post: 07-30-2008, 03:23 PM
  4. Replies: 86
    Last Post: 02-29-2008, 05:31 PM
  5. Nice Custom Base Made From Scratch!
    By Yorick in forum Downloads
    Replies: 8
    Last Post: 08-08-2007, 07:48 PM
Posting Permissions
  • You may not post new threads
  • You may not post replies
  • You may not post attachments
  • You may not edit your posts
  •