mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
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.
This commit is contained in:
1 parent
b352e89b48
commit
2eadc3ea10
4 files changed
+497
-1
No files matched your search
@@ -31,7 +31,6 @@ dist/
|
||||
node_modules
|
||||
|
||||
Factorio*
|
||||
factorio*
|
||||
/factorio
|
||||
/instances
|
||||
/sharedMods
|
||||
|
||||
@@ -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}<<</.test(exchangeString)) {
|
||||
throw new Error("Not a map exchange string");
|
||||
}
|
||||
|
||||
let buf = Buffer.from(exchangeString.slice(3, -3), "base64");
|
||||
try {
|
||||
// eslint-disable-next-line node/no-sync
|
||||
buf = zlib.inflateSync(buf);
|
||||
} catch (err) {
|
||||
if (err.code.startsWith("Z_")) {
|
||||
throw new Error("Malformed map exchange string: zlib inflate failed");
|
||||
}
|
||||
}
|
||||
|
||||
let state = new MapReaderState(buf);
|
||||
let data;
|
||||
|
||||
try {
|
||||
data = {
|
||||
version: readVersion(state),
|
||||
unknown: readUInt8(state),
|
||||
map_gen_settings: readMapGenSettings(state),
|
||||
map_settings: readMapSettings(state),
|
||||
checksum: readUInt32(state),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.code === "ERR_OUT_OF_RANGE") {
|
||||
throw new Error("Malformed map exchange string: reached end before finishing parsing");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (state.pos !== buf.length) {
|
||||
throw new Error("Malformed map exchange string: data after end");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readMapExchangeString,
|
||||
};
|
||||
@@ -4,11 +4,13 @@
|
||||
* @author Hornwitser
|
||||
*/
|
||||
"use strict";
|
||||
const exchange_string = require("./exchange_string");
|
||||
const export_ = require("./export");
|
||||
const patch = require("./patch");
|
||||
const server = require("./server");
|
||||
|
||||
module.exports = {
|
||||
...exchange_string,
|
||||
...export_,
|
||||
...patch,
|
||||
...server,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const zlib = require("zlib");
|
||||
|
||||
const libFactorio = require("@clusterio/lib/factorio");
|
||||
|
||||
|
||||
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 {
|
||||
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("<<blah>>"),
|
||||
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")
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user