Implement mod pack storage

Add ModPack data type with corresponding storage on the master, messages
to create, update and delete them, as well as commands to invoke them.
The ModPack data type contains a list of mods with versions and optional
sha1 hashes, and mod settings to be used.
This commit is contained in:
Hornwitser committed 2022-09-16 02:44:06 +02:00
1 parent dfcbc559b8
commit 9fb3d6d8db
16 files changed
+987 -3

No files matched your search

+2
View File
@@ -89,6 +89,8 @@ class Control extends libLink.Link {
async saveListUpdateEventHandler() { }
async modPackUpdateEventHandler() { }
async modUpdateEventHandler() { }
async userUpdateEventHandler() { }
+203
View File
@@ -14,6 +14,7 @@ const libLink = require("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
const libConfig = require("@clusterio/lib/config");
const libCommand = require("@clusterio/lib/command");
const libData = require("@clusterio/lib/data");
const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
const libLoggingUtils = require("@clusterio/lib/logging_utils");
const libHelpers = require("@clusterio/lib/helpers");
@@ -860,6 +861,207 @@ instanceCommands.add(new libCommand.Command({
},
}));
const modPackCommands = new libCommand.CommandTree({ name: "mod-pack", description: "Mod Pack" });
modPackCommands.add(new libCommand.Command({
definition: ["show <mod-pack>", "Show details of mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to show", type: "string" });
}],
handler: async function(args, control) {
let response = await libLink.messages.getModPack.send(control, {
id: await libCommand.resolveModPack(control, args.modPack),
});
for (let [field, value] of Object.entries(response.mod_pack)) {
if (field === "mods") {
print(`${field}:`);
for (let entry of value) {
print(` ${entry.name} ${entry.version}${entry.hash ? ` (${entry.hash})` : ""}`);
}
} else if (field === "settings") {
print(`${field}:`);
for (let [scope, settings] of Object.entries(value)) {
print(` ${scope}:`);
for (let [setting, settingValue] of Object.entries(settings)) {
print(` ${setting}: ${JSON.stringify(settingValue.value)}`);
}
}
} else {
print(`${field}: ${value}`);
}
}
},
}));
modPackCommands.add(new libCommand.Command({
definition: [["list", "l"], "List mod packs in the cluster"],
handler: async function(args, control) {
let response = await libLink.messages.listModPacks.send(control);
let fields = ["id", "name", "factorio_version"];
for (let entry of response.list) {
for (let field of Object.keys(entry)) {
if (!fields.includes(field)) {
delete entry[field];
}
}
}
print(asTable(response.list));
},
}));
function setModPackSettings(modPack, args) {
function doSettings(settings, cast) {
for (let i = 0; i + 2 < settings.length; i += 3) {
const [scope, name, value] = settings.slice(i, i + 3);
if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) {
throw new libErrors.CommandError(
`Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}`
);
}
modPack.settings[scope].set(name, { value: cast(value) });
}
}
doSettings(args.boolSetting || [], value => {
if (!["true", "false"].includes(value)) {
throw new libErrors.CommandError(`boolean value must be one of true or false, not ${value}`);
}
return value === "true";
});
doSettings(args.intSetting || [], value => {
// eslint-disable-next-line radix
let number = Number.parseInt(value);
if (isNaN(number)) {
throw new libErrors.CommandError(`int value must be an integer, not ${value}`);
}
return number;
});
doSettings(args.doubleSetting || [], value => {
let number = Number.parseFloat(value);
if (isNaN(number)) {
throw new libErrors.CommandError(`double value must be a number, not ${value}`);
}
return number;
});
doSettings(args.stringSetting || [], value => value);
}
function setModPackMods(modPack, mods) {
for (let mod of mods || []) {
const [name, version, sha1] = mod.split(":");
if (!version) {
throw new libErrors.CommandError("Added mod must be formatted as name:version or name:version:sha1");
}
if (!/^\d+\.\d+\.\d+$/.test(version)) {
throw new libErrors.CommandError("version must match the format digit.digit.digit");
}
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
throw new libErrors.CommandError("sha1 must be a 40 digit lower case hex string");
}
modPack.mods.set(name, { name, version, hash: sha1 ? `sha1:${sha1}` : undefined });
}
}
modPackCommands.add(new libCommand.Command({
definition: ["create <name> <factorio-version>", "Create mod-pack", (yargs) => {
yargs.positional("name", { describe: "Name of mod pack to create", type: "string" });
yargs.positional("factorio-version", { describe: "Version of factorio the mod pack is for", type: "string" });
yargs.options({
"description": { describe: "Description for mod pack", type: "string" },
"mods": { describe: "Mods in the form of name:version[:sha1]", array: true, type: "string" },
"bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" },
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
});
}],
handler: async function(args, control) {
const modPack = new libData.ModPack();
modPack.name = args.name;
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!/^\d+\.\d+(\.\d+)?$/.test(args.factorioVersion)) {
throw new libErrors.CommandError("factorio-version must match the format digit.digit[.digit]");
}
modPack.factorioVersion = args.factorioVersion;
}
setModPackMods(modPack, args.mods);
setModPackSettings(modPack, args);
await libLink.messages.createModPack.send(control, { mod_pack: modPack.toJSON() });
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
modPackCommands.add(new libCommand.Command({
definition: ["edit <mod-pack>", "Edit mod-pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to remove from", type: "string" });
yargs.options({
"name": { describe: "New name for mod pack", type: "string" },
"description": { describe: "New description for mod pack", type: "string" },
"factorio-version": { describe: "Set version of factorio the mod pack is for", type: "string" },
"add-mods": { describe: "Mods in the form of name:version[:sha1] to add", array: true, type: "string" },
"remove-mods": { describe: "Name of mods to remove", array: true, type: "string" },
"bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" },
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
"remove-setting": { describe: "Remove a setting", array: true, nargs: 2, type: "string" },
});
}],
handler: async function(args, control) {
let response = await libLink.messages.getModPack.send(control, {
id: await libCommand.resolveModPack(control, args.modPack),
});
let modPack = new libData.ModPack(response.mod_pack);
if (args.name) { modPack.name = args.name; }
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!/^\d+\.\d+(\.\d+)?$/.test(args.factorioVersion)) {
throw new libErrors.CommandError("factorio-version must match the format digit.digit[.digit]");
}
modPack.factorioVersion = args.factorioVersion;
}
setModPackMods(modPack, args.addMods);
for (let name of args.removeMods || []) {
if (modPack.mods.has(name)) {
modPack.mods.delete(name);
} else {
logger.warn(`Mod ${name} did not exist on ${modPack.name}`);
}
}
setModPackSettings(modPack, args);
if (args.removeSetting) {
for (let i = 0; i + 1 < args.removeSetting.length; i += 2) {
const [scope, name] = settings.slice(i, i + 2);
if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) {
throw new libErrors.CommandError(
`Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}`
);
}
if (modPack.settings[scope].has(name)) {
modPack.settings[scope].delete(name);
} else {
logger.warn(`Mod setting ${scope} ${name} did not exist on ${modPack.name}`);
}
}
}
await libLink.messages.updateModPack.send(control, { mod_pack: modPack.toJSON() });
},
}));
modPackCommands.add(new libCommand.Command({
definition: ["delete <mod-pack>", "Delete mod-pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to delete", type: "string" });
}],
handler: async function(args, control) {
const id = await libCommand.resolveModPack(control, args.modPack);
await libLink.messages.deleteModPack.send(control, { id });
},
}));
const modCommands = new libCommand.CommandTree({ name: "mod", description: "Manage uploaded mods" });
modCommands.add(new libCommand.Command({
definition: ["show <name> <mod-version>", "Show details for a mod stored in the cluster", (yargs) => {
@@ -1339,6 +1541,7 @@ async function registerCommands(controlPlugins, yargs) {
rootCommands.add(masterCommands);
rootCommands.add(slaveCommands);
rootCommands.add(instanceCommands);
rootCommands.add(modPackCommands);
rootCommands.add(modCommands);
rootCommands.add(permissionCommands);
rootCommands.add(roleCommands);
+34
View File
@@ -198,6 +198,39 @@ async function resolveInstance(client, instanceName) {
return instanceId;
}
/**
* Resolve a string to a mod pack ID
*
* Resolevs a string with either a mod pack name or an id into an integer
* with the mod pack ID.
*
* @param {module:lib/link.Link} client -
* link to master server to query mod pack on.
* @param {string} modPackName - string with name or id of mod pack.
* @returns {Promise<number>} mod pack ID.
* @static
*/
async function resolveModPack(client, modPackName) {
let modPackId;
if (/^-?\d+/.test(modPackName)) {
modPackId = parseInt(modPackName, 10);
} else {
let response = await libLink.messages.listModPacks.send(client);
for (let mod_pack of response.list) {
if (mod_pack["name"] === modPackName) {
modPackId = mod_pack["id"];
break;
}
}
if (modPackId === undefined) {
throw new libErrors.CommandError(`No mod pack named ${modPackName}`);
}
}
return modPackId;
}
/**
* Retrieve role object from string
*
@@ -244,5 +277,6 @@ module.exports = {
resolveSlave,
resolveInstance,
resolveModPack,
retrieveRole,
};
+215
View File
@@ -0,0 +1,215 @@
"use strict";
const zlib = require("zlib");
const libSchema = require("../schema");
/**
* A setting for a mod.
* @typedef {object} module:lib/data/ModPack~ModSetting
* @property {boolean|number|string} value - Value of the given mod setting.
*/
const ModSettingJsonSchema = {
type: "object",
additionalProperties: false,
required: ["value"],
properties: {
"value": { type: ["boolean", "number", "string"] },
},
};
const ModSettingsJsonSchema = {
type: "object",
additionalProperties: ModSettingJsonSchema,
};
/**
* A mod in a mod pack
* @typedef {object} module:lib/data/ModPack~ModRecord
* @property {string} name - name of the mod.
* @property {string} version - version of the mod.
* @property {string} hash - hash of the zip file.
*/
const ModRecordJsonSchema = {
type: "object",
additionalProperties: false,
required: ["name", "version"],
properties: {
"name": { type: "string" },
"version": { type: "string" },
"hash": { type: "string" },
},
};
/**
* Factorio Mod Pack
*
* Tracks mods and settings for a collection of Factorio mods.
* @alias module:lib/data/ModPack
*/
class ModPack {
/**
* Id of this mod pack
* @type {number}
*/
id;
/**
* Name of this mod pack.
* @type {string}
*/
name = "";
/**
* Description for this modpcak.
* @type {string}
*/
description = "";
/**
* Version of Factorio this mod pack is for
* @type {string}
*/
factorioVersion = "1.1.0";
/**
* Integer representation of the factorioVersion
* @type {number}
*/
get integerFactorioVersion() {
const [major, minor, sub] = this.factorioVersion.split(".").map(n => Number.parseInt(n, 10));
return major * 0x100000000 + minor * 0x10000 + (sub || 0); // Can't use bitwise here because this is 48-bits.
}
/**
* Mods included in this mod pack
* @type {Map<string, module:lib/data/ModPack~ModRecord>}
*/
mods = new Map();
/**
* Mod settings for this mod pack
* @type {Object<string, Map<string, module:lib/data/ModPack~ModSetting>>=}
*/
settings = {
"startup": new Map(),
"runtime-global": new Map(),
"runtime-per-user": new Map(),
};
/**
* True if this mod pack has been deleted from the list of mod packs.
* @type {boolean}
*/
isDeleted = false;
static jsonSchema = {
type: "object",
additionalProperties: false,
properties: {
"id": { type: "integer" },
"name": { type: "string" },
"description": { type: "string" },
"factorio_version": { type: "string" },
"mods": {
type: "array",
items: ModRecordJsonSchema,
},
"settings": {
type: "object",
additionalProperties: false,
required: ["startup", "runtime-global", "runtime-per-user"],
properties: {
"startup": ModSettingsJsonSchema,
"runtime-global": ModSettingsJsonSchema,
"runtime-per-user": ModSettingsJsonSchema,
},
},
"is_deleted": { type: "boolean" },
},
};
static validate = libSchema.compile(this.jsonSchema);
constructor(json = {}) {
if (json.id) {
this.id = json.id;
} else {
this.id = Math.random() * 2**31 | 0;
}
if (json.name) { this.name = json.name; }
if (json.description) { this.description = json.description; }
if (json.factorio_version) { this.factorioVersion = json.factorio_version; }
if (json.mods) { this.mods = new Map(json.mods.map(m => [m.name, m])); }
if (json.settings) {
this.settings = {
"startup": new Map(Object.entries(json.settings["startup"])),
"runtime-global": new Map(Object.entries(json.settings["runtime-global"])),
"runtime-per-user": new Map(Object.entries(json.settings["runtime-per-user"])),
};
}
if (json.is_deleted) { this.isDeleted = json.is_deleted; }
}
toJSON() {
let json = {
id: this.id,
};
if (this.name) { json.name = this.name; }
if (this.description) { json.description = this.description; }
json.factorio_version = this.factorioVersion;
if (this.mods.size) { json.mods = [...this.mods.values()]; }
if (this.settings) {
json.settings = {
"startup": Object.fromEntries(this.settings["startup"]),
"runtime-global": Object.fromEntries(this.settings["runtime-global"]),
"runtime-per-user": Object.fromEntries(this.settings["runtime-per-user"]),
};
}
if (this.isDeleted) { json.is_deleted = this.isDeleted; }
return json;
}
toModPackString(includeHash = false) {
const json = this.toJSON();
delete json.id;
if (json.mods && !includeHash) {
json.mods = json.mods.map(m => {
m = { ...m };
delete m.hash;
return m;
});
}
// eslint-disable-next-line node/no-sync
let buf = zlib.deflateSync(JSON.stringify(json));
return buf.toString("base64");
}
static fromModPackString(modPackString) {
let buf = Buffer.from(modPackString, "base64");
try {
// eslint-disable-next-line node/no-sync
buf = zlib.inflateSync(buf);
} catch (err) {
if (err.code.startsWith("Z_")) {
throw new Error("Malformed mod pack string: zlib inflate failed");
}
}
let json;
try {
json = JSON.parse(buf);
} catch (err) {
throw new Error(`Malformed mod pack string: ${err.message}`);
}
if (!this.validate(json)) {
throw new Error("Malformed mod pack string: Schema validation failed");
}
return new this(json);
}
}
module.exports = ModPack;
+2 -1
View File
@@ -5,8 +5,9 @@
*/
"use strict";
const ModInfo = require("./ModInfo");
const ModPack = require("./ModPack");
module.exports = {
ModInfo,
ModPack,
};
+73
View File
@@ -710,6 +710,71 @@ messages.sendRcon = new Request({
},
});
messages.getModPack = new Request({
type: "get_mod_pack",
links: ["control-master", "instance-slave", "slave-master"],
permission: "core.mod_pack.get",
forwardTo: "master",
requestProperties: {
"id": { type: "integer" },
},
responseProperties: {
"mod_pack": libData.ModPack.jsonSchema,
},
});
messages.listModPacks = new Request({
type: "list_mod_packs",
links: ["control-master"],
permission: "core.mod_pack.list",
responseProperties: {
"list": {
type: "array",
items: libData.ModPack.jsonSchema,
},
},
});
messages.createModPack = new Request({
type: "create_mod_pack",
links: ["control-master"],
permission: "core.mod_pack.create",
requestProperties: {
"mod_pack": libData.ModPack.jsonSchema,
},
});
messages.updateModPack = new Request({
type: "update_mod_pack",
links: ["control-master"],
permission: "core.mod_pack.update",
requestProperties: {
"mod_pack": libData.ModPack.jsonSchema,
},
});
messages.deleteModPack = new Request({
type: "delete_mod_pack",
links: ["control-master"],
permission: "core.mod_pack.delete",
requestProperties: {
"id": { type: "integer" },
},
});
messages.setModPackSubscriptions = new Request({
type: "set_mod_pack_subscriptions",
links: ["control-master"],
permission: "core.mod_pack.subscribe",
requestProperties: {
"all": { type: "boolean" },
"mod_pack_ids": {
type: "array",
items: { type: "integer" },
},
},
});
messages.getMod = new Request({
type: "get_mod",
links: ["control-master"],
@@ -1325,6 +1390,14 @@ messages.saveListUpdate = new Event({
},
});
messages.modPackUpdate = new Event({
type: "mod_pack_update",
links: ["master-control"],
eventProperties: {
"mod_pack": libData.ModPack.jsonSchema,
},
});
messages.modUpdate = new Event({
type: "mod_update",
links: ["master-control"],
+13
View File
@@ -493,6 +493,19 @@ class BaseMasterPlugin {
*/
async onPrepareSlaveDisconnect(connection) { }
/**
* Called when a mod pack is updated
*
* Invoked when a mod pack has been added, updated or deleted in the
* cluster.
*
* If the mod pack has been deleted its `.isDeleted` property will be
* true.
*
* @param {module:lib/data/ModPack} modPack - Mod pack that updated.
*/
async onModPackUpdated(modPack) { }
/**
* Called when a mod stored on the master server is updated
*
+33
View File
@@ -511,6 +511,39 @@ definePermission({
description: "Send arbitrary RCON commands to instances.",
});
definePermission({
name: "core.mod_pack.get",
title: "Retrieve mod pack",
description: "Get the details of a mod pack in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.mod_pack.list",
title: "List mod packs",
description: "Get the full list of a mod packs in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.mod_pack.subscribe",
title: "Subscribe to mod pack updates",
description: "Subscribe to be notified on updates on the details of mod packs.",
grantByDefault: true,
});
definePermission({
name: "core.mod_pack.create",
title: "Create mod pack",
description: "Create new mod pack in the cluster.",
});
definePermission({
name: "core.mod_pack.update",
title: "Update mod pack",
description: "Edit existing mod pack definition in the cluster.",
});
definePermission({
name: "core.mod_pack.delete",
title: "Delete mod pack",
description: "Delete mod pack definition in the cluster.",
});
definePermission({
name: "core.mod.get",
title: "Get mods",
+62
View File
@@ -5,7 +5,9 @@ const jwt = require("jsonwebtoken");
const path = require("path");
const libConfig = require("@clusterio/lib/config");
const libData = require("@clusterio/lib/data");
const libErrors = require("@clusterio/lib/errors");
const libHelpers = require("@clusterio/lib/helpers");
const libLink = require("@clusterio/lib/link");
const { logger } = require("@clusterio/lib/logging");
const libLoggingUtils = require("@clusterio/lib/logging_utils");
@@ -55,6 +57,11 @@ class ControlConnection extends BaseConnection {
instance_ids: [],
};
this.modPackSubscriptions = {
all: false,
mod_pack_ids: [],
};
this.modSubscriptions = {
all: false,
mod_names: [],
@@ -465,6 +472,61 @@ class ControlConnection extends BaseConnection {
return { save: result.save };
}
async getModPackRequestHandler(message) {
let { id } = message.data;
let modPack = this._master.modPacks.get(id);
if (!modPack) {
throw new libErrors.RequestError(`Mod pack with ID ${id} does not exist`);
}
return { mod_pack: modPack.toJSON() };
}
async listModPacksRequestHandler(message) {
return { list: [...this._master.modPacks.values()].map(pack => pack.toJSON()) };
}
async setModPackSubscriptionsRequestHandler(message) {
this.modPackSubscriptions = message.data;
}
async createModPackRequestHandler(message) {
let modPack = new libData.ModPack(message.data.mod_pack);
if (this._master.modPacks.has(modPack.id)) {
throw new libErrors.RequestError(`Mod pack with ID ${modPack.id} already exist`);
}
this._master.modPacks.set(modPack.id, modPack);
this._master.modPackUpdated(modPack);
}
async updateModPackRequestHandler(message) {
let modPack = new libData.ModPack(message.data.mod_pack);
if (!this._master.modPacks.has(modPack.id)) {
throw new libErrors.RequestError(`Mod pack with ID ${modPack.id} does not exist`);
}
this._master.modPacks.set(modPack.id, modPack);
this._master.modPackUpdated(modPack);
}
async deleteModPackRequestHandler(message) {
let { id } = message.data;
let modPack = this._master.modPacks.get(id);
if (!modPack) {
throw new libErrors.RequestError(`Mod pack with ID ${id} does not exist`);
}
modPack.isDeleted = true;
this._master.modPacks.delete(id);
this._master.modPackUpdated(modPack);
}
modPackUpdated(modPack) {
if (
this.modPackSubscriptions.all
|| this.modPackSubscriptions.mod_pack_ids.includes(modPack.id)
) {
libLink.messages.modPackUpdate.send(this, { mod_pack: modPack.toJSON() });
}
}
async getModRequestHandler(message) {
let { name, version } = message.data;
let key = `${name}_${version}`;
+39
View File
@@ -66,6 +66,12 @@ class Master {
*/
this.instances = null;
/**
* Mapping of mod pack id to mod pack
* @type {Map<number, module:lib/data/ModPack>}
*/
this.modPacks = null;
/**
* Mapping of mod names to mod infos
* @type {Map<string, module:lib/data/ModInfo>}
@@ -168,6 +174,7 @@ class Master {
this.slaves = await Master.loadSlaves(path.join(databaseDirectory, "slaves.json"));
this.instances = await Master.loadInstances(path.join(databaseDirectory, "instances.json"));
this.modPacks = await Master.loadModPacks(path.join(databaseDirectory, "mod-packs.json"));
this.userManager = new UserManager(this.config);
await this.userManager.load(path.join(databaseDirectory, "users.json"));
this.exportManifest = await Master.loadJsonObject(path.join(databaseDirectory, "export_manifest.json"));
@@ -295,6 +302,10 @@ class Master {
await Master.saveInstances(path.join(databaseDirectory, "instances.json"), this.instances);
}
if (this.modPacks) {
await Master.saveModPacks(path.join(databaseDirectory, "mod-packs.json"), this.modPacks);
}
if (this.userManager) {
await this.userManager.save(path.join(databaseDirectory, "users.json"));
}
@@ -371,6 +382,23 @@ class Master {
await libFileOps.safeOutputFile(filePath, JSON.stringify(serialized, null, 4));
}
static async loadModPacks(filePath) {
let json;
try {
json = JSON.parse(await fs.readFile(filePath));
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
return new Map();
}
return new Map(json.map(e => [e.id, new libData.ModPack(e)]));
}
static async saveModPacks(filePath, modPacks) {
await libFileOps.safeOutputFile(filePath, JSON.stringify([...modPacks.values()]));
}
static async loadModInfos(modsDirectory) {
let mods = new Map();
for (let entry of await fs.readdir(modsDirectory, { withFileTypes: true })) {
@@ -562,6 +590,17 @@ class Master {
}
}
modPackUpdated(modPack) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.connector.closing) {
continue;
}
controlConnection.modPackUpdated(modPack);
}
libPlugin.invokeHook(this.plugins, "onModPackUpdated", modPack);
}
modUpdated(mod) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.connector.closing) {
+5
View File
@@ -3,6 +3,7 @@ const libConfig = require("@clusterio/lib/config");
const libLink = require("@clusterio/lib/link");
const { logger } = require("@clusterio/lib/logging");
const libPlugin = require("@clusterio/lib/plugin");
const libErrors = require("@clusterio/lib/errors");
const PlayerStats = require("@clusterio/lib/PlayerStats");
const BaseConnection = require("./BaseConnection");
@@ -167,6 +168,10 @@ class SlaveConnection extends BaseConnection {
this._master.saveListUpdate(message.data);
}
async getModPackRequestHandler(message) {
throw new libErrors.RequestError("Not implemented");
}
async logMessageEventHandler(message) {
this._master.clusterLogger.log({
...message.data.info,
+78
View File
@@ -0,0 +1,78 @@
import { useContext, useEffect, useState } from "react";
import { libData, libLink, libLogging } from "@clusterio/lib";
import ControlContext from "../components/ControlContext";
const { logger } = libLogging;
export function useModPack(id) {
let control = useContext(ControlContext);
let [modPack, setModPack] = useState({ loading: true });
function updateModPack() {
libLink.messages.getModPack.send(control, { id }).then(result => {
setModPack(new libData.ModPack(result.mod_pack));
}).catch(err => {
logger.error(`Failed to get mod pack: ${err}`);
setModPack({ missing: true });
});
}
useEffect(() => {
if (typeof id !== "number") {
setModPack({ missing: true });
return undefined;
}
updateModPack();
control.onModPackUpdate(id, setModPack);
return () => {
control.offModPackUpdate(id, setModPack);
};
}, [id]);
return [modPack];
}
export function useModPackList() {
let control = useContext(ControlContext);
let [modPackList, setModPackList] = useState([]);
function updateModPackList() {
libLink.messages.listModPacks.send(control).then(result => {
setModPackList(result.list.map(pack => new libData.ModPack(pack)));
}).catch(err => {
logger.error(`Failed to list mod packs:\n${err}`);
});
}
useEffect(() => {
updateModPackList();
function updateHandler(newModPack) {
setModPackList(oldList => {
let newList = oldList.concat();
let index = newList.findIndex(u => u.id === newModPack.id);
if (!newModPack.isDeleted) {
if (index !== -1) {
newList[index] = newModPack;
} else {
newList.push(newModPack);
}
} else if (index !== -1) {
newList.splice(index, 1);
}
return newList;
});
}
control.onModPackUpdate(null, updateHandler);
return () => {
control.offModPackUpdate(null, updateHandler);
};
}, []);
return [modPackList];
}
+64 -2
View File
@@ -86,6 +86,7 @@ export class Control extends libLink.Link {
this.slaveUpdateHandlers = new Map();
this.instanceUpdateHandlers = new Map();
this.saveListUpdateHandlers = new Map();
this.modPackUpdateHandlers = new Map();
this.modUpdateHandlers = new Map();
this.userUpdateHandlers = new Map();
this.logHandlers = new Map();
@@ -103,6 +104,9 @@ export class Control extends libLink.Link {
this.updateSaveListSubscriptions().catch(err => logger.error(
`Unexpected error updating save list subscriptions:\n${err.stack}`
));
this.updateModPackSubscriptions().catch(err => logger.error(
`Unexpected error updating mod pack subscriptions:\n${err.stack}`
));
this.updateModSubscriptions().catch(err => logger.error(
`Unexpected error updating mod subscriptions:\n${err.stack}`
));
@@ -323,6 +327,64 @@ export class Control extends libLink.Link {
});
}
async modPackUpdateEventHandler(message) {
let modPack = new libData.ModPack(message.data.mod_pack);
let handlers = [].concat(
this.modPackUpdateHandlers.get(null) || [],
this.modPackUpdateHandlers.get(modPack.id) || [],
);
for (let handler of handlers) {
handler(modPack);
}
}
async onModPackUpdate(id, handler) {
if (id !== null && typeof id !== "number") {
throw new Error("Invalid mod pack id");
}
let handlers = this.modPackUpdateHandlers.get(id);
if (!handlers) {
handlers = [];
this.modPackUpdateHandlers.set(id, handlers);
}
handlers.push(handler);
if (handlers.length === 1) {
await this.updateModPackSubscriptions();
}
}
async offModPackUpdate(id, handler) {
let handlers = this.modPackUpdateHandlers.get(id);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for mod pack ${id} exist`);
}
let index = handlers.lastIndexOf(handler);
if (index === -1) {
throw new Error(`Given handler is not registered for mod pack ${id}`);
}
handlers.splice(index, 1);
if (!handlers.length) {
this.modPackUpdateHandlers.delete(id);
await this.updateModPackSubscriptions();
}
}
async updateModPackSubscriptions() {
if (!this.connector.connected) {
return;
}
await libLink.messages.setModPackSubscriptions.send(this, {
all: this.modPackUpdateHandlers.has(null),
mod_pack_ids: [...this.modPackUpdateHandlers.keys()].filter(k => k !== null),
});
}
async modUpdateEventHandler(message) {
let mod = new libData.ModInfo(message.data.mod);
let handlers = [].concat(
@@ -355,12 +417,12 @@ export class Control extends libLink.Link {
async offModUpdate(name, handler) {
let handlers = this.modUpdateHandlers.get(name);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for mod pack ${name} exist`);
throw new Error(`No handlers for mod ${name} exist`);
}
let index = handlers.lastIndexOf(handler);
if (index === -1) {
throw new Error(`Given handler is not registered for mod pack ${name}`);
throw new Error(`Given handler is not registered for mod ${name}`);
}
handlers.splice(index, 1);
+65
View File
@@ -6,6 +6,7 @@ const events = require("events");
const phin = require("phin");
const libBuildMod = require("@clusterio/lib/build_mod");
const libData = require("@clusterio/lib/data");
const libHash = require("@clusterio/lib/hash");
const libLink = require("@clusterio/lib/link");
const libUsers = require("@clusterio/lib/users");
@@ -820,6 +821,70 @@ describe("Integration of Clusterio", function() {
});
});
describe("mod-pack create", function() {
it("should create a mod-pack", async function() {
await execCtl("mod-pack create empty-pack 1.1");
let response = await libLink.messages.listModPacks.send(getControl());
assert(response.list.some(modPack => modPack.name === "empty-pack"), "created pack is not in the list");
});
it("should allow setting all fields", async function() {
await execCtl(
"mod-pack create full-pack 0.17 " +
"--description Description " +
"--mods empty_mod:1.0.0 " +
"--bool-setting startup MyBool true " +
"--int-setting runtime-global MyInt 1235 " +
"--double-setting runtime-global MyDouble 12.25 " +
"--string-setting runtime-per-user MyString a-string"
);
let response = await libLink.messages.listModPacks.send(getControl());
let modPack = response.list.find(entry => entry.name === "full-pack");
assert(modPack, "created mod pack not found");
let reference = new libData.ModPack();
reference.id = modPack.id;
reference.name = "full-pack";
reference.description = "Description";
reference.factorioVersion = "0.17";
reference.mods.set("empty_mod", { name: "empty_mod", version: "1.0.0" });
reference.settings["startup"].set("MyBool", { value: true });
reference.settings["runtime-global"].set("MyInt", { value: 1235 });
reference.settings["runtime-global"].set("MyDouble", { value: 12.25 });
reference.settings["runtime-per-user"].set("MyString", { value: "a-string" });
assert.deepEqual(new libData.ModPack(modPack), reference);
});
});
describe("mod-pack list", function() {
it("runs", async function() {
await execCtl("mod-pack list");
});
});
describe("mod-pack show", function() {
it("runs", async function() {
await execCtl("mod-pack show empty-pack");
});
});
describe("mod-pack edit", function() {
it("runs", async function() {
await execCtl("mod-pack edit full-pack --factorio-version 1.2");
let response = await libLink.messages.listModPacks.send(getControl());
let modPack = response.list.find(entry => entry.name === "full-pack");
assert(modPack, "created mod pack not found");
assert.equal(modPack.factorio_version, "1.2");
});
});
describe("mod-pack delete", function() {
it("deletes the pack", async function() {
await execCtl("mod-pack delete full-pack");
let response = await libLink.messages.listModPacks.send(getControl());
let modPack = response.list.find(entry => entry.name === "full-pack");
assert(!modPack, "mod pack not deleted");
});
});
describe("mod upload", function() {
it("uploads a mod", async function() {
await libBuildMod.build({
+5
View File
@@ -29,6 +29,7 @@ class TestControl extends libLink.Link {
this.instanceUpdates = [];
this.saveListUpdates = [];
this.modUpdates = [];
this.modPackUpdates = [];
this.userUpdates = [];
this.connector.on("connect", () => {
@@ -75,6 +76,10 @@ class TestControl extends libLink.Link {
this.modUpdates.push(message.data.mod);
}
async modPackUpdateEventHandler(message) {
this.modPackUpdates.push(message.data.mod_pack);
}
async userUpdateEventHandler(message) {
this.userUpdates.push(message.data);
}
+94
View File
@@ -0,0 +1,94 @@
"use strict";
const assert = require("assert").strict;
const zlib = require("zlib");
const libSchema = require("@clusterio/lib/schema");
const ModPack = require("@clusterio/lib/data/ModPack");
describe("lib/data/ModPack", function() {
describe("class ModPack", function() {
it("should round trip serialize", function() {
const validate = libSchema.compile(ModPack.jsonSchema);
function check(pack) {
const json = JSON.parse(JSON.stringify(pack));
if (!validate(json)) {
throw validate.errors;
}
assert.deepEqual(new ModPack(json), pack);
const packStringed = ModPack.fromModPackString(pack.toModPackString(true));
packStringed.id = pack.id;
assert.deepEqual(packStringed, pack);
}
check(new ModPack());
check(new ModPack({ name: "MyPack" }));
check(new ModPack({ description: "My Description" }));
check(new ModPack({ factorio_version: "2.0" }));
check(new ModPack({ mods: [
{ name: "subspace_storage", version: "1.99.8" },
{ name: "clusterio_lib", version: "0.1.2", hash: "sha1:012345abcd" },
]}));
check(new ModPack({ settings: {
"startup": {
"bool-setting": { "value": true },
},
"runtime-global": {
"number-setting": { "value": 123 },
},
"runtime-per-user": {
"string-setting": { "value": "a string" },
},
}}));
check(new ModPack({ deleted: true }));
check(new ModPack({
name: "Super pack",
description: "Every option at once.",
factorio_version: "2.0",
mods: [
{ name: "subspace_storage", version: "1.99.8" },
{ name: "clusterio_lib", version: "0.1.2", hash: "sha1:012345abcd" },
],
settings: {
"startup": {
"bool-setting": { "value": true },
},
"runtime-global": {
"number-setting": { "value": 123 },
},
"runtime-per-user": {
"string-setting": { "value": "a string" },
},
},
deleted: true,
}));
});
it("should sort integer factorio versions lexicographically", function() {
let unsortedVersions = ["1.0", "1.1.0", "0.1", "3.0.0", "1.2", "0.3.1", "0.3.3", "2.1.1", "0.0.1"];
let sortedVersions = ["0.0.1", "0.1", "0.3.1", "0.3.3", "1.0", "1.1.0", "1.2", "2.1.1", "3.0.0"];
let factorioMods = unsortedVersions.map(v => new ModPack({ factorio_version: v }));
factorioMods.sort((a, b) => a.integerFactorioVersion - b.integerFactorioVersion);
assert.deepEqual(factorioMods.map(mod => mod.factorioVersion), sortedVersions);
});
describe(".fromModPackString()", function() {
it("should handle malformed strings", function() {
assert.throws(
() => ModPack.fromModPackString("AMalformedString"),
new Error("Malformed mod pack string: zlib inflate failed")
);
assert.throws(
// eslint-disable-next-line node/no-sync
() => ModPack.fromModPackString(Buffer.from(zlib.deflateSync("Not Json")).toString("base64")),
new Error("Malformed mod pack string: Unexpected token N in JSON at position 0")
);
assert.throws(
// eslint-disable-next-line node/no-sync
() => ModPack.fromModPackString(Buffer.from(zlib.deflateSync('{"i":1}')).toString("base64")),
new Error("Malformed mod pack string: Schema validation failed")
);
});
});
});
});