[GUIDE] Creating a PA Hub Plugin

Discussion in 'Mod Support' started by Raevn, November 30, 2014.

  1. Raevn

    Raevn Moderator Alumni

    Messages:
    4,226
    Likes Received:
    4,324
    This guide details how to create a plugin for PA Hub. Plugins allow PA Hub to extend and customise it's functionality. Plugins are one of many types of content supported; each type is managed by a content store, which is responsible for locating it's content, and controlling the behaviour of them (eg, determining what being "enabled" and "disabled" means). The creation of content stores will be examined in more detail later.

    The plugin format & template

    Plugins are controlled by the plugin store ("com.pahub.content.plugin.store.plugin"), which searches for content in the plugin directory:
    • Windows: %userprofile%\AppData\Local\Uber Entertainment\Planetary Annihilation\pahub\content\plugin
    • Linux: <home>/.local/Uber Entertainment/Planetary Annihilation/pahub/content/plugin
    • Mac: <home>/Library/Application Support/Uber Entertainment/Planetary Annihilation/pahub/content/plugin
    Each plugin is contained within its own folder which is named after their content_id. Within that folder are several important files:
    • content-info.json: contains meta-data for the plugin
    • A javascript file that controls the behaviour of the plugin (defined in the content-info.json file)
    • icon.png: the icon that will appear in the content hub
    Content Info file
    A template of the content-info.json is described below. The example is from the community plugin:

    Code:
    {
        "content_id": "com.pahub.content.plugin.community",
        "store_id": "com.pahub.content.plugin.store.plugin",
        "display_name": "Community",
        "description": "Plugin for community functionality including News, IRC Chat and Forums",
        "author": "Raevn",
        "version": "0.3.0",
        "date": "2014/11/05",
        "resources": [
            "com.pahub.content.plugin.community.js",
            "com.pahub.content.plugin.community.css",
            "remarkable.min.js"
        ],
        "load_func": "load_plugin_community",
        "unload_func": "unload_plugin_community",
        "enabled": true,
        "required": {
            "com.pahub": ">=0.2.1",
            "com.pahub.content.plugin.contenthub": ">=0.2.0"
        }
    }
    Parameters:
    • content_id: the unique ID of the content. Convention is "com.pahub.content.plugin.<name>"
    • store_id: The content_id of the store for the content. In the case of plugins, this value will always be "com.pahub.content.plugin.store.plugin"
    • display_name: The name of the plugin as it will appear in the content hub
    • description: A short description of the function of the plugin
    • author: The author of the plugin
    • version: The version number of the plugin. Must be in the following format: #.#.# (Major.Minor.Revision). An optional tag is allowed, eg #.#.#-tag
    • date: The date when the plugin was last updated.
    • resources: A list of js or css files (relative to the plugin directory) to load.
    • load_func: Name of the function to call when the plugin is loaded. The function will have two parameters passed - data (the content of the plugin's content-info file), and folder, the folder path of the plugin.
    • unload_func: Name of the function to call when the plugin is unloaded. The function will have one parameter passed - data (the content of the plugin's content-info file).
    • enabled: Set this to true.
    • required: This defines any dependencies the plugin may have. It is a map of values, with the keys being content_ids of other content items, and the value being the version requirements, in the semver format (details).
    Javascript File

    The javascript file(s) contain the code that drives the behaviour of the plugin. There are two functions to define - one that is called whenever the plugin is enabled, and one for when it is disabled. The name of these function is defined in the content-info.json file, in the load_func and unload_func parameters respectively.

    A sample javascript file template is shown below:

    Code:
    //load_func
    function load_plugin_community(data, folder) {
        //define API functions here
        pahub.api["<plugin>"] = {
            sampleFunction: function(param) { model.<plugin>.sampleFunction(param); }
        }
    
        //ko & other vars/funcs
        model["<plugin>"] = {
            sampleFunction: function(param) {
                //sampleFunction code
            }
        }
    
        //remainder of on-load code
    }
    
    //unload_func
    function unload_plugin_community(data) {
        //on-unload code
        //remember to remove any tabs, sections, vars, api functions and scripts that were added.
    }
    Icon File

    Icons are 200x200 PNG files. The convention used so far for plugins is a stencil shape (approx 128x128 centered in the 200x200 icon), coloured solid blue RGB(0, 140, 255). For example:
    icon.png icon.png icon.png
    Last edited: December 1, 2014
    trialq, cola_colin, Clopse and 2 others like this.
  2. Raevn

    Raevn Moderator Alumni

    Messages:
    4,226
    Likes Received:
    4,324
    Creating Content Stores

    Content stores are plugins that define a content store via the API call pahub.api.content.addContentStore(store_id, display_name, data). They also have a number of additional attributes that may be defined or are required in the content-info.json file.

    Additional content info file parameters
    The following additional parameters are available to override default content handling functionality, and to define other aspects of the content store:
    • content_enabled_func: Name of the function to call when a child content item is enabled. The function will have one parameters passed - content (the content item object of the enabled content item).
    • content_disabled_func: Name of the function to call when a child content item is disabled. The function will have one parameters passed - content (the content item object of the disabled content item).
    • custom_install_content_func: Name of the function to call when a child content item is installed. The function will have two parameters passed - content_id (the content_id of the content item being installed) and update (true if an existing local content item is being updated, false if it is a new install of a content item).
    • custom_write_content_func: Name of the function to call when the content-info.json of a child content item needs to be written to. The function will have one parameter passed - content (the content item object).
    • find_local_content_func: Name of the function to call in order to search for locally installed content. The function will have one parameter passed - store_id (the id of the content store). This function should return an array of content item objects. Each object should be of the form:
      Code:
      {
          content_id: <content_id>,
          store_id: <store_id>,
          url: <absolute folder path of content>
          data: <the content-info data for the item (either loaded or generated procedurally).>
      }
    • find_online_content_func: Name of the function to call in order to load online content. The function will have two parameter passed - store_id (the id of the content store) and catalogJSON (the catalog of online content downloaded from online_content_path). This should call pahub.api.content.addContentItem(...) as required to add in the content items.
    • local_content_path: The relative path to the folder where local content for this store is kept. The path should be relative to the Planetary Annihilation Data folder
      TODO: list this folder path for different OSs.
    • online_content_path: The url from which to download the JSON catalog of online content.
    • content_colour: A 3 item array which defines the RGB value used as the colour for the content items from this store.
    Last edited: December 1, 2014
    dom314, cola_colin and Clopse like this.
  3. Raevn

    Raevn Moderator Alumni

    Messages:
    4,226
    Likes Received:
    4,324
    Walk Through: Creating the Map Content Store
    To illustrate the creation of a plugin and content store, I will walk through the process of creating the Map Content store, for allowing players to easily find and download maps. This is a somewhat complicated type of content store, as it requires most of the in-built functionality to be over-riden in order to work with .pas maps, rather than the native content-info.json files, as well as the need to dynamically generate a mod for the maps to be able to be loaded in-game.

    This will be updated as the plugin is developed, so expect the content to change.
    NOTE: The code below is currently using updated versions of PAHub and other plugins that have not yet been made available.

    Step 1: Planning & Setup

    Planning


    We first need to consider how a map store will work. The plugin will work via the excellent System Sharing mod by @cptconundrum. The system sharing mod allows map packs, which is where this plugin comes in - we need to dynamically build a map pack from the maps installed via this content store, according to the map pack definition describe in this guide.

    It would also be best if straight .pas files can be used, which means overriding the default content handling behaviour of PA Hub to avoid the need for content-info.json files.

    Setup

    A new folder called "com.pahub.content.plugin.store.map" needs to be created under the plahub/content/plugin directory. Within this, create the following (empty for now) files:
    • content-info.json
    • com.pahub.content.plugin.store.map.js
    • icon.png
    The content of these files will be discussed and implemented in the following sections.

    Step 2: content-info.json
    The content-info file for the map content store is described below:
    Code:
    {
        "content_id": "com.pahub.content.plugin.store.map",
        "store_id": "com.pahub.content.plugin.store.plugin",
        "display_name": "Map Store",
        "description": "(Store) Manage installed Maps, which can be used with the System Sharing Mod by cptconundrum",
        "author": "Raevn",
        "version": "0.1.0",
        "date": "2014/12/01",
        "resources": [
            "com.pahub.content.plugin.store.map.js"
        ],
        "load_func": "load_map_store_plugin",
        "unload_func": "unload_map_store_plugin",
        "custom_install_content_func": "map_store_install_content",
        "custom_write_content_func": "map_store_write_content",
        "find_local_content_func": "map_store_find_local_content",
        "find_online_content_func": "map_store_find_online_content",
        "local_content_path": "mods/com.pa.pahub.mods.maps/ui/mods/com.pa.pahub.mods.maps/systems",
        "online_content_path": "https://raw.githubusercontent.com/pamods/mods-conundrum/master/cShareSystems_serverList/serverlist.json",
        "content_name": "Map",
        "enabled": true,
        "content_colour": [
            0,
            255,
            140
        ],
        "required": {
            "com.pahub": ">=0.4.0",
            "com.pahub.content.plugin.contenthub": ">=0.2.0", 
            "com.pahub.content.plugin.store.mod": "0.3.0"
        }
    }
    
    Step 3: Icon
    Keeping with the convention for icons, icon.png:
    icon.png
    Also, icons have been made indicated qty of planets on the map, from 1 to 16:
    system1.png system2.png system3.png system4.png system5.png

    Step 4: Javascript code

    Loading/Unloading the plugin
    We need to take care of a few things to start with when loading the plugin. The first thing that is needed is to define and register the content store for maps. We also want to save the folder path of the plugin - we'll need that later. When unloading, we want to ensure we remove the content store we created and other variables.

    Code:
    function load_map_store_plugin(data, folder) {
        pahub.api.content.addContentStore(data.content_id, data.display_name, data);
    
        model.content["maps"] = {
            folder: folder
        }
    }
    
    function unload_map_store_plugin(data) {
        //TODO: remove sort methods once API to do so is available
        unsetConstant("PAHUB_CLIENT_MODS_DIR");
        delete model.content["maps"];
    }

    Creating the map pack mod
    The system sharing mod works via the creation of a customised mod that lists all the maps in the map pack, as well as containing the maps themselves. We need to create this mod when the plugin loads, if it doesn't already exist.

    The following code is added to load_map_store_plugin to create the mod's folder structure and modinfo.json file:
    Code:
        setConstant("PAHUB_CLIENT_MODS_DIR", path.join(constant.PA_DATA_DIR, "mods"));
    
        //create the folder structure for the mod
        if (fs.existsSync(path.join(constant.PAHUB_CLIENT_MODS_DIR, "com.pa.pahub.mods.maps", "ui", "mods", "com.pa.pahub.mods.maps", "systems")) == false) {
            mkdirp.sync(path.join(constant.PAHUB_CLIENT_MODS_DIR, "com.pa.pahub.mods.maps", "ui", "mods", "com.pa.pahub.mods.maps", "systems"));
        }
        //create the modinfo.json file
        if (fs.existsSync(path.join(constant.PAHUB_CLIENT_MODS_DIR, "com.pa.pahub.mods.maps", "modinfo.json")) == false) {
            var modinfoJSON = {
                "identifier": "com.pa.pahub.mods.maps",
                "content_id": "com.pa.pahub.mods.maps",
                "store_id": "com.pahub.content.plugin.store.mod.client",
                "context": "client",
                "display_name": "Offline Maps",
                "description": " ",
                "author": "pahub",
                "version": "1.0.0",
                "signature": "not yet implemented",
                "priority": 500,
                "enabled": true,
                "category": [
                    "ui"
                ],
                "scenes": {
                    "load_planet": [
                        "coui://ui/mods/com.pa.pahub.mods.maps/maplist.js"
                    ]
                },
                "dependencies": [
                    "com.pa.conundrum.cShareSystems"
                ]
            };
            writeJSONtoFile(path.join(constant.PAHUB_CLIENT_MODS_DIR, "com.pa.pahub.mods.maps", "modinfo.json"), modinfoJSON);
        }
    We will create the maplist.js file and write the .pas files out separately, as this needs to occur anytime the chosen maps change.

    Creating the maplist.js file
    This function is added to model.content.maps. It detects all locally installed maps from the map content store, and uses it to form a list to write out to maplist.js.

    The enabled status of maps is ignored here; ideally, content stores will be able to turn the enabled/disabled functionality off for it's content, but for now it will just be a quirk. PA Hub will not remember the enabled/disabled state of maps when it is restarted (they will always load as enabled).

    Code:
            writeMaplist: function () {
                if (pahub.api.content.contentStoreExists("com.pahub.content.plugin.store.map") == true) {
                    var store = pahub.api.content.getContentStore("com.pahub.content.plugin.store.map");
                    var maps = store.local_content_items();
                    var enabled_maps = [];
                    for (var i = 0; i < maps.length; i++) {
                        enabled_maps.push("\"coui://ui/mods/com.pa.pahub.mods.maps/systems/" + maps[i].data.file + ".pas\"");
                    }
                    var mapListJS = "cShareSystems.load_pas(\"Offline Systems\", [\n" + enabled_maps.join(",\n") + "]);";
                    writeToFile(path.join(constant.PAHUB_CLIENT_MODS_DIR, "com.pa.pahub.mods.maps", "ui", "mods", "com.pa.pahub.mods.maps", "maplist.js"), mapListJS);
                } else {
                    pahub.api.log.addLogMessage("error", "Failed to find content store 'com.pahub.content.plugin.store.plugin'");
                }
            }
    Last edited: December 6, 2014
  4. Raevn

    Raevn Moderator Alumni

    Messages:
    4,226
    Likes Received:
    4,324
    Online content detection
    The system sharing mods has several map servers defined; we want to capture all the maps, so we want to first get the list of these servers, and then poll each of them for their maps. The online_content_path has been set to the URL of the server list, so now we need to overwrite the online mod detection and download the list of maps from them. Since the maps don't actually "exist", we need to generate the content data on the fly. But this also means we can add additional information - such as map metadata.

    Code:
    function map_store_find_online_content(store_id, catalogJSON) {
        /*
            catalogJSON format:
            {
                "servers" : [
                    {
                        "name" : "Default Server",
                        "save_url" : "http://1-dot-winged-will-482.appspot.com/save",
                        "search_url" : "http://1-dot-winged-will-482.appspot.com/search"
                    },
                    ...
                ]
            }
        */
        if (pahub.api.content.contentStoreExists(store_id) == true) {
            var store = pahub.api.content.getContentStore(store_id);
            if (catalogJSON.hasOwnProperty("servers") == true) {
                var serverList = catalogJSON.servers;
                for (var i = 0; i < serverList.length; i++) {
             
                    //TODO: hard-coded limit will eventually be a problem.
                    var params = "?start=0&limit=150&name=&creator=&minPlanets=1&maxPlanets=16&sort_field=system_id&sort_direction=DESC";
                    (function(servername, i) {
                        pahub.api.resource.loadResource(serverList[i].search_url + params, "get", {
                            name: "map information (" + serverList[i].name + ")",
                            mode: "async",
                            success: function(resource) {
                                try {
                                    var serverMapsJSON = JSON.parse(resource.data);
                                } catch (err) {
                                    pahub.api.log.addLogMessage("error", "Failed to parse system data from server '" + serverList[i].name + "'");
                                    return;
                                }
                                if (serverMapsJSON.hasOwnProperty("systems") == true) {
                                    var systemList = serverMapsJSON.systems;
                                    for (var j = 0; j < systemList.length; j++) {
                                        systemList[j]["server"] = servername;
                                        if (systemList[j].hasOwnProperty("name") == true) {
                                            var planetNames = ""
                                            if (systemList[j].hasOwnProperty("planets") == true) {
                                                var planetCount = systemList[j].planets.length
                                                for (var k = 0; k < planetCount; k++) {
                                                    if (k > 0) {
                                                        planetNames += ", ";
                                                    }
                                                    planetNames += systemList[j].planets[k]["name"] || "Unnamed Planet";
                                                }
                                             
                                                var file_name = systemList[j].name.replace(/[^a-z0-9]/gi, '_').toLowerCase();
                                                var system_content_id = "com.pahub.content.map." + file_name;
                                             
                                                var systemInfo = {
                                                    "content_id": system_content_id,
                                                    "file": file_name,
                                                    "store_id": "com.pahub.content.plugin.store.map",
                                                    "display_name": systemList[j].name,
                                                    "description": (systemList[j]["description"] ? systemList[j]["description"] + "\n" : "") + planetCount + " planet system. Planets: " + planetNames,
                                                    "author": systemList[j]["creator"] || "",
                                                    "version": systemList[j]["version"] || "1.0.0",
                                                    "date": systemList[j]["date"] || "2014/11/05",
                                                    "icon": path.join(model.content.maps.folder, "system" + planetCount + ".png"),
                                                    "enabled": true,
                                                    "map_data": systemList[j]
                                                }
                                                pahub.api.content.addContentItem(false, store_id, system_content_id, systemInfo.display_name, "", systemInfo);
                                            } else {
                                                pahub.api.log.addLogMessage("warn", "No planets defined for system '" + systemList[j].name + "'");
                                            }
                                        } else {
                                            pahub.api.log.addLogMessage("warn", "A system with no name is defined for server '" + serverList[i].name + "'");
                                        }
                                    }
                                } else {
                                    pahub.api.log.addLogMessage("warn", "No systems are defined for server '" + serverList[i].name + "'");
                                }
                            },
                            fail: function(resource) {
                                pahub.api.log.addLogMessage("error", "Failed to download system list for server '" + serverList[i].name + "'");
                            }
                        });
                    })(serverList[i].name, i);
                }
            } else {
                pahub.api.log.addLogMessage("error", "No map servers are defined");
            }
        } else {
            pahub.api.log.addLogMessage("error", "Failed to find content store '" + store_id + "'");
        }
    }
    Last edited: December 4, 2014
    cola_colin and Clopse like this.
  5. Raevn

    Raevn Moderator Alumni

    Messages:
    4,226
    Likes Received:
    4,324
    Local Content Detection
    Loading local content involves searching inside the systems folder of the mod that we created for any .pas files. We then need to read in the .pas files and generate a content object to return in the array.

    Code:
    function map_store_find_local_content(store_id) {
        var content_queue = [];
      
        if (pahub.api.content.contentStoreExists(store_id) == true) {
            var store = pahub.api.content.getContentStore(store_id);
          
            var files = [];
            var mapsFolder = path.join(constant.PAHUB_CLIENT_MODS_DIR, "com.pa.pahub.mods.maps", "ui", "mods", "com.pa.pahub.mods.maps", "systems");
            if(fs.existsSync(mapsFolder) == true ) {
          
                try {
                    files = fs.readdirSync(mapsFolder);
                } catch (err) {
                    pahub.api.log.addLogMessage("error", "Failed reading map directory");
                    pahub.api.log.addLogMessage("error", "Error " + err.number + ": " + err.description);
                    return [];
                }
              
                files.forEach(function(file, index) {
                    if (path.extname(file) == ".pas") {
                        var pasJSON = readJSONfromFile(path.join(mapsFolder,file));
                        if (pasJSON != false) {
                            var planetNames = ""
                            if (pasJSON.hasOwnProperty("planets") == true) {
                                var planetCount = pasJSON.planets.length
                              
                                for (var k = 0; k < pasJSON.planets.length; k++) {
                                    if (k > 0) {
                                        planetNames += ", ";
                                    }
                                    planetNames += pasJSON.planets[k].name;
                                }
                              
                                var systemInfo = {
                                    "content_id": "com.pahub.content.map." + path.basename(file, ".pas"),
                                    "file": path.basename(file, ".pas"),
                                    "store_id": "com.pahub.content.plugin.store.map",
                                    "display_name": pasJSON["name"] || "Unknown System",
                                    "description": (pasJSON["description"] ? pasJSON["description"] + "\n" : "") + planetCount + " planet system. Planets: " + planetNames,
                                    "author": pasJSON["creator"] || "",
                                    "version": pasJSON["version"] || "1.0.0",
                                    "date": pasJSON["date"] || "2014/11/05",
                                    "icon": path.join(model.content.maps.folder, "system" + planetCount + ".png"),
                                    "enabled": true,
                                    "required": {
                                        "com.pa.pahub.mods.maps" : "*",
                                        "com.pa.conundrum.cShareSystems" : "*"
                                    },
                                    "map_data": pasJSON
                                }
                              
                                content_queue.push({
                                    content_id: systemInfo.content_id,
                                    store_id: systemInfo.store_id,
                                    url: path.join(path.join(constant.PAHUB_CLIENT_MODS_DIR, "com.pa.pahub.mods.maps", "ui", "mods", "com.pa.pahub.mods.maps", "systems", file)),
                                    data: systemInfo
                                });
                            } else {
                                pahub.api.log.addLogMessage("warn", "No planets defined for system '" + pasJSON["name"] + "'");
                            }
                        } else {
                            pahub.api.log.addLogMessage("error", "Failed loading map '" + path.join(mapsFolder,file) + "'");
                        }
                    }
                });
            } else {
                pahub.api.log.addLogMessage("error", "Failed loading maps: folder does not exist");
            }
        } else {
            pahub.api.log.addLogMessage("error", "Failed to find content store '" + store_id + "'");
        }
        return content_queue;
    }
    One additional consideration with local content is writing out the content information whenever the content is enabled or disabled. The default behaviour of this in PA Hub will write out the equivalent of content-info.json, which is not what we want - we just want to write out the system data again. We have already saved this data under the map_data parameter, so we need to override the write function and write that out instead:

    Code:
    function map_store_write_content(content) {
        var data = content.data.map_data;
        writeJSONtoFile(path.normalize(content.url), data);
        model.content.maps.writeMaplist();
    }
    Installing maps
    Installing maps is simpler than other content, since all the data is already available in the online content data. All we need to do is copy the map_data component and save it.

    Code:
    function map_store_install_content(content_id, update) {
        if (pahub.api.content.contentItemExists(false, content_id) == true) {
            var content = pahub.api.content.getContentItem(false, content_id);
            var data = $.extend({}, content.data.map_data);
            writeJSONtoFile(path.join(constant.PA_DATA_DIR, content.store.data.local_content_path, content.data.file + ".pas"), data);
            model.content.maps.writeMaplist();
        } else {
            pahub.api.log.addLogMessage("error", "Failed to install map '" + content_id + "' (map data not found)");
        }
    }
    With this in place, the map content store functionality is done. I've also added two sorting functions - sort by Planets, and sort by Server.
    Last edited: December 4, 2014
    cola_colin likes this.
  6. Raevn

    Raevn Moderator Alumni

    Messages:
    4,226
    Likes Received:
    4,324
    Reserved

Share This Page