synthetic-factorio 0.0.1

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 ADDED
@@ -0,0 +1,45 @@
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
+ ```
@@ -0,0 +1,98 @@
1
+ import { LuaState } from 'lua-state';
2
+ export interface FactorioEngineOptions {
3
+ /**
4
+ * List of mods to execute. Include "vanilla" mods like base or elevated-rails if needed.
5
+ */
6
+ mods: string[];
7
+ /**
8
+ * Directory where mods are executed from
9
+ */
10
+ modDir?: string;
11
+ /**
12
+ * Any mod in this list will be ignored when checking dependencies of other installed mods.
13
+ */
14
+ ignoredDependencies?: string[];
15
+ /**
16
+ * Calls to log() in mods will be funnelled through this callback. Defaults to console.log if not specified.
17
+ * @param message the message to log
18
+ */
19
+ logHandler?: (message: string) => void;
20
+ }
21
+ type SettingValue = number | boolean | string | runtime.Color;
22
+ interface SettingsData {
23
+ global?: Record<string, SettingValue>;
24
+ player?: Record<string, SettingValue>;
25
+ startup?: Record<string, SettingValue>;
26
+ }
27
+ interface SettingsHolder {
28
+ global: Record<string, runtime.ModSetting>;
29
+ player_default: Record<string, runtime.ModSetting>;
30
+ startup: Record<string, runtime.ModSetting>;
31
+ get_player_settings: () => Record<string, runtime.ModSetting>;
32
+ }
33
+ type EventPayload = Omit<runtime.EventData, "name" | "tick"> & Partial<Pick<runtime.EventData, "name" | "tick">>;
34
+ /**
35
+ * A synthetic factorio runtime to run lua scripts and inspect their results
36
+ */
37
+ export declare class FactorioEngine {
38
+ luaState: LuaState;
39
+ readonly mods: string[];
40
+ readonly modDir: string;
41
+ settings: SettingsHolder;
42
+ private remoteInterfaces;
43
+ private remoteHandlers;
44
+ private ignoredDependencies;
45
+ private logHandler;
46
+ private modRuntimes;
47
+ constructor(options: FactorioEngineOptions);
48
+ /**
49
+ * Run the settings phase for all mods in dependency order and populate settings with the resulting defaults
50
+ * @param mods Run the specified mods in order. Omit to run all mods in dependency order.
51
+ */
52
+ runSettingsPhase(mods?: string[] | undefined): void;
53
+ /**
54
+ * Run the data phase for all mods in dependency order
55
+ * @param mods Run the specified mods in order. Omit to run all mods in dependency order.
56
+ */
57
+ runDataPhase(mods?: string[] | undefined): void;
58
+ private buildModsGlobal;
59
+ /**
60
+ * Get the current data.raw prototype collection
61
+ * @returns data collection
62
+ */
63
+ getRawData(): prototype.dataCollection;
64
+ /**
65
+ * Apply settings that will be retrievable by mods
66
+ * @param settings Settings to apply
67
+ */
68
+ setSettings(settings: SettingsData): void;
69
+ private mergeSettings;
70
+ /**
71
+ * Run the control phase for all mods in dependency order
72
+ * @param mods Run the specified mods in order. Omit to run all mods in dependency order.
73
+ */
74
+ runControlPhase(mods?: string[] | undefined): void;
75
+ /**
76
+ * Trigger registered on_init handlers for all mods in dependency order
77
+ * @param mods Run the specified mods in order. Omit to run all mods in dependency order.
78
+ */
79
+ triggerInit(mods?: string[] | undefined): void;
80
+ /**
81
+ * Trigger an event for all mods in dependency order
82
+ * @param eventType The event type to trigger
83
+ * @param payload The event payload
84
+ * @param mods Run the specified mods in order. Omit to run all mods in dependency order.
85
+ */
86
+ triggerEvent<T extends EventPayload>(eventType: defines.events, payload: T, mods?: string[] | undefined): void;
87
+ registerRemote(iface: string, fn: string, handler: (...args: any[]) => any): void;
88
+ private initModRuntime;
89
+ private postModRuntime;
90
+ private runModFile;
91
+ private getModOrder;
92
+ private getModPath;
93
+ private getModInfo;
94
+ private initLua;
95
+ private setPackagePaths;
96
+ private setModContext;
97
+ }
98
+ export {};
@@ -0,0 +1,292 @@
1
+ import { LuaState } from 'lua-state';
2
+ import { existsSync } from 'node:fs';
3
+ import path, { dirname } from 'node:path';
4
+ import { modDir } from "./defaultValues.js";
5
+ import { getDependencies, getModInfo } from "./modInfoUtil.js";
6
+ import { fileURLToPath } from 'node:url';
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+ /**
10
+ * A synthetic factorio runtime to run lua scripts and inspect their results
11
+ */
12
+ export class FactorioEngine {
13
+ luaState;
14
+ mods;
15
+ modDir;
16
+ settings;
17
+ remoteInterfaces;
18
+ remoteHandlers;
19
+ ignoredDependencies;
20
+ logHandler;
21
+ modRuntimes = {};
22
+ constructor(options) {
23
+ this.luaState = this.initLua();
24
+ this.mods = options.mods;
25
+ this.modDir = options.modDir || modDir();
26
+ this.ignoredDependencies = options.ignoredDependencies || [];
27
+ this.logHandler = options.logHandler || console.log;
28
+ this.settings = {
29
+ startup: {},
30
+ global: {},
31
+ player_default: {},
32
+ // This should handle proper per-player settings, good enough for now
33
+ get_player_settings: () => settings.player_default,
34
+ };
35
+ this.remoteInterfaces = {};
36
+ this.remoteHandlers = {};
37
+ }
38
+ /**
39
+ * Run the settings phase for all mods in dependency order and populate settings with the resulting defaults
40
+ * @param mods Run the specified mods in order. Omit to run all mods in dependency order.
41
+ */
42
+ runSettingsPhase(mods = undefined) {
43
+ const modOrder = mods || this.getModOrder();
44
+ this.luaState.setGlobal('mods', this.buildModsGlobal());
45
+ for (let mod of modOrder) {
46
+ this.runModFile(mod, 'settings.lua');
47
+ }
48
+ this.luaState.setGlobal('mods', null);
49
+ const data = this.getRawData();
50
+ const settingsData = {
51
+ startup: {},
52
+ global: {},
53
+ player: {},
54
+ };
55
+ for (let settingType of ['bool-setting', 'int-setting', 'double-setting', 'string-setting', 'color-setting']) {
56
+ if (data[settingType]) {
57
+ for (let name in data[settingType]) {
58
+ let setting = data[settingType][name];
59
+ if (setting.setting_type === 'startup') {
60
+ settingsData.startup[setting.name] = setting.default_value;
61
+ }
62
+ else if (setting.setting_type === 'runtime-global') {
63
+ settingsData.global[setting.name] = setting.default_value;
64
+ }
65
+ else if (setting.setting_type === 'runtime-per-user') {
66
+ settingsData.player[setting.name] = setting.default_value;
67
+ }
68
+ }
69
+ }
70
+ }
71
+ this.setSettings(settingsData);
72
+ }
73
+ /**
74
+ * Run the data phase for all mods in dependency order
75
+ * @param mods Run the specified mods in order. Omit to run all mods in dependency order.
76
+ */
77
+ runDataPhase(mods = undefined) {
78
+ this.luaState.setGlobal('settings', { startup: this.settings?.startup });
79
+ this.luaState.setGlobal('mods', this.buildModsGlobal());
80
+ const modOrder = mods || this.getModOrder();
81
+ for (const mod of modOrder) {
82
+ this.runModFile(mod, 'data.lua');
83
+ }
84
+ for (const mod of modOrder) {
85
+ this.runModFile(mod, 'data-updates.lua');
86
+ }
87
+ for (const mod of modOrder) {
88
+ this.runModFile(mod, 'data-final-fixes.lua');
89
+ }
90
+ this.luaState.setGlobal('mods', null);
91
+ }
92
+ buildModsGlobal() {
93
+ const modsGlobal = {};
94
+ for (const modName of this.mods) {
95
+ modsGlobal[modName] = this.getModInfo(modName).version;
96
+ }
97
+ return modsGlobal;
98
+ }
99
+ /**
100
+ * Get the current data.raw prototype collection
101
+ * @returns data collection
102
+ */
103
+ getRawData() {
104
+ return (this.luaState.getGlobal('data.raw') || {});
105
+ }
106
+ /**
107
+ * Apply settings that will be retrievable by mods
108
+ * @param settings Settings to apply
109
+ */
110
+ setSettings(settings) {
111
+ this.mergeSettings(this.settings.global, settings.global);
112
+ this.mergeSettings(this.settings.startup, settings.startup);
113
+ this.mergeSettings(this.settings.player_default, settings.player);
114
+ }
115
+ mergeSettings(settings, values) {
116
+ for (const key in values) {
117
+ settings[key] = { value: values[key] };
118
+ }
119
+ }
120
+ /**
121
+ * Run the control phase for all mods in dependency order
122
+ * @param mods Run the specified mods in order. Omit to run all mods in dependency order.
123
+ */
124
+ runControlPhase(mods = undefined) {
125
+ const modOrder = mods || this.getModOrder();
126
+ for (const mod of modOrder) {
127
+ this.initModRuntime(mod);
128
+ this.luaState.setGlobal('script', {
129
+ on_init: (handler) => {
130
+ this.modRuntimes[mod].initHandlers.push(handler);
131
+ },
132
+ on_event: (eventType, handler) => {
133
+ this.modRuntimes[mod].eventHandlers[eventType] ||= [];
134
+ this.modRuntimes[mod].eventHandlers[eventType].push(handler);
135
+ },
136
+ });
137
+ this.luaState.eval(`script.mod_name = '${mod}'`);
138
+ this.runModFile(mod, 'control.lua');
139
+ this.postModRuntime(mod);
140
+ }
141
+ }
142
+ /**
143
+ * Trigger registered on_init handlers for all mods in dependency order
144
+ * @param mods Run the specified mods in order. Omit to run all mods in dependency order.
145
+ */
146
+ triggerInit(mods = undefined) {
147
+ const modOrder = mods || this.getModOrder();
148
+ for (const mod of modOrder) {
149
+ this.initModRuntime(mod);
150
+ const handlers = this.modRuntimes[mod].initHandlers || [];
151
+ for (const handler of handlers) {
152
+ handler();
153
+ }
154
+ this.postModRuntime(mod);
155
+ }
156
+ }
157
+ /**
158
+ * Trigger an event for all mods in dependency order
159
+ * @param eventType The event type to trigger
160
+ * @param payload The event payload
161
+ * @param mods Run the specified mods in order. Omit to run all mods in dependency order.
162
+ */
163
+ triggerEvent(eventType, payload, mods = undefined) {
164
+ const modOrder = mods || this.getModOrder();
165
+ for (const mod of modOrder) {
166
+ this.initModRuntime(mod);
167
+ const handlers = this.modRuntimes[mod].eventHandlers[eventType] || [];
168
+ payload.name ??= eventType;
169
+ payload.tick ??= 0;
170
+ for (let handler of handlers) {
171
+ handler(payload);
172
+ }
173
+ this.postModRuntime(mod);
174
+ }
175
+ }
176
+ registerRemote(iface, fn, handler) {
177
+ this.remoteInterfaces[iface] ||= {};
178
+ this.remoteInterfaces[iface][fn] = true;
179
+ this.remoteHandlers[iface] ||= {};
180
+ this.remoteHandlers[iface][fn] = handler;
181
+ }
182
+ initModRuntime(mod) {
183
+ this.modRuntimes[mod] ??= { eventHandlers: {}, initHandlers: [], storage: {} };
184
+ this.luaState.setGlobal('storage', this.modRuntimes[mod].storage);
185
+ this.luaState.setGlobal('remote', {
186
+ interfaces: this.remoteInterfaces,
187
+ call: (iface, fn, ...args) => {
188
+ return this.remoteHandlers[iface][fn](...args);
189
+ }
190
+ });
191
+ }
192
+ postModRuntime(mod) {
193
+ this.modRuntimes[mod].storage = this.luaState.getGlobal('storage');
194
+ }
195
+ runModFile(modName, fileName) {
196
+ const targetFile = path.join(this.getModPath(modName), fileName);
197
+ if (existsSync(targetFile)) {
198
+ this.setModContext(modName);
199
+ this.luaState.evalFile(targetFile);
200
+ }
201
+ }
202
+ getModOrder() {
203
+ const modGraph = {};
204
+ for (const modName of this.mods) {
205
+ const dependencyInfo = getDependencies(this.getModPath(modName));
206
+ var incompat = dependencyInfo.incompatible.find(d => this.mods.includes(d));
207
+ if (incompat) {
208
+ throw new Error(`Failed to load mods, ${modName} and ${incompat} are incompatible`);
209
+ }
210
+ const dependencies = dependencyInfo.required.filter(d => !this.ignoredDependencies.includes(d) || this.mods.includes(d));
211
+ // If an optional dependency is installed, treat it as a regular one because it influences load order the same way
212
+ // If an optional dependency is not installed, ignore it
213
+ dependencies.push(...dependencyInfo.optional.filter(d => this.mods.includes(d)));
214
+ modGraph[modName] = dependencies;
215
+ }
216
+ const modOrder = ['core'];
217
+ for (const modName of this.mods) {
218
+ const dependencies = modGraph[modName];
219
+ const unmetDeps = dependencies.filter(d => !this.mods.includes(d));
220
+ if (unmetDeps.length) {
221
+ throw new Error(`Mod ${modName} is missing dependencies ${unmetDeps}`);
222
+ }
223
+ }
224
+ let finished = false;
225
+ while (!finished) {
226
+ finished = true;
227
+ let targetMod = undefined;
228
+ for (let key in modGraph) {
229
+ finished = false;
230
+ if (!targetMod && modGraph[key].length === 0) {
231
+ targetMod = key;
232
+ }
233
+ }
234
+ if (finished) {
235
+ return modOrder;
236
+ }
237
+ if (!targetMod) {
238
+ throw new Error(`Circular dependency detected, failed to finish resolving ${JSON.stringify(modGraph)}`);
239
+ }
240
+ modOrder.push(targetMod);
241
+ delete modGraph[targetMod];
242
+ for (const key in modGraph) {
243
+ const index = modGraph[key].indexOf(targetMod);
244
+ if (index >= 0) {
245
+ modGraph[key].splice(index, 1);
246
+ }
247
+ }
248
+ }
249
+ return modOrder;
250
+ }
251
+ getModPath(modName) {
252
+ return path.join(this.modDir, `../mods/__${modName}__`);
253
+ }
254
+ getModInfo(modName) {
255
+ return getModInfo(this.getModPath(modName));
256
+ }
257
+ initLua() {
258
+ const luaState = new LuaState();
259
+ luaState.setGlobal('log', s => this.logHandler(s));
260
+ const utilPath = path.join(__dirname, '../util_scripts');
261
+ luaState.evalFile(path.join(utilPath, 'data_setup.lua'));
262
+ luaState.evalFile(path.join(utilPath, 'defines.lua'));
263
+ luaState.evalFile(path.join(utilPath, 'require_handler.lua'));
264
+ /*Lua 5.3 deprecates math.atan2, but Factorio is on Lua 5.2.1
265
+ Could downgrade Lua, but for now this patch seems to do it */
266
+ luaState.eval('math.atan2 = math.atan');
267
+ return luaState;
268
+ }
269
+ setPackagePaths(...paths) {
270
+ this.luaState.eval(`package.path = '${[...paths].join(';').replace(/\\/g, '/')}'`);
271
+ }
272
+ setModContext(modName) {
273
+ this.setPackagePaths(
274
+ // This is the main import path. The engine will try to rewrite mods to match this path first, making them relative to whatever mod's file is currently running
275
+ // When a mod imports from another mod via __modname__.path syntax, that mod is injected at the root
276
+ // See require_handler.lua for more
277
+ `${this.modDir}/?.lua`, // importing from other mods is allowed, using the __modname__/path syntax
278
+ // These all become active in the "second attempt" import if the first one fails
279
+ `${this.modDir}/__core__/lualib/?.lua`, // lualib files are available to import by name to all mods
280
+ `${this.modDir}/__base__/?.lua`, // Some mods require from vanilla mods without specifying modname, which Factorio allows, so add them back in as lower priority
281
+ `${this.modDir}/__space-age__/?.lua`, `${this.modDir}/__elevated-rails__/?.lua`, `${this.modDir}/__quality__/?.lua`, `${this.modDir}/__recycler__/?.lua`);
282
+ // Used by require_handler.lua to keep track of the current "state"
283
+ this.luaState.eval(`current_mod_root_path = '${modName}'`);
284
+ // see require_handler.lua
285
+ if (modName === 'core' || modName === 'base' || modName === 'quality' || modName === 'recycler' || modName === 'elevated-rails' || modName === 'space-age') {
286
+ this.luaState.setGlobal('suppress_require_errors', true);
287
+ }
288
+ else {
289
+ this.luaState.setGlobal('suppress_require_errors', false);
290
+ }
291
+ }
292
+ }
@@ -0,0 +1,77 @@
1
+ export interface ModManagerOptions {
2
+ /**
3
+ * Directory where mods are installed
4
+ */
5
+ modDir?: string;
6
+ /**
7
+ * Working directory for files like mod downloads
8
+ */
9
+ workingDir?: string;
10
+ }
11
+ export interface ModInstallOptions {
12
+ /**
13
+ * Clear previously-installed files and re-install
14
+ */
15
+ clearCache?: boolean;
16
+ }
17
+ export interface ModDownloadOptions extends ModInstallOptions {
18
+ /**
19
+ * Clear previously-downloaded files and force re-download
20
+ */
21
+ clearDownload?: boolean;
22
+ /**
23
+ * Check if a newer version exists and download if needed
24
+ */
25
+ checkLatest?: boolean;
26
+ }
27
+ export interface ModPortalDownloadOptions extends ModDownloadOptions {
28
+ /**
29
+ * Version of the mod to download, if omitted either installs latest from portal or uses latest downloaded depending on `checkLatest` option.
30
+ */
31
+ version?: string;
32
+ /**
33
+ * True to skip installing dependent mods. By default all required dependent mods are installed.
34
+ */
35
+ skipDependencies?: boolean;
36
+ /**
37
+ * Dependencies in this list will be skipped, useful for skipping large asset-only deps that are not needed for testing.
38
+ */
39
+ omitDependencies?: string[];
40
+ }
41
+ /**
42
+ * Utility class to download/install mods so they can be found by FactorioEngine
43
+ */
44
+ export declare class ModManager {
45
+ readonly modDir: string;
46
+ readonly workingDir: string;
47
+ constructor(options?: ModManagerOptions);
48
+ /**
49
+ * Download all vanilla "mod" data from https://github.com/wube/factorio-data
50
+ * Requires git to be available at the command line
51
+ * @param options Mod Installation options
52
+ */
53
+ installVanillaMods(options?: ModDownloadOptions): Promise<void>;
54
+ /**
55
+ * Install mod from a directory - essentially a directory copy to the correct location
56
+ * @param modName Name of the mod
57
+ * @param sourcePath Path to copy from
58
+ * @param options Mod Installation Options
59
+ */
60
+ installDirectoryMod(modName: string, sourcePath: string, options?: ModInstallOptions): void;
61
+ /**
62
+ * Downloads and installs a mod from Factorio's mod portal.
63
+ * Requires the environment variables FACTORIO_USERNAME and FACTORIO_TOKEN to be set
64
+ * @param modName Name of the mod
65
+ * @param options Mod Installation Optiions
66
+ */
67
+ installPortalMod(modName: string, options?: ModPortalDownloadOptions): Promise<void>;
68
+ private getModInfo;
69
+ /**
70
+ * Install a mod from a zip file
71
+ * @param modName Name of the mod
72
+ * @param sourcePath Path to the zip file
73
+ * @param options Mod Installation Options
74
+ */
75
+ installZipMod(modName: string, sourcePath: string, options?: ModInstallOptions): Promise<void>;
76
+ private getModPath;
77
+ }