From 86bd07666fccc81a80bc13c668085a33ec052ead Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 9 Jun 2021 17:34:48 +0200 Subject: [PATCH] First working draft of inventory sync --- plugins/inventory_sync/.npmignore | 1 + plugins/inventory_sync/README.md | 12 ++ plugins/inventory_sync/control.js | 30 +++++ plugins/inventory_sync/info.js | 120 ++++++++++++++++++ plugins/inventory_sync/instance.js | 87 +++++++++++++ plugins/inventory_sync/master.js | 78 ++++++++++++ .../inventory_sync/module/inventory_sync.lua | 82 ++++++++++++ plugins/inventory_sync/module/module.json | 4 + plugins/inventory_sync/package.json | 32 +++++ plugins/inventory_sync/web/index.css | 0 plugins/inventory_sync/web/index.jsx | 32 +++++ plugins/inventory_sync/webpack.config.js | 34 +++++ 12 files changed, 512 insertions(+) create mode 100644 plugins/inventory_sync/.npmignore create mode 100644 plugins/inventory_sync/README.md create mode 100644 plugins/inventory_sync/control.js create mode 100644 plugins/inventory_sync/info.js create mode 100644 plugins/inventory_sync/instance.js create mode 100644 plugins/inventory_sync/master.js create mode 100644 plugins/inventory_sync/module/inventory_sync.lua create mode 100644 plugins/inventory_sync/module/module.json create mode 100644 plugins/inventory_sync/package.json create mode 100644 plugins/inventory_sync/web/index.css create mode 100644 plugins/inventory_sync/web/index.jsx create mode 100644 plugins/inventory_sync/webpack.config.js diff --git a/plugins/inventory_sync/.npmignore b/plugins/inventory_sync/.npmignore new file mode 100644 index 00000000..6de001d8 --- /dev/null +++ b/plugins/inventory_sync/.npmignore @@ -0,0 +1 @@ +webpack.config.js diff --git a/plugins/inventory_sync/README.md b/plugins/inventory_sync/README.md new file mode 100644 index 00000000..8c2067a2 --- /dev/null +++ b/plugins/inventory_sync/README.md @@ -0,0 +1,12 @@ +# Clusterio inventory sync plugin + +Carry over player inventory between servers + +## Installation + +Run the following commands in the folder Clusterio is installed to: + + npm install @clusterio/plugin-inventory_sync + npx clusteriomaster plugin add @clusterio/plugin-inventory_sync + +Substitute clusteriomaster with clusterioslave or clusterioctl if this a dedicate slave or ctl installation respectively. diff --git a/plugins/inventory_sync/control.js b/plugins/inventory_sync/control.js new file mode 100644 index 00000000..680e79af --- /dev/null +++ b/plugins/inventory_sync/control.js @@ -0,0 +1,30 @@ +"use strict"; +const libPlugin = require("@clusterio/lib/plugin"); +const info = require("./info"); +const libCommand = require("@clusterio/lib/command"); + + +const globalChatCommands = new libCommand.CommandTree({ + name: "global-chat", description: "Global Chat plugin commands", +}); +globalChatCommands.add(new libCommand.Command({ + definition: ["shout ", "Send message to all instances", (yargs) => { + yargs.positional("message", { describe: "message to send", type: "string" }); + }], + handler: async function(args, control) { + await info.messages.chat.send(control, { + instance_name: "Console", + content: args.message, + }); + }, +})); + +class ControlPlugin extends libPlugin.BaseControlPlugin { + async addCommands(rootCommand) { + rootCommand.add(globalChatCommands); + } +} + +module.exports = { + ControlPlugin, +}; diff --git a/plugins/inventory_sync/info.js b/plugins/inventory_sync/info.js new file mode 100644 index 00000000..03fecb81 --- /dev/null +++ b/plugins/inventory_sync/info.js @@ -0,0 +1,120 @@ +"use strict"; +const libLink = require("@clusterio/lib/link"); +const libConfig = require("@clusterio/lib/config"); +const libUsers = require("@clusterio/lib/users"); + +class MasterConfigGroup extends libConfig.PluginConfigGroup { } +MasterConfigGroup.defaultAccess = ["master", "slave", "control"]; +MasterConfigGroup.groupName = "inventory_sync"; +MasterConfigGroup.define({ + name: "autosave_interval", + title: "Autosave Interval", + description: "Interval the player data is autosaved at in seconds.", + type: "number", + initial_value: 60, +}); +MasterConfigGroup.define({ + name: "spectator_until_ready", + title: "Spectator mode until ready", + description: "Set the player into spectator mode until the inventory is fully synchronized.", + type: "boolean", + initial_value: true, +}); +MasterConfigGroup.define({ + name: "clear_old_inventory", + title: "Clear old inventory on join", + description: "Clear old inventory on join to prevent duplication of items. Can cause greater disruption on failed or slow sync.", + type: "boolean", + initial_value: true, +}); +MasterConfigGroup.finalize(); + +class InstanceConfigGroup extends libConfig.PluginConfigGroup { } +InstanceConfigGroup.defaultAccess = ["master", "slave", "control"]; +InstanceConfigGroup.groupName = "inventory_sync"; +InstanceConfigGroup.define({ + name: "enable_inventory_sync", + title: "Syncronize inventories", + description: "Syncronize inventories. Disable if this instance is running incompatible mods or otherwise shouldn't sync.", + type: "boolean", + initial_value: true, +}); +InstanceConfigGroup.finalize(); + +libUsers.definePermission({ + name: "inventory_sync.inventory.view", + title: "View player inventories", + description: "View player inventories", + grantByDefault: true, +}); + +module.exports = { + name: "inventory_sync", + title: "Inventory sync", + description: "Synchronizes players inventories between instances", + + instanceEntrypoint: "instance", + InstanceConfigGroup, + + masterEntrypoint: "master", + MasterConfigGroup, + + webEntrypoint: "./web", + routes: ["/inventory"], + + messages: { + chat: new libLink.Event({ + type: "inventory_sync:chat", + links: ["instance-slave", "slave-master", "master-slave", "slave-instance"], + forwardTo: "master", + broadcastTo: "instance", + eventProperties: { + "instance_name": { type: "string" }, + "content": { type: "string" }, + }, + }), + upload: new libLink.Request({ + type: "inventory_sync:upload", + links: ["instance-slave", "slave-master"], + forwardTo: "master", + requestProperties: { + "instance_id": { type: "integer" }, + "instance_name": { type: "string" }, + "player_name": { type: "string" }, + "inventory": { type: "string" }, + }, + responseProperties: { + "success": { type: "boolean" }, + }, + }), + download: new libLink.Request({ + type: "inventory_sync:download", + links: ["instance-slave", "slave-master"], + forwardTo: "master", + requestProperties: { + "player_name": { type: "string" }, + }, + responseProperties: { + "player_name": { type: "string" }, + "inventory": { type: "string" }, + }, + }), + databaseStats: new libLink.Request({ + type: "inventory_sync:databaseStats", + links: ["control-master"], + permission: "inventory_sync.inventory.view", + requestProperties: {}, + responseProperties: { + "database_size": { type: "integer" }, + "database_entries": { type: "integer" }, + "largest_entry": { + type: "object", + properties: { + name: { type: "string" }, + size: { type: "number" }, + }, + }, + }, + }), + }, +}; diff --git a/plugins/inventory_sync/instance.js b/plugins/inventory_sync/instance.js new file mode 100644 index 00000000..159c2c12 --- /dev/null +++ b/plugins/inventory_sync/instance.js @@ -0,0 +1,87 @@ +/** + * @module + */ +"use strict"; +const libPlugin = require("@clusterio/lib/plugin"); +const libLuaTools = require("@clusterio/lib/lua_tools"); + +class InstancePlugin extends libPlugin.BaseInstancePlugin { + async init() { + this.inventoryQueue = []; + + // setInterval(() => { + + // console.log("Connected") + // this.info.messages.upload.send(this.instance, { + // instance_id: this.instance.id, + // player: "danielv", + // inventory: "stuff", + // }) + // },5000) + + + this.instance.server.on("ipc-inventory_sync_upload", content => + this.handleUpload(content).catch(err => this.logger.error( + `Error handling ipc-inventory_sync_upload:\n${err.stack}` + )) + ) + this.instance.server.on("ipc-inventory_sync_download", content => + this.handleDownload(content).catch(err => this.logger.error( + `Error handling ipc-inventory_sync_download:\n${err.stack}` + )) + ) + } + + onMasterConnectionEvent(event) { + console.log("Connectionevent", event) + if (event === "connect") { + } + } + + async handleUpload(player) { + console.log("Uploading", player) + this.info.messages.upload.send(this.instance, { + instance_id: this.instance.id, + instance_name: this.instance.name, + player_name: player.name, + inventory: JSON.stringify(player), + }) + } + async handleDownload(player) { + console.log("Downloading", player.player_name) + let response = await this.info.messages.download.send(this.instance, { + player_name: player.player_name + }) + if (response.inventory) { + console.log("Sending command") + await this.sendRcon(`/sc inventory_sync.downloadInventory('${response.player_name}', '${libLuaTools.escapeString(response.inventory)}')`) + } + } + + async chatEventHandler(message) { + // TODO check if cross server chat is enabled + let content = `[${message.data.instance_name}] ${removeTags(message.data.content)}`; + await this.sendRcon(`/sc game.print('${libLuaTools.escapeString(content)}')`, true); + } + + sendChat(message) { + this.info.messages.chat.send(this.instance, { + instance_name: this.instance.name, + content: message, + }); + } + + async onOutput(output) { + if (output.type === "action" && output.action === "INVENTORY") { + if (this.slave.connector.connected) { + this.sendChat(output.message); + } else { + this.inventoryQueue.push(output.message); + } + } + } +} + +module.exports = { + InstancePlugin, +}; diff --git a/plugins/inventory_sync/master.js b/plugins/inventory_sync/master.js new file mode 100644 index 00000000..71889dcc --- /dev/null +++ b/plugins/inventory_sync/master.js @@ -0,0 +1,78 @@ +"use strict"; +const fs = require("fs-extra"); +const path = require("path"); + +const libPlugin = require("@clusterio/lib/plugin"); + +async function loadDatabase(config, logger) { + let itemsPath = path.resolve(config.get("master.database_directory"), "inventories.json"); + logger.verbose(`Loading ${itemsPath}`); + try { + let content = await fs.readFile(itemsPath); + return JSON.parse(content); + + } catch (err) { + if (err.code === "ENOENT") { + logger.verbose("Creating new inventory database"); + return {}; + } + throw err; + } +} + +async function saveDatabase(masterConfig, inventories, logger) { + if (inventories) { + let file = path.resolve(masterConfig.get("master.database_directory"), "inventories.json"); + logger.verbose(`writing ${file}`); + let content = JSON.stringify(inventories); + await fs.outputFile(file, content); + } +} + +class MasterPlugin extends libPlugin.BaseMasterPlugin { + async init() { + this.inventories = await loadDatabase(this.master.config, this.logger); + this.autosaveId = setInterval(() => { + saveDatabase(this.master.config, this.inventories, this.logger).catch(err => { + this.logger.error(`Unexpected error autosaving inventories:\n${err.stack}`); + }); + }, this.master.config.get("inventory_sync.autosave_interval") * 1000); + } + async uploadRequestHandler(message) { + console.log("Saving inventory for", message.data.player_name) + this.inventories[message.data.player_name] = message.data.inventory + + return { success: true } + } + async downloadRequestHandler(message) { + console.log("Downloading inventory for", message.data.player_name) + return { + player_name: message.data.player_name, + inventory: this.inventories[message.data.player_name] + } + } + async onShutdown() { + clearInterval(this.autosaveId); + await saveDatabase(this.master.config, this.inventories, this.logger); + } + async databaseStatsRequestHandler(message) { + let inventories = Object.keys(this.inventories) + .map(name => ({ + name, + length: this.inventories[name].length + })) + .sort((a, b) => a.stringified.length - b.stringified.length) + return { + database_size: inventories.reduce((a, b) => b - a, 0), + database_entries: inventories.length, + largest_entry: { + name: inventories[0].name, + size: inventories[0].length, + }, + } + } +} + +module.exports = { + MasterPlugin, +}; diff --git a/plugins/inventory_sync/module/inventory_sync.lua b/plugins/inventory_sync/module/inventory_sync.lua new file mode 100644 index 00000000..ebce407e --- /dev/null +++ b/plugins/inventory_sync/module/inventory_sync.lua @@ -0,0 +1,82 @@ +--[[ + +When a player leaves the game, serialize their inventory and upload it to the master. + +When a player joins the game, send a request to the master server for the players inventory. + +When the master sends an inventory, parse it and give it to the player. + + +packages/slave/modules/clusterio +]] + +local serialize = require("modules/clusterio/serialize") +local clusterio_api = require("modules/clusterio/api") + +local inventories = { + defines.inventory.character_main, + defines.inventory.character_guns, + defines.inventory.character_ammo, + defines.inventory.character_armor, + defines.inventory.character_vehicle, + defines.inventory.character_trash, +} + +inventory_sync = {} + +inventory_sync.events = {} +-- Download inventory from master +inventory_sync.events[defines.events.on_player_joined_game] = function(event) + local playerIndex = event.player_index + inventory_sync.initiateInventoryDownload(playerIndex) +end + +function inventory_sync.initiateInventoryDownload(playerIndex) + game.players[playerIndex].print("Initiating inventory download...") + global["inv_sync_download_start_tick "..game.players[playerIndex].name] = game.tick + clusterio_api.send_json("inventory_sync_download", { + player_name = game.players[playerIndex].name + }) +end + +-- This function is called by instance.js as /sc inventory_sync.downloadInventory("danielv", Escaped JSON string) with data from the master +function inventory_sync.downloadInventory(playerName, data) + local serialized_player = game.json_to_table(data) + local player = game.players[playerName] + -- game.print(serpent.block(serialized_player)) + for _, inv in pairs(inventories) do + local inventory = player.get_inventory(inv) + if inventory ~= nil and serialized_player.inventories[tostring(inv)] ~= nil then + serialize.deserialize_inventory(inventory, serialized_player.inventories[tostring(inv)]) + end + end + local startTick = global["inv_sync_download_start_tick "..game.players[playerName].name] + game.print("Imported inventory for "..playerName.." in "..game.tick - startTick.." ticks") +end + +-- Upload inventory when a player leaves the game. Triggers on restart after crash if player was online during crash. +inventory_sync.events[defines.events.on_player_left_game] = function(event) + local playerIndex = event.player_index + + inventory_sync.uploadInventory(playerIndex) +end + +function inventory_sync.uploadInventory(playerIndex) + local player = game.players[playerIndex] + + local serialized_player = { + name = player.name, + inventories = {} + } + + for _, inv in pairs(inventories) do + local inventory = player.get_inventory(inv) + if inventory ~= nil then + local serialized_inventory = serialize.serialize_inventory(inventory) + serialized_player.inventories[inv] = serialized_inventory + end + end + clusterio_api.send_json("inventory_sync_upload", serialized_player) +end + +return inventory_sync diff --git a/plugins/inventory_sync/module/module.json b/plugins/inventory_sync/module/module.json new file mode 100644 index 00000000..96c3db30 --- /dev/null +++ b/plugins/inventory_sync/module/module.json @@ -0,0 +1,4 @@ +{ + "name": "inventory_sync", + "load": ["inventory_sync.lua"] +} diff --git a/plugins/inventory_sync/package.json b/plugins/inventory_sync/package.json new file mode 100644 index 00000000..3237132f --- /dev/null +++ b/plugins/inventory_sync/package.json @@ -0,0 +1,32 @@ +{ + "name": "@clusterio/plugin-inventory_sync", + "version": "2.0.0-alpha.6", + "description": "Clusterio plugin forwarding between Factorio servers", + "author": "Danielv ", + "homepage": "https://github.com/clusterio/factorioClusterio#readme", + "license": "MIT", + "repository": "clusterio/factorioClusterio", + "scripts": { + "build": "webpack-cli --env production", + "test": "echo \"Error: run tests from root\" && exit 1" + }, + "bugs": { + "url": "https://github.com/clusterio/factorioClusterio/issues" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "@clusterio/lib": "^2.0.0-alpha.0" + }, + "devDependencies": { + "@clusterio/lib": "^2.0.0-alpha.6", + "@clusterio/web_ui": "^2.0.0-alpha.6", + "webpack": "^5.36.2", + "webpack-cli": "^4.7.0", + "webpack-merge": "^5.2.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/plugins/inventory_sync/web/index.css b/plugins/inventory_sync/web/index.css new file mode 100644 index 00000000..e69de29b diff --git a/plugins/inventory_sync/web/index.jsx b/plugins/inventory_sync/web/index.jsx new file mode 100644 index 00000000..3cd384d2 --- /dev/null +++ b/plugins/inventory_sync/web/index.jsx @@ -0,0 +1,32 @@ +import React, { useContext } from "react"; +import { Typography } from "antd"; + +import libPlugin from "@clusterio/lib/plugin"; +import { notifyErrorHandler, PageLayout, ControlContext } from "@clusterio/web_ui"; +import info from "../info"; + +import "./index.css"; + +const { Paragraph } = Typography; + +function InventoryPage() { + // let control = useContext(ControlContext); + + return +

Inventory sync

+
; +} + +export class WebPlugin extends libPlugin.BaseWebPlugin { + async init() { + this.pages = [ + { path: "/inventory", sidebarName: "Inventory sync", content: }, + ]; + } + + onMasterConnectionEvent(event) { + if (event === "connect") { + + } + } +} diff --git a/plugins/inventory_sync/webpack.config.js b/plugins/inventory_sync/webpack.config.js new file mode 100644 index 00000000..1dd85e59 --- /dev/null +++ b/plugins/inventory_sync/webpack.config.js @@ -0,0 +1,34 @@ +"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.jsx", + output: { + publicPath: "auto", + filename: "bundle.js", + path: path.resolve(__dirname, "dist", "web"), + }, + plugins: [ + new webpack.container.ModuleFederationPlugin({ + name: "inventory_sync", + library: { type: "var", name: "plugin_inventory_sync" }, + filename: "remoteEntry.js", + exposes: { + "./info": "./info.js", + "./package.json": "./package.json", + "./web": "./web/index.jsx", + }, + shared: { + "@clusterio/lib/config": { import: false }, + "@clusterio/lib/link": { import: false }, + "@clusterio/web_ui": { import: false }, + "ajv": { import: false }, + }, + }), + ], +});