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.
@@ -0,0 +1,181 @@
1
+ import path from 'node:path';
2
+ import { promisify } from 'node:util';
3
+ import { exec } from 'node:child_process';
4
+ import { cpSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs';
5
+ import fetch from 'node-fetch';
6
+ import { glob } from 'glob';
7
+ import semverCompare from 'semver-compare';
8
+ import { pipeline } from 'node:stream';
9
+ import extractZip from 'extract-zip';
10
+ import { getDependencies, getModInfo } from "./modInfoUtil.js";
11
+ import { modDir, workingDir } from "./defaultValues.js";
12
+ const vanillaMods = ['core', 'base', 'quality', 'recycler', 'elevated-rails', 'space-age'];
13
+ /**
14
+ * Utility class to download/install mods so they can be found by FactorioEngine
15
+ */
16
+ export class ModManager {
17
+ modDir;
18
+ workingDir;
19
+ constructor(options) {
20
+ this.modDir = options?.modDir || modDir();
21
+ this.workingDir = options?.workingDir || workingDir();
22
+ }
23
+ /**
24
+ * Download all vanilla "mod" data from https://github.com/wube/factorio-data
25
+ * Requires git to be available at the command line
26
+ * @param options Mod Installation options
27
+ */
28
+ async installVanillaMods(options) {
29
+ const run = promisify(exec);
30
+ const gitDir = path.join(this.workingDir, 'factorio-data');
31
+ if (existsSync(gitDir) && options?.clearDownload) {
32
+ rmSync(gitDir, { recursive: true });
33
+ }
34
+ if (existsSync(gitDir) && options?.checkLatest) {
35
+ await run('git pull', { cwd: gitDir });
36
+ }
37
+ if (!existsSync(gitDir)) {
38
+ mkdirSync(this.workingDir, { recursive: true });
39
+ await run('git clone --depth=1 https://github.com/wube/factorio-data.git', { cwd: this.workingDir });
40
+ }
41
+ for (let mod of vanillaMods) {
42
+ this.installDirectoryMod(mod, path.join(gitDir, mod), options);
43
+ }
44
+ }
45
+ /**
46
+ * Install mod from a directory - essentially a directory copy to the correct location
47
+ * @param modName Name of the mod
48
+ * @param sourcePath Path to copy from
49
+ * @param options Mod Installation Options
50
+ */
51
+ installDirectoryMod(modName, sourcePath, options) {
52
+ const targetPath = this.getModPath(modName);
53
+ if (existsSync(targetPath) && options?.clearCache) {
54
+ rmSync(targetPath, { recursive: true });
55
+ }
56
+ if (!existsSync(targetPath)) {
57
+ cpSync(sourcePath, targetPath, { recursive: true });
58
+ }
59
+ }
60
+ /**
61
+ * Downloads and installs a mod from Factorio's mod portal.
62
+ * Requires the environment variables FACTORIO_USERNAME and FACTORIO_TOKEN to be set
63
+ * @param modName Name of the mod
64
+ * @param options Mod Installation Optiions
65
+ */
66
+ async installPortalMod(modName, options) {
67
+ let modInfo = undefined;
68
+ let targetVersion = undefined;
69
+ const downloadDir = path.join(this.workingDir, 'downloads');
70
+ mkdirSync(downloadDir, { recursive: true });
71
+ if (options?.version) {
72
+ targetVersion = options?.version;
73
+ }
74
+ else if (options?.checkLatest) {
75
+ modInfo = await this.getModInfo(modName);
76
+ targetVersion = modInfo.releases.slice(-1)[0].version;
77
+ }
78
+ let zipPath = undefined;
79
+ if (targetVersion) {
80
+ zipPath = path.join(downloadDir, `${modName}_${targetVersion}.zip`);
81
+ }
82
+ else {
83
+ const modFiles = await glob(`${modName}_*.zip`, { cwd: downloadDir });
84
+ if (modFiles.length) {
85
+ let latest = '0.0.0';
86
+ for (const file of modFiles) {
87
+ const version = file.replace(/\.zip$/, '').split('_').slice(-1)[0];
88
+ if (semverCompare(latest, version) < 0) {
89
+ latest = version;
90
+ }
91
+ }
92
+ zipPath = path.join(downloadDir, `${modName}_${latest}.zip`);
93
+ }
94
+ }
95
+ if (zipPath && options?.clearDownload) {
96
+ rmSync(zipPath);
97
+ }
98
+ if (!zipPath) {
99
+ if (!modInfo) {
100
+ modInfo = await this.getModInfo(modName);
101
+ }
102
+ targetVersion = modInfo.releases.slice(-1)[0].version;
103
+ zipPath = path.join(downloadDir, `${modName}_${targetVersion}.zip`);
104
+ }
105
+ if (!existsSync(zipPath)) {
106
+ if (!modInfo) {
107
+ modInfo = await this.getModInfo(modName);
108
+ }
109
+ let modDownloadUrl;
110
+ if (targetVersion) {
111
+ let url = modInfo.releases.find(r => r.version === targetVersion)?.download_url;
112
+ if (url) {
113
+ modDownloadUrl = url;
114
+ }
115
+ else {
116
+ throw new Error(`Cannot find version ${targetVersion} of mod ${modName}`);
117
+ }
118
+ }
119
+ else {
120
+ modDownloadUrl = modInfo.releases.slice(-1)[0].download_url;
121
+ }
122
+ if (!process.env.FACTORIO_USERNAME) {
123
+ throw new Error('Cannot download mod, env variable FACTORIO_USERNAME is not set');
124
+ }
125
+ if (!process.env.FACTORIO_TOKEN) {
126
+ throw new Error('Cannot download mod, env variable FACTORIO_TOKEN is not set');
127
+ }
128
+ const apiDownloadUrl = `https://mods.factorio.com/${modDownloadUrl}?username=${process.env.FACTORIO_USERNAME}&token=${process.env.FACTORIO_TOKEN}`;
129
+ const streamPipeline = promisify(pipeline);
130
+ const response = await fetch(apiDownloadUrl);
131
+ if (!response.ok || !response.body) {
132
+ throw new Error(`Failed to download mod ${modName}: ${response.statusText}`);
133
+ }
134
+ await streamPipeline(response.body, createWriteStream(zipPath));
135
+ }
136
+ let isOutdated = false;
137
+ const targetPath = this.getModPath(modName);
138
+ if (existsSync(targetPath) && targetVersion) {
139
+ const modInfo = getModInfo(targetPath);
140
+ if (modInfo.version != targetVersion) {
141
+ isOutdated = true;
142
+ }
143
+ }
144
+ await this.installZipMod(modName, zipPath, { ...options, clearCache: isOutdated ? true : options?.clearCache });
145
+ if (!options?.skipDependencies) {
146
+ const dependencies = getDependencies(this.getModPath(modName));
147
+ for (let dependency of dependencies.required) {
148
+ if (!vanillaMods.includes(dependency) && !options?.omitDependencies?.includes(dependency)) {
149
+ await this.installPortalMod(dependency, { ...options, version: undefined });
150
+ }
151
+ }
152
+ }
153
+ }
154
+ async getModInfo(modName) {
155
+ const response = await fetch(`https://mods.factorio.com/api/mods/${modName}`);
156
+ return (await response.json());
157
+ }
158
+ /**
159
+ * Install a mod from a zip file
160
+ * @param modName Name of the mod
161
+ * @param sourcePath Path to the zip file
162
+ * @param options Mod Installation Options
163
+ */
164
+ async installZipMod(modName, sourcePath, options) {
165
+ const targetPath = this.getModPath(modName);
166
+ if (existsSync(targetPath) && options?.clearCache) {
167
+ rmSync(targetPath, { recursive: true });
168
+ }
169
+ if (!existsSync(targetPath)) {
170
+ const unzipDir = path.join(this.workingDir, 'unzip');
171
+ mkdirSync(unzipDir, { recursive: true });
172
+ await extractZip(sourcePath, { dir: unzipDir });
173
+ // 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);
176
+ }
177
+ }
178
+ getModPath(modName) {
179
+ return path.join(this.modDir, `__${modName}__`);
180
+ }
181
+ }
@@ -0,0 +1,3 @@
1
+ export declare const libName = "synthetic-factorio";
2
+ export declare const modDir: () => string;
3
+ export declare const workingDir: () => string;
@@ -0,0 +1,5 @@
1
+ import envPaths from 'env-paths';
2
+ import path from 'path';
3
+ export const libName = 'synthetic-factorio';
4
+ export const modDir = () => path.join(envPaths(libName).cache, 'mods');
5
+ export const workingDir = () => path.join(envPaths(libName).cache, 'working');
@@ -0,0 +1,3 @@
1
+ export { FactorioEngine, FactorioEngineOptions } from './FactorioEngine.ts';
2
+ export { ModManager, ModManagerOptions, ModDownloadOptions, ModInstallOptions, ModPortalDownloadOptions } from './ModManager.ts';
3
+ export { tableToArray } from './utils.ts';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { FactorioEngine } from "./FactorioEngine.js";
2
+ export { ModManager } from "./ModManager.js";
3
+ export { tableToArray } from "./utils.js";
@@ -0,0 +1,18 @@
1
+ export interface ModInfo {
2
+ name: string;
3
+ version: string;
4
+ title: string;
5
+ author: string;
6
+ contact: string;
7
+ homepage: string;
8
+ factorio_version: string;
9
+ description: string;
10
+ dependencies: string[];
11
+ }
12
+ export interface Dependencies {
13
+ required: string[];
14
+ optional: string[];
15
+ incompatible: string[];
16
+ }
17
+ export declare function getModInfo(modDir: string): ModInfo;
18
+ export declare function getDependencies(modDir: string): Dependencies;
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ export function getModInfo(modDir) {
4
+ const filePath = path.join(modDir, 'info.json');
5
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
6
+ }
7
+ export function getDependencies(modDir) {
8
+ const modInfo = getModInfo(modDir);
9
+ const deps = {
10
+ required: [],
11
+ optional: [],
12
+ incompatible: [],
13
+ };
14
+ // https://lua-api.factorio.com/latest/auxiliary/mod-structure.html#Dependency
15
+ for (let dependency of modInfo.dependencies) {
16
+ let parts = dependency.split(' ');
17
+ if (parts[0] === '!') {
18
+ deps.incompatible.push(parts[1]);
19
+ }
20
+ // Hidden or non-hidden optional deps are equivalent here as they're only a mod portal concept
21
+ // Recommended optional is also functionally equivalent to optional for our purposes
22
+ else if (parts[0] === '?' || parts[0] === '(?)' || parts[0] === '+') {
23
+ deps.optional.push(parts[1]);
24
+ }
25
+ // For now ignoring ~ dependencies (dependency that doesn't affect load order)
26
+ // I'm unsure of the use case and haven't encountered one, and mostly the engine needs dependencies for load order concerns
27
+ else if (parts[0] !== '~') {
28
+ deps.required.push(parts[0]);
29
+ }
30
+ }
31
+ return deps;
32
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Many parts of the Factorio API return tables for array/list-like data
3
+ * i.e. { 1: 'some-value', 2: 'some-other-value' } instead of ['some-value', 'some-other-value']
4
+ * This function will convert them to a proper array
5
+ * @param table Array-like table from lua
6
+ * @returns Input table converted to a proper array
7
+ */
8
+ export declare function tableToArray<T>(table: {
9
+ [key: number]: T;
10
+ }): T[];
package/dist/utils.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Many parts of the Factorio API return tables for array/list-like data
3
+ * i.e. { 1: 'some-value', 2: 'some-other-value' } instead of ['some-value', 'some-other-value']
4
+ * This function will convert them to a proper array
5
+ * @param table Array-like table from lua
6
+ * @returns Input table converted to a proper array
7
+ */
8
+ export function tableToArray(table) {
9
+ const result = [];
10
+ for (const key in table) {
11
+ const index = parseInt(key) - 1;
12
+ if (isNaN(index)) {
13
+ throw new Error(`Failed to convert table to array, found non-numeric key ${key}`);
14
+ }
15
+ if (index < 0) {
16
+ throw new Error(`Failed to convert table to array, found key ${key} less than 1`);
17
+ }
18
+ result[index] = table[key];
19
+ }
20
+ return result;
21
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "synthetic-factorio",
3
+ "version": "0.0.1",
4
+ "description": "Synthetic Factorio runtime for unit testing",
5
+ "license": "ISC",
6
+ "author": "Spencer Guest",
7
+ "type": "module",
8
+ "scripts": {
9
+ "test": "vitest",
10
+ "test:ci": "vitest run",
11
+ "build": "tsc"
12
+ },
13
+ "files": [
14
+ "dist/**/*.js",
15
+ "dist/**/*.d.ts",
16
+ "util_scripts/**/*.lua"
17
+ ],
18
+ "main": "dist/index.js",
19
+ "types": "dist/index.d.ts",
20
+ "dependencies": {
21
+ "env-paths": "^4.0.0",
22
+ "extract-zip": "^2.0.1",
23
+ "glob": "^13.0.6",
24
+ "lua-state": "^1.2.0",
25
+ "node-fetch": "^3.3.2",
26
+ "semver-compare": "^1.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^25.9.3",
30
+ "@types/semver-compare": "^1.0.3",
31
+ "factorio-types": "^1.2.69",
32
+ "vitest": "^5.0.0"
33
+ }
34
+ }
@@ -0,0 +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
18
+ end