diff --git a/.eslintrc.js b/.eslintrc.js index 0ff9374b..659219fd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -44,6 +44,7 @@ module.exports = { "files": [ "packages/web_ui/src/**/*.ts?(x)", "packages/lib/browser.ts", + "packages/create/templates/*/web/**/*.{t,j}s?(x)", "{packages,plugins}/*/web/**/*.ts?(x)", ], "env": { @@ -94,6 +95,19 @@ module.exports = { "node/shebang": "off", }, }, + { + "files": [ + "packages/create/templates/**/*.{t,j}s?(x)", + ], + "rules": { + "node/no-missing-require": "off", + "node/no-missing-import": "off", + "spaced-comment": "off", + "comma-spacing": "off", + "max-len": "off", + "indent": "off", + }, + }, ], "rules": { diff --git a/CHANGELOG.md b/CHANGELOG.md index a15e75b4..31908653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Many thanks to the following for contributing to this release: ### Changes - Fixed Inventory Sync reporting a negative database size [ยค598](https://github.com/clusterio/clusterio/pull/598). +- Fixed plugin events being invoked while a host connection was in an invalid state. Many thanks to the following for contributing to this release: [@Cooldude2606](https://github.com/Cooldude2606) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..a8501f0d --- /dev/null +++ b/codecov.yml @@ -0,0 +1,6 @@ +coverage: + status: + project: + default: + if_ci_failed: success + informational: true diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index c87b3b40..faf88a5f 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -39,6 +39,7 @@ The usual guides for creating such packages apply. At minimum the `package.json` file must contain a version entry. A possible workflow for developing plugins is to place the plugin in a sub-directory of where clusterio has been installed, and rely on Node.js searching up the folder heirarchy for it to find `@clusterio/lib`. +After creating a directory for your plugin, the boilerplate can be generated automaticlly from a set of tempaltes using `npm exec @clusterio/create -y -- --plugin-template` after which you will be asked which areas your plugin will interact with and your plugin's name. To add it to `plugin-list.json` so that it gets loaded use the `plugin add ` sub-command to either clusteriocontroller, clusteriohost or clusterioctl. Note that it's important that the path starts with ./ or ../ (use .\ or ..\ on Windows). @@ -300,6 +301,20 @@ local clusterio_api = require("modules/clusterio/api") clusterio_api.send_json("my_plugin_foo", { data = 123 }) ``` +For convience a helper function is exposed on server specifically for handling IPC events. + +```js +async init() { + this.instance.server.handle("my_plugin_foo", this.handleFoo.bind(this)); +} + +async handleFoo(content) { + // Do stuff with content +} +``` + +**Note:** This only handles IPC events, all other events must use `on`. You should not include the `ipc-` prefix when using this method. Error logging is handled for you, if you want custom error handling use `on`. + It's recommended to either use the plugin name as the channel name or to prefix the channel name with the name of the plugin if you need multiple channels. It's also important to catch any errors that might occur as they will otherwise be propogated to the instance code and kill the server. diff --git a/packages/controller/src/WsServer.ts b/packages/controller/src/WsServer.ts index 50c37d9c..05576336 100644 --- a/packages/controller/src/WsServer.ts +++ b/packages/controller/src/WsServer.ts @@ -284,7 +284,9 @@ ${err.stack}` ); } - connection = new HostConnection(data, connector, this.controller, this.remoteAddr(req)); + // Handle close before HostConnection does. This avoids a host + // connection event happening between the connection breaking and + // it being removed from the host connections list. connector.on("close", () => { if (this.hostConnections.get(data.id) === connection) { this.hostConnections.delete(data.id); @@ -292,6 +294,8 @@ ${err.stack}` logger.warn("Unlisted HostConnection closed"); } }); + + connection = new HostConnection(data, connector, this.controller, this.remoteAddr(req)); this.hostConnections.set(data.id, connection); let src = new lib.Address(lib.Address.host, data.id); connector.ready(socket, src, sessionToken, undefined); @@ -331,10 +335,10 @@ ${err.stack}` let [connector, sessionToken] = this.createSession(new lib.Address(lib.Address.control, id)); logger.verbose(`WsServer | registered control ${id} from ${this.remoteAddr(req)}`); - let connection = new ControlConnection(data, connector, this.controller, user, id); connector.on("close", () => { this.controlConnections.delete(id); }); + let connection = new ControlConnection(data, connector, this.controller, user, id); this.controlConnections.set(id, connection); let account = new lib.AccountDetails( user.name, diff --git a/packages/create/create.js b/packages/create/create.js index c222ab04..66e4084a 100755 --- a/packages/create/create.js +++ b/packages/create/create.js @@ -12,10 +12,23 @@ const util = require("util"); const yargs = require("yargs"); const { levels, logger, setLogLevel } = require("./logging"); +const { copyPluginTemplates } = require("./template"); let dev = false; const scriptExt = process.platform === "win32" ? ".cmd" : ""; const finished = util.promisify(stream.finished); +// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4 +// See nodejs/node#21771 and nodejs/node#30701 for the apparent fix that was applied +// One option is to not call rl.close: SBoudrias/Inquirer.js#767 +// But this comes with its own issues: SBoudrias/Inquirer.js#899 +// The solution below was adapted from aws-amplify/amplify-cli#12347 +if (process.platform === "win32") { + const _inquirer_close = inquirer.ui.Prompt.prototype.close; + inquirer.ui.Prompt.prototype.close = function() { + this.rl.terminal = false; + _inquirer_close.call(this); + }; +} const availablePlugins = [ { name: "Global Chat", value: "@clusterio/plugin-global_chat" }, @@ -505,6 +518,7 @@ WantedBy=multi-user.target async function inquirerMissingArgs(args) { let answers = {}; if (args.mode) { answers.mode = args.mode; } + if (args["plugin-template"]) { answers.mode = "plugin-template"; } answers = await inquirer.prompt([ { type: "list", @@ -517,6 +531,7 @@ async function inquirerMissingArgs(args) { { name: "Host only", value: "host" }, { name: "Ctl only", value: "ctl" }, { name: "Plugins only", value: "plugins" }, + { name: "Plugin template", value: "plugin-template" }, ], }, ], answers); @@ -644,6 +659,38 @@ async function inquirerMissingArgs(args) { } } + if (answers.mode === "plugin-template") { + if (args["plugin-template"] && args["plugin-template"].length > 0) { + answers.pluginTemplate = args["plugin-template"]; + } + if (args["plugin-name"]) { + answers.pluginName = args["plugin-name"]; + } + answers.plugins = []; + answers = await inquirer.prompt([ + { + type: "checkbox", + name: "pluginTemplate", + message: "Plugin templates to use", + choices: [ + { name: "Controller", value: "controller" }, + { name: "Host", value: "host" }, + { name: "Instance", value: "instance" }, + { name: "Lua Module", value: "module" }, + { name: "Command Line", value: "ctl" }, + { name: "Web UI", value: "web" }, + { name: "No Typescript", value: "js" }, + ], + }, + { + type: "input", + name: "pluginName", + message: "Name of your new plugin", + default: path.basename(path.resolve()), + }, + ], answers); + } + if (dev) { answers.plugins = []; } else if (args.plugins) { @@ -720,7 +767,7 @@ async function main() { }) .option("mode", { nargs: 1, describe: "Operating mode to install", - choices: ["standalone", "controller", "host", "ctl", "plugins"], + choices: ["standalone", "controller", "host", "ctl", "plugins", "plugin-template"], }) .option("migrate-rename", { nargs: 0, describe: "Migrate from before slave/master rename", default: false, type: "boolean", @@ -746,6 +793,12 @@ async function main() { .option("plugins", { array: true, describe: "Plugins to install", type: "string", }) + .option("plugin-template", { + array: true, describe: "Plugin template to use [plugin-template]", type: "string", + }) + .option("plugin-name", { + nargs: 1, describe: "Name of your new plugin [plugin-template]", type: "string", + }) .strict() ; @@ -779,6 +832,13 @@ async function main() { } let answers = await inquirerMissingArgs(args); + logger.verbose(JSON.stringify(answers)); + + if (answers.pluginTemplate) { + await copyPluginTemplates(answers.pluginName, answers.pluginTemplate); + return; + } + if (answers.downloadHeadless) { if (answers.factorioDir !== "factorio") { throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio"); @@ -786,8 +846,6 @@ async function main() { await downloadLinuxServer(); } - logger.verbose(JSON.stringify(answers)); - if (!dev) { await installClusterio(answers.mode, answers.plugins); } diff --git a/packages/create/template.js b/packages/create/template.js new file mode 100644 index 00000000..71ce6734 --- /dev/null +++ b/packages/create/template.js @@ -0,0 +1,171 @@ +"use strict"; +const { logger } = require("./logging"); +const fs = require("fs-extra"); +const path = require("path"); + +async function copyTemplateFile(src, dst, properties) { + logger.verbose(`Writing ${dst} from template ${src}`); + + // Closure, get the property value or throw + function getProperty(property) { + if (!properties.hasOwnProperty(property)) { + throw new Error(`Unknown property (${property}) found in template file ${src}`); + } + + return properties[property]; + } + + // Closure, Get the boolean conjunction of a set of properties + function propertyConjunction(props) { + for (const property of props) { + if (property.startsWith("!")) { + if (Boolean(getProperty(property.slice(1)))) { + return false; + } + } else if (!Boolean(getProperty(property))) { + return false; + } + } + + return true; + } + + // Process the the template file + const badChars = /[\(\)-+/*<>=]/; + const templateData = await fs.readFile(src, "utf8"); + const outputData = templateData + // match preprocessor comments: //%// + .replace(/^\/\/%\/\/.*\n/gm, "") + // match preprocessor insert: __property__ + .replace(/__([a-zA-Z][a-zA-Z0-9_]*?[a-zA-Z0-9]?)__/gm, (_, property) => getProperty(property)) + // match preprocessor conditionals: //%if [// COMMENT]\n \n//%endif [COMMENT]\n + .replace(/^\/\/%if ([^\n\/]*)(?:\/\/.*?)?\n((?:.|\n)*?)\/\/%endif.*\n/gm, (_, condition, content) => { + let include = false; + const conjunctions = condition.split("|"); + for (const conjunct of conjunctions) { + if (badChars.test(conjunct)) { + throw new Error("Preprocessor condition can only contain logical operators" + + "such as & | or ! it can not have arithmetic, comparisons or parenthesis" + ); + } + + const property_names = conjunct.split("&").map(s => s.trim()); + if (propertyConjunction(property_names)) { + include = true; + break; + } + } + + return include ? content : ""; + }); + + // Attempt to write the output file, warn if it already exists + try { + await fs.outputFile(dst, outputData, { flag: "wx" }); + } catch (err) { + if (err.code === "EEXIST") { + logger.warn(`Could not create file ${dst} because it already exists`); + } else { + throw err; + } + } +} + +async function copyPluginTemplates(pluginName, templates) { + logger.info(`Please wait, coping templates for ${templates.join(", ")}`); + const files = new Map(); + const prepare = []; + + const javascriptOnly = templates.includes("js"); + const webpack = templates.includes("controller") || templates.includes("web"); + + // Get the file extension and path to the templates + const ext = javascriptOnly ? "js" : "ts"; + const templatePath = path.resolve(__dirname, javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts"); + const commonPath = path.resolve(__dirname, "./templates/common"); + + // Files included in all templates + files.set(".gitignore", path.join(commonPath, ".gitignore")); + files.set(".npmignore", path.join(commonPath, ".npmignore")); + files.set("package.json", path.join(commonPath, "package.json")); + files.set(`messages.${ext}`, path.join(templatePath, `messages.${ext}`)); + files.set(`index.${ext}`, path.join(templatePath, `index.${ext}`)); + + // Files and dependencies to support typescript + if (!javascriptOnly) { + prepare.push("tsc --build"); + files.set("tsconfig.json", path.join(templatePath, "tsconfig.json")); + files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json")); + if (webpack) { + files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json")); + } + } + + // Files and dependences to support webpack + if (webpack) { + prepare.push("webpack-cli --env production"); + files.set("webpack.config.js", path.join(commonPath, "webpack.config.js")); + files.set(`web/index.${ext}x`, path.join(templatePath, + templates.includes("web") ? `web/plugin.${ext}x` : `web/no_plugin.${ext}x` + )); + } + + // Files for the controller + if (templates.includes("controller")) { + files.set(`controller.${ext}`, path.join(templatePath, `controller.${ext}`)); + } + + // Files for hosts + if (templates.includes("host")) { + files.set(`host.${ext}`, path.join(templatePath, `host.${ext}`)); + } + + // Files for instances + if (templates.includes("instance")) { + files.set(`instance.${ext}`, path.join(templatePath, `instance.${ext}`)); + } + + // Files for lua modules + if (templates.includes("module")) { + files.set(".luacheckrc", path.join(commonPath, ".luacheckrc")); + files.set("module/module.json", path.join(commonPath, "module/module.json")); + files.set("module/control.lua", path.join(commonPath, "module/control.lua")); + files.set("module/module_exports.lua", path.join(commonPath, "module/module_exports.lua")); + if (templates.includes("instance")) { + files.set("module/globals.lua", path.join(commonPath, "module/globals.lua")); + } + } + + // Files for the control tool + if (templates.includes("ctl")) { + files.set(`ctl.${ext}`, path.join(templatePath, `ctl.${ext}`)); + } + + // Properties that will control the replacements in the templates + const properties = { + typescript: !javascriptOnly, + controller: templates.includes("controller"), + host: templates.includes("host"), + instance: templates.includes("instance"), + module: templates.includes("module"), + ctl: templates.includes("ctl"), + web: templates.includes("web"), + plugin_name: pluginName, + prepare: prepare.join(" && "), + ext: ext, + }; + + // Write all the template files + const writes = []; + for (let [dst, src] of files) { + writes.push(copyTemplateFile(src, dst, properties)); + } + + // Wait for writes to complete + await Promise.all(writes); + logger.info("Successfully wrote all template files"); +} + +module.exports = { + copyPluginTemplates, +}; diff --git a/packages/create/templates/common/.gitignore b/packages/create/templates/common/.gitignore new file mode 100644 index 00000000..1a993212 --- /dev/null +++ b/packages/create/templates/common/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +package-lock.json diff --git a/packages/create/templates/common/.luacheckrc b/packages/create/templates/common/.luacheckrc new file mode 100644 index 00000000..9b043265 --- /dev/null +++ b/packages/create/templates/common/.luacheckrc @@ -0,0 +1,1697 @@ +--[[---------------------------------------------------------------------------- + LICENSE + .luacheckrc for Factorio Version 0.18.8, luacheck version 0.23.0 + This file is free and unencumbered software released into the public domain. + + Anyone is free to copy, modify, publish, use, compile, sell, or + distribute this file, either in source code form or as a compiled + binary, for any purpose, commercial or non-commercial, and by any + means. + + In jurisdictions that recognize copyright laws, the author or authors + of this file dedicate any and all copyright interest in the + software to the public domain. We make this dedication for the benefit + of the public at large and to the detriment of our heirs and + successors. We intend this dedication to be an overt act of + relinquishment in perpetuity of all present and future rights to this + software under copyright law. + + THE FILE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR + OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + + For more information, please refer to +------------------------------------------------------------------------------]] +local LINE_LENGTH = false + +local IGNORE = {'21./%w+_$', '21./^_%w+$', '213/[ijk]', '213/index', '213/key'} + +-- These globals are not available to the factorio API +local NOT_GLOBALS = {'coroutine', 'io', 'socket', 'dofile', 'loadfile'} + +local STD_CONTROL = 'lua52+factorio+factorio_control+stdlib+factorio_defines' +local STD_DATA = 'lua52+factorio+factorio_data+stdlib+stdlib_data+factorio_defines' + +-- For Base and Core Mods +local STD_BASE_DATA = 'lua52+factorio+factorio_data+factorio_defines+factorio_base_data' +local STD_BASE_CONTROL = 'lua52+factorio+factorio_control+factorio_defines+factorio_base_control' + +do -- Assume Factorio Control Stage as Default + std = STD_CONTROL + --cache = true + not_globals = NOT_GLOBALS + ignore = IGNORE + quiet = 1 -- pass -q option + max_cyclomatic_complexity = false + codes = true + max_line_length = LINE_LENGTH + max_code_line_length = LINE_LENGTH + max_string_line_length = LINE_LENGTH + max_comment_line_length = LINE_LENGTH + + --List of files and directories to exclude + exclude_files = { + --Ignore special folders + '**/.trash/', + '**/.history/', + '**/stdlib/vendor/', + + --Ignore development mods + '**/combat-tester/', + '**/test-maker/', + '**/trailer/', + + --Ignore love Includes + '**/love/includes/' + } +end + +do -- Set default prototype files + files['**/data.lua'].std = STD_DATA + files['**/data-updates.lua'].std = STD_DATA + files['**/data-final-fixes.lua'].std = STD_DATA + files['**/settings.lua'].std = STD_DATA + files['**/settings-updates.lua'].std = STD_DATA + files['**/settings-final-fixes.lua'].std = STD_DATA + files['**/prototypes/'].std = STD_DATA + files['**/settings/'].std = STD_DATA +end + +do -- Base and Core mod files + local base_scenarios = { + std = STD_BASE_CONTROL .. '+factorio_base_scenarios+factorio_base_story', + --ignore = {'212/event', '111', '112', '113', '211', '212', '213', '311', '411', '412', '421', '422', '423', '431', '432', '512'} + ignore = {'...'} + } + files['**/base/scenarios/'] = base_scenarios + files['**/base/tutorials/'] = base_scenarios + files['**/base/campaigns/'] = base_scenarios + files['**/wip-scenario/'] = base_scenarios + + files['**/base/migrations/'] = {std = STD_BASE_CONTROL} + + files['**/core/lualib/'] = {std = STD_BASE_CONTROL} + files['**/core/lualib/util.lua'] = {globals = {'util', 'table'}, ignore = {'432/object'}} + files['**/core/lualib/silo-script.lua'] = {globals = {'silo_script'}, ignore = {'4../player'}} + files['**/core/lualib/production-score.lua'] = {globals = {'production_score', 'get_price_recursive'}, ignore = {'4../player'}} + files['**/core/lualib/story*'] = {std = '+factorio_base_story', ignore = {'42./k', '42./filter'}} + files['**/core/lualib/mod-gui.lua'] = {globals = {'mod_gui'}} + files['**/core/lualib/camera.lua'] = {globals = {'camera'}} + files['**/core/lualib/builder.lua'] = {globals = {'Builder', 'builder', 'action', 'down', 'right'}} + + files['**/core/lualib/bonus-gui-ordering/'] = {std = STD_BASE_DATA} + files['**/core/lualib/dataloader.lua'] = {globals = {'data'}} + files['**/core/lualib/circuit-connector-*'] = {std = STD_BASE_DATA..'+factorio_circuit_connector_generated'} + files['**/core/lualib/bonus-gui-ordering.lua'] = {globals = {'bonus_gui_ordering'}} + + files['**/base/prototypes/'] = {std = STD_BASE_DATA} + files['**/core/prototypes/'] = {std = STD_BASE_DATA} + files['**/core/prototypes/noise-programs.lua'] = {ignore = {'212/x', '212/y', '212/tile', '212/map'}} +end + +do -- Stdlib Files + local stdlib_control = { + std = 'lua52+factorio+factorio_control+stdlib+factorio_defines', + max_line_length = LINE_LENGTH + } + + local stdlib_data = { + std = 'lua52+factorio+factorio_data+stdlib+factorio_defines', + max_line_length = LINE_LENGTH + } + + -- Assume control stage for stdlib + files['**/stdlib/'] = stdlib_control + + -- Assume generic lua for stdlib utils + files['**/stdlib/utils/**'].std = 'lua52+stdlib' + + -- STDLIB data stage files + files['**/stdlib/data/'] = stdlib_data + + -- STDLIB Busted Spec + files['**/spec/**'] = { + globals = {'serpent', 'log', 'package.remove_stdlib'}, + std = 'lua52c+busted+factorio_defines+factorio_control+stdlib' + } + + -- Love + files['**/love/'].std = 'luajit+love+love_extra+stdlib+stdlib_data' +end + +do -- Factorio STDs-- + stds.factorio = { + --Set the read only variables + read_globals = { + -- @log@: Gives writing access to Factorio's logger instance. + "log", + -- @serpent@: Lua serializer and pretty printer. (https://github.com/pkulchenko/serpent). + "serpent", + -- @table_size@: Returns the number of elements inside an LUA table. + "table_size", + -- @lldebugger@: Provided by lua local debugger vscode extension. + lldebugger = { + fields = {'requestBreak'}, + other_fields = true, + read_only = false, + }, + -- @__DebugAdapter@: Provided by Factorio Mod Debug vscode extension. + __DebugAdapter = { + fields = {'print', 'stepIgnoreAll', 'stepIgnore', 'breakpoint'}, + other_fields = true, + read_only = false + }, + util = { + fields = { + "by_pixel", "distance", "findfirstentity", "positiontostr", "formattime", "moveposition", "oppositedirection", + "ismoduleavailable", "multiplystripes", "format_number", "increment", "color", "conditional_return", + "add_shift", "merge", "premul_color", "encode", "decode", "insert_safe", + table = { + fields = { + "compare", "deepcopy" + }, + }, + }, + }, + table = { + fields = { + "compare", "deepcopy" + }, + }, + }, + } + + stds.factorio_control = { + read_globals = { + + -- @commands@: + commands = { + fields = { + "add_command", "commands", "game_commands", "remove_command" + }, + }, + + -- @settings@: + settings = { + fields = { + "get_player_settings", + startup = {other_fields = true}, + global = {other_fields = true}, + player = {other_fields = true}, + get = {read_only = false}, -- stdlib added + get_startup = {read_only = false} -- stdlib added + }, + }, + + -- @script@: Provides an interface for registering event handlers. + -- (http://lua-api.factorio.com/latest/LuaBootstrap.html) + script = { + fields = { + "on_event", "on_nth_tick", "on_configuration_changed", "on_init", "on_load", "generate_event_name", + "raise_event", "get_event_handler", "mod_name", "get_event_order", + "is_game_in_debug_mode", "object_name", "set_event_filter", "get_event_filter", "register_metatable", + active_mods = {read_only = true, other_fields = true}, + }, + other_fields = false, + }, + + -- @remote@: Allows inter-mod communication by providing a repository of interfaces that is shared by all mods. + -- (http://lua-api.factorio.com/latest/LuaRemote.html) + remote = { + fields = { + interfaces = {read_only = false, other_fields = true}, + "add_interface", "remove_interface", "call" + }, + read_only = true, + other_fields = false, + }, + + rcon = { + fields = {'print'} + }, + + rendering = { + other_fields = false, + read_only = true, + fields = { + 'draw_animation', + 'draw_line', + 'draw_text', + 'draw_circle', + 'draw_rectangle', + 'draw_arc', + 'draw_polygon', + 'draw_sprite', + 'draw_light', + 'destroy', + 'is_font_valid', + 'is_valid', + 'get_all_ids', + 'clear', + 'get_type', + 'get_surface', + 'get_time_to_live', + 'set_time_to_live', + 'get_forces', + 'set_forces', + 'get_players', + 'set_players', + 'get_color', + 'set_color', + 'get_width', + 'set_width', + 'get_from', + 'set_from', + 'get_to', + 'set_to', + 'get_gap_amount', + 'set_gap_amount', + 'get_gap_length', + 'set_gap_length', + 'get_target', + 'set_target', + 'get_orientation', + 'set_orientation', + 'get_scale', + 'set_scale', + 'get_text', + 'set_text', + 'get_font', + 'set_font', + 'get_alignment', + 'set_alignment', + 'get_scale_with_zoom', + 'set_scale_with_zoom', + 'get_filled', + 'set_filled', + 'get_radius', + 'set_radius', + 'get_left_top', + 'set_left_top', + 'get_right_bottom', + 'set_right_bottom', + 'get_max_radius', + 'set_max_radius', + 'get_min_radius', + 'set_min_radius', + 'get_start_angle', + 'set_start_angle', + 'get_angle', + 'set_angle', + 'get_vertices', + 'set_vertices', + 'get_sprite', + 'set_sprite', + 'get_x_scale', + 'set_x_scale', + 'get_y_scale', + 'set_y_scale', + 'get_render_layer', + 'set_render_layer', + 'get_orientation_target', + 'set_orientation_target', + 'get_oriented_offset', + 'set_oriented_offset', + 'get_intensity', + 'set_intensity', + 'get_minimum_darkness', + 'set_minimum_darkness' + } + }, + + -- @game@: Main object through which most of the API is accessed. + -- It is, however, not available inside handlers registered with @script.on_load@. + -- (http://lua-api.factorio.com/latest/LuaGameScript.html) + game ={ + other_fields = false, + read_only = false, + fields = { + 'auto_save', + 'ban_player', + 'check_consistency', + 'check_prototype_translations', + 'count_pipe_groups', + 'create_force', + 'create_profiler', + 'create_random_generator', + 'create_surface', + 'decode_string', + 'delete_surface', + 'direction_to_string', + 'disable_replay', + 'disable_tips_and_tricks', + 'disable_tutorial_triggers', + 'encode_string', + 'evaluate_expression', + 'force_crc', + 'get_active_entities_count', + 'get_entity_by_tag', + 'get_filtered_achievement_prototypes', + 'get_filtered_decorative_prototypes', + 'get_filtered_entity_prototypes', + 'get_filtered_equipment_prototypes', + 'get_filtered_fluid_prototypes', + 'get_filtered_item_prototypes', + 'get_filtered_mod_setting_prototypes', + 'get_filtered_recipe_prototypes', + 'get_filtered_technology_prototypes', + 'get_filtered_tile_prototypes', + 'get_map_exchange_string', + 'get_player', + 'get_surface', + 'get_train_stops', + 'help', + 'is_demo', + 'is_multiplayer', + 'is_valid_sound_path', + 'is_valid_sprite_path', + 'json_to_table', + 'kick_player', + 'log_recipe_locale', + 'merge_forces', + 'mute_player', + 'parse_map_exchange_string', + 'play_sound', + 'print', + 'print_stack_size', + 'purge_player', + 'regenerate_entity', + 'reload_mods', + 'reload_script', + 'remove_offline_players', + 'remove_path', + 'reset_time_played', + 'save_atlas', + 'server_save', + 'set_game_state', + 'set_wait_for_screenshots_to_finish', + 'show_message_dialog', + 'table_to_json', + 'take_screenshot', + 'take_technology_screenshot', + 'unban_player', + 'unmute_player', + 'write_file', + + achievement_prototypes = {read_only = true, other_fields = true}, + active_mods = {read_only = true, other_fields = true}, + ammo_category_prototypes = {read_only = true, other_fields = true}, + autoplace_control_prototypes = {read_only = true, other_fields = true}, + backer_names = {read_only = true, other_fields = true}, + connected_players = {read_only = true, other_fields = true}, + custom_input_prototypes = {read_only = true, other_fields = true}, + damage_prototypes = {read_only = true, other_fields = true}, + decorative_prototypes = {read_only = true, other_fields = true}, + default_map_gen_settings = {read_only = true, other_fields = true}, + difficulty = {read_only = true, other_fields = true}, + difficulty_settings = {read_only = true, other_fields = true}, + entity_prototypes = {read_only = true, other_fields = true}, + equipment_category_prototypes = {read_only = true, other_fields = true}, + equipment_grid_prototypes = {read_only = true, other_fields = true}, + equipment_prototypes = {read_only = true, other_fields = true}, + finished = {read_only = true, other_fields = true}, + fluid_prototypes = {read_only = true, other_fields = true}, + forces = {read_only = true, other_fields = true}, + fuel_category_prototypes = {read_only = true, other_fields = true}, + item_group_prototypes = {read_only = true, other_fields = true}, + item_prototypes = {read_only = true, other_fields = true}, + item_subgroup_prototypes = {read_only = true, other_fields = true}, + map_settings = {read_only = true, other_fields = true}, + mod_setting_prototypes = {read_only = true, other_fields = true}, + module_category_prototypes = {read_only = true, other_fields = true}, + named_noise_expressions = {read_only = true, other_fields = true}, + noise_layer_prototypes = {read_only = true, other_fields = true}, + object_name = {read_only = true, other_fields = true}, + particle_prototypes = {read_only = true, other_fields = true}, + permissions = {read_only = true, other_fields = true}, + player = {read_only = true, other_fields = true}, + players = {read_only = true, other_fields = true}, + pollution_statistics = {read_only = true, other_fields = true}, + recipe_category_prototypes = {read_only = true, other_fields = true}, + recipe_prototypes = {read_only = true, other_fields = true}, + resource_category_prototypes = {read_only = true, other_fields = true}, + shortcut_prototypes = {read_only = true, other_fields = true}, + styles = {read_only = true, other_fields = true}, + surfaces = {read_only = true, other_fields = true}, + technology_prototypes = {read_only = true, other_fields = true}, + tick = {read_only = true, other_fields = true}, + ticks_played = {read_only = true, other_fields = true}, + tile_prototypes = {read_only = true, other_fields = true}, + trivial_smoke_prototypes = {read_only = true, other_fields = true}, + virtual_signal_prototypes = {read_only = true, other_fields = true}, + + autosave_enabled = {read_only = false, other_fields = false}, + draw_resource_selection = {read_only = false, other_fields = false}, + enemy_has_vision_on_land_mines = {read_only = false, other_fields = false}, + speed = {read_only = false, other_fields = false}, + tick_paused = {read_only = false, other_fields = false}, + ticks_to_run = {read_only = false, other_fields = false} + }, + }, + }, + + globals = { + -- @global@: The global dictionary, useful for storing data persistent across a save-load cycle. + -- Writing access is given to the mod-id field (for mod-wise saved data). + -- (http://lua-api.factorio.com/latest/Global.html) + "global", + + -- @MOD@: Keep it organized, use this variable for anything that "NEEDS" to be global for some reason. + "MOD" + }, + } + + stds.factorio_data = { + + read_globals = { + data = { + fields = { + raw = { + other_fields = true, + read_only = false + }, + "extend", "is_demo" + }, + }, + + settings = { + fields = { + "startup", "global", "player", "get", "get_startup" + }, + }, + + mods = { + other_fields = true + }, + + -- Popular mods that think they need globals! + angelsmods = { + other_fields = true + }, + + -- Popular mods that think they need globals! + bobmods = { + other_fields = true + }, + + + } + } +end + +do -- Factorio Base/Core STDs-- + stds.factorio_base_control = { + read_globals = {"silo_script", "mod_gui", "camera"} + } + + stds.factorio_base_scenarios = { + globals = { + "check_automate_science_packs_advice", "check_research_hints", "check_supplies", "manage_attacks", "all_dead", + "on_win", "difficulty_number", "init_attack_data", "handle_attacks", "count_items_in_container", "progress", "scanned", + "check_light", "check_machine_gun", "level", "story_table", + + "tightspot_prices", "tightspot_make_offer", "tightspot_init", "tightspot_get_required_balance", + "tightspot_init_level", "tightspot_init_spending_frame", "tightspot_init_progress_frame", "tightspot_update_progress", "tightspot_update_spending", + "tightspot_get_missing_to_win", "tightspot_sell_back", "tightspot_start_level", "tightspot_show_level_description", "tightspot_update_speed_label", + "map_ignore", "tightspot_check_level", "land_price", + + "transport_belt_madness_init", "transport_belt_madness_init_level", "transport_belt_madness_create_chests", "transport_belt_madness_fill_chests", + "transport_belt_madness_start_level", "map_ignore", "map_clear", "map_load", "map_save", "transport_belt_madness_show_level_description", + "transport_belt_madness_check_level", "transport_belt_madness_next_level", "transport_belt_madness_clear_level", "transport_belt_madness_contains_next_level", + + "restricted", "check_built_items", "result", "disable_combat_technologies", "apply_character_modifiers", "apply_combat_modifiers", "apply_balance", + "load_config", "starting_area_constant", "create_next_surface", "end_round", "prepare_next_round", "silo_died","choose_joining_gui", + "destroy_joining_guis", "create_random_join_gui", "create_auto_assign_gui", "create_pick_join_gui", "create_config_gui", "make_config_table", "default", + "make_team_gui", "make_team_gui_config", "add_team_button_press", "trash_team_button_press", "remove_team_from_team_table", "add_team_to_team_table", + "set_teams_from_gui", "on_team_button_press", "make_color_dropdown", "create_balance_option", "create_disable_frame", "disable_frame", "parse_disabled_items", + "set_balance_settings", "config_confirm", "parse_config_from_gui", "get_color", "roll_starting_area", "delete_roll_surfaces", "auto_assign", + "destroy_config_for_all", "prepare_map", "set_evolution_factor", "update_players_on_team_count", "random_join", "init_player_gui", + "destroy_player_gui", "objective_button_press", "admin_button_press", "admin_frame_button_press", "diplomacy_button_press", "update_diplomacy_frame", + "diplomacy_frame_button_press", "team_changed_diplomacy", "diplomacy_check_press", "get_stance", "give_inventory", "setup_teams", "disable_items_for_all", + "set_random_team", "set_diplomacy", "create_spawn_positions", "set_spawn_position", "set_team_together_spawns", "chart_starting_area_for_force_spawns", + "check_starting_area_chunks_are_generated", "check_player_color", "check_round_start", "clear_starting_area_enemies", "check_no_rush_end", "check_no_rush_players", + "finish_setup", "chart_area_for_force", "setup_start_area_copy", "update_copy_progress", "update_progress_bar", "copy_paste_starting_area_tiles", + "copy_paste_starting_area_entities", "create_silo_for_force", "setup_research", "on_chunk_generated", "get_distance_to_nearest_spawn", + "create_wall_for_force", "fpn", "give_items", "create_item_frame", "create_technologies_frame", "create_cheat_frame", "create_day_frame", + "time_modifier", "points_per_second_start", "points_per_second_level_subtract", "levels", "update_info", "get_time_left", "update_time_left", + "on_joined", "make_frame", "update_frame", "update_table", "calculate_task_item_multiplayer", "setup_config", "select_from_probability_table", + "select_inventory", "select_equipment", "select_challange_type", "save_round_statistics", "start_challenge", "create_teams", "set_areas", + "decide_player_team", "set_teams", "refresh_leaderboard", "set_player", "generate_technology_list", "generate_research_task","setup_unlocks", + "check_technology_progress", "generate_production_task", "generate_shopping_list_task", "set_gui_flow_table", "create_visibility_button", + "check_item_lists", "update_task_gui", "check_end_of_round", "end_round_gui_update", "try_to_check_victory", "update_gui", "check_start_round", + "check_start_set_areas", "check_start_setting_entities", "check_set_areas", "check_clear_areas", "check_chests", "check_chests_shopping_list", + "check_chests_production", "check_input_chests", "fill_input_chests", "check_victory", "shopping_task_finished", "calculate_force_points", + "update_research_task_table", "update_production_task_table", "update_shopping_list_task_table", "create_joined_game_gui", "pre_ending_round", + "player_ending_prompt", "update_end_timer", "update_begin_timer", "team_finished", "save_points_list", "give_force_players_points", + "update_winners_list", "set_spectator", "set_character", "give_starting_inventory", "give_equipment", "shuffle_table", "format_time", + "spairs", "fill_leaderboard", "create_grid", "simple_entities", "save_map_data", "clear_map", "create_tiles", "recreate_entities", + "map_sets", "give_points", "init_forces", "init_globals", "init_unit_settings", "check_next_wave", "next_wave", "calculate_wave_power", + "wave_end", "make_next_spawn_tick", "check_spawn_units", "get_wave_units", "spawn_units", "randomize_ore", "set_command", "command_straglers", + "unit_config", "make_next_wave_tick", "time_to_next_wave", "time_to_wave_end", "rocket_died", "unit_died", "get_bounty_price", "setup_waypoints", + "insert_items", "give_starting_equipment", "give_spawn_equipment", "next_round_button_visible", "gui_init", "create_wave_frame", "create_money_frame", + "create_upgrade_gui", "update_upgrade_listing", "upgrade_research", "get_upgrades", "get_money", "update_connected_players", "update_round_number", + "set_research", "set_recipes", "check_deconstruction", "check_blueprint_placement", "loop_entities", "experiment_items", + "setup", "story_gui_click", "clear_surface", "add_run_trains_button", "puzzle_condition", "basic_signals", + "loop_trains", "Y_offset", "ghosts_1", "ghosts_2", "required_path", "through_wall_path", "count", "check_built_real_rail", + "current_ghosts_count", "other", "rails", "set_rails", "straight_section", "late_entities", "entities", "stop", + "get_spawn_coordinate", + + --tutorials + "intermission", "create_entities_on_tick", "on_player_created", "required_count", "non_player_entities", "clear_rails", + "chest", "damage", "furnace", "init_prototypes", "build_infi_table", "junk", "update_player_tags", "time_left", "team_production", + "create_task_frame", "create_visibilty_buttons", "update_leaderboard", "in_in_area" + } + } + + stds.factorio_base_data = { + globals = { + --Style + "make_cursor_box", "make_full_cursor_box", + "default_container_padding", "default_orange_color", "default_light_orange_color", "warning_red_color", + "achievement_green_color", "achievement_tan_color", "orangebuttongraphcialset", "bluebuttongraphcialset", + "bonus_gui_ordering", "trivial_smoke", "technology_slot_base_width", "technology_slot_base_height", "default_frame_font_vertical_compensation", + + --Belts + "transport_belt_connector_frame_sprites", "transport_belt_circuit_wire_connection_point", "transport_belt_circuit_wire_max_distance", + "transport_belt_circuit_connector_sprites", "ending_patch_prototype", "basic_belt_horizontal", "basic_belt_vertical", + "basic_belt_ending_top", "basic_belt_ending_bottom", "basic_belt_ending_side", "basic_belt_starting_top", "basic_belt_starting_bottom", + "basic_belt_starting_side", "fast_belt_horizontal", "fast_belt_vertical", "fast_belt_ending_top", "fast_belt_ending_bottom", + "fast_belt_ending_side", "fast_belt_starting_top", "fast_belt_starting_bottom", "fast_belt_starting_side", "express_belt_horizontal", + "express_belt_vertical", "express_belt_ending_top", "express_belt_ending_bottom", "express_belt_ending_side", "express_belt_starting_top", + "express_belt_starting_bottom", "express_belt_starting_side", + + --Circuit Connectors + "circuit_connector_definitions", "default_circuit_wire_max_distance", "inserter_circuit_wire_max_distance", + "universal_connector_template", "belt_connector_template", "belt_frame_connector_template", "inserter_connector_template", + + --Inserter Circuit Connectors + "inserter_circuit_wire_max_distance", "inserter_default_stack_control_input_signal", + + --Sounds/beams + "make_heavy_gunshot_sounds", "make_light_gunshot_sounds", "make_laser_sounds", + + --Gun/Laser + "gun_turret_extension", "gun_turret_extension_shadow", "gun_turret_extension_mask", "gun_turret_attack", + "laser_turret_extension", "laser_turret_extension_shadow", "laser_turret_extension_mask", + + --Pipes + "pipecoverspictures", "pipepictures", "assembler2pipepictures", "assembler3pipepictures", "make_heat_pipe_pictures", + + --Combinators + "generate_arithmetic_combinator", "generate_decider_combinator", "generate_constant_combinator", + + --Rail + "destroyed_rail_pictures", "rail_pictures", "rail_pictures_internal", "standard_train_wheels", "drive_over_tie", + "rolling_stock_back_light", "rolling_stock_stand_by_light", + + --Enemies + "make_enemy_autoplace", "make_enemy_spawner_autoplace", "make_enemy_worm_autoplace", + "make_spitter_attack_animation", "make_spitter_run_animation", "make_spitter_dying_animation", + "make_spitter_attack_parameters", "make_spitter_roars", "make_spitter_dying_sounds", + "make_spawner_idle_animation", "make_spawner_die_animation", + "make_biter_run_animation", "make_biter_attack_animation", "make_biter_die_animation", + "make_biter_roars", "make_biter_dying_sounds", "make_biter_calls", + "make_worm_roars", "make_worm_dying_sounds", "make_worm_folded_animation", "make_worm_preparing_animation", + "make_worm_prepared_animation", "make_worm_attack_animation", "make_worm_die_animation", + + --Other + "tile_variations_template", "make_water_autoplace_settings", + "make_unit_melee_ammo_type", "make_trivial_smoke", "make_4way_animation_from_spritesheet", "flying_robot_sounds", + "productivitymodulelimitation", "crash_trigger", "capsule_smoke", "make_beam", "playeranimations", + "make_blood_tint", "make_shadow_tint", + + --tiles + "water_transition_template", "make_water_transition_template", "water_autoplace_settings", "water_tile_type_names", + "patch_for_inner_corner_of_transition_between_transition", + } + } + + stds.factorio_base_story = { + globals = { + "story_init_helpers", "story_update_table", "story_init", "story_update", "story_on_tick", "story_add_update", + "story_remove_update", "story_jump_to", "story_elapsed", "story_elapsed_check", "story_show_message_dialog", + "set_goal", "player_set_goal", "on_player_joined", "flash_goal", "set_info", "player_set_info", "export_entities", + "list", "recreate_entities", "entity_to_connect", "limit_camera", "find_gui_recursive", "enable_entity_export", + "add_button", "on_gui_click", "set_continue_button_style", "add_message_log", "story_add_message_log", + "player_add_message_log", "message_log_frame", "message_log_scrollpane", "message_log_close_button", + "message_log_table", "toggle_message_log_button", "toggle_objective_button", "message_log_init", + "add_gui_recursive", "add_toggle_message_log_button", "add_toggle_objective_button", "mod_gui", + "flash_message_log_button", "flash_message_log_on_tick", "story_gui_click", "story_points_by_name", "story_branches", + "player", "surface", "deconstruct_on_tick", "recreate_entities_on_tick", "flying_congrats", "story_table" + } + } + + stds.factorio_circuit_connector_generated = { + globals = { + 'default_circuit_wire_max_distance', 'circuit_connector_definitions', 'universal_connector_template', + 'belt_connector_template', 'belt_frame_connector_template', 'inserter_connector_template', 'inserter_connector_template', + 'inserter_circuit_wire_max_distance', 'inserter_default_stack_control_input_signal', 'transport_belt_connector_frame_sprites', + 'transport_belt_circuit_wire_max_distance', + } + } +end + +do -- STDLIB STDs-- + stds.stdlib = { + read_globals = {}, + globals = { + 'STDLIB', + 'prequire', + 'rawtostring', + 'traceback', + 'data_traceback', + 'inspect', + 'serpent', + 'inline_if', + 'install', + 'log', + 'concat', + 'GAME', + 'AREA', + 'POSITION', + 'TILE', + 'SURFACE', + 'CHUNK', + 'COLOR', + 'ENTITY', + 'INVENTORY', + 'RESOURCE', + 'CONFIG', + 'LOGGER', + 'QUEUE', + 'EVENT', + 'GUI', + 'PLAYER', + 'FORCE', + 'MATH', + 'STRING', + 'TABLE' + } + } + + stds.stdlib_control = {} + + stds.stdlib_data = { + globals = { + 'DATA', + 'RECIPE', + 'ITEM', + 'FLUID', + 'ENTITY', + 'TECHNOLOGY', + 'CATEGORY' + } + } +end + +do -- Love STDs + stds.love_extra = { + read_globals = { + love = { + fields = { + arg = { + fields = { + 'parseGameArguments', + 'parseOption', + 'getLow', + 'optionIndices', + 'options' + } + } + } + }, + 'coroutine', + 'io', + 'socket', + 'dofile', + 'loadfile' + }, + globals = { + love = { + fields = { + 'handlers' + } + } + } + } +end + +do -- Factorio Defines STDs-- + stds.factorio_defines = { + read_globals = { + defines = { + fields = { + alert_type = { + fields = { + 'entity_destroyed', + 'entity_under_attack', + 'not_enough_construction_robots', + 'no_material_for_construction', + 'not_enough_repair_packs', + 'turret_fire', + 'custom', + 'no_storage', + 'train_out_of_fuel', + 'fluid_mixing' + } + }, + behavior_result = { + fields = { + 'in_progress', + 'fail', + 'success', + 'deleted' + } + }, + build_check_type = { + fields = { + 'script', + 'manual', + 'ghost_place', + 'ghost_revive' + } + }, + chain_signal_state = { + fields = { + 'all_open', + 'partially_open', + 'none_open' + } + }, + chunk_generated_status = { + fields = { + 'nothing', + 'custom_tiles', + 'basic_tiles', + 'corrected_tiles', + 'tiles', + 'entities' + } + }, + circuit_condition_index = { + fields = { + 'inserter_circuit', + 'inserter_logistic', + 'lamp', + 'arithmetic_combinator', + 'decider_combinator', + 'constant_combinator', + 'offshore_pump', + 'pump' + } + }, + circuit_connector_id = { + fields = { + 'accumulator', + 'constant_combinator', + 'container', + 'programmable_speaker', + 'rail_signal', + 'rail_chain_signal', + 'roboport', + 'storage_tank', + 'wall', + 'electric_pole', + 'inserter', + 'lamp', + 'combinator_input', + 'combinator_output', + 'offshore_pump', + 'pump' + } + }, + command = { + fields = { + 'attack', + 'go_to_location', + 'compound', + 'group', + 'attack_area', + 'wander', + 'flee', + 'stop', + 'build_base' + } + }, + compound_command = { + fields = { + 'logical_and', + 'logical_or', + 'return_last' + } + }, + control_behavior = { + fields = { + inserter = { + fields = { + circuit_mode_of_operation = { + fields = { + 'enable_disable', + 'set_filters', + 'read_hand_contents', + 'set_stack_size' + } + }, + hand_read_mode = { + fields = { + 'hold', + 'pulse' + } + } + } + }, + lamp = { + fields = { + circuit_mode_of_operation = { + fields = { + 'use_colors' + } + } + } + }, + logistic_container = { + fields = { + circuit_mode_of_operation = { + fields = { + 'send_contents', + 'set_requests' + } + } + } + }, + mining_drill = { + fields = { + resource_read_mode = { + fields = { + 'this_miner', + 'entire_patch' + } + } + } + }, + roboport = { + fields = { + circuit_mode_of_operation = { + fields = { + 'read_logistics', + 'read_robot_stats' + } + } + } + }, + train_stop = { + fields = { + circuit_mode_of_operation = { + fields = { + 'enable_disable', + 'send_to_train', + 'read_from_train', + 'read_stopped_train' + } + } + } + }, + transport_belt = { + fields = { + content_read_mode = { + fields = { + 'pulse', + 'hold' + } + } + } + }, + type = { + fields = { + 'container', + 'generic_on_off', + 'inserter', + 'lamp', + 'logistic_container', + 'roboport', + 'storage_tank', + 'train_stop', + 'decider_combinator', + 'arithmetic_combinator', + 'constant_combinator', + 'transport_belt', + 'accumulator', + 'rail_signal', + 'rail_chain_signal', + 'wall', + 'mining_drill', + 'programmable_speaker' + } + } + } + }, + controllers = { + fields = { + 'ghost', + 'character', + 'god', + 'editor', + 'cutscene', + 'spectator' + } + }, + deconstruction_item = { + fields = { + entity_filter_mode = { + fields = { + 'whitelist', + 'blacklist' + } + }, + tile_filter_mode = { + fields = { + 'whitelist', + 'blacklist' + } + }, + tile_selection_mode = { + fields = { + 'normal', + 'always', + 'never', + 'only' + } + } + } + }, + difficulty = { + fields = { + 'easy', + 'normal', + 'hard' + } + }, + difficulty_settings = { + fields = { + recipe_difficulty = { + fields = { + 'normal', + 'expensive' + } + }, + technology_difficulty = { + fields = { + 'normal', + 'expensive' + } + } + } + }, + direction = { + fields = { + 'north', + 'northeast', + 'east', + 'southeast', + 'south', + 'southwest', + 'west', + 'northwest' + } + }, + distraction = { + fields = { + 'by_enemy', + 'by_anything', + 'by_damage' + } + }, + entity_status = { + fields = { + 'working', + 'no_power', + 'no_fuel', + 'no_recipe', + 'no_input_fluid', + 'no_research_in_progress', + 'no_minable_resources', + 'low_input_fluid', + 'low_power', + 'disabled_by_control_behavior', + 'disabled_by_script', + 'fluid_ingredient_shortage', + 'fluid_production_overload', + 'item_ingredient_shortage', + 'item_production_overload', + 'marked_for_deconstruction', + 'missing_required_fluid', + 'missing_science_packs', + 'waiting_for_source_items', + 'waiting_for_space_in_destination', + 'waiting_to_launch_rocket' + } + }, + events = { + fields = { + 'on_tick', + 'on_gui_click', + 'on_gui_confirmed', + 'on_gui_text_changed', + 'on_gui_checked_state_changed', + 'on_entity_died', + 'on_post_entity_died', + 'on_entity_damaged', + 'on_picked_up_item', + 'on_built_entity', + 'on_sector_scanned', + 'on_player_mined_item', + 'on_put_item', + 'on_rocket_launched', + 'on_pre_player_mined_item', + 'on_chunk_generated', + 'on_player_crafted_item', + 'on_robot_built_entity', + 'on_robot_pre_mined', + 'on_robot_mined', + 'on_research_started', + 'on_research_finished', + 'on_player_rotated_entity', + 'on_player_set_quickbar_slot', + 'on_marked_for_deconstruction', + 'on_cancelled_deconstruction', + 'on_trigger_created_entity', + 'on_trigger_fired_artillery', + 'on_train_changed_state', + 'on_player_created', + 'on_resource_depleted', + 'on_player_driving_changed_state', + 'on_force_created', + 'on_forces_merging', + 'on_player_cursor_stack_changed', + 'on_pre_entity_settings_pasted', + 'on_entity_settings_pasted', + 'on_player_main_inventory_changed', + 'on_player_armor_inventory_changed', + 'on_player_ammo_inventory_changed', + 'on_player_gun_inventory_changed', + 'on_player_placed_equipment', + 'on_player_removed_equipment', + 'on_pre_player_died', + 'on_player_died', + 'on_player_respawned', + 'on_player_joined_game', + 'on_player_left_game', + 'on_player_built_tile', + 'on_player_mined_tile', + 'on_robot_built_tile', + 'on_robot_mined_tile', + 'on_player_selected_area', + 'on_player_alt_selected_area', + 'on_player_changed_surface', + 'on_selected_entity_changed', + 'on_market_item_purchased', + 'on_player_dropped_item', + 'on_biter_base_built', + 'on_player_changed_force', + 'on_entity_renamed', + 'on_gui_selection_state_changed', + 'on_runtime_mod_setting_changed', + 'on_difficulty_settings_changed', + 'on_surface_created', + 'on_surface_deleted', + 'on_pre_surface_deleted', + 'on_player_mined_entity', + 'on_robot_mined_entity', + 'on_train_created', + 'on_gui_elem_changed', + 'on_player_setup_blueprint', + 'on_player_deconstructed_area', + 'on_player_configured_blueprint', + 'on_console_chat', + 'on_console_command', + 'on_player_removed', + 'on_pre_player_removed', + 'on_player_used_capsule', + 'script_raised_built', + 'script_raised_destroy', + 'script_raised_revive', + 'on_player_promoted', + 'on_player_demoted', + 'on_combat_robot_expired', + 'on_player_changed_position', + 'on_mod_item_opened', + 'on_gui_opened', + 'on_gui_closed', + 'on_gui_value_changed', + 'on_player_muted', + 'on_player_unmuted', + 'on_player_cheat_mode_enabled', + 'on_player_cheat_mode_disabled', + 'on_character_corpse_expired', + 'on_pre_ghost_deconstructed', + 'on_player_pipette', + 'on_player_display_resolution_changed', + 'on_player_display_scale_changed', + 'on_pre_player_crafted_item', + 'on_player_cancelled_crafting', + 'on_chunk_charted', + 'on_technology_effects_reset', + 'on_land_mine_armed', + 'on_forces_merged', + 'on_player_trash_inventory_changed', + 'on_pre_player_left_game', + 'on_pre_surface_cleared', + 'on_surface_cleared', + 'on_chunk_deleted', + 'on_pre_chunk_deleted', + 'on_train_schedule_changed', + 'on_player_banned', + 'on_player_kicked', + 'on_player_unbanned', + 'on_rocket_launch_ordered', + 'on_script_path_request_finished', + 'on_ai_command_completed', + 'on_marked_for_upgrade', + 'on_cancelled_upgrade', + 'on_player_toggled_map_editor', + 'on_entity_cloned', + 'on_area_cloned', + 'on_brush_cloned', + 'on_game_created_from_scenario', + 'on_surface_imported', + 'on_surface_renamed', + 'on_player_toggled_alt_mode', + 'on_player_repaired_entity', + 'on_player_fast_transferred', + 'on_pre_robot_exploded_cliff', + 'on_robot_exploded_cliff', + 'on_entity_spawned', + 'on_cutscene_waypoint_reached', + 'on_unit_group_created', + 'on_unit_added_to_group', + 'on_unit_removed_from_group', + 'on_unit_group_finished_gathering', + 'on_build_base_arrived', + 'on_chart_tag_added', + 'on_chart_tag_modified', + 'on_chart_tag_removed', + 'on_lua_shortcut', + 'on_gui_location_changed', + 'on_gui_selected_tab_changed', + 'on_gui_switch_state_changed', + 'on_force_cease_fire_changed', + 'on_force_friends_changed', + 'on_string_translated', + 'on_script_trigger_effect' + } + }, + flow_precision_index = { + fields = { + 'one_second', + 'one_minute', + 'ten_minutes', + 'one_hour', + 'ten_hours', + 'fifty_hours', + 'two_hundred_fifty_hours', + 'one_thousand_hours' + } + }, + group_state = { + fields = { + 'gathering', + 'moving', + 'attacking_distraction', + 'attacking_target', + 'finished', + 'pathfinding', + 'wander_in_group' + } + }, + gui_type = { + fields = { + 'entity', + 'research', + 'controller', + 'production', + 'item', + 'bonus', + 'trains', + 'achievement', + 'blueprint_library', + 'equipment', + 'logistic', + 'other_player', + 'kills', + 'permissions', + 'tutorials', + 'custom', + 'server_management', + 'player_management', + 'tile' + } + }, + input_action = { + fields = { + 'activate_copy', + 'activate_cut', + 'activate_paste', + 'add_permission_group', + 'add_train_station', + 'admin_action', + 'alt_select_area', + 'alt_select_blueprint_entities', + 'alternative_copy', + 'begin_mining', + 'begin_mining_terrain', + 'build_item', + 'build_rail', + 'build_terrain', + 'cancel_craft', + 'cancel_deconstruct', + 'cancel_new_blueprint', + 'cancel_research', + 'cancel_upgrade', + 'change_active_item_group_for_crafting', + 'change_active_item_group_for_filters', + 'change_active_quick_bar', + 'change_arithmetic_combinator_parameters', + 'change_blueprint_book_record_label', + 'change_decider_combinator_parameters', + 'change_item_label', + 'change_multiplayer_config', + 'change_picking_state', + 'change_programmable_speaker_alert_parameters', + 'change_programmable_speaker_circuit_parameters', + 'change_programmable_speaker_parameters', + 'change_riding_state', + 'change_shooting_state', + 'change_single_blueprint_record_label', + 'change_train_stop_station', + 'change_train_wait_condition', + 'change_train_wait_condition_data', + 'clean_cursor_stack', + 'clear_selected_blueprint', + 'clear_selected_deconstruction_item', + 'clear_selected_upgrade_item', + 'connect_rolling_stock', + 'copy', + 'copy_entity_settings', + 'craft', + 'create_blueprint_like', + 'cursor_split', + 'cursor_transfer', + 'custom_input', + 'cycle_blueprint_book_backwards', + 'cycle_blueprint_book_forwards', + 'deconstruct', + 'delete_blueprint_library', + 'delete_blueprint_record', + 'delete_custom_tag', + 'delete_permission_group', + 'destroy_opened_item', + 'disconnect_rolling_stock', + 'drag_train_schedule', + 'drag_train_wait_condition', + 'drop_blueprint_record', + 'drop_item', + 'drop_to_blueprint_book', + 'edit_custom_tag', + 'edit_permission_group', + 'export_blueprint', + 'fast_entity_split', + 'fast_entity_transfer', + 'go_to_train_station', + 'grab_blueprint_record', + 'gui_checked_state_changed', + 'gui_click', + 'gui_confirmed', + 'gui_elem_changed', + 'gui_location_changed', + 'gui_selected_tab_changed', + 'gui_selection_state_changed', + 'gui_switch_state_changed', + 'gui_text_changed', + 'gui_value_changed', + 'import_blueprint', + 'import_blueprint_string', + 'import_permissions_string', + 'inventory_split', + 'inventory_transfer', + 'launch_rocket', + 'lua_shortcut', + 'map_editor_action', + 'market_offer', + 'mod_settings_changed', + 'open_achievements_gui', + 'open_blueprint_library_gui', + 'open_blueprint_record', + 'open_bonus_gui', + 'open_character_gui', + 'open_equipment', + 'open_gui', + 'open_item', + 'open_kills_gui', + 'open_logistic_gui', + 'open_mod_item', + 'open_production_gui', + 'open_technology_gui', + 'open_train_gui', + 'open_train_station_gui', + 'open_trains_gui', + 'open_tutorials_gui', + 'paste_entity_settings', + 'place_equipment', + 'quick_bar_pick_slot', + 'quick_bar_set_selected_page', + 'quick_bar_set_slot', + 'remove_cables', + 'remove_train_station', + 'reset_assembling_machine', + 'rotate_entity', + 'select_area', + 'select_blueprint_entities', + 'select_entity_slot', + 'select_item', + 'select_mapper_slot', + 'select_next_valid_gun', + 'select_tile_slot', + 'set_auto_launch_rocket', + 'set_autosort_inventory', + 'set_behavior_mode', + 'set_car_weapons_control', + 'set_circuit_condition', + 'set_circuit_mode_of_operation', + 'set_deconstruction_item_tile_selection_mode', + 'set_deconstruction_item_trees_and_rocks_only', + 'set_entity_color', + 'set_entity_energy_property', + 'set_filter', + 'set_heat_interface_mode', + 'set_heat_interface_temperature', + 'set_infinity_container_filter_item', + 'set_infinity_container_remove_unfiltered_items', + 'set_infinity_pipe_filter', + 'set_inserter_max_stack_size', + 'set_inventory_bar', + 'set_logistic_filter_item', + 'set_logistic_filter_signal', + 'set_logistic_trash_filter_item', + 'set_request_from_buffers', + 'set_research_finished_stops_game', + 'set_signal', + 'set_single_blueprint_record_icon', + 'set_splitter_priority', + 'set_train_stopped', + 'setup_assembling_machine', + 'setup_blueprint', + 'setup_single_blueprint_record', + 'smart_pipette', + 'stack_split', + 'stack_transfer', + 'start_repair', + 'start_research', + 'start_walking', + 'stop_building_by_moving', + 'switch_connect_to_logistic_network', + 'switch_constant_combinator_state', + 'switch_inserter_filter_mode_state', + 'switch_power_switch_state', + 'switch_to_rename_stop_gui', + 'take_equipment', + 'toggle_deconstruction_item_entity_filter_mode', + 'toggle_deconstruction_item_tile_filter_mode', + 'toggle_driving', + 'toggle_enable_vehicle_logistics_while_moving', + 'toggle_equipment_movement_bonus', + 'toggle_map_editor', + 'toggle_personal_roboport', + 'toggle_show_entity_info', + 'translate_string', + 'undo', + 'upgrade', + 'upgrade_opened_blueprint', + 'use_artillery_remote', + 'use_item', + 'wire_dragging', + 'write_to_console' + } + }, + inventory = { + fields = { + 'fuel', + 'burnt_result', + 'chest', + 'furnace_source', + 'furnace_result', + 'furnace_modules', + 'character_main', + 'character_guns', + 'character_ammo', + 'character_armor', + 'character_vehicle', + 'character_trash', + 'god_main', + 'editor_main', + 'editor_guns', + 'editor_ammo', + 'editor_armor', + 'roboport_robot', + 'roboport_material', + 'robot_cargo', + 'robot_repair', + 'assembling_machine_input', + 'assembling_machine_output', + 'assembling_machine_modules', + 'lab_input', + 'lab_modules', + 'mining_drill_modules', + 'item_main', + 'rocket_silo_rocket', + 'rocket_silo_result', + 'rocket', + 'car_trunk', + 'car_ammo', + 'cargo_wagon', + 'turret_ammo', + 'beacon_modules', + 'character_corpse', + 'artillery_turret_ammo', + 'artillery_wagon_ammo' + } + }, + logistic_member_index = { + fields = { + 'logistic_container', + 'vehicle_storage', + 'character_requester', + 'character_storage', + 'character_provider', + 'generic_on_off_behavior' + } + }, + logistic_mode = { + fields = { + 'active_provider', + 'storage', + 'requester', + 'passive_provider', + 'buffer' + } + }, + mouse_button_type = { + fields = { + 'left', + 'right', + 'middle' + } + }, + rail_connection_direction = { + fields = { + 'left', + 'straight', + 'right' + } + }, + rail_direction = { + fields = { + 'front', + 'back' + } + }, + render_mode = { + fields = { + 'game', + 'chart', + 'chart_zoomed_in' + } + }, + rich_text_setting = { + fields = { + 'enabled', + 'disabled', + 'highlight' + } + }, + riding = { + fields = { + acceleration = { + fields = { + 'nothing', + 'accelerating', + 'braking', + 'reversing' + } + }, + direction = { + fields = { + 'left', + 'straight', + 'right' + } + } + } + }, + shooting = { + fields = { + 'not_shooting', + 'shooting_enemies', + 'shooting_selected' + } + }, + signal_state = { + fields = { + 'open', + 'closed', + 'reserved', + 'reserved_by_circuit_network' + } + }, + train_state = { + fields = { + 'on_the_path', + 'path_lost', + 'no_schedule', + 'no_path', + 'arrive_signal', + 'wait_signal', + 'arrive_station', + 'wait_station', + 'manual_control_stop', + 'manual_control' + } + }, + transport_line = { + fields = { + 'left_line', + 'right_line', + 'left_underground_line', + 'right_underground_line', + 'secondary_left_line', + 'secondary_right_line', + 'left_split_line', + 'right_split_line', + 'secondary_left_split_line', + 'secondary_right_split_line' + } + }, + wire_connection_id = { + fields = { + 'electric_pole', + 'power_switch_left', + 'power_switch_right' + } + }, + wire_type = { + fields = { + 'red', + 'green', + 'copper' + } + }, + -- Defines additional modules + color = { + other_fields = true + }, + anticolor = { + other_fields = true + }, + lightcolor = { + other_fields = true + }, + time = { + fields = { + 'second', + 'minute', + 'hour', + 'day', + 'week', + 'month', + 'year' + } + } + } + } + } + } +end + +--[[ Options + "ignore", "std", "globals", "unused_args", "self", "compat", "global", "unused", "redefined", + "unused_secondaries", "allow_defined", "allow_defined_top", "module", + "read_globals", "new_globals", "new_read_globals", "enable", "only", "not_globals", + "max_line_length", "max_code_line_length", "max_string_line_length", "max_comment_line_length", + "max_cyclomatic_complexity" +--]] + +--[[ Warnings list + -- 011 A syntax error. + -- 021 An invalid inline option. + -- 022 An unpaired inline push directive. + -- 023 An unpaired inline pop directive. + -- 111 Setting an undefined global variable. + -- 112 Mutating an undefined global variable. + -- 113 Accessing an undefined global variable. + -- 121 Setting a read-only global variable. + -- 122 Setting a read-only field of a global variable. + -- 131 Unused implicitly defined global variable. + -- 142 Setting an undefined field of a global variable. + -- 143 Accessing an undefined field of a global variable. + -- 211 Unused local variable. + -- 212 Unused argument. + -- 213 Unused loop variable. + -- 221 Local variable is accessed but never set. + -- 231 Local variable is set but never accessed. + -- 232 An argument is set but never accessed. + -- 233 Loop variable is set but never accessed. + -- 241 Local variable is mutated but never accessed. + -- 311 Value assigned to a local variable is unused. + -- 312 Value of an argument is unused. + -- 313 Value of a loop variable is unused. + -- 314 Value of a field in a table literal is unused. + -- 321 Accessing uninitialized local variable. + -- 331 Value assigned to a local variable is mutated but never accessed. + -- 341 Mutating uninitialized local variable. + -- 411 Redefining a local variable. + -- 412 Redefining an argument. + -- 413 Redefining a loop variable. + -- 421 Shadowing a local variable. + -- 422 Shadowing an argument. + -- 423 Shadowing a loop variable. + -- 431 Shadowing an upvalue. + -- 432 Shadowing an upvalue argument. + -- 433 Shadowing an upvalue loop variable. + -- 511 Unreachable code. + -- 512 Loop can be executed at most once. + -- 521 Unused label. + -- 531 Left-hand side of an assignment is too short. + -- 532 Left-hand side of an assignment is too long. + -- 541 An empty do end block. + -- 542 An empty if branch. + -- 551 An empty statement. + -- 611 A line consists of nothing but whitespace. + -- 612 A line contains trailing whitespace. + -- 613 Trailing whitespace in a string. + -- 614 Trailing whitespace in a comment. + -- 621 Inconsistent indentation (SPACE followed by TAB). + -- 631 Line is too long. +--]] diff --git a/packages/create/templates/common/.npmignore b/packages/create/templates/common/.npmignore new file mode 100644 index 00000000..fbac3cb2 --- /dev/null +++ b/packages/create/templates/common/.npmignore @@ -0,0 +1,3 @@ +webpack.config.js +dist/web/build +dist/browser diff --git a/packages/create/templates/common/module/control.lua b/packages/create/templates/common/module/control.lua new file mode 100644 index 00000000..2747b83b --- /dev/null +++ b/packages/create/templates/common/module/control.lua @@ -0,0 +1,54 @@ +local clusterio_api = require("modules/clusterio/api") + +--- Top level module table, contains event handlers and public methods +local MyModule = { + events = {}, + on_nth_tick = {}, +} + +--- global is 'synced' between players, you should use your plugin name to avoid conflicts +-- setupGlobalData should either be removed or called during clusterio_api.events.on_server_startup +local globalData = {} +local function setupGlobalData() + if global["__plugin_name__"] == nil then + global["__plugin_name__"] = { + -- starting values go here + } + end + globalData = global["__plugin_name__"] +end + +--- Public methods should be available though your top level module table +function MyModule.foo() + game.print("foo") +end + +--- Private methods should be local to the file, this will prevent others from calling it +local function bar() + game.print("bar") +end + +--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_load +MyModule.events[clusterio_api.events.on_server_startup] = function(event) + setupGlobalData() + game.print(game.table_to_json(event)) +end + +--- Factorio events are accessible through defines.events, you can have one handler per event per module +MyModule.events[defines.events.on_player_crafted_item] = function(event) + game.print(game.table_to_json(event)) +//%if instance + clusterio_api.send_json("__plugin_name__-plugin_example_ipc", { + tick = game.tick, player_name = game.get_player(event.player_index).name + }) +//%endif +end + +--- Nth tick is a special case that requires its own table, the index represents the time period between calls in ticks +MyModule.on_nth_tick[300] = function() + game.print(game.tick) + bar() +end + +--- Always return the top level module table from control, this is how clusterio will access your event handlers +return MyModule diff --git a/packages/create/templates/common/module/globals.lua b/packages/create/templates/common/module/globals.lua new file mode 100644 index 00000000..b4d0bea0 --- /dev/null +++ b/packages/create/templates/common/module/globals.lua @@ -0,0 +1,3 @@ +--- Expose any globals here, typically just your control file if you want to access it through rcon or commands +-- luacheck: globals __plugin_name__ +__plugin_name__ = require("modules/__plugin_name__/control") diff --git a/packages/create/templates/common/module/module.json b/packages/create/templates/common/module/module.json new file mode 100644 index 00000000..9df0e849 --- /dev/null +++ b/packages/create/templates/common/module/module.json @@ -0,0 +1,14 @@ +{ + "name": "__plugin_name__", + "load": [ + "control.lua" + ], + "require": [ +//%if instance + "globals.lua" +//%endif + ], + "dependencies": { + "clusterio": "*" + } +} diff --git a/packages/create/templates/common/module/module_exports.lua b/packages/create/templates/common/module/module_exports.lua new file mode 100644 index 00000000..d8dd0ae4 --- /dev/null +++ b/packages/create/templates/common/module/module_exports.lua @@ -0,0 +1,3 @@ +--- Can contain anything you want to allow other plugins to have access to, this example exposes the control file +-- Access the exports from other modules using require("modules/__plugin_name__") +return require("modules/__plugin_name__/control") diff --git a/packages/create/templates/common/package.json b/packages/create/templates/common/package.json new file mode 100644 index 00000000..66b20070 --- /dev/null +++ b/packages/create/templates/common/package.json @@ -0,0 +1,53 @@ +{ + "name": "__plugin_name__", + "version": "0.1.0", + "description": "Example Description. Package. Change me in package.json", +//%if typescript + "main": "dist/node/index.js", +//%endif +//%if !typescript + "main": "index.js", +//%endif + "scripts": { + "prepare": "__prepare__" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@clusterio/lib": "^2.0.0-alpha.17" + }, + "devDependencies": { +//%// Typescript and type declarations +//%if typescript + "typescript": "^5.1.6", + "@types/node": "^20.4.5", +//%endif +//%if typescript & web + "@types/react": "^18.2.21", +//%endif +//%// Everything else +//%if web + "antd": "^5.13.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", +//%endif +//%if web | controller // The controller uses webpack even when there is no plugin code + "webpack": "^5.88.2", + "webpack-cli": "^5.1.4", + "webpack-merge": "^5.9.0", + "@clusterio/web_ui": "^2.0.0-alpha.17", +//%endif + "@clusterio/lib": "^2.0.0-alpha.17" + }, + "dependencies": { + "@sinclair/typebox": "^0.30.4" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "clusterio", + "factorio" + ] +} diff --git a/packages/create/templates/common/webpack.config.js b/packages/create/templates/common/webpack.config.js new file mode 100644 index 00000000..bf153d04 --- /dev/null +++ b/packages/create/templates/common/webpack.config.js @@ -0,0 +1,36 @@ +"use strict"; +const path = require("path"); +const webpack = require("webpack"); +const { merge } = require("webpack-merge"); + +const common = require("@clusterio/web_ui/webpack.common"); + +module.exports = (env = {}) => merge(common(env), { + context: __dirname, + entry: "./web/index.__ext__x", + output: { + path: path.resolve(__dirname, "dist", "web"), + }, + plugins: [ + new webpack.container.ModuleFederationPlugin({ + name: "__plugin_name__", + library: { type: "window", name: "plugin___plugin_name__" }, + exposes: { + "./": "./index.__ext__", + "./package.json": "./package.json", +//%if web + "./web": "./web/index.__ext__x", +//%endif + }, + shared: { + "@clusterio/lib": { import: false }, + "@clusterio/web_ui": { import: false }, +//%if web + "antd": { import: false }, + "react": { import: false }, + "react-dom": { import: false }, +//%endif + }, + }), + ], +}); diff --git a/packages/create/templates/plugin-js/controller.js b/packages/create/templates/plugin-js/controller.js new file mode 100644 index 00000000..266e06fa --- /dev/null +++ b/packages/create/templates/plugin-js/controller.js @@ -0,0 +1,73 @@ +"use strict"; +const lib = require("@clusterio/lib"); +const { BaseControllerPlugin } = require("@clusterio/controller"); + +const { + PluginExampleEvent, PluginExampleRequest, +//%if controller & web // Subscribing requires web content and the controller + ExampleSubscribableUpdate, ExampleSubscribableValue, +//%endif +} = require("./messages"); + +class ControllerPlugin extends BaseControllerPlugin { +//%if controller & web // Subscribing requires web content and the controller + exampleDatabase; + storageDirty = false; + +//%endif + async init() { + this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); + this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); +//%if controller & web // Subscribing requires web content and the controller + this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this)); + this.exampleDatabase = new Map(); // If needed, replace with loading from database file +//%endif + } + + async onControllerConfigFieldChanged(field, curr, prev) { + this.logger.info(`controller::onControllerConfigFieldChanged ${field}`); + } +//%if instance + + async onInstanceConfigFieldChanged(instance, field, curr, prev) { + this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`); + } +//%endif + + async onSaveData() { + this.logger.info("controller::onSaveData"); + } + + async onShutdown() { + this.logger.info("controller::onShutdown"); + } + + async onPlayerEvent(instance, event) { + this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`); + } + + async handlePluginExampleEvent(event) { + this.logger.info(JSON.stringify(event)); + } + + async handlePluginExampleRequest(request) { + this.logger.info(JSON.stringify(request)); + return { + myResponseString: request.myString, + myResponseNumbers: request.myNumberArray, + }; + } +//%if controller & web // Subscribing requires web content and the controller + + async handleExampleSubscription(request) { + const values = [...this.exampleDatabase.values()].filter( + value => value.updatedAtMs > request.lastRequestTimeMs, + ); + return values.length ? new ExampleSubscribableUpdate(values) : null; + } +//%endif +} + +module.exports = { + ControllerPlugin, +}; diff --git a/packages/create/templates/plugin-js/ctl.js b/packages/create/templates/plugin-js/ctl.js new file mode 100644 index 00000000..ed1d1a32 --- /dev/null +++ b/packages/create/templates/plugin-js/ctl.js @@ -0,0 +1,31 @@ +"use strict"; +const { BaseCtlPlugin } = require("@clusterio/ctl"); +const { CommandTree, Command } = require("@clusterio/lib"); +const { PluginExampleEvent, PluginExampleRequest } = require("./messages"); +/* eslint-disable no-console */ + +const pluginCommands = new CommandTree({ + name: "__plugin_name__", description: "The default description I forgot to change", +}); + +pluginCommands.add(new Command({ + definition: ["my-command ", "My command description that I really should change", (yargs) => { + yargs.positional("foo", { describe: "foo is foo!", type: "string" }); + }], + handler: async function(args, control) { + const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3])); + console.log(response); + console.log(args); + }, +})); + +/* eslint-enable no-console */ +class CtlPlugin extends BaseCtlPlugin { + async addCommands(rootCommand) { + rootCommand.add(pluginCommands); + } +} + +module.exports = { + CtlPlugin, +}; diff --git a/packages/create/templates/plugin-js/host.js b/packages/create/templates/plugin-js/host.js new file mode 100644 index 00000000..2aed38b9 --- /dev/null +++ b/packages/create/templates/plugin-js/host.js @@ -0,0 +1,35 @@ +"use strict"; +const lib = require("@clusterio/lib"); +const { BaseHostPlugin } = require("@clusterio/host"); +const { PluginExampleEvent, PluginExampleRequest } = require("./messages"); + +class HostPlugin extends BaseHostPlugin { + async init() { + this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); + this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); + } + + async onHostConfigFieldChanged(field, curr, prev) { + this.logger.info(`host::onInstanceConfigFieldChanged ${field}`); + } + + async onShutdown() { + this.logger.info("host::onShutdown"); + } + + async handlePluginExampleEvent(event) { + this.logger.info(JSON.stringify(event)); + } + + async handlePluginExampleRequest(request) { + this.logger.info(JSON.stringify(request)); + return { + myResponseString: request.myString, + myResponseNumbers: request.myNumberArray, + }; + } +} + +module.exports = { + HostPlugin, +}; diff --git a/packages/create/templates/plugin-js/index.js b/packages/create/templates/plugin-js/index.js new file mode 100644 index 00000000..67d5df0d --- /dev/null +++ b/packages/create/templates/plugin-js/index.js @@ -0,0 +1,109 @@ +"use strict"; +const lib = require("@clusterio/lib"); +const Messages = require("./messages"); + +lib.definePermission({ + name: "__plugin_name__.example.permission.event", + title: "Example permission event", + description: "Example Description. Event. Change me in index.js", +}); + +lib.definePermission({ + name: "__plugin_name__.example.permission.request", + title: "Example permission request", + description: "Example Description. Request. Change me in index.js", +}); +//%if controller & web // Subscribing requires web content and the controller + +lib.definePermission({ + name: "__plugin_name__.example.permission.subscribe", + title: "Example permission subscribe", + description: "Example Description. Subscribe. Change me in index.js", +}); +//%endif +//%if web + +lib.definePermission({ + name: "__plugin_name__.page.view", + title: "Example page view permission", + description: "Example Description. View. Change me in index.js", +}); +//%endif + +const plugin = { + name: "__plugin_name__", + title: "__plugin_name__", + description: "Example Description. Plugin. Change me in index.js", +//%if controller + + controllerEntrypoint: "./controller", + controllerConfigFields: { + "__plugin_name__.myControllerField": { + title: "My Controller Field", + description: "This should be removed from index.js", + type: "string", + initialValue: "Remove Me", + }, + }, +//%endif +//%if host + + hostEntrypoint: "./host", + hostConfigFields: { + "__plugin_name__.myHostField": { + title: "My Host Field", + description: "This should be removed from index.js", + type: "string", + initialValue: "Remove Me", + }, + }, +//%endif +//%if instance + + instanceEntrypoint: "./instance", + instanceConfigFields: { + "__plugin_name__.myInstanceField": { + title: "My Instance Field", + description: "This should be removed from index.js", + type: "string", + initialValue: "Remove Me", + }, + }, +//%endif +//%if ctl + + ctlEntrypoint: "./ctl", + controlConfigFields: { + "__plugin_name__.myControlField": { + title: "My Control Field", + description: "This should be removed from index.js", + type: "string", + initialValue: "Remove Me", + }, + }, +//%endif + + messages: [ + Messages.PluginExampleEvent, + Messages.PluginExampleRequest, +//%if controller & web // Subscribing requires web content and the controller + Messages.ExampleSubscribableUpdate, +//%endif + ], +//%if web // Web content template has an example route which is the plugin name + + webEntrypoint: "./web", + routes: [ + "/__plugin_name__", + ], +//%endif +//%if controller & !web // The controller always includes web entry even if there is no content + + webEntrypoint: "./web", + routes: [], +//%endif +}; + +module.exports = { + plugin, +}; diff --git a/packages/create/templates/plugin-js/instance.js b/packages/create/templates/plugin-js/instance.js new file mode 100644 index 00000000..d3bfa189 --- /dev/null +++ b/packages/create/templates/plugin-js/instance.js @@ -0,0 +1,55 @@ +"use strict"; +const lib = require("@clusterio/lib"); +const { BaseInstancePlugin } = require("@clusterio/host"); +const { PluginExampleEvent, PluginExampleRequest } = require("./messages"); + +class InstancePlugin extends BaseInstancePlugin { + async init() { + this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); + this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); +//%if module + this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this)); +//%endif + } + + async onInstanceConfigFieldChanged(field, curr, prev) { + this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`); + } + + async onStart() { + this.logger.info("instance::onStart"); + } + + async onStop() { + this.logger.info("instance::onStop"); + } + + async onPlayerEvent(event) { + this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`); + } + + async handlePluginExampleEvent(event) { + this.logger.info(JSON.stringify(event)); +//%if module + this.sendRcon("/sc __plugin_name__.foo()"); +//%endif + } + + async handlePluginExampleRequest(request) { + this.logger.info(JSON.stringify(request)); + return { + myResponseString: request.myString, + myResponseNumbers: request.myNumberArray, + }; + } +//%if module + + async handlePluginExampleIPC(event) { + this.logger.info(JSON.stringify(event)); + } +//%endif +} + +module.exports = { + InstancePlugin, +}; diff --git a/packages/create/templates/plugin-js/messages.js b/packages/create/templates/plugin-js/messages.js new file mode 100644 index 00000000..aaffd9f6 --- /dev/null +++ b/packages/create/templates/plugin-js/messages.js @@ -0,0 +1,101 @@ +"use strict"; +const { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } = require("@clusterio/lib"); +const { Type } = require("@sinclair/typebox"); + +class PluginExampleEvent { + static type = "event"; + static src = ["host", "control"]; + static dst = ["controller", "host", "instance"]; + static plugin = "__plugin_name__"; + static permission = "__plugin_name__.example.permission.event"; + + constructor(myString, myNumberArray) { + this.myString = myString; + this.myNumberArray = myNumberArray; + } + + static jsonSchema = Type.Object({ + "myString": Type.String(), + "myNumberArray": Type.Array(Type.Number()), + }); + + static fromJSON(json) { + return new PluginExampleEvent(json.myString, json.myNumberArray); + } +} + +class PluginExampleRequest { + static type = "request"; + static src = ["host", "control"]; + static dst = ["controller", "host", "instance"]; + static plugin = "__plugin_name__"; + static permission = "__plugin_name__.example.permission.request"; + + constructor(myString, myNumberArray) { + this.myString = myString; + this.myNumberArray = myNumberArray; + } + + static jsonSchema = Type.Object({ + "myString": Type.String(), + "myNumberArray": Type.Array(Type.Number()), + }); + + static fromJSON(json) { + return new PluginExampleRequest(json.myString, json.myNumberArray); + } + + static Response = plainJson(Type.Object({ + "myResponseString": Type.String(), + "myResponseNumbers": Type.Array(Type.Number()), + })); +} +//%if controller & web // Subscribing requires web content and the controller + +class ExampleSubscribableValue { + constructor(id, updatedAtMs, isDeleted) { + this.id = id; + this.updatedAtMs = updatedAtMs; + this.isDeleted = isDeleted; + } + + static jsonSchema = Type.Object({ + id: Type.String(), + updatedAtMs: Type.Number(), + isDeleted: Type.Boolean(), + }); + + static fromJSON(json) { + return new this(json.id, json.updatedAtMs, json.isDeleted); + } +} + +class ExampleSubscribableUpdate { + static type = "event"; + static src = "controller"; + static dst = "control"; + static plugin = "__plugin_name__"; + static permission = "__plugin_name__.example.permission.subscribe"; + + constructor(updates) { + this.updates = updates; + } + + static jsonSchema = Type.Object({ + "updates": Type.Array(ExampleSubscribableValue.jsonSchema), + }); + + static fromJSON(json) { + return new this(json.updates.map(update => ExampleSubscribableValue.fromJSON(update))); + } +} +//%endif + +module.exports = { + PluginExampleEvent, + PluginExampleRequest, +//%if controller & web // Subscribing requires web content and the controller + ExampleSubscribableValue, + ExampleSubscribableUpdate, +//%endif +}; diff --git a/packages/create/templates/plugin-js/web/no_plugin.jsx b/packages/create/templates/plugin-js/web/no_plugin.jsx new file mode 100644 index 00000000..e69de29b diff --git a/packages/create/templates/plugin-js/web/plugin.jsx b/packages/create/templates/plugin-js/web/plugin.jsx new file mode 100644 index 00000000..9cc5942e --- /dev/null +++ b/packages/create/templates/plugin-js/web/plugin.jsx @@ -0,0 +1,78 @@ +import React, { + useContext, useEffect, useState, +//%if controller & web // Subscribing requires web content and the controller + useCallback, useSyncExternalStore, +//%endif +} from "react"; + +// import { +// +// } from "antd"; + +import { + BaseWebPlugin, PageLayout, Control, ControlContext, notifyErrorHandler, +} from "@clusterio/web_ui"; + +import { + PluginExampleEvent, PluginExampleRequest, +//%if controller & web // Subscribing requires web content and the controller + ExampleSubscribableUpdate, +//%endif +} from "../messages"; + +import * as lib from "@clusterio/lib"; + +function MyTemplatePage() { + let control = useContext(ControlContext); +//%if controller & web // Subscribing requires web content and the controller + const plugin = control.plugins.get("__plugin_name__"); + const [subscribableData, synced] = plugin.useSubscribableData(); +//%endif + + return +

__plugin_name__

+//%if controller & web // Subscribing requires web content and the controller + Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])} +//%endif +
; +} + +export class WebPlugin extends BaseWebPlugin { +//%if controller & web // Subscribing requires web content and the controller + subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control); + +//%endif + async init() { + this.pages = [ + { + path: "/__plugin_name__", + sidebarName: "__plugin_name__", + permission: "__plugin_name__.page.view", + content: , + }, + ]; + + this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); + this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); + } +//%if controller & web // Subscribing requires web content and the controller + + useSubscribableData() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback) => this.subscribableData.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot()); + } +//%endif + + async handlePluginExampleEvent(event) { + this.logger.info(JSON.stringify(event)); + } + + async handlePluginExampleRequest(request) { + this.logger.info(JSON.stringify(request)); + return { + myResponseString: request.myString, + myResponseNumbers: request.myNumberArray, + }; + } +} diff --git a/packages/create/templates/plugin-ts/controller.ts b/packages/create/templates/plugin-ts/controller.ts new file mode 100644 index 00000000..cab2d3c0 --- /dev/null +++ b/packages/create/templates/plugin-ts/controller.ts @@ -0,0 +1,68 @@ +import * as lib from "@clusterio/lib"; +import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller"; + +import { + PluginExampleEvent, PluginExampleRequest, +//%if controller & web // Subscribing requires web content and the controller + ExampleSubscribableUpdate, ExampleSubscribableValue, +//%endif +} from "./messages"; + +export class ControllerPlugin extends BaseControllerPlugin { +//%if controller & web // Subscribing requires web content and the controller + exampleDatabase!: Map; + storageDirty = false; + +//%endif + async init() { + this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); + this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); +//%if controller & web // Subscribing requires web content and the controller + this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this)); + this.exampleDatabase = new Map(); // If needed, replace with loading from database file +//%endif + } + + async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { + this.logger.info(`controller::onControllerConfigFieldChanged ${field}`); + } +//%if instance + + async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) { + this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`); + } +//%endif + + async onSaveData() { + this.logger.info("controller::onSaveData"); + } + + async onShutdown() { + this.logger.info("controller::onShutdown"); + } + + async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) { + this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`); + } + + async handlePluginExampleEvent(event: PluginExampleEvent) { + this.logger.info(JSON.stringify(event)); + } + + async handlePluginExampleRequest(request: PluginExampleRequest) { + this.logger.info(JSON.stringify(request)); + return { + myResponseString: request.myString, + myResponseNumbers: request.myNumberArray, + }; + } +//%if controller & web // Subscribing requires web content and the controller + + async handleExampleSubscription(request: lib.SubscriptionRequest) { + const values = [...this.exampleDatabase.values()].filter( + value => value.updatedAtMs > request.lastRequestTimeMs, + ); + return values.length ? new ExampleSubscribableUpdate(values) : null; + } +//%endif +} diff --git a/packages/create/templates/plugin-ts/ctl.ts b/packages/create/templates/plugin-ts/ctl.ts new file mode 100644 index 00000000..75c3a1a4 --- /dev/null +++ b/packages/create/templates/plugin-ts/ctl.ts @@ -0,0 +1,27 @@ +type Control = any; // import type { Control } from "@clusterio/ctl"; +import { BaseCtlPlugin } from "@clusterio/ctl"; +import { CommandTree, Command } from "@clusterio/lib"; +import { PluginExampleEvent, PluginExampleRequest } from "./messages"; +/* eslint-disable no-console */ + +const pluginCommands = new CommandTree({ + name: "__plugin_name__", description: "The default description I forgot to change", +}); + +pluginCommands.add(new Command({ + definition: ["my-command ", "My command description that I really should change", (yargs) => { + yargs.positional("foo", { describe: "foo is foo!", type: "string" }); + }], + handler: async function(args: { foo: string }, control: Control) { + const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3])); + console.log(response); + console.log(args); + }, +})); + +/* eslint-enable no-console */ +export class CtlPlugin extends BaseCtlPlugin { + async addCommands(rootCommand: CommandTree) { + rootCommand.add(pluginCommands); + } +} diff --git a/packages/create/templates/plugin-ts/host.ts b/packages/create/templates/plugin-ts/host.ts new file mode 100644 index 00000000..bdfc05b5 --- /dev/null +++ b/packages/create/templates/plugin-ts/host.ts @@ -0,0 +1,30 @@ +import * as lib from "@clusterio/lib"; +import { BaseHostPlugin } from "@clusterio/host"; +import { PluginExampleEvent, PluginExampleRequest } from "./messages"; + +export class HostPlugin extends BaseHostPlugin { + async init() { + this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); + this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); + } + + async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) { + this.logger.info(`host::onInstanceConfigFieldChanged ${field}`); + } + + async onShutdown() { + this.logger.info("host::onShutdown"); + } + + async handlePluginExampleEvent(event: PluginExampleEvent) { + this.logger.info(JSON.stringify(event)); + } + + async handlePluginExampleRequest(request: PluginExampleRequest) { + this.logger.info(JSON.stringify(request)); + return { + myResponseString: request.myString, + myResponseNumbers: request.myNumberArray, + }; + } +} diff --git a/packages/create/templates/plugin-ts/index.ts b/packages/create/templates/plugin-ts/index.ts new file mode 100644 index 00000000..d90f22be --- /dev/null +++ b/packages/create/templates/plugin-ts/index.ts @@ -0,0 +1,127 @@ +import * as lib from "@clusterio/lib"; +import * as Messages from "./messages"; + +lib.definePermission({ + name: "__plugin_name__.example.permission.event", + title: "Example permission event", + description: "Example Description. Event. Change me in index.ts", +}); + +lib.definePermission({ + name: "__plugin_name__.example.permission.request", + title: "Example permission request", + description: "Example Description. Request. Change me in index.ts", +}); +//%if controller & web // Subscribing requires web content and the controller + +lib.definePermission({ + name: "__plugin_name__.example.permission.subscribe", + title: "Example permission subscribe", + description: "Example Description. Subscribe. Change me in index.ts", +}); +//%endif +//%if web + +lib.definePermission({ + name: "__plugin_name__.page.view", + title: "Example page view permission", + description: "Example Description. View. Change me in index.ts", +}); +//%endif + +declare module "@clusterio/lib" { +//%if controller + export interface ControllerConfigFields { + "__plugin_name__.myControllerField": string; + } +//%endif +//%if host + export interface HostConfigFields { + "__plugin_name__.myHostField": string; + } +//%endif +//%if instance + export interface InstanceConfigFields { + "__plugin_name__.myInstanceField": string; + } +//%endif +//%if ctl + export interface ControlConfigFields { + "__plugin_name__.myControlField": string; + } +//%endif +} + +export const plugin: lib.PluginDeclaration = { + name: "__plugin_name__", + title: "__plugin_name__", + description: "Example Description. Plugin. Change me in index.ts", +//%if controller + + controllerEntrypoint: "./dist/node/controller", + controllerConfigFields: { + "__plugin_name__.myControllerField": { + title: "My Controller Field", + description: "This should be removed from index.js", + type: "string", + initialValue: "Remove Me", + }, + }, +//%endif +//%if host + + hostEntrypoint: "./dist/node/host", + hostConfigFields: { + "__plugin_name__.myHostField": { + title: "My Host Field", + description: "This should be removed from index.js", + type: "string", + initialValue: "Remove Me", + }, + }, +//%endif +//%if instance + + instanceEntrypoint: "./dist/node/instance", + instanceConfigFields: { + "__plugin_name__.myInstanceField": { + title: "My Instance Field", + description: "This should be removed from index.js", + type: "string", + initialValue: "Remove Me", + }, + }, +//%endif +//%if ctl + + ctlEntrypoint: "./dist/node/ctl", + controlConfigFields: { + "__plugin_name__.myControlField": { + title: "My Control Field", + description: "This should be removed from index.js", + type: "string", + initialValue: "Remove Me", + }, + }, +//%endif + + messages: [ + Messages.PluginExampleEvent, + Messages.PluginExampleRequest, +//%if controller & web // Subscribing requires web content and the controller + Messages.ExampleSubscribableUpdate, +//%endif + ], +//%if web // Web content template has an example route which is the plugin name + + webEntrypoint: "./web", + routes: [ + "/__plugin_name__", + ], +//%endif +//%if controller & !web // The controller always includes web entry even if there is no content + + webEntrypoint: "./web", + routes: [], +//%endif +}; diff --git a/packages/create/templates/plugin-ts/instance.ts b/packages/create/templates/plugin-ts/instance.ts new file mode 100644 index 00000000..3cb7d18a --- /dev/null +++ b/packages/create/templates/plugin-ts/instance.ts @@ -0,0 +1,57 @@ +import * as lib from "@clusterio/lib"; +import { BaseInstancePlugin } from "@clusterio/host"; +import { PluginExampleEvent, PluginExampleRequest } from "./messages"; +//%if module + +type PuginExampleIPC = { + tick: number, + player_name: string, +}; +//%endif + +export class InstancePlugin extends BaseInstancePlugin { + async init() { + this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); + this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); +//%if module + this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this)); +//%endif + } + + async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { + this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`); + } + + async onStart() { + this.logger.info("instance::onStart"); + } + + async onStop() { + this.logger.info("instance::onStop"); + } + + async onPlayerEvent(event: lib.PlayerEvent) { + this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`); +//%if module + this.sendRcon("/sc __plugin_name__.foo()"); +//%endif + } + + async handlePluginExampleEvent(event: PluginExampleEvent) { + this.logger.info(JSON.stringify(event)); + } + + async handlePluginExampleRequest(request: PluginExampleRequest) { + this.logger.info(JSON.stringify(request)); + return { + myResponseString: request.myString, + myResponseNumbers: request.myNumberArray, + }; + } +//%if module + + async handlePluginExampleIPC(event: PuginExampleIPC) { + this.logger.info(JSON.stringify(event)); + } +//%endif +} diff --git a/packages/create/templates/plugin-ts/messages.ts b/packages/create/templates/plugin-ts/messages.ts new file mode 100644 index 00000000..c0514e33 --- /dev/null +++ b/packages/create/templates/plugin-ts/messages.ts @@ -0,0 +1,97 @@ +import { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } from "@clusterio/lib"; +import { Type, Static } from "@sinclair/typebox"; + +export class PluginExampleEvent { + declare ["constructor"]: typeof PluginExampleEvent; + static type = "event" as const; + static src = ["host", "control"] as const; + static dst = ["controller", "host", "instance"] as const; + static plugin = "__plugin_name__" as const; + static permission = "__plugin_name__.example.permission.event"; + + constructor( + public myString: string, + public myNumberArray: number[], + ) { + } + + static jsonSchema = Type.Object({ + "myString": Type.String(), + "myNumberArray": Type.Array(Type.Number()), + }); + + static fromJSON(json: Static) { + return new PluginExampleEvent(json.myString, json.myNumberArray); + } +} + +export class PluginExampleRequest { + declare ["constructor"]: typeof PluginExampleRequest; + static type = "request" as const; + static src = ["host", "control"] as const; + static dst = ["controller", "host", "instance"] as const; + static plugin = "__plugin_name__" as const; + static permission = "__plugin_name__.example.permission.request"; + + constructor( + public myString: string, + public myNumberArray: number[], + ) { + } + + static jsonSchema = Type.Object({ + "myString": Type.String(), + "myNumberArray": Type.Array(Type.Number()), + }); + + static fromJSON(json: Static) { + return new PluginExampleRequest(json.myString, json.myNumberArray); + } + + static Response = plainJson(Type.Object({ + "myResponseString": Type.String(), + "myResponseNumbers": Type.Array(Type.Number()), + })); +} +//%if controller & web // Subscribing requires web content and the controller + +export class ExampleSubscribableValue { + constructor( + public id: string, + public updatedAtMs: number, + public isDeleted: boolean, + ) { + } + + static jsonSchema = Type.Object({ + id: Type.String(), + updatedAtMs: Type.Number(), + isDeleted: Type.Boolean(), + }); + + static fromJSON(json: Static) { + return new this(json.id, json.updatedAtMs, json.isDeleted); + } +} + +export class ExampleSubscribableUpdate { + declare ["constructor"]: typeof ExampleSubscribableUpdate; + static type = "event" as const; + static src = "controller" as const; + static dst = "control" as const; + static plugin = "__plugin_name__" as const; + static permission = "__plugin_name__.example.permission.subscribe"; + + constructor( + public updates: ExampleSubscribableValue[], + ) { } + + static jsonSchema = Type.Object({ + "updates": Type.Array(ExampleSubscribableValue.jsonSchema), + }); + + static fromJSON(json: Static) { + return new this(json.updates.map(update => ExampleSubscribableValue.fromJSON(update))); + } +} +//%endif diff --git a/packages/create/templates/plugin-ts/tsconfig.browser.json b/packages/create/templates/plugin-ts/tsconfig.browser.json new file mode 100644 index 00000000..e1b39d0f --- /dev/null +++ b/packages/create/templates/plugin-ts/tsconfig.browser.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.browser.json", + "references": [ + { "path": "../../packages/lib/tsconfig.browser.json" }, + { "path": "../../packages/web_ui/tsconfig.browser.json" }, + ], + "include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ], + "compilerOptions": { + "outDir": "dist/browser", + } +} diff --git a/packages/create/templates/plugin-ts/tsconfig.json b/packages/create/templates/plugin-ts/tsconfig.json new file mode 100644 index 00000000..136e8ab6 --- /dev/null +++ b/packages/create/templates/plugin-ts/tsconfig.json @@ -0,0 +1,9 @@ +{ + "files": [], + "references": [ +//%if web + { "path": "./tsconfig.browser.json" }, +//%endif + { "path": "./tsconfig.node.json" } + ] +} diff --git a/packages/create/templates/plugin-ts/tsconfig.node.json b/packages/create/templates/plugin-ts/tsconfig.node.json new file mode 100644 index 00000000..b6197500 --- /dev/null +++ b/packages/create/templates/plugin-ts/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.node.json", + "references": [ + { "path": "../../packages/lib/tsconfig.node.json" }, + ], + "include": ["./**/*.ts"], + "exclude": ["test/*", "./dist/*"], + "compilerOptions": { + "outDir": "dist/node", + }, +} diff --git a/packages/create/templates/plugin-ts/web/no_plugin.tsx b/packages/create/templates/plugin-ts/web/no_plugin.tsx new file mode 100644 index 00000000..e69de29b diff --git a/packages/create/templates/plugin-ts/web/plugin.tsx b/packages/create/templates/plugin-ts/web/plugin.tsx new file mode 100644 index 00000000..df32127d --- /dev/null +++ b/packages/create/templates/plugin-ts/web/plugin.tsx @@ -0,0 +1,85 @@ +import React, { + useContext, useEffect, useState, +//%if controller & web // Subscribing requires web content and the controller + useCallback, useSyncExternalStore, +//%endif +} from "react"; + +// import { +// +// } from "antd"; + +import { + BaseWebPlugin, PageLayout, Control, ControlContext, notifyErrorHandler, +} from "@clusterio/web_ui"; + +import { + PluginExampleEvent, PluginExampleRequest, +//%if controller & web // Subscribing requires web content and the controller + ExampleSubscribableUpdate, +//%endif +} from "../messages"; + +import * as lib from "@clusterio/lib"; + +function MyTemplatePage() { + const control = useContext(ControlContext); +//%if controller & web // Subscribing requires web content and the controller + const plugin = control.plugins.get("__plugin_name__") as WebPlugin; + const [subscribableData, synced] = plugin.useSubscribableData(); +//%endif + + return +

__plugin_name__

+//%if controller & web // Subscribing requires web content and the controller + Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])} +//%endif +
; +} + +export class WebPlugin extends BaseWebPlugin { +//%if controller & web // Subscribing requires web content and the controller + subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control); + +//%endif + async init() { + this.pages = [ + { + path: "/__plugin_name__", + sidebarName: "__plugin_name__", + // This permission is client side only, so it must match the permission string of a resource request to be secure + // An undefined value means that the page will always be visible +//%if controller & web // Subscribing requires web content and the controller + permission: "__plugin_name__.example.permission.subscribe", +//%endif +//%if !controller | !web + permission: undefined, // "__plugin_name__.example.permission.request", +//%endif + content: , + }, + ]; + + this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); + this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); + } +//%if controller & web // Subscribing requires web content and the controller + + useSubscribableData() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback: () => void) => this.subscribableData.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot()); + } +//%endif + + async handlePluginExampleEvent(event: PluginExampleEvent) { + this.logger.info(JSON.stringify(event)); + } + + async handlePluginExampleRequest(request: PluginExampleRequest) { + this.logger.info(JSON.stringify(request)); + return { + myResponseString: request.myString, + myResponseNumbers: request.myNumberArray, + }; + } +} diff --git a/packages/create/templates/readmd.md b/packages/create/templates/readmd.md new file mode 100644 index 00000000..7a47444c --- /dev/null +++ b/packages/create/templates/readmd.md @@ -0,0 +1,114 @@ +# Templates + +Create has the ability to create new plugins from templates to avoid needing to manually copy lots of boiler plate. This folder contains all those templates for both Javascript and Typescript plugins. These templates have custom preprocessor instructions that can be used to alter a template based on developer input, this syntax is described below. + +- [The Basics](#the-basics) +- [Insert Instruction](#insert-instruction) (`__variable__`) +- [Conditional Instruction](#conditional-instruction) (`//%if` `//%endif`) +- [Available Variables](#available-variables) + +## The Basics + +All files can contain preprocessor instructions whether json, js, lua, or anything else. All instructions use the same syntax although it has been designed to work well with js and ts files. + +Except for the insert instruction, all preprocessors use a `//%` prefix and must be on their own line. These lines are never included in the output. Parsing is stopped when `//` is encountered to allow for comments on preprocessors. Eg. `//%if foo // this is a comment`. + +Empty instructions, aka just comments, can be inserted into templates using `//%//` and are not included in the output file. + +`templates/plugin-js` contains all the files which will be used by a javascript only plugin. +`templates/plugin-ts` contains all the files which will be used by a plugin that supports typescript. +`templates/common` contains files used by both types of plugins, this is mostly lua code or root files like `package.json` + +Files are not copied via wildcards and instead are explicitly listed within `template.js` so any new templates will need to be added there. + +## Insert Instruction + +There are a number of template variables available to be inserted into templates. All variables are raw string values, that is they are inserted directly into the output file without any modifications. To signify a place to insert a variable you need only surround its identifier with double underscores, examples below. + +Example Variables: + +```sh +my_number=1 +my_string=foo +my_property=bar: "baz" +``` + +Example Template: + +```ts +const __my_string__: number = __my_number__; +console.log(__my_string__); + +console.log({ + foo: "Hello, __my_string__!", + __my_property__, + my_property: __my_string__, +}); +``` + +Example Output: + +```ts +const foo: number = 1; +console.log(foo); + +console.log({ + foo: "Hello, foo!", + bar: "baz", + my_property: foo, +}); +``` + +## Conditional Instruction + +Conditionals allow for code to be included/omitted based on the value of template variables. Currently only boolean logic in [disjunctive normal form](https://en.wikipedia.org/wiki/Disjunctive_normal_form) is supported and any variables that are not booleans are evaluated with the [Boolean constructor](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean). + +Variables used in condition statements do not require double underscores. Their values can be inverted using `!`. Arithmetic and comparison operations are not supported. Parentheses are also not supported, conditions must be in DNF. + +Conversion to DNF: +`A & (B | !C)` -> `A & B | A & !C` evaluated as `(A and B) or (A and not C)` + +Example Variables: + +```sh +my_string=foo +say_hello=true +say_done=true +verbose=false +``` + +Example Template: + +```ts +//%if verbose +console.log("Starting..."); +//%endif +//%if say_hello & my_string +console.log("Hello, __my_string__!"); +//%endif +//%if say_done | verbose +console.log("Done."); +//%endif +``` + +Example Output: + +```ts +console.log("Hello, foo!"); +console.log("Done."); +``` + +## Available Variables + +| Identifier | Example | Description | +| - | - | - | +| typescript | `true` / `false` | `true` if typescript templates are included | +| controller | `true` / `false` | `true` if controller templates are included | +| host | `true` / `false` | `true` if host templates are included | +| instance | `true` / `false` | `true` if instance templates are included | +| module | `true` / `false` | `true` if module templates are included | +| ctl | `true` / `false` | `true` if control templates are included | +| web | `true` / `false` | `true` if web templates are included | +| plugin_name | `my_plugin` | name of the plugin | +| prepare | `tsc --build` | commands to run for the prepare script | +| ext | `ts` / `js` | file extension of source files | diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index 60089036..e61f91c0 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -562,6 +562,13 @@ export class FactorioServer extends events.EventEmitter { } } + // TODO at the moment this is just a helper function, it has the potential for schema checking + handle(eventName: string, handler: (event: Event) => Promise) { + this.on(`ipc-${eventName}`, (event) => handler(event).catch(err => { + this._logger.error(`Error handling ipc event:\n${err.stack}`); + })); + } + async _handleIpc(line: Buffer) { let channelEnd = line.indexOf("?"); if (channelEnd === -1) { diff --git a/packages/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts index 1819e645..e53fdb39 100644 --- a/packages/lib/src/ModStore.ts +++ b/packages/lib/src/ModStore.ts @@ -1,8 +1,10 @@ import fs from "fs-extra"; import path from "path"; +import { Static } from "@sinclair/typebox"; import { logger } from "./logging"; import { ModInfo } from "./data"; import TypedEventEmitter from "./TypedEventEmitter"; +import { safeOutputFile } from "./file_ops"; export interface ModStoreEvents { /** A stored mod was created, updated or deleted */ @@ -74,6 +76,21 @@ export default class ModStore extends TypedEventEmitter[]; + const cacheFilename = path.join(modsDirectory, "mod-info-cache.json"); + let cache = new Map(); + try { + const content = await fs.readFile(cacheFilename, "utf8"); + cache = new Map((JSON.parse(content) as ModInfoCache).map(item => { + const mod = ModInfo.fromJSON(item); + return [mod.filename, mod]; + })); + } catch (err: any) { + if (err.code !== "ENOENT") { + logger.warn(`Error loading ${cacheFilename}:\n${err.stack}`); + } + } + const files = new Map(); for (let entry of await fs.readdir(modsDirectory, { withFileTypes: true })) { if (entry.isDirectory()) { @@ -81,10 +98,23 @@ export default class ModStore extends TypedEventEmitter mod.toJSON()); + await safeOutputFile(cacheFilename, JSON.stringify(content)); + } + return new this(modsDirectory, files); } } diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index b555f0bb..e29db8d3 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -116,6 +116,11 @@ export default class ModInfo { */ size = 0; + /** + * Last modification time of the file. + */ + mtimeMs = 0; + /** * SHA1 hash of this mod */ @@ -147,6 +152,7 @@ export default class ModInfo { static jsonSchema = Type.Object({ ...this.infoJsonSchema.properties, "size": Type.Optional(Type.Integer()), + "mtime_ms": Type.Optional(Type.Number()), "sha1": Type.Optional(Type.String()), "updated_at_ms": Type.Optional(Type.Number()), "is_deleted": Type.Optional(Type.Boolean()), @@ -170,6 +176,7 @@ export default class ModInfo { // Additional data if (json.size) { modInfo.size = json.size; } + if (json.mtime_ms) { modInfo.mtimeMs = json.mtime_ms; } if (json.sha1) { modInfo.sha1 = json.sha1; } if (json.updated_at_ms) { modInfo.updatedAtMs = json.updated_at_ms; } if (json.is_deleted) { modInfo.isDeleted = json.is_deleted; } @@ -192,6 +199,7 @@ export default class ModInfo { json.dependencies = this.dependencies; } if (this.size) { json.size = this.size; } + if (this.mtimeMs) { json.mtime_ms = this.mtimeMs; } if (this.sha1) { json.sha1 = this.sha1; } if (this.updatedAtMs) { json.updated_at_ms = this.updatedAtMs; } if (this.isDeleted) { json.is_deleted = this.isDeleted; } @@ -229,6 +237,7 @@ export default class ModInfo { size: stat.size, sha1: await libHash.hashFile(modPath), + mtime_ms: stat.mtimeMs, updated_at_ms: stat.mtimeMs, is_deleted: false, }); diff --git a/packages/lib/src/link/link.ts b/packages/lib/src/link/link.ts index 0f8f2d32..1d3d3381 100644 --- a/packages/lib/src/link/link.ts +++ b/packages/lib/src/link/link.ts @@ -107,6 +107,9 @@ export class Link { ) { this.handle(libData.PingRequest, async () => {}); + // Prevent warnings about possible memory leak due to large number of event listeners + connector.setMaxListeners(20); + // Process messages received by the connector connector.on("message", payload => { try { diff --git a/packages/web_ui/src/components/SavesList.tsx b/packages/web_ui/src/components/SavesList.tsx index 66f4f78d..c9ebf916 100644 --- a/packages/web_ui/src/components/SavesList.tsx +++ b/packages/web_ui/src/components/SavesList.tsx @@ -151,7 +151,7 @@ function TransferModal(props: ModalProps) { autoFocus showSearch filterOption={(input, option) => ( - (option?.title.toLowerCase().indexOf(input.toLowerCase()) ?? -1) >= 0 + (option?.title!.toLowerCase().indexOf(input.toLowerCase()) ?? -1) >= 0 )} > {[...instances.values()].filter( diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 4fe1f626..acf69b94 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -1054,6 +1054,7 @@ describe("Integration of Clusterio", function() { "factorioVersion: 1.1\n" + "dependencies:\n" + `size: ${stat.size}\n` + + `mtimeMs: ${stat.mtimeMs}\n` + `sha1: ${hash}\n` + `updatedAtMs: ${stat.mtimeMs}\n` + "isDeleted: false\n", diff --git a/test/lib/data/ModInfo.js b/test/lib/data/ModInfo.js index 50e104db..9e8d6c65 100644 --- a/test/lib/data/ModInfo.js +++ b/test/lib/data/ModInfo.js @@ -33,6 +33,7 @@ describe("lib/data/ModInfo", function() { check(ModInfo.fromJSON({ dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"] })); check(ModInfo.fromJSON({ filename: "MyMod_1.0.0.zip" })); check(ModInfo.fromJSON({ size: 1024 })); + check(ModInfo.fromJSON({ mtime_ms: new Date(2024, 0, 1).getTime() })); check(ModInfo.fromJSON({ sha1: "verified-as-MyMod" })); check(ModInfo.fromJSON({ is_deleted: true })); @@ -49,6 +50,7 @@ describe("lib/data/ModInfo", function() { dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"], filename: "MyMod_1.0.0.zip", size: 1024, + mtime_ms: new Date(2024, 0, 1).getTime(), sha1: "verified-as-MyMod", is_deleted: true, })); @@ -92,6 +94,7 @@ describe("lib/data/ModInfo", function() { dependencies: [], filename: "empty_mod_1.0.0.zip", size: stat.size, + mtime_ms: stat.mtimeMs, updated_at_ms: stat.mtimeMs, sha1: hash, })