From 021ee1725b089ca3aa4ed4b7e44c9a5c4792db15 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:21:28 +0000 Subject: [PATCH] Support enemy_expansion.build_base_unit_dispatch_cooldown in exchange strings Factorio 2.1.13 added build_base_unit_dispatch_cooldown as the last field of enemy_expansion in the map exchange string, and rejects map settings without it. Track the full version in the reader state and read the field for 2.1.13 and later. Adds a fixture string generated with 2.1.14 and uses it in the load-scenario integration test. Co-Authored-By: Claude Fable 5 --- packages/lib/src/factorio/exchange_string.ts | 19 ++++++++++++++++--- test/integration/clusterio.js | 10 +++++++--- test/lib/factorio/exchange_string.js | 10 ++++++++++ test/lib/factorio/test_settings.js | 9 +++++++++ test/lib/factorio/test_strings.js | 17 +++++++++++++++++ 5 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/lib/src/factorio/exchange_string.ts b/packages/lib/src/factorio/exchange_string.ts index cc87f24f..adefe668 100644 --- a/packages/lib/src/factorio/exchange_string.ts +++ b/packages/lib/src/factorio/exchange_string.ts @@ -3,14 +3,23 @@ import zlib from "zlib"; class MapReaderState { pos = 0; last_position = { x: 0, y: 0 }; + /** Version of Factorio the string was created with */ + version = [0, 0, 0, 0]; /** True when a version greater than 2.0.0 is detected */ gt_v2_0 = false; /** True when a version greater than 2.1.0 is detected */ gt_v2_1 = false; - // If more versions are required then we should expose version directly constructor( public buf: Buffer ) { } + + /** True when the version is at least the given major.minor.patch */ + atLeast(major: number, minor: number, patch = 0) { + const [vMajor, vMinor, vPatch] = this.version; + if (vMajor !== major) { return vMajor > major; } + if (vMinor !== minor) { return vMinor > minor; } + return vPatch >= patch; + } } function readUInt8(state: MapReaderState) { @@ -281,6 +290,9 @@ function readEnemyExpansion(state: MapReaderState) { evolution_group_size_factor: readOptional(state, readDouble), // v2.1 min_expansion_cooldown: readOptional(state, readUInt32), max_expansion_cooldown: readOptional(state, readUInt32), + ...state.atLeast(2, 1, 13) ? { + build_base_unit_dispatch_cooldown: readOptional(state, readUInt32), // v2.1.13 + } : {}, } : { enabled: readOptional(state, readBool), max_expansion_distance: readOptional(state, readUInt32), @@ -460,8 +472,9 @@ export function readMapExchangeString(exchangeString: string) { try { const version = readVersion(state); - state.gt_v2_0 = version[0] >= 2; - state.gt_v2_1 = version[0] >= 2 && version[1] >= 1; + state.version = version; + state.gt_v2_0 = state.atLeast(2, 0); + state.gt_v2_1 = state.atLeast(2, 1); data = { version: version, unknown: readUInt8(state), diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 780a4dcc..0814b88f 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -1208,10 +1208,14 @@ describe("Integration of Clusterio", function() { await execCtl("instance start 44"); const instance = (await getInstances()).get(44); - const isV2 = lib.integerPartialVersion(instance.factorioVersion) > lib.integerPartialVersion("2.0"); - const isV2_1 = lib.integerPartialVersion(instance.factorioVersion) > lib.integerPartialVersion("2.1"); + const version = lib.integerPartialVersion(instance.factorioVersion); + const isV2 = version > lib.integerPartialVersion("2.0"); + const isV2_1 = version > lib.integerPartialVersion("2.1"); + const isV2_1_13 = version >= lib.integerPartialVersion("2.1.13"); let exchangeString = testStrings.modified; - if (isV2_1) { + if (isV2_1_13) { + exchangeString = testStrings.modified_v2_1_13; + } else if (isV2_1) { exchangeString = testStrings.modified_v2_1; } else if (isV2) { exchangeString = testStrings.modified_v2; diff --git a/test/lib/factorio/exchange_string.js b/test/lib/factorio/exchange_string.js index 10d1421f..eaea8458 100644 --- a/test/lib/factorio/exchange_string.js +++ b/test/lib/factorio/exchange_string.js @@ -69,6 +69,16 @@ describe("lib/factorio/exchange_string", function() { }); }); + it("should parse post v2.1.13 strings", function() { + assert.deepEqual(lib.readMapExchangeString(testStrings.modified_v2_1_13), { + version: [2, 1, 14, 1], + unknown: 0, + map_gen_settings: testSettings.modified_v2_1_map_gen_settings, + map_settings: testSettings.modified_v2_1_13_map_settings, + checksum: 272054359, + }); + }); + it("should handle malformed strings", function() { assert.throws( () => lib.readMapExchangeString("<>"), diff --git a/test/lib/factorio/test_settings.js b/test/lib/factorio/test_settings.js index 3c295c14..fea7eb77 100644 --- a/test/lib/factorio/test_settings.js +++ b/test/lib/factorio/test_settings.js @@ -615,6 +615,14 @@ const modified_v2_1_map_settings = { }, }; +const modified_v2_1_13_map_settings = { + ...modified_v2_1_map_settings, + "enemy_expansion": { + ...modified_v2_1_map_settings.enemy_expansion, + "build_base_unit_dispatch_cooldown": 1234, + }, +}; + const modified_space_age_map_gen_settings = { autoplace_controls: { "aquilo_crude_oil": { frequency: 1, size: 1, richness: 1 }, @@ -703,5 +711,6 @@ module.exports = { modified_v2_map_settings, modified_v2_1_map_gen_settings, modified_v2_1_map_settings, + modified_v2_1_13_map_settings, modified_space_age_map_gen_settings, }; diff --git a/test/lib/factorio/test_strings.js b/test/lib/factorio/test_strings.js index 8b81be75..eebad109 100644 --- a/test/lib/factorio/test_strings.js +++ b/test/lib/factorio/test_strings.js @@ -86,10 +86,27 @@ Gx0+5gy4RUpPimXd3JJg0hSVAZVaD42oXlGs+OdLeL8NIjXUO043NJp 38bydxUydeQi4j66kCmzxzUMyYTVaPneF1Uj+vlxQH+ZT82gl0Du2he Y+lJ/JEpK/WZNmBM/mgXgnKlfCACLzljjH+s/hYo=<<<`; +const modified_v2_1_13 = ` +>>>eNp1U89rE0EUftMmNYm15hAFRWoOPYiQEKKgFMmOXsSDB0HwJHGy +mejSzU47u1utHszBgwdBkF70olcrehHvAS/2JvUfqNSDvbVSpAehzsv +sTNYEH7zHt9/7/YadAAIzSmE64wrmA/QcowVXLC5yWRGSA4Cl866M27 +wiPD/F0gIPeHel0mIhBlOA/sCV86QIdIW+rTAdsHjZC5uu73U66Y5ZK +dyFMM0cDyMmIy+422SSs2ZXeGEUY7VUUhiJIGmaMJHkPHy3tuYobSBz +OJYs8OKunqSHvI68zyIucQ8TCeTjr9vrvSezgHrwGMoHB6gKbao4VCA +9HU0UmUi26IogksKfZ/GD+ZbHwnylVq3XUI6lXR3Jl2IeuCu5erX2r9 +ssN5J+csw/rFGrnhtInvt8mUWeCI5a1PTZAg+zyZGhfFnpFXs4Qsij0 +oer3x+uOkSvUKUJ2E+Yfssw1xLw4j39nytjXHDegB1HNdm9t/X00/5e +g/x5u7txvXXHITd/FJfCs9sN5TyCx5xEM2WRzt00U+XoKKPAq5co2w7 +JYkZpmFakZGd1AkjtxhSQjYziizPKvHmmTPmUDWuYoiVKOgP57RB9yS +0Dvo1NMEfJJWw1i+YLmuywM1C1jwbPKaGnjffEMETl1yE9Qxu/9C7rp +u3nVP+RQebGLpzeY4SxwbkUKGDDtjU/J9P3/nrIfNHXdPAWgFF7JHkZ +0L+YLmXPjcc3XoAz1D69BVjk1sULxb83nuUA<<<`; + module.exports = { default: default_, modified, modified_v2, modified_v2_1, + modified_v2_1_13, modified_space_age, };