synthetic-factorio 0.0.1 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,45 +1,47 @@
1
- # Synthetic Factorio
2
-
3
- Synthetic Factorio nodeJS runtime for unit testing mods.
4
-
5
- This is **not** a Factorio emulator, it is not a game that can be played.
6
-
7
- ```typescript
8
- import { ModManager, FactorioEngine } from 'synthetic-factorio';
9
-
10
- const manager = new ModManager();
11
- // install vanilla "mod" data from https://github.com/wube/factorio-data (requires git CLI)
12
- await manager.installVanillaMods();
13
- // install mod by name from mod portal (requires FACTORIO_USERNAME and FACTORIO_TOKEN env variables)
14
- await manager.installPortalMod('SimpleSeablock');
15
- // install a mod in development from local files
16
- manager.installDirectoryMod('my-mod', './my-mod-files', { clearCache: true });
17
-
18
- // initialize the synthetic factorio engine and specify the mods that should be active
19
- // vanilla "mods" need to be specified, including "base", however "core" does not need to be listed
20
- const engine = new FactorioEngine({
21
- mods: ['base', 'space-age', 'quality', 'recycler', 'elevated-rails', 'my-mod']
22
- });
23
- // run all mods through their settings phase
24
- engine.runSettingsPhase();
25
- // customize settings for your specific test run
26
- engine.setSettings({ startup: { 'my-mod-setting': true } });
27
- // run all mods through their data phase
28
- engine.runDataPhase();
29
-
30
- // read the data.raw collection and run test assertions
31
- console.log(engine.getRawData()['item']['stone-furnace']);
32
-
33
- // run control phase - NOTE: control phase support is currently very limited
34
- engine.runControlPhase(['my-mod']);
35
-
36
- // run any registered on_init callbacks
37
- engine.triggerInit();
38
-
39
- // trigger an event with the payload of your choice
40
- // Partial on the generic and casts on the sub-properties are recommended to avoid the need to setup the entire event payload
41
- engine.triggerEvent<Partial<runtime.on_chunk_generated>>(defines.events.on_chunk_generated, {
42
- position: { x: 0, y: 0 },
43
- surface: { name: 'nauvis' } as runtime.LuaSurface,
44
- });
45
- ```
1
+ # Synthetic Factorio
2
+
3
+ <!--Hidden version bump 4-->
4
+
5
+ Synthetic Factorio nodeJS runtime for unit testing mods.
6
+
7
+ This is **not** a Factorio emulator, it is not a game that can be played.
8
+
9
+ ```typescript
10
+ import { ModManager, FactorioEngine } from 'synthetic-factorio';
11
+
12
+ const manager = new ModManager();
13
+ // install vanilla "mod" data from https://github.com/wube/factorio-data (requires git CLI)
14
+ await manager.installVanillaMods();
15
+ // install mod by name from mod portal (requires FACTORIO_USERNAME and FACTORIO_TOKEN env variables)
16
+ await manager.installPortalMod('SimpleSeablock');
17
+ // install a mod in development from local files
18
+ manager.installDirectoryMod('my-mod', './my-mod-files', { clearCache: true });
19
+
20
+ // initialize the synthetic factorio engine and specify the mods that should be active
21
+ // vanilla "mods" need to be specified, including "base", however "core" does not need to be listed
22
+ const engine = new FactorioEngine({
23
+ mods: ['base', 'space-age', 'quality', 'recycler', 'elevated-rails', 'my-mod']
24
+ });
25
+ // run all mods through their settings phase
26
+ engine.runSettingsPhase();
27
+ // customize settings for your specific test run
28
+ engine.setSettings({ startup: { 'my-mod-setting': true } });
29
+ // run all mods through their data phase
30
+ engine.runDataPhase();
31
+
32
+ // read the data.raw collection and run test assertions
33
+ console.log(engine.getRawData()['item']['stone-furnace']);
34
+
35
+ // run control phase - NOTE: control phase support is currently very limited
36
+ engine.runControlPhase(['my-mod']);
37
+
38
+ // run any registered on_init callbacks
39
+ engine.triggerInit();
40
+
41
+ // trigger an event with the payload of your choice
42
+ // Partial on the generic and casts on the sub-properties are recommended to avoid the need to setup the entire event payload
43
+ engine.triggerEvent<Partial<runtime.on_chunk_generated>>(defines.events.on_chunk_generated, {
44
+ position: { x: 0, y: 0 },
45
+ surface: { name: 'nauvis' } as runtime.LuaSurface,
46
+ });
47
+ ```
@@ -7,6 +7,11 @@ export interface ModManagerOptions {
7
7
  * Working directory for files like mod downloads
8
8
  */
9
9
  workingDir?: string;
10
+ /**
11
+ * Function to accept diagnostic logging, if not supplied then no diagnostics will be logged. Can be verbose, primarily intended for troubleshooting
12
+ * @param message Message to be logged
13
+ */
14
+ logger?: (message: string) => void;
10
15
  }
11
16
  export interface ModInstallOptions {
12
17
  /**
@@ -44,6 +49,7 @@ export interface ModPortalDownloadOptions extends ModDownloadOptions {
44
49
  export declare class ModManager {
45
50
  readonly modDir: string;
46
51
  readonly workingDir: string;
52
+ readonly log: (message: string) => void;
47
53
  constructor(options?: ModManagerOptions);
48
54
  /**
49
55
  * Download all vanilla "mod" data from https://github.com/wube/factorio-data
@@ -16,9 +16,11 @@ const vanillaMods = ['core', 'base', 'quality', 'recycler', 'elevated-rails', 's
16
16
  export class ModManager {
17
17
  modDir;
18
18
  workingDir;
19
+ log;
19
20
  constructor(options) {
20
21
  this.modDir = options?.modDir || modDir();
21
22
  this.workingDir = options?.workingDir || workingDir();
23
+ this.log = options?.logger || (() => { });
22
24
  }
23
25
  /**
24
26
  * Download all vanilla "mod" data from https://github.com/wube/factorio-data
@@ -28,13 +30,17 @@ export class ModManager {
28
30
  async installVanillaMods(options) {
29
31
  const run = promisify(exec);
30
32
  const gitDir = path.join(this.workingDir, 'factorio-data');
33
+ this.log(`Installing vanilla mods at ${gitDir}`);
31
34
  if (existsSync(gitDir) && options?.clearDownload) {
35
+ this.log(`gitDir ${gitDir} exists, deleting`);
32
36
  rmSync(gitDir, { recursive: true });
33
37
  }
34
38
  if (existsSync(gitDir) && options?.checkLatest) {
39
+ this.log(`Vanilla mod repo exists at ${gitDir}, pulling latest`);
35
40
  await run('git pull', { cwd: gitDir });
36
41
  }
37
42
  if (!existsSync(gitDir)) {
43
+ this.log(`Vanilla mod repo does not exist at ${gitDir}, cloning`);
38
44
  mkdirSync(this.workingDir, { recursive: true });
39
45
  await run('git clone --depth=1 https://github.com/wube/factorio-data.git', { cwd: this.workingDir });
40
46
  }
@@ -50,12 +56,18 @@ export class ModManager {
50
56
  */
51
57
  installDirectoryMod(modName, sourcePath, options) {
52
58
  const targetPath = this.getModPath(modName);
59
+ this.log(`Installing mod ${modName} to ${targetPath} from ${sourcePath}`);
53
60
  if (existsSync(targetPath) && options?.clearCache) {
61
+ this.log(`Removing existing mod ${modName} at ${targetPath}`);
54
62
  rmSync(targetPath, { recursive: true });
55
63
  }
56
64
  if (!existsSync(targetPath)) {
65
+ this.log(`Copying mod ${modName} from ${sourcePath} to ${targetPath}`);
57
66
  cpSync(sourcePath, targetPath, { recursive: true });
58
67
  }
68
+ else {
69
+ this.log(`Mod ${modName} already exists at ${targetPath}`);
70
+ }
59
71
  }
60
72
  /**
61
73
  * Downloads and installs a mod from Factorio's mod portal.
@@ -64,24 +76,29 @@ export class ModManager {
64
76
  * @param options Mod Installation Optiions
65
77
  */
66
78
  async installPortalMod(modName, options) {
79
+ this.log(`Installing ${modName} from portal`);
67
80
  let modInfo = undefined;
68
81
  let targetVersion = undefined;
69
82
  const downloadDir = path.join(this.workingDir, 'downloads');
70
83
  mkdirSync(downloadDir, { recursive: true });
71
84
  if (options?.version) {
72
85
  targetVersion = options?.version;
86
+ this.log(`Mod ${modName} installed at specific version ${targetVersion}`);
73
87
  }
74
88
  else if (options?.checkLatest) {
75
89
  modInfo = await this.getModInfo(modName);
76
90
  targetVersion = modInfo.releases.slice(-1)[0].version;
91
+ this.log(`Mod ${modName} latest version is ${targetVersion}`);
77
92
  }
78
93
  let zipPath = undefined;
79
94
  if (targetVersion) {
80
95
  zipPath = path.join(downloadDir, `${modName}_${targetVersion}.zip`);
96
+ this.log(`Installing mod ${modName} at target version ${targetVersion} to ${zipPath}`);
81
97
  }
82
98
  else {
83
99
  const modFiles = await glob(`${modName}_*.zip`, { cwd: downloadDir });
84
100
  if (modFiles.length) {
101
+ this.log(`Found ${modFiles.length} existing versions of mod ${modName}`);
85
102
  let latest = '0.0.0';
86
103
  for (const file of modFiles) {
87
104
  const version = file.replace(/\.zip$/, '').split('_').slice(-1)[0];
@@ -89,10 +106,16 @@ export class ModManager {
89
106
  latest = version;
90
107
  }
91
108
  }
109
+ this.log(`Latest downloaded version of ${modName} is ${latest}`);
92
110
  zipPath = path.join(downloadDir, `${modName}_${latest}.zip`);
111
+ this.log(`Using zip path ${zipPath} for ${modName}`);
112
+ }
113
+ else {
114
+ this.log(`Did not find previously-installed version of ${modName}`);
93
115
  }
94
116
  }
95
117
  if (zipPath && options?.clearDownload) {
118
+ this.log(`Deleting existing zip at ${zipPath} for ${modName}`);
96
119
  rmSync(zipPath);
97
120
  }
98
121
  if (!zipPath) {
@@ -100,7 +123,9 @@ export class ModManager {
100
123
  modInfo = await this.getModInfo(modName);
101
124
  }
102
125
  targetVersion = modInfo.releases.slice(-1)[0].version;
126
+ this.log(`Found ${targetVersion} version of ${modName} from mod info`);
103
127
  zipPath = path.join(downloadDir, `${modName}_${targetVersion}.zip`);
128
+ this.log(`Using zip path ${zipPath} for ${modName}`);
104
129
  }
105
130
  if (!existsSync(zipPath)) {
106
131
  if (!modInfo) {
@@ -111,6 +136,7 @@ export class ModManager {
111
136
  let url = modInfo.releases.find(r => r.version === targetVersion)?.download_url;
112
137
  if (url) {
113
138
  modDownloadUrl = url;
139
+ this.log(`Download url for ${modName} at target version ${targetVersion} is ${modDownloadUrl}`);
114
140
  }
115
141
  else {
116
142
  throw new Error(`Cannot find version ${targetVersion} of mod ${modName}`);
@@ -118,6 +144,7 @@ export class ModManager {
118
144
  }
119
145
  else {
120
146
  modDownloadUrl = modInfo.releases.slice(-1)[0].download_url;
147
+ this.log(`Download url for ${modName} is ${modDownloadUrl}`);
121
148
  }
122
149
  if (!process.env.FACTORIO_USERNAME) {
123
150
  throw new Error('Cannot download mod, env variable FACTORIO_USERNAME is not set');
@@ -135,10 +162,12 @@ export class ModManager {
135
162
  }
136
163
  let isOutdated = false;
137
164
  const targetPath = this.getModPath(modName);
165
+ this.log(`Using path ${targetPath} for ${modName}`);
138
166
  if (existsSync(targetPath) && targetVersion) {
139
167
  const modInfo = getModInfo(targetPath);
140
168
  if (modInfo.version != targetVersion) {
141
169
  isOutdated = true;
170
+ this.log(`Mod ${modName} is outdated`);
142
171
  }
143
172
  }
144
173
  await this.installZipMod(modName, zipPath, { ...options, clearCache: isOutdated ? true : options?.clearCache });
@@ -146,10 +175,17 @@ export class ModManager {
146
175
  const dependencies = getDependencies(this.getModPath(modName));
147
176
  for (let dependency of dependencies.required) {
148
177
  if (!vanillaMods.includes(dependency) && !options?.omitDependencies?.includes(dependency)) {
178
+ this.log(`Installing dependency ${dependency} for ${modName}`);
149
179
  await this.installPortalMod(dependency, { ...options, version: undefined });
150
180
  }
181
+ else {
182
+ this.log(`Not necessary to install dependency ${dependency} for ${modName}`);
183
+ }
151
184
  }
152
185
  }
186
+ else {
187
+ this.log(`Skipping dependencies for ${modName}`);
188
+ }
153
189
  }
154
190
  async getModInfo(modName) {
155
191
  const response = await fetch(`https://mods.factorio.com/api/mods/${modName}`);
@@ -163,16 +199,24 @@ export class ModManager {
163
199
  */
164
200
  async installZipMod(modName, sourcePath, options) {
165
201
  const targetPath = this.getModPath(modName);
202
+ this.log(`Installing ${modName} as zip from ${sourcePath} to ${targetPath}`);
166
203
  if (existsSync(targetPath) && options?.clearCache) {
204
+ this.log(`Deleting existing mod ${modName} at ${targetPath}`);
167
205
  rmSync(targetPath, { recursive: true });
168
206
  }
169
207
  if (!existsSync(targetPath)) {
170
208
  const unzipDir = path.join(this.workingDir, 'unzip');
209
+ this.log(`Unzipping ${modName} from ${sourcePath} to ${unzipDir}`);
171
210
  mkdirSync(unzipDir, { recursive: true });
172
211
  await extractZip(sourcePath, { dir: unzipDir });
173
212
  // Mod contents are in a single dir inside the zip file, and the dir can have any name so we need to handle that
174
- let dirs = readdirSync(unzipDir);
175
- renameSync(path.join(unzipDir, dirs[0]), targetPath);
213
+ const dirs = readdirSync(unzipDir);
214
+ const sourceDir = path.join(unzipDir, dirs[0]);
215
+ this.log(`Renaming ${sourceDir} to ${targetPath} for ${modName}`);
216
+ renameSync(sourceDir, targetPath);
217
+ }
218
+ else {
219
+ this.log(`Mod zip ${modName} already exists at ${targetPath}, no action needed`);
176
220
  }
177
221
  }
178
222
  getModPath(modName) {
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "synthetic-factorio",
3
- "version": "0.0.1",
3
+ "version": "0.2.4",
4
4
  "description": "Synthetic Factorio runtime for unit testing",
5
5
  "license": "ISC",
6
6
  "author": "Spencer Guest",
7
7
  "type": "module",
8
+ "repository": "https://github.com/sguest/synthetic-factorio",
8
9
  "scripts": {
9
10
  "test": "vitest",
10
11
  "test:ci": "vitest run",
@@ -1,18 +1,18 @@
1
- -- define data and its extend and raw methods in lua, JS will later read this global to extract it
2
- -- and may inject into data.raw to pre-populate the table
3
- -- this is handled in lua instead of a JS callback for performance reasons
4
- -- thousands of data.extend calls doing interop into JS is unusably slow
5
-
6
- data = data or {}
7
- data.raw = data.raw or {}
8
-
9
- function data.extend(self, values)
10
- for i, value in ipairs(values) do
11
- -- space-age tries to extend on the string "./data/__space-age__/sound/ambient/space/interlude-6/interlude-6.lua"
12
- -- (i.e. not a valid prototype object, just that string) so we need to gracefully handle invalid objects
13
- if(value.name ~= nil and value.type ~= nil) then
14
- data.raw[value.type] = data.raw[value.type] or {}
15
- data.raw[value.type][value.name] = value
16
- end
17
- end
1
+ -- define data and its extend and raw methods in lua, JS will later read this global to extract it
2
+ -- and may inject into data.raw to pre-populate the table
3
+ -- this is handled in lua instead of a JS callback for performance reasons
4
+ -- thousands of data.extend calls doing interop into JS is unusably slow
5
+
6
+ data = data or {}
7
+ data.raw = data.raw or {}
8
+
9
+ function data.extend(self, values)
10
+ for i, value in ipairs(values) do
11
+ -- space-age tries to extend on the string "./data/__space-age__/sound/ambient/space/interlude-6/interlude-6.lua"
12
+ -- (i.e. not a valid prototype object, just that string) so we need to gracefully handle invalid objects
13
+ if(value.name ~= nil and value.type ~= nil) then
14
+ data.raw[value.type] = data.raw[value.type] or {}
15
+ data.raw[value.type][value.name] = value
16
+ end
17
+ end
18
18
  end
@@ -1,63 +1,63 @@
1
- local old_require = require
2
-
3
- -- Wrapper around lua's require() to handle several Factorio-isms that are used by either vanilla or other mods that break under regular lua import rules
4
-
5
- -- Mods can require() the same filename across different mods, and each one will load its own version
6
- -- for example both mods base and space-age import "prototypes.tile.tiles" but refer to different files
7
- -- The lua spec says such an import re-uses the existing module, even if package.paths has changed, so this is a workaround
8
- -- Each mod starts in its own "context" defined by the global current_mod_root_path
9
- -- Imports are first attempted from this root path, and then allowed to fall back on error to find things like lualib
10
- -- If a mod explicitly imports from another mod using the double-underscore syntax, i.e. require("__modname__.file")
11
- -- then current_mod_root_path is set to that mod, and then restored after
12
-
13
- -- This cannot be handled by running mods in different lua contexts because mods can re-use globals that were previously declared
14
- -- for example "apply_heat_pipe_glow" is defined in base.prototypes.entity.entities, but then used in space-age.prototypes.entity.entities without being re-declared
15
- -- Therefore both mods need to load in the same lua context to preserve globals
16
-
17
- function require(name, ...)
18
- local mod_changed = false
19
- local previous_mod = current_mod_root_path
20
- local new_name = name
21
- if(string.find(name, '__')) then
22
- mod_changed = true
23
- local end_index = string.find(name, '__', 3)
24
- current_mod_root_path = string.sub(name, 3, end_index - 1)
25
- else
26
- new_name = '__' .. current_mod_root_path .. '__.' .. name
27
- end
28
-
29
- -- This is the first load attempt that tries within the context of current_mod_root_path
30
- local status, lib = pcall(old_require, new_name, ...)
31
- if(mod_changed) then
32
- current_mod_root_path = previous_mod
33
- end
34
- if(status) then return lib end
35
-
36
- if(not string.find(lib, string.format("module '%s' not found", new_name), 1, true)) then
37
- error(lib)
38
- end
39
-
40
- -- First import has failed, fall back to the exact name that was provided. Most commonly this is loading lualib
41
- -- though it also handles the case where mods require paths from vanilla mods without specifying the mod prefix
42
- status, lib = pcall(old_require, name, ...)
43
- if(status) then return lib end
44
- if(not string.find(lib, string.format("module '%s' not found", name), 1, true)) then
45
- error(lib)
46
- end
47
-
48
- -- The factorio-data repo (https://github.com/wube/factorio-data) is the source for the data for the vanilla "mods"
49
- -- This repo doesn't have things like graphics, sounds or menu simulations, but these are still require()d in many places from the existing lua files
50
- -- The global suppress_require_errors is set during vanilla mod loading to suppress these
51
- -- They are also suppressed when loading from the vanilla mod namespaces to handle loads from other mods
52
- -- By default non-vanilla mods do not suppress, set the global suppress_require_errors = true to change
53
- if(suppress_require_errors or string.find(name, '__base__') or string.find(name, '__space-age__', 1, true) or string.find(name, '__quality__') or string.find(name, '__recycler__') or string.find(name, '__elevated-rails__', 1, true)) then
54
- if(string.find(name, 'graphics')) then
55
- return { width = 1, height = 1, shift = util.by_pixel(1.0, 1.0), line_length = 5 }
56
- end
57
- if(string.find(name, 'menu-simulations', 1, true) or string.find(name, 'sound')) then
58
- return {}
59
- end
60
- end
61
-
62
- error(lib)
1
+ local old_require = require
2
+
3
+ -- Wrapper around lua's require() to handle several Factorio-isms that are used by either vanilla or other mods that break under regular lua import rules
4
+
5
+ -- Mods can require() the same filename across different mods, and each one will load its own version
6
+ -- for example both mods base and space-age import "prototypes.tile.tiles" but refer to different files
7
+ -- The lua spec says such an import re-uses the existing module, even if package.paths has changed, so this is a workaround
8
+ -- Each mod starts in its own "context" defined by the global current_mod_root_path
9
+ -- Imports are first attempted from this root path, and then allowed to fall back on error to find things like lualib
10
+ -- If a mod explicitly imports from another mod using the double-underscore syntax, i.e. require("__modname__.file")
11
+ -- then current_mod_root_path is set to that mod, and then restored after
12
+
13
+ -- This cannot be handled by running mods in different lua contexts because mods can re-use globals that were previously declared
14
+ -- for example "apply_heat_pipe_glow" is defined in base.prototypes.entity.entities, but then used in space-age.prototypes.entity.entities without being re-declared
15
+ -- Therefore both mods need to load in the same lua context to preserve globals
16
+
17
+ function require(name, ...)
18
+ local mod_changed = false
19
+ local previous_mod = current_mod_root_path
20
+ local new_name = name
21
+ if(string.find(name, '__')) then
22
+ mod_changed = true
23
+ local end_index = string.find(name, '__', 3)
24
+ current_mod_root_path = string.sub(name, 3, end_index - 1)
25
+ else
26
+ new_name = '__' .. current_mod_root_path .. '__.' .. name
27
+ end
28
+
29
+ -- This is the first load attempt that tries within the context of current_mod_root_path
30
+ local status, lib = pcall(old_require, new_name, ...)
31
+ if(mod_changed) then
32
+ current_mod_root_path = previous_mod
33
+ end
34
+ if(status) then return lib end
35
+
36
+ if(not string.find(lib, string.format("module '%s' not found", new_name), 1, true)) then
37
+ error(lib)
38
+ end
39
+
40
+ -- First import has failed, fall back to the exact name that was provided. Most commonly this is loading lualib
41
+ -- though it also handles the case where mods require paths from vanilla mods without specifying the mod prefix
42
+ status, lib = pcall(old_require, name, ...)
43
+ if(status) then return lib end
44
+ if(not string.find(lib, string.format("module '%s' not found", name), 1, true)) then
45
+ error(lib)
46
+ end
47
+
48
+ -- The factorio-data repo (https://github.com/wube/factorio-data) is the source for the data for the vanilla "mods"
49
+ -- This repo doesn't have things like graphics, sounds or menu simulations, but these are still require()d in many places from the existing lua files
50
+ -- The global suppress_require_errors is set during vanilla mod loading to suppress these
51
+ -- They are also suppressed when loading from the vanilla mod namespaces to handle loads from other mods
52
+ -- By default non-vanilla mods do not suppress, set the global suppress_require_errors = true to change
53
+ if(suppress_require_errors or string.find(name, '__base__') or string.find(name, '__space-age__', 1, true) or string.find(name, '__quality__') or string.find(name, '__recycler__') or string.find(name, '__elevated-rails__', 1, true)) then
54
+ if(string.find(name, 'graphics')) then
55
+ return { width = 1, height = 1, shift = util.by_pixel(1.0, 1.0), line_length = 5 }
56
+ end
57
+ if(string.find(name, 'menu-simulations', 1, true) or string.find(name, 'sound')) then
58
+ return {}
59
+ end
60
+ end
61
+
62
+ error(lib)
63
63
  end