From 2eadc3ea101de4e43fd1773d5d19c70f91894e6a Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 18 May 2021 16:06:19 +0200 Subject: [PATCH 01/16] Add Map Exchange String parser Add readMapExchangeString to lib/factorio which parses and decodes the data in Map Exchange into a format suitable for giving to the Factorio server command line. --- .gitignore | 1 - packages/lib/factorio/exchange_string.js | 393 +++++++++++++++++++++++ packages/lib/factorio/index.js | 2 + test/lib/factorio/exchange_string.js | 102 ++++++ 4 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 packages/lib/factorio/exchange_string.js create mode 100644 test/lib/factorio/exchange_string.js diff --git a/.gitignore b/.gitignore index d93623fb..56c8fbd4 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,6 @@ dist/ node_modules Factorio* -factorio* /factorio /instances /sharedMods diff --git a/packages/lib/factorio/exchange_string.js b/packages/lib/factorio/exchange_string.js new file mode 100644 index 00000000..52698c0f --- /dev/null +++ b/packages/lib/factorio/exchange_string.js @@ -0,0 +1,393 @@ +"use strict"; +const zlib = require("zlib"); + + +class MapReaderState { + constructor(buf) { + this.pos = 0; + this.buf = buf; + this.last_position = { x: 0, y: 0 }; + } +} + +function readUInt8(state) { + let value = state.buf.readUInt8(state.pos); + state.pos += 1; + return value; +} + +function readBool(state) { + let value = readUInt8(state) !== 0; + return value; +} + +function readInt16(state) { + let value = state.buf.readInt16LE(state.pos); + state.pos += 2; + return value; +} + +function readUInt16(state) { + let value = state.buf.readUInt16LE(state.pos); + state.pos += 2; + return value; +} + +function readInt32(state) { + let value = state.buf.readInt32LE(state.pos); + state.pos += 4; + return value; +} + +function readUInt32(state) { + let value = state.buf.readUInt32LE(state.pos); + state.pos += 4; + return value; +} + +function readUInt32so(state) { + let value = readUInt8(state); + if (value === 0xff) { + return readUInt32(state); + } + + return value; +} + +function readFloat(state) { + let value = state.buf.readFloatLE(state.pos); + state.pos += 4; + return value; +} + +function readDouble(state) { + let value = state.buf.readDoubleLE(state.pos); + state.pos += 8; + return value; +} + +function readString(state) { + let size = readUInt32so(state); + let data = state.buf.slice(state.pos, state.pos + size).toString("utf-8"); + state.pos += size; + return data; +} + +function readOptional(state, readValue) { + let load = readUInt8(state) !== 0; + if (!load) { + return null; + } + return readValue(state); +} + +function readArray(state, readItem) { + let size = readUInt32so(state); + + let array = []; + for (let i = 0; i < size; i++) { + let item = readItem(state); + array.push(item); + } + + return array; +} + +function readDict(state, readKey, readValue) { + let size = readUInt32so(state); + + let mapping = new Map(); + for (let i = 0; i < size; i++) { + let key = readKey(state); + let value = readValue(state); + mapping.set(key, value); + } + + return mapping; +} + +function readVersion(state) { + let major = readUInt16(state); + let minor = readUInt16(state); + let patch = readUInt16(state); + let developer = readUInt16(state); + return [major, minor, patch, developer]; +} + +function readFrequencySizeRichness(state) { + return { + frequency: readFloat(state), + size: readFloat(state), + richness: readFloat(state), + }; +} + +function readAutoplaceSetting(state) { + return { + treat_missing_as_default: readBool(state), + settings: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)), + }; +} + +function readMapPosition(state) { + let x, y; + let x_diff = readInt16(state) / 256; + if (x_diff === 0x7fff / 256) { + x = readInt32(state) / 256; + y = readInt32(state) / 256; + } else { + let y_diff = readInt16(state) / 256; + x = state.last_position.x + x_diff; + y = state.last_position.y + y_diff; + } + state.last_position.x = x; + state.last_position.x = y; + return { x, y }; +} + +function readBoundingBox(state) { + return { + left_top: readMapPosition(state), + right_bottom: readMapPosition(state), + orientation: { + x: readInt16(state), + y: readInt16(state), + }, + }; +} + +function readCliffSettings(state) { + return { + name: readString(state), + elevation_0: readFloat(state), + elevation_interval: readFloat(state), + richness: readFloat(state), + }; +} + +function readMapGenSettings(state) { + return { + terrain_segmentation: readFloat(state), + water: readFloat(state), + autoplace_controls: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)), + autoplace_settings: Object.fromEntries(readDict(state, readString, readAutoplaceSetting)), + default_enable_all_autoplace_controls: readBool(state), + seed: readUInt32(state), + width: readUInt32(state), + height: readUInt32(state), + area_to_generate_at_start: readBoundingBox(state), + starting_area: readFloat(state), + peaceful_mode: readBool(state), + starting_points: readArray(state, readMapPosition), + property_expression_names: Object.fromEntries(readDict(state, readString, readString)), + cliff_settings: readCliffSettings(state), + }; +} + +function readPollution(state) { + return { + enabled: readOptional(state, readBool), + diffusion_ratio: readOptional(state, readDouble), + min_to_diffuse: readOptional(state, readDouble), + ageing: readOptional(state, readDouble), + expected_max_per_chunk: readOptional(state, readDouble), + min_to_show_per_chunk: readOptional(state, readDouble), + min_pollution_to_damage_trees: readOptional(state, readDouble), + pollution_with_max_forest_damage: readOptional(state, readDouble), + pollution_per_tree_damage: readOptional(state, readDouble), + pollution_restored_per_tree_damage: readOptional(state, readDouble), + max_pollution_to_restore_trees: readOptional(state, readDouble), + enemy_attack_pollution_consumption_modifier: readOptional(state, readDouble), + }; +} + +function readSteeringValue(state) { + return { + radius: readOptional(state, readDouble), + separation_factor: readOptional(state, readDouble), + separation_force: readOptional(state, readDouble), + force_unit_fuzzy_goto_behavior: readOptional(state, readBool), + }; + +} + +function readSteering(state) { + return { + default: readSteeringValue(state), + moving: readSteeringValue(state), + }; +} + +function readEnemyEvolution(state) { + return { + enabled: readOptional(state, readBool), + time_factor: readOptional(state, readDouble), + destroy_factor: readOptional(state, readDouble), + pollution_factor: readOptional(state, readDouble), + }; +} + +function readEnemyExpansion(state) { + return { + enabled: readOptional(state, readBool), + max_expansion_distance: readOptional(state, readUInt32), + friendly_base_influence_radius: readOptional(state, readUInt32), + enemy_building_influence_radius: readOptional(state, readUInt32), + building_coefficient: readOptional(state, readDouble), + other_base_coefficient: readOptional(state, readDouble), + neighbouring_chunk_coefficient: readOptional(state, readDouble), + neighbouring_base_chunk_coefficient: readOptional(state, readDouble), + max_colliding_tiles_coefficient: readOptional(state, readDouble), + settler_group_min_size: readOptional(state, readUInt32), + settler_group_max_size: readOptional(state, readUInt32), + min_expansion_cooldown: readOptional(state, readUInt32), + max_expansion_cooldown: readOptional(state, readUInt32), + }; +} + +function readUnitGroup(state) { + return { + min_group_gathering_time: readOptional(state, readUInt32), + max_group_gathering_time: readOptional(state, readUInt32), + max_wait_time_for_late_members: readOptional(state, readUInt32), + max_group_radius: readOptional(state, readDouble), + min_group_radius: readOptional(state, readDouble), + max_member_speedup_when_behind: readOptional(state, readDouble), + max_member_slowdown_when_ahead: readOptional(state, readDouble), + max_group_slowdown_factor: readOptional(state, readDouble), + max_group_member_fallback_factor: readOptional(state, readDouble), + member_disown_distance: readOptional(state, readDouble), + tick_tolerance_when_member_arrives: readOptional(state, readUInt32), + max_gathering_unit_groups: readOptional(state, readUInt32), + max_unit_group_size: readOptional(state, readUInt32), + }; +} + +function readPathFinder(state) { + return { + fwd2bwd_ratio: readOptional(state, readInt32), + goal_pressure_ratio: readOptional(state, readDouble), + use_path_cache: readOptional(state, readBool), + max_steps_worked_per_tick: readOptional(state, readDouble), + max_work_done_per_tick: readOptional(state, readUInt32), + short_cache_size: readOptional(state, readUInt32), + long_cache_size: readOptional(state, readUInt32), + short_cache_min_cacheable_distance: readOptional(state, readDouble), + short_cache_min_algo_steps_to_cache: readOptional(state, readUInt32), + long_cache_min_cacheable_distance: readOptional(state, readDouble), + cache_max_connect_to_cache_steps_multiplier: readOptional(state, readUInt32), + cache_accept_path_start_distance_ratio: readOptional(state, readDouble), + cache_accept_path_end_distance_ratio: readOptional(state, readDouble), + negative_cache_accept_path_start_distance_ratio: readOptional(state, readDouble), + negative_cache_accept_path_end_distance_ratio: readOptional(state, readDouble), + cache_path_start_distance_rating_multiplier: readOptional(state, readDouble), + cache_path_end_distance_rating_multiplier: readOptional(state, readDouble), + stale_enemy_with_same_destination_collision_penalty: readOptional(state, readDouble), + ignore_moving_enemy_collision_distance: readOptional(state, readDouble), + enemy_with_different_destination_collision_penalty: readOptional(state, readDouble), + general_entity_collision_penalty: readOptional(state, readDouble), + general_entity_subsequent_collision_penalty: readOptional(state, readDouble), + extended_collision_penalty: readOptional(state, readDouble), + max_clients_to_accept_any_new_request: readOptional(state, readUInt32), + max_clients_to_accept_short_new_request: readOptional(state, readUInt32), + direct_distance_to_consider_short_request: readOptional(state, readUInt32), + short_request_max_steps: readOptional(state, readUInt32), + short_request_ratio: readOptional(state, readDouble), + min_steps_to_check_path_find_termination: readOptional(state, readUInt32), + start_to_goal_cost_multiplier_to_terminate_path_find: readOptional(state, readDouble), + overload_levels: readOptional(state, (p) => readArray(p, readUInt32)), + overload_multipliers: readOptional(state, (p) => readArray(p, readDouble)), + negative_path_cache_delay_interval: readOptional(state, readUInt32), + }; +} + +function readDifficultySettings(state) { + return { + recipe_difficulty: readUInt8(state), + technology_difficulty: readUInt8(state), + technology_price_multiplier: readDouble(state), + research_queue_setting: ["always", "after-victory", "never"][readUInt8(state)], + }; +} + +function readMapSettings(state) { + return { + pollution: readPollution(state), + steering: readSteering(state), + enemy_evolution: readEnemyEvolution(state), + enemy_expansion: readEnemyExpansion(state), + unit_group: readUnitGroup(state), + path_finder: readPathFinder(state), + max_failed_behavior_count: readUInt32(state), + difficulty_settings: readDifficultySettings(state), + }; +} + +/** + * @typedef {object} module:lib/factorio.MapExchangeResult + * @property {array} version - + * Version of Factorio the string was created with. + * @property {object} map_gen_settings - + * Decoded map generator settings in the format the --map-gen-settings + * command line option to Factorio expect. + * @property {object} map_settings - + * Decoded map settings in the format the --map-settings command line option + * to Factorio expects. + * @property {number} checksum - checksum for the exchange string. + */ + +/** + * Parse a Map Exchange String + * + * Reads and decodes the data in the given map exchange string and returns + * data structures that can be fed into the Factorio server when creating a + * save to set the map gen settings and the map settings for the save. + * + * @param {string} exchangeString - Max Exchange String to parse. + * @returns {module:lib/factorio.MapExchangeResult} Parsed result. + * @memberof module:lib/factorio + */ +function readMapExchangeString(exchangeString) { + exchangeString = exchangeString.replace(/[ \t\n\r]+/g, ""); + if (!/>>>[0-9a-zA-Z\/+]+={0,3}<<>>eNpjZGBkUGQAgwZ7EOZgSc5PzIHxQJgrOb+gILVIN78oFVmYM +7moNCVVNz8TVXFqXmpupW5SYjGKYo7Movw8dBNYi0vy81BFSopSU +4uRRbhLixLzMktz0fUyMF5imubZ0CLHAML/6xkU/v8HYSDrAdAvI +MzA2ABRCRSDAdbknMy0NAYGBUcgdgJJMzIyVousc39YNcWeEaJGz +wHK+AAVOZAEE/GEMfwccEqpwBgmSOYYg8FnJAbE0hKgFVBVHA4IB +kSyBSTJyNj7duuC78cu2DH+Wfnxkm9Sgj2joavIuw9G6+yAkuwgf +zLBiVkzQWAnzCsMMDMf2EOlbtoznj0DAm/sGVlBOkRAhIMFkDjgz +czAKMAHZC3oARIKMgwwp9nBjBFxYEwDg28wnzyGMS7bo/sDGBA2I +MPlQMQJEAG2EO4yRgjTod+B0UEeJiuJUALUb8SA7IYUhA9Pwqw9j +GQ/mkMwIwLZH2giKg5YooELZGEKnHjBDHcNMDwvsMN4DvMdGJlBD +JCqL0AxCA8kAzMKQgs4gIObmQEBgGmjrO/lHgDxa6G4<<< +`; + +let testString = ` +>>>eNp1UzFs00AUvZ8mbRIEypCBSlCCyFAhOQqBKULxlQUxMCCxgnCc +S2rh2On5LAgMZOjAgISEurQLXSmCBTGwRWKhEkgIJraiMgBiaGmFOiC +FO5/PsUz6pfv+9/6/99+/kwEBOo2k6c83NhrZtOkaNkIDXa286fZ6hG +ouJaJIwTmT+i2iuZYdQ3GeOKTb15qGJ4oxQsMglbWo60iGYcSQ8Zjrh +GUhwighHpcRSBHIEZ8ajuV35dmBwIPK1DRxmMX6ADnqmre1Rb/D86UF +hNYX1lZncZpZNkGpmQ41PE87J6RFTe4YjND4hPDq943NwfIcEmv0AJV +GI7F4tMXViYVgECgCjoWWPm66DqOurXmEMcvp1A3/br1pGV5Oq1ZqVW +Hzk0ralCz5xDH79a5vM6tnW4Rma5XgQPVk8kTXtTzmU5Jg1g6tm0hfr +ZwPLGPaVrstL6p0SYwEAPeLLy9/vbeigxysgsPgIESGTYVcCYMnL/Bh +qbRKoQsq2NFBdt+LBbIp4y3CqiweBzK5LJIAu4vbD18f7Dfg77PdT1e +bt3S4/q2w5J39IbQfFW+TitzaqrA3ahSkOLf0MPVFhw/vhf3SYUqcOC +PczkoKQfXaNILCMb5df8Rd6QRS0hqKpoihHdgfNcm2Cj7ryTnKGC4K8 +jnh3gmXQRElVwYyxI8x4FMqOzsu4edrKK6hNZ5wU7V9G+ufEFL+7yHi +cySQMp7wDHnRsBW571ORGn6fH2fUDj/FwV0iUbXPMbmTP7akkt8ChiL +/BPceEs3j1M3iz71/ObT9tA==<<<`; + +// Simple linear congruential random number generator +class Random { + constructor(seed) { + this.prev = seed; + } + + next() { + let curr = (1103515245 * this.prev + 12345) & 0x7fffffff; + this.prev = curr; + return curr >> 16; + } +} + +describe("lib/factorio/exchange_string", function() { + describe("readMapExchangeString", function() { + it("should parse a valid string", function() { + let result = libFactorio.readMapExchangeString(defaultString); + assert.equal(result.map_gen_settings.seed, 1234567890); + + result = libFactorio.readMapExchangeString(testString); + assert.equal(result.checksum, 4092204126); + }); + + it("should handle malformed strings", function() { + assert.throws( + () => libFactorio.readMapExchangeString("<>"), + new Error("Not a map exchange string") + ); + + assert.throws( + () => libFactorio.readMapExchangeString(defaultString.slice(0, 100)), + new Error("Not a map exchange string") + ); + + assert.throws( + () => libFactorio.readMapExchangeString(`>>>${Buffer.from("abk430ia404ah3b4").toString("base64")}<<<`), + new Error("Malformed map exchange string: zlib inflate failed") + ); + + for (let i = 0; i < 100; i++) { + let gen = new Random(i); + let size = gen.next() % 200 + 100; + let data = Buffer.alloc(size); + for (let j = 0; j < size; j++) { + data[j] = gen.next() % 256; + } + // eslint-disable-next-line node/no-sync + data = zlib.deflateSync(data); + assert.throws( + () => libFactorio.readMapExchangeString(`>>>${data.toString("base64")}<<<`), + new Error("Malformed map exchange string: reached end before finishing parsing") + ); + } + + let pastEnd = Buffer.from(defaultString.replace(/><\n/g, ""), "base64"); + // eslint-disable-next-line node/no-sync + pastEnd = zlib.deflateSync(Buffer.concat([zlib.inflateSync(pastEnd), Buffer.from("junk")])); + assert.throws( + () => libFactorio.readMapExchangeString(`>>>${pastEnd.toString("base64")}<<<`), + new Error("Malformed map exchange string: data after end") + ); + }); + }); +}); From bda103d7a07dd438718add8f44b534991ce47245 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 19 May 2021 21:43:31 +0200 Subject: [PATCH 02/16] Fix --dev mode broken by master refactor --- packages/master/src/Master.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/master/src/Master.js b/packages/master/src/Master.js index ccadbf3a..412fb76b 100644 --- a/packages/master/src/Master.js +++ b/packages/master/src/Master.js @@ -140,7 +140,7 @@ class Master { const compiler = webpack(webpackConfigs); this.devMiddleware = webpackDevMiddleware(compiler, {}); - app.use(this.devMiddleware); + this.app.use(this.devMiddleware); } let databaseDirectory = this.config.get("master.database_directory"); @@ -243,7 +243,7 @@ class Master { await fs.outputFile(this.configPath, JSON.stringify(this.config.serialize(), null, 4)); if (this.devMiddleware) { - await new Promise((resolve, reject) => { devMiddleware.close(resolve); }); + await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); }); } let databaseDirectory = this.config.get("master.database_directory"); From 6ffcb8c4513b613040773ee468c58614a05088fc Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Thu, 20 May 2021 21:22:01 +0200 Subject: [PATCH 03/16] Add instance update subscription Provide the control link with the ability to subscribe to updates when instance status and properties changes. This allows the web interface to respond in real time to instance starts and stops that are triggered from somewhere else. --- packages/ctl/ctl.js | 2 + packages/lib/link/messages.js | 49 +++++++++++++--- packages/lib/users.js | 12 ++++ packages/master/src/ControlConnection.js | 41 ++++++++++++- packages/master/src/Master.js | 20 ++++++- packages/master/src/SlaveConnection.js | 4 +- .../src/components/InstanceViewPage.jsx | 11 +--- packages/web_ui/src/model/instance.jsx | 24 +++++--- packages/web_ui/src/util/websocket.jsx | 58 +++++++++++++++++++ test/integration/clusterio.js | 16 +++++ test/integration/index.js | 11 ++++ 11 files changed, 219 insertions(+), 29 deletions(-) diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index 20243a75..b4051db2 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -748,6 +748,8 @@ class Control extends libLink.Link { this.keepOpen = false; } + async instanceUpdateEventHandler() { } + async setLogSubscriptions({ all = false, master = false, diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index e4aedcb6..8586ce39 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -353,6 +353,26 @@ messages.listSlaves = new Request({ }, }); +let instanceProperties = { + "name": { type: "string" }, + "id": { type: "integer" }, + "assigned_slave": { type: ["null", "integer"] }, + "status": { enum: [ + "unknown", "unassigned", "stopped", "starting", "running", "stopping", + "creating_save", "exporting_data", + ]}, +}; + +messages.getInstance = new Request({ + type: "get_instance", + links: ["control-master"], + permission: "core.instance.get", + requestProperties: { + "id": { type: "integer" }, + }, + responseProperties: instanceProperties, +}); + messages.listInstances = new Request({ type: "list_instances", links: ["control-master"], @@ -363,20 +383,25 @@ messages.listInstances = new Request({ items: { additionalProperties: false, required: ["name", "id", "assigned_slave", "status"], - properties: { - "name": { type: "string" }, - "id": { type: "integer" }, - "assigned_slave": { type: ["null", "integer"] }, - "status": { enum: [ - "unknown", "unassigned", "stopped", "starting", "running", "stopping", - "creating_save", "exporting_data", - ]}, - }, + properties: instanceProperties, }, }, }, }); +messages.setInstanceSubscriptions = new Request({ + type: "set_instance_subscriptions", + links: ["control-master"], + permission: "core.instance.subscribe", + requestProperties: { + "all": { type: "boolean" }, + "instance_ids": { + type: "array", + items: { type: "integer" }, + }, + }, +}); + messages.generateSlaveToken = new Request({ type: "generate_slave_token", links: ["control-master"], @@ -985,6 +1010,12 @@ messages.instanceStatusChanged = new Event({ }, }); +messages.instanceUpdate = new Event({ + type: "instance_update", + links: ["master-control"], + eventProperties: instanceProperties, +}); + messages.masterConnectionEvent = new Event({ type: "master_connection_event", links: ["slave-instance"], diff --git a/packages/lib/users.js b/packages/lib/users.js index dd9e0930..3ba6f39e 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -333,12 +333,24 @@ definePermission({ description: "Create slave configs via the master server.", }); +definePermission({ + name: "core.instance.get", + title: "Get instance", + description: "Get the details of an instance in the cluster.", + grantByDefault: true, +}); definePermission({ name: "core.instance.list", title: "List instances", description: "Get the full list of instances in the cluster.", grantByDefault: true, }); +definePermission({ + name: "core.instance.subscribe", + title: "Subscribe to instance updates", + description: "Subscribe to be notified on updates on the details and status of instances.", + grantByDefault: true, +}); definePermission({ name: "core.instance.create", title: "Create instance", diff --git a/packages/master/src/ControlConnection.js b/packages/master/src/ControlConnection.js index a592a6ce..c9ad29b3 100644 --- a/packages/master/src/ControlConnection.js +++ b/packages/master/src/ControlConnection.js @@ -38,6 +38,11 @@ class ControlConnection extends BaseConnection { */ this.user = user; + this.instanceSubscriptions = { + all: false, + instance_ids: [], + }; + this.logTransport = null; this.logSubscriptions = { all: false, @@ -122,6 +127,21 @@ class ControlConnection extends BaseConnection { return { serialized_config: slaveConfig.serialize() }; } + async getInstanceRequestHandler(message) { + let id = message.data.id; + let instance = this._master.instances.get(id); + if (!instance) { + throw new libErrors.RequestError(`Instance with ID ${id} does not exist`); + } + + return { + id, + name: instance.config.get("instance.name"), + assigned_slave: instance.config.get("instance.assigned_slave"), + status: instance.status, + }; + } + async listInstancesRequestHandler(message) { let list = []; for (let instance of this._master.instances.values()) { @@ -135,6 +155,24 @@ class ControlConnection extends BaseConnection { return { list }; } + async setInstanceSubscriptionsRequestHandler(message) { + this.instanceSubscriptions = message.data; + } + + instanceUpdated(instance) { + if ( + this.instanceSubscriptions.all + || this.instanceSubscriptions.instance_ids.includes(instance.config.get("instance.id")) + ) { + libLink.messages.instanceUpdate.send(this, { + id: instance.config.get("instance.id"), + name: instance.config.get("instance.name"), + assigned_slave: instance.config.get("instance.assigned_slave"), + status: instance.status, + }); + } + } + // XXX should probably add a hook for slave reuqests? async createInstanceRequestHandler(message) { let instanceConfig = new libConfig.InstanceConfig("master"); @@ -174,7 +212,7 @@ class ControlConnection extends BaseConnection { let instance = { config: instanceConfig, status: "unassigned" }; this._master.instances.set(instanceId, instance); await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", instance, null); - this._master.addInstancePluginHooks(instance); + this._master.addInstanceHooks(instance); } async deleteInstanceRequestHandler(message, request) { @@ -190,6 +228,7 @@ class ControlConnection extends BaseConnection { let prev = instance.status; instance.status = "deleted"; + this.instanceUpdated(instance); await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", instance, prev); } diff --git a/packages/master/src/Master.js b/packages/master/src/Master.js index 412fb76b..644b404b 100644 --- a/packages/master/src/Master.js +++ b/packages/master/src/Master.js @@ -152,7 +152,7 @@ class Master { await this.userManager.load(path.join(databaseDirectory, "users.json")); for (let instance of this.instances.values()) { - this.addInstancePluginHooks(instance); + this.addInstanceHooks(instance); } // Make sure we're actually going to listen on a port @@ -361,10 +361,26 @@ class Master { } } - addInstancePluginHooks(instance) { + addInstanceHooks(instance) { instance.config.on("fieldChanged", (group, field, prev) => { + if (group.name === "instance" && field === "name") { + this.instanceUpdated(instance); + } + libPlugin.invokeHook(this.plugins, "onInstanceConfigFieldChanged", instance, group, field, prev); }); + + this.instanceUpdated(instance); + } + + instanceUpdated(instance) { + for (let controlConnection of this.wsServer.controlConnections) { + if (controlConnection.connector.closing) { + continue; + } + + controlConnection.instanceUpdated(instance); + } } async loadPlugins() { diff --git a/packages/master/src/SlaveConnection.js b/packages/master/src/SlaveConnection.js index 9bbdfd4e..19829d0f 100644 --- a/packages/master/src/SlaveConnection.js +++ b/packages/master/src/SlaveConnection.js @@ -65,6 +65,7 @@ class SlaveConnection extends BaseConnection { let prev = instance.status; instance.status = message.data.status; logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`); + this._master.instanceUpdated(instance); await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", instance, prev); } @@ -99,6 +100,7 @@ class SlaveConnection extends BaseConnection { let prev = masterInstance.status; masterInstance.status = instanceData.status; logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`); + this._master.instanceUpdated(instance); await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", masterInstance, prev); } continue; @@ -107,7 +109,7 @@ class SlaveConnection extends BaseConnection { instanceConfig.set("instance.assigned_slave", this._id); let newInstance = { config: instanceConfig, status: instanceData.status }; this._master.instances.set(instanceConfig.get("instance.id"), newInstance); - this._master.addInstancePluginHooks(newInstance); + this._master.addInstanceHooks(newInstance); await libLink.messages.assignInstance.send(this, { instance_id: instanceConfig.get("instance.id"), serialized_config: instanceConfig.serialize("slave"), diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx index f44b5c34..cc0a55d2 100644 --- a/packages/web_ui/src/components/InstanceViewPage.jsx +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -26,7 +26,7 @@ export default function InstanceViewPage(props) { let history = useHistory(); let control = useContext(ControlContext); - let [instance, updateInstance] = useInstance(instanceId); + let [instance] = useInstance(instanceId); let [slave] = useSlave(Number(instance["assigned_slave"])); let [creatingSave, setCreatingSave] = useState(false); @@ -44,10 +44,7 @@ export default function InstanceViewPage(props) { } let instanceButtons = - + {instance.status === "stopped" && + +
+ + + + + + + + + +
+ +
+ + + + + + +
+
+ ; +} diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx index cc0a55d2..4b7cd077 100644 --- a/packages/web_ui/src/components/InstanceViewPage.jsx +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -12,6 +12,8 @@ import InstanceConsole from "./InstanceConsole"; import InstanceRcon from "./InstanceRcon"; import AssignInstanceModal from "./AssignInstanceModal"; import StartStopInstanceButton from "./StartStopInstanceButton"; +import CreateSaveModal from "./CreateSaveModal"; +import LoadScenarioModal from "./LoadScenarioModal"; import { notifyErrorHandler } from "../util/notify"; import { useInstance } from "../model/instance"; import { useSlave } from "../model/slave"; @@ -29,8 +31,6 @@ export default function InstanceViewPage(props) { let [instance] = useInstance(instanceId); let [slave] = useSlave(Number(instance["assigned_slave"])); - let [creatingSave, setCreatingSave] = useState(false); - let nav = [{ name: "Instances", path: "/instances" }, { name: instance.name || "Unknown" }]; if (instance.loading) { return ; @@ -45,19 +45,8 @@ export default function InstanceViewPage(props) { let instanceButtons = - {instance.status === "stopped" && } + + { + setVisible(false); + }).catch( + notifyErrorHandler("Error creating save") + ).finally(() => { + setLoadingScenario(false); + }); + } + + function convertExchangeString() { + let exchangeString = form.getFieldValue("exchangeString"); + let result; + try { + result = readMapExchangeString(exchangeString); + } catch (err) { + form.setFields([{ name: "exchangeString", errors: [err.message] }]); + return; + } + form.setFields([{ name: "exchangeString", errors: [], value: "" }]); + form.setFieldsValue({ + mapGenSettings: JSON.stringify(result.map_gen_settings, null, 4), + mapSettings: JSON.stringify(result.map_settings, null, 4), + }); + } + + function handleCancel() { + setVisible(false); + } + + return <> + + +
+ + + + + + + + + +
+ +
+ + + + + + +
+
+ ; +} diff --git a/packages/web_ui/webpack.common.js b/packages/web_ui/webpack.common.js index df0fac39..6a850827 100644 --- a/packages/web_ui/webpack.common.js +++ b/packages/web_ui/webpack.common.js @@ -92,8 +92,13 @@ module.exports = (env = {}) => ({ "util": require.resolve("util/"), "os": require.resolve("os-browserify/browser"), + // Required for zlib + "assert": require.resolve("assert"), + "stream": require.resolve("stream-browserify"), + "events": require.resolve("events/"), "path": require.resolve("path-browserify"), + "zlib": require.resolve("browserify-zlib"), }, }, }); diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index a8f80793..042c9979 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -7,6 +7,7 @@ const events = require("events"); const libLink = require("@clusterio/lib/link"); const libUsers = require("@clusterio/lib/users"); +const testStrings = require("../lib/factorio/test_strings"); const { TestControl, TestControlConnector, url, controlToken, slowTest, get, exec, execCtl, sendRcon, getControl, instancesDir, @@ -184,7 +185,7 @@ describe("Integration of Clusterio", function() { "afk-auto-kick", "Kick if AFK for more than 2 minutes.", ], [ - "allow_commands", true, + "allow_commands", "true", "allow-commands", "Allow Lua commands: Yes.", ], [ @@ -315,6 +316,33 @@ describe("Integration of Clusterio", function() { }); }); + describe("instance load-scenario", function() { + it("starts the instance with the given settings", async function() { + slowTest(this); + await execCtl("instance config set 44 factorio.enable_save_patching false"); + await execCtl("instance config set 44 player_auth.load_plugin false"); + await execCtl("instance config set 44 research_sync.load_plugin false"); + await execCtl("instance config set 44 statistics_exporter.load_plugin false"); + await execCtl("instance config set 44 subspace_storage.load_plugin false"); + try { + let exchangeString = testStrings.modified.replace(/[\n\r]+/g, ""); + let args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString}"`; + await execCtl(`instance load-scenario test ${args}`); + await checkInstanceStatus(44, "running"); + await sendRcon(44, '/c game.print("disable achievements")'); + await sendRcon(44, '/c game.print("disable achievements")'); + assert.equal(await sendRcon(44, "/c rcon.print(game.default_map_gen_settings.seed)"), "1234\n"); + assert.equal(await sendRcon(44, "/c rcon.print(game.map_settings.pollution.ageing)"), "1.5\n"); + assert.equal( + await sendRcon(44, "/c rcon.print(game.difficulty_settings.research_queue_setting)"), "never\n" + ); + + } finally { + await execCtl("instance stop test"); + } + }); + }); + describe("instance delete", function() { it("deletes the instance", async function() { slowTest(this); diff --git a/test/lib/factorio/exchange_string.js b/test/lib/factorio/exchange_string.js index 854a6893..8164a241 100644 --- a/test/lib/factorio/exchange_string.js +++ b/test/lib/factorio/exchange_string.js @@ -4,37 +4,8 @@ const zlib = require("zlib"); const libFactorio = require("@clusterio/lib/factorio"); +const testStrings = require("./test_strings"); -let defaultString = ` ->>>eNpjZGBkUGQAgwZ7EOZgSc5PzIHxQJgrOb+gILVIN78oFVmYM -7moNCVVNz8TVXFqXmpupW5SYjGKYo7Movw8dBNYi0vy81BFSopSU -4uRRbhLixLzMktz0fUyMF5imubZ0CLHAML/6xkU/v8HYSDrAdAvI -MzA2ABRCRSDAdbknMy0NAYGBUcgdgJJMzIyVousc39YNcWeEaJGz -wHK+AAVOZAEE/GEMfwccEqpwBgmSOYYg8FnJAbE0hKgFVBVHA4IB -kSyBSTJyNj7duuC78cu2DH+Wfnxkm9Sgj2joavIuw9G6+yAkuwgf -zLBiVkzQWAnzCsMMDMf2EOlbtoznj0DAm/sGVlBOkRAhIMFkDjgz -czAKMAHZC3oARIKMgwwp9nBjBFxYEwDg28wnzyGMS7bo/sDGBA2I -MPlQMQJEAG2EO4yRgjTod+B0UEeJiuJUALUb8SA7IYUhA9Pwqw9j -GQ/mkMwIwLZH2giKg5YooELZGEKnHjBDHcNMDwvsMN4DvMdGJlBD -JCqL0AxCA8kAzMKQgs4gIObmQEBgGmjrO/lHgDxa6G4<<< -`; - -let testString = ` ->>>eNp1UzFs00AUvZ8mbRIEypCBSlCCyFAhOQqBKULxlQUxMCCxgnCc -S2rh2On5LAgMZOjAgISEurQLXSmCBTGwRWKhEkgIJraiMgBiaGmFOiC -FO5/PsUz6pfv+9/6/99+/kwEBOo2k6c83NhrZtOkaNkIDXa286fZ6hG -ouJaJIwTmT+i2iuZYdQ3GeOKTb15qGJ4oxQsMglbWo60iGYcSQ8Zjrh -GUhwighHpcRSBHIEZ8ajuV35dmBwIPK1DRxmMX6ADnqmre1Rb/D86UF -hNYX1lZncZpZNkGpmQ41PE87J6RFTe4YjND4hPDq943NwfIcEmv0AJV -GI7F4tMXViYVgECgCjoWWPm66DqOurXmEMcvp1A3/br1pGV5Oq1ZqVW -Hzk0ralCz5xDH79a5vM6tnW4Rma5XgQPVk8kTXtTzmU5Jg1g6tm0hfr -ZwPLGPaVrstL6p0SYwEAPeLLy9/vbeigxysgsPgIESGTYVcCYMnL/Bh -qbRKoQsq2NFBdt+LBbIp4y3CqiweBzK5LJIAu4vbD18f7Dfg77PdT1e -bt3S4/q2w5J39IbQfFW+TitzaqrA3ahSkOLf0MPVFhw/vhf3SYUqcOC -PczkoKQfXaNILCMb5df8Rd6QRS0hqKpoihHdgfNcm2Cj7ryTnKGC4K8 -jnh3gmXQRElVwYyxI8x4FMqOzsu4edrKK6hNZ5wU7V9G+ufEFL+7yHi -cySQMp7wDHnRsBW571ORGn6fH2fUDj/FwV0iUbXPMbmTP7akkt8ChiL -/BPceEs3j1M3iz71/ObT9tA==<<<`; // Simple linear congruential random number generator class Random { @@ -52,10 +23,10 @@ class Random { describe("lib/factorio/exchange_string", function() { describe("readMapExchangeString", function() { it("should parse a valid string", function() { - let result = libFactorio.readMapExchangeString(defaultString); + let result = libFactorio.readMapExchangeString(testStrings.default); assert.equal(result.map_gen_settings.seed, 1234567890); - result = libFactorio.readMapExchangeString(testString); + result = libFactorio.readMapExchangeString(testStrings.modified); assert.equal(result.checksum, 4092204126); }); @@ -66,7 +37,7 @@ describe("lib/factorio/exchange_string", function() { ); assert.throws( - () => libFactorio.readMapExchangeString(defaultString.slice(0, 100)), + () => libFactorio.readMapExchangeString(testStrings.default.slice(0, 100)), new Error("Not a map exchange string") ); @@ -90,7 +61,7 @@ describe("lib/factorio/exchange_string", function() { ); } - let pastEnd = Buffer.from(defaultString.replace(/><\n/g, ""), "base64"); + let pastEnd = Buffer.from(testStrings.default.replace(/><\n/g, ""), "base64"); // eslint-disable-next-line node/no-sync pastEnd = zlib.deflateSync(Buffer.concat([zlib.inflateSync(pastEnd), Buffer.from("junk")])); assert.throws( diff --git a/test/lib/factorio/test_strings.js b/test/lib/factorio/test_strings.js new file mode 100644 index 00000000..243d4a01 --- /dev/null +++ b/test/lib/factorio/test_strings.js @@ -0,0 +1,38 @@ +"use strict"; +// Exchange strings for testing with + +let default_ = ` +>>>eNpjZGBkUGQAgwZ7EOZgSc5PzIHxQJgrOb+gILVIN78oFVmYM +7moNCVVNz8TVXFqXmpupW5SYjGKYo7Movw8dBNYi0vy81BFSopSU +4uRRbhLixLzMktz0fUyMF5imubZ0CLHAML/6xkU/v8HYSDrAdAvI +MzA2ABRCRSDAdbknMy0NAYGBUcgdgJJMzIyVousc39YNcWeEaJGz +wHK+AAVOZAEE/GEMfwccEqpwBgmSOYYg8FnJAbE0hKgFVBVHA4IB +kSyBSTJyNj7duuC78cu2DH+Wfnxkm9Sgj2joavIuw9G6+yAkuwgf +zLBiVkzQWAnzCsMMDMf2EOlbtoznj0DAm/sGVlBOkRAhIMFkDjgz +czAKMAHZC3oARIKMgwwp9nBjBFxYEwDg28wnzyGMS7bo/sDGBA2I +MPlQMQJEAG2EO4yRgjTod+B0UEeJiuJUALUb8SA7IYUhA9Pwqw9j +GQ/mkMwIwLZH2giKg5YooELZGEKnHjBDHcNMDwvsMN4DvMdGJlBD +JCqL0AxCA8kAzMKQgs4gIObmQEBgGmjrO/lHgDxa6G4<<< +`; + +let modified = ` +>>>eNp1UzFs00AUvZ8mbRIEypCBSlCCyFAhOQqBKULxlQUxMCCxgnCc +S2rh2On5LAgMZOjAgISEurQLXSmCBTGwRWKhEkgIJraiMgBiaGmFOiC +FO5/PsUz6pfv+9/6/99+/kwEBOo2k6c83NhrZtOkaNkIDXa286fZ6hG +ouJaJIwTmT+i2iuZYdQ3GeOKTb15qGJ4oxQsMglbWo60iGYcSQ8Zjrh +GUhwighHpcRSBHIEZ8ajuV35dmBwIPK1DRxmMX6ADnqmre1Rb/D86UF +hNYX1lZncZpZNkGpmQ41PE87J6RFTe4YjND4hPDq943NwfIcEmv0AJV +GI7F4tMXViYVgECgCjoWWPm66DqOurXmEMcvp1A3/br1pGV5Oq1ZqVW +Hzk0ralCz5xDH79a5vM6tnW4Rma5XgQPVk8kTXtTzmU5Jg1g6tm0hfr +ZwPLGPaVrstL6p0SYwEAPeLLy9/vbeigxysgsPgIESGTYVcCYMnL/Bh +qbRKoQsq2NFBdt+LBbIp4y3CqiweBzK5LJIAu4vbD18f7Dfg77PdT1e +bt3S4/q2w5J39IbQfFW+TitzaqrA3ahSkOLf0MPVFhw/vhf3SYUqcOC +PczkoKQfXaNILCMb5df8Rd6QRS0hqKpoihHdgfNcm2Cj7ryTnKGC4K8 +jnh3gmXQRElVwYyxI8x4FMqOzsu4edrKK6hNZ5wU7V9G+ufEFL+7yHi +cySQMp7wDHnRsBW571ORGn6fH2fUDj/FwV0iUbXPMbmTP7akkt8ChiL +/BPceEs3j1M3iz71/ObT9tA==<<<`; + +module.exports = { + default: default_, + modified, +}; From b53ed7f6ca3cd120ba038d327ed27f4702aeb983 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Thu, 20 May 2021 21:48:38 +0200 Subject: [PATCH 07/16] Add Export data button to instance page Add missing button to do the export data action on an instance. --- .../web_ui/src/components/InstanceViewPage.jsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx index 4b7cd077..582b7d8d 100644 --- a/packages/web_ui/src/components/InstanceViewPage.jsx +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -31,6 +31,8 @@ export default function InstanceViewPage(props) { let [instance] = useInstance(instanceId); let [slave] = useSlave(Number(instance["assigned_slave"])); + let [exportingData, setExportingData] = useState(false); + let nav = [{ name: "Instances", path: "/instances" }, { name: instance.name || "Unknown" }]; if (instance.loading) { return ; @@ -47,6 +49,22 @@ export default function InstanceViewPage(props) { + Date: Wed, 26 May 2021 15:55:22 +0200 Subject: [PATCH 08/16] Add save listing and subscriptions Add link messages for listing saves and listening for changes to the list of saves for instances along with a table in the instance view page showing the save list for the instance and a list-saves command to ctl. --- packages/ctl/ctl.js | 17 +++++ packages/lib/link/messages.js | 52 +++++++++++++- packages/lib/users.js | 12 +++- packages/master/src/ControlConnection.js | 18 +++++ packages/master/src/Master.js | 10 +++ packages/master/src/SlaveConnection.js | 4 ++ packages/slave/slave.js | 72 +++++++++++++++++++ .../src/components/InstanceViewPage.jsx | 5 +- packages/web_ui/src/components/SavesList.jsx | 57 +++++++++++++++ .../web_ui/src/components/SectionHeader.jsx | 8 +++ packages/web_ui/src/index.css | 21 ++++++ packages/web_ui/src/index.jsx | 2 + packages/web_ui/src/model/saves.jsx | 34 +++++++++ packages/web_ui/src/util/websocket.jsx | 58 +++++++++++++++ test/integration/clusterio.js | 15 ++++ test/integration/index.js | 8 +++ 16 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 packages/web_ui/src/components/SavesList.jsx create mode 100644 packages/web_ui/src/components/SectionHeader.jsx create mode 100644 packages/web_ui/src/model/saves.jsx diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index 7f464d22..c510e4db 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -338,6 +338,21 @@ async function loadMapSettings(args) { }; } +instanceCommands.add(new libCommand.Command({ + definition: ["list-saves ", "list saves on an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to list saves on", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + let response = await libLink.messages.listSaves.send(control, { instance_id: instanceId }); + for (let entry of response.list) { + entry.mtime = new Date(entry.mtime_ms).toLocaleString(); + delete entry.mtime_ms; + } + print(asTable(response.list)); + }, +})); + instanceCommands.add(new libCommand.Command({ definition: ["create-save [name]", "Create a new save on an instance", (yargs) => { yargs.positional("instance", { describe: "Instance to create on", type: "string" }); @@ -794,6 +809,8 @@ class Control extends libLink.Link { async instanceUpdateEventHandler() { } + async saveListUpdateEventHandler() { } + async setLogSubscriptions({ all = false, master = false, diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index 672777b8..7e390fed 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -493,10 +493,37 @@ messages.startInstance = new Request({ forwardTo: "instance", }); +let saveList = { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["type", "name", "size", "mtime_ms", "loaded"], + properties: { + "type": { enum: ["file", "directory", "special"] }, + "name": { type: "string" }, + "size": { type: "integer" }, + "mtime_ms": { type: "number" }, + "loaded": { type: "boolean" }, + "default": { type: "boolean" }, + }, + }, +}; + +messages.listSaves = new Request({ + type: "list_saves", + links: ["control-master", "master-slave", "slave-instance"], + permission: "core.instance.save.list", + forwardTo: "instance", + responseProperties: { + "list": saveList, + }, +}); + messages.createSave = new Request({ type: "create_save", links: ["control-master", "master-slave", "slave-instance"], - permission: "core.instance.create_save", + permission: "core.instance.save.create", forwardTo: "instance", requestProperties: { "name": { type: "string" }, @@ -506,6 +533,19 @@ messages.createSave = new Request({ }, }); +messages.setSaveListSubscriptions = new Request({ + type: "set_save_list_subscriptions", + links: ["control-master"], + permission: "core.instance.save.list_subscribe", + requestProperties: { + "all": { type: "boolean" }, + "instance_ids": { + type: "array", + items: { type: "integer" }, + }, + }, +}); + messages.loadScenario = new Request({ type: "load_scenario", links: ["control-master", "master-slave", "slave-instance"], @@ -1025,6 +1065,16 @@ messages.instanceUpdate = new Event({ eventProperties: instanceProperties, }); +messages.saveListUpdate = new Event({ + type: "save_list_update", + links: ["instance-slave", "slave-master", "master-control"], + forwardTo: "master", + eventProperties: { + "instance_id": { type: "integer" }, + "list": saveList, + }, +}); + messages.masterConnectionEvent = new Event({ type: "master_connection_event", links: ["slave-instance"], diff --git a/packages/lib/users.js b/packages/lib/users.js index 3ba6f39e..947bc807 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -372,10 +372,20 @@ definePermission({ description: "Assign or reassign instances to a slave.", }); definePermission({ - name: "core.instance.create_save", + name: "core.instance.save.list", + title: "List saves on instance", + description: "List the saves currently on the instance.", +}); +definePermission({ + name: "core.instance.save.create", title: "Create new instance save", description: "Create new savegames on instances.", }); +definePermission({ + name: "core.instance.save.list_subscribe", + title: "Subscribe to save list updatess", + description: "Subscribe to be notifed on updates to the list of saves of instances.", +}); definePermission({ name: "core.instance.export_data", title: "Export locale and icons", diff --git a/packages/master/src/ControlConnection.js b/packages/master/src/ControlConnection.js index c9ad29b3..3afbce9f 100644 --- a/packages/master/src/ControlConnection.js +++ b/packages/master/src/ControlConnection.js @@ -43,6 +43,11 @@ class ControlConnection extends BaseConnection { instance_ids: [], }; + this.saveListSubscriptions = { + all: false, + instance_ids: [], + }; + this.logTransport = null; this.logSubscriptions = { all: false, @@ -326,6 +331,19 @@ class ControlConnection extends BaseConnection { } } + async setSaveListSubscriptionsRequestHandler(message) { + this.saveListSubscriptions = message.data; + } + + saveListUpdate(data) { + if ( + this.saveListSubscriptions.all + || this.saveListSubscriptions.instance_ids.includes(data.instance_id) + ) { + libLink.messages.saveListUpdate.send(this, data); + } + } + async setLogSubscriptionsRequestHandler(message) { this.logSubscriptions = message.data; this.updateLogSubscriptions(); diff --git a/packages/master/src/Master.js b/packages/master/src/Master.js index 644b404b..22367afb 100644 --- a/packages/master/src/Master.js +++ b/packages/master/src/Master.js @@ -383,6 +383,16 @@ class Master { } } + saveListUpdate(data) { + for (let controlConnection of this.wsServer.controlConnections) { + if (controlConnection.connector.closing) { + continue; + } + + controlConnection.saveListUpdate(data); + } + } + async loadPlugins() { for (let pluginInfo of this.pluginInfos) { if (!this.config.group(pluginInfo.name).get("load_plugin")) { diff --git a/packages/master/src/SlaveConnection.js b/packages/master/src/SlaveConnection.js index 3243cd11..f4c71d1b 100644 --- a/packages/master/src/SlaveConnection.js +++ b/packages/master/src/SlaveConnection.js @@ -146,6 +146,10 @@ class SlaveConnection extends BaseConnection { libLink.messages.syncUserLists.send(this, { adminlist, banlist, whitelist }); } + async saveListUpdateEventHandler(message) { + this._master.saveListUpdate(message.data); + } + async logMessageEventHandler(message) { this._master.clusterLogger.log({ ...message.data.info, diff --git a/packages/slave/slave.js b/packages/slave/slave.js index c1c0028e..b6dc2df9 100755 --- a/packages/slave/slave.js +++ b/packages/slave/slave.js @@ -169,6 +169,7 @@ class Instance extends libLink.Link { this._status = "stopped"; this._running = false; + this._loadedSave = null; this.server = new libFactorio.FactorioServer( factorioDir, this._dir, serverOptions ); @@ -209,9 +210,50 @@ class Instance extends libLink.Link { } } + static async listSaves(savesDir, loadedSave) { + let defaultSave = null; + if (loadedSave === null) { + defaultSave = await libFileOps.getNewestFile( + savesDir, (name) => !name.endsWith(".tmp.zip") + ); + } + + let list = []; + for (let name of await fs.readdir(savesDir)) { + let type; + let stat = await fs.stat(path.join(savesDir, name)); + if (stat.isFile()) { + type = "file"; + } else if (stat.isDirectory()) { + type = "directory"; + } else { + type = "special"; + } + + list.push({ + name, + type, + size: stat.size, + mtime_ms: stat.mtimeMs, + loaded: name === loadedSave, + default: name === defaultSave, + }); + } + + return list; + } + + async sendSaveListUpdate() { + libLink.messages.saveListUpdate.send(this, { + instance_id: this.id, + list: await Instance.listSaves(this.server.writePath("saves"), this._loadedSave), + }); + } + async _autosave(name) { let stat = await fs.stat(this.path("saves", `${name}.zip`)); instanceFactorioAutosaveSize.labels(String(this.id)).set(stat.size); + await this.sendSaveListUpdate(); } notifyStatus(status) { @@ -234,6 +276,7 @@ class Instance extends libLink.Link { notifyExit() { this._running = false; + this._loadedSave = null; this.notifyStatus("stopped"); this.config.off("fieldChanged", this._configFieldChanged); @@ -252,6 +295,10 @@ class Instance extends libLink.Link { for (let pluginInstance of this.plugins.values()) { pluginInstance.onExit(); } + + this.sendSaveListUpdate().catch(err => { + this.logger.error(`Unexpected error updating save list:\n${err.stack}`); + }); } async _loadPlugin(pluginInfo, slave) { @@ -496,6 +543,7 @@ class Instance extends libLink.Link { }); this.server.on("exit", () => this.notifyExit()); + this._loadedSave = saveName; await this.server.start(saveName); if (this.config.get("factorio.enable_save_patching")) { @@ -503,6 +551,7 @@ class Instance extends libLink.Link { await this.updateInstanceData(); } + await this.sendSaveListUpdate(); await libPlugin.invokeHook(this.plugins, "onStart"); this._running = true; @@ -690,6 +739,12 @@ class Instance extends libLink.Link { } } + async listSavesRequestHandler(message) { + return { + list: await Instance.listSaves(this.server.writePath("saves"), this._loadedSave), + }; + } + async createSaveRequestHandler(message) { this.notifyStatus("creating_save"); try { @@ -1301,6 +1356,23 @@ class Slave extends libLink.Link { return await request.send(instanceConnection, message.data); } + async listSavesRequestHandler(message, request) { + let instanceId = message.data.instance_id; + let instanceConnection = this.instanceConnections.get(instanceId); + if (instanceConnection) { + return await request.send(instanceConnection, message.data); + } + + let instanceInfo = this.instanceInfos.get(instanceId); + if (!instanceInfo) { + throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`); + } + + return { + list: await Instance.listSaves(path.join(instanceInfo.path, "saves"), null), + }; + } + async createSaveRequestHandler(message, request) { let instanceId = message.data.instance_id; let instanceConnection = await this._connectInstance(instanceId); diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx index 582b7d8d..bcf9b974 100644 --- a/packages/web_ui/src/components/InstanceViewPage.jsx +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -14,6 +14,8 @@ import AssignInstanceModal from "./AssignInstanceModal"; import StartStopInstanceButton from "./StartStopInstanceButton"; import CreateSaveModal from "./CreateSaveModal"; import LoadScenarioModal from "./LoadScenarioModal"; +import SavesList from "./SavesList"; +import SectionHeader from "./SectionHeader"; import { notifyErrorHandler } from "../util/notify"; import { useInstance } from "../model/instance"; import { useSlave } from "../model/slave"; @@ -47,7 +49,6 @@ export default function InstanceViewPage(props) { let instanceButtons = -