diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index f0770a01..3ba681ab 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -20,8 +20,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 8 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: @@ -48,11 +46,9 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 8 - uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 20.x - run: pnpm i --no-frozen-lockfile - run: pnpm run lint @@ -61,11 +57,9 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 8 - uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 20.x - run: pnpm i --no-frozen-lockfile - run: pnpm run build-mod # Upload build to artifacts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..854a9358 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,48 @@ + +name: Publish + +on: + release: + types: + - released + +jobs: + npm: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 8 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + registry-url: https://registry.npmjs.org + - run: pnpm install --no-frozen-lockfile + - run: pnpm -r --filter='./{plugins,packages}/*' publish --no-git-checks + env: + NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }} + - uses: softprops/action-gh-release@v2 + with: + files: pnpm-lock.yaml + + factorio: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 8 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + - run: pnpm install --no-frozen-lockfile + - run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist + - run: node ./publish.js factorio ./dist + env: + FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }} + - uses: softprops/action-gh-release@v2 + with: + files: ./dist/* diff --git a/.gitignore b/.gitignore index eb984bd0..293e0bcb 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,12 @@ dist/ node_modules Factorio* +!*Factorio*.ts +!*Factorio*.tsx +!*Factorio*.js /factorio !/test/file/factorio +!/test/file/html/*.html !/test/lib/factorio !/packages/lib/src/factorio /instances diff --git a/CHANGELOG.md b/CHANGELOG.md index bb2ca15f..fbbc9cba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,51 @@ Many thanks to the following for contributing to this release: [@username](https://github.com/username) --> +## Version 2.0.0-alpha.22 + +### Major Features + +- Added a download dependencies button which downloads missing dependencies. [#726](https://github.com/clusterio/clusterio/issues/726) +- Added an update mod button which downloads the latest compatible version of a mod. [#725](https://github.com/clusterio/clusterio/issues/725) + +### Features + +- Added online player list to instance details page. [#666](https://github.com/clusterio/clusterio/issues/666) +- Added ability to restart instances as a single action. [#779](https://github.com/clusterio/clusterio/issues/779) +- Added ability to exclude instances from the 'Start all' web UI button. [#451](https://github.com/clusterio/clusterio/issues/451) +- Added plugin update detection for restart required [#759](https://github.com/clusterio/clusterio/issues/759) +- Added detection for mismatched mod and mod pack factorio version. [#727](https://github.com/clusterio/clusterio/issues/727) +- Added detection for dependency issues such as incompatible or wrong version. [#727](https://github.com/clusterio/clusterio/issues/727) +- Added a quick fix button for resolving detected dependency issues. [#801](https://github.com/clusterio/clusterio/issues/801) + +### Changes + +- Restart host / controller is now the default action with stop moved to a dropdown. [#780](https://github.com/clusterio/clusterio/issues/780) +- Restart host / controller no longer shows unless it is enabled for that machine. [#780](https://github.com/clusterio/clusterio/issues/780) +- Plugin templates no longer depend on a dev install of Clusterio. [#786](https://github.com/clusterio/clusterio/issues/786) +- Better error message for when a plugin is missing an index file. [#799](https://github.com/clusterio/clusterio/pull/799) +- `ModStore.downloadMods` no longer checks if the mod is already downloaded. [#801](https://github.com/clusterio/clusterio/issues/801 +- Mod version for buildins will always be fully qualified, matching the requirement enforced on other mods. [#801](https://github.com/clusterio/clusterio/issues/801)) +- Subscriptions can now be filtered. [#788](https://github.com/clusterio/clusterio/pull/788) + +### Fixes + +- Fixed the missing space between action buttons on the users and hosts list. [#780](https://github.com/clusterio/clusterio/issues/780) +- Fixed "rendered more hooks" error when refreshing hosts page. [#769](https://github.com/clusterio/clusterio/issues/769) +- Fixed inventory combinator time signal not always updating [#610](https://github.com/clusterio/clusterio/issues/610) +- Renamed templates for `.gitignore` and `.npmignore` that caused `create --plugin-template` to fail. [#800](https://github.com/clusterio/clusterio/pull/800) +- Fixed npm update button failing to apply update and restart. + +### Breaking changes + +- `ModPortalDownloadRequest` now requires an array of mod version pairs rather than a single mod name and version. [#801](https://github.com/clusterio/clusterio/issues/801) +- Version strings (e.g. `1.0`, `1.0.0`) now have stricter typing and validation, passing unchecked strings to functions will error. [#801](https://github.com/clusterio/clusterio/issues/801) + +Many thanks to the following for contributing to this release: +[@Cooldude2606](https://github.com/Cooldude2606) +[@Danielv123](https://github.com/Danielv123) +[@Hornwitser](https://github.com/Hornwitser) + ## Version 2.0.0-alpha.21 ### Major Features diff --git a/README.md b/README.md index a1c54886..42e51dbb 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), thi ## Running Clusterio -After completing the installation start up the controller and at least one host separately. +After completing the installation and creating your admin user, start up the controller and at least one host separately. The installer provides the `run-controller` and `run-host` scripts to make this simple. Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in. diff --git a/clean.js b/clean.js index 1a94d74d..8e15dcba 100644 --- a/clean.js +++ b/clean.js @@ -47,6 +47,9 @@ async function removeBuildArtifacts() { tasks.push( tryRemove(path.join(basePath, packageName, "dist")) ); + tasks.push( + tryRemove(path.join(basePath, packageName, "node_modules", ".cache")) + ); } } @@ -81,10 +84,10 @@ async function removeExecutionArtifacts() { * Main function for this script */ async function main() { - await yargs + await yargs(process.argv.slice(2)) .scriptName("clean") - .usage("$0 [options]") - .option("dry", { + .option("dry-run", { + alias: "dry", nargs: 0, describe: "Will not delete any files", default: false, @@ -105,7 +108,7 @@ async function main() { DRY = argv.dry; removeBuildArtifacts(); }) - .demandCommand(1, "You need to specify a command to run") + .demandCommand(1, "A command must be specified") .strict() .parse(); } diff --git a/docs/developing-for-clusterio.md b/docs/developing-for-clusterio.md index 4218ce9f..a311af44 100644 --- a/docs/developing-for-clusterio.md +++ b/docs/developing-for-clusterio.md @@ -120,7 +120,7 @@ There's also the `add_libraries` function exported by event_handler, which accep ## Communicating with Clusterio Clusterio uses a homebrew protocol based on sending JSON payloads over a WebSocket connection. -See [socket.md](socket.md) for the implementation details of it. +See [protocol.md](devs/protocol.md) for the implementation details of it. It's also possible to write a plugin for Clusterio that exposes a custom interface over HTTP, WebSocket or any other technology supported by Node.js. diff --git a/eslint.config.mjs b/eslint.config.mjs index 587ffe36..d7b7e8e2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -494,6 +494,7 @@ export default defineConfig([{ "rules": { "node/no-missing-require": "off", "node/no-missing-import": "off", + "@stylistic/indent": "off", "spaced-comment": "off", "comma-spacing": "off", "max-len": "off", diff --git a/package.json b/package.json index 068ef25e..672fabe8 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,11 @@ "name": "root", "private": true, "files": [], + "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316", "scripts": { "build": "tsc --build", "watch": "tsc --build --watch", - "build-mod": "node ./packages/lib/build_mod.js --source-dir ./packages/host/lua/clusterio_lib", + "build-mod": "pnpm run -r build-mod --output-dir ../../dist", "test": "pnpm run build && mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"", "silent-test": "SILENT_TEST=y pnpm test", "fast-test": "FAST_TEST=y pnpm test", @@ -27,7 +28,7 @@ ] }, "engines": { - "node": ">=18" + "node": ">=20" }, "workspaces": [ "packages/*", diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index e4299534..5e3a4838 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -127,7 +127,8 @@ void new lib.Gauge( async function handleBootstrapCommand( args: any, - controllerConfig: lib.ControllerConfig + controllerConfig: lib.ControllerConfig, + controllerConfigLock: lib.LockFile, ): Promise { let subCommand = args._[1]; @@ -141,6 +142,8 @@ async function handleBootstrapCommand( await userManager.load(path.join(databaseDirectory, "users.json")); if (subCommand === "create-admin") { + await controllerConfigLock.acquire(); // Also needed to write to the database files + if (!args.name) { logger.error("name cannot be blank"); process.exitCode = 1; @@ -156,6 +159,7 @@ async function handleBootstrapCommand( admin.roleIds.add(adminRole.id); admin.isAdmin = true; await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json")); + await controllerConfigLock.release(); } else if (subCommand === "generate-user-token") { let user = userManager.getByName(args.name); @@ -251,6 +255,7 @@ async function initialize(): Promise { default: "plugin-list.json", type: "string", }) + .option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false }) .command("plugin", "Manage available plugins", lib.pluginCommand) .command("config", "Manage Controller config", lib.configCommand) .command("bootstrap", "Bootstrap access to cluster", yargs => { @@ -281,6 +286,7 @@ async function initialize(): Promise { type: "boolean", nargs: 0, default: false, describe: "Start the controller in recovery mode with all plugins disabled and hosts disconnected", }); + yargs.option("check-user-count", { hidden: true, type: "boolean", nargs: 0, default: true }); yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 }); yargs.option("dev-plugin", { hidden: true, type: "array" }); }) @@ -350,8 +356,14 @@ async function initialize(): Promise { lib.registerPluginMessages(pluginInfos); lib.addPluginConfigFields(pluginInfos); - let controllerConfig; const controllerConfigPath = args.config; + const controllerConfigLockPath = `${controllerConfigPath}.lock`; + if (args.bypassLockFile) { + await fs.unlink(controllerConfigLockPath).catch(() => {}); // ignore error, file might not exist + } + + let controllerConfig; + const controllerConfigLock = new lib.LockFile(controllerConfigLockPath); logger.info(`Loading config from ${controllerConfigPath}`); try { controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath); @@ -377,10 +389,11 @@ async function initialize(): Promise { } if (command === "config") { - await lib.handleConfigCommand(args, controllerConfig); - + await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock); } else if (command === "bootstrap") { - await handleBootstrapCommand(args, controllerConfig); + await handleBootstrapCommand(args, controllerConfig, controllerConfigLock); + } else if (shouldRun) { + await controllerConfigLock.acquire(); // Hold the lock until process exit } return { @@ -417,6 +430,14 @@ async function startup() { ...await Controller.bootstrap(controllerConfig) ); + // Refuse to start if there are no users loaded + if (args.checkUserCount && controller.userManager.users.size === 0) { + logger.fatal( + "Cannot start controller, no users loaded.\n" + + "Try `npx clusteriocontroller bootstrap create-admin `"); + process.exit(1); + } + let secondSigint = false; process.on("SIGINT", () => { if (secondSigint) { diff --git a/packages/controller/package.json b/packages/controller/package.json index 1868a960..9f350bd6 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -3,10 +3,10 @@ "description": "Implementation of Clusterio controller", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.21", + "version": "2.0.0-alpha.22b", "main": "dist/node/index.js", "scripts": { - "prepare": "tsc --build && webpack-cli --env production", + "prepare": "tsc --build && webpack-cli --mode production", "test": "echo \"Error: no test specified\" && exit 1" }, "bin": { @@ -17,45 +17,45 @@ "factorio" ], "engines": { - "node": ">=18" + "node": ">=20" }, "dependencies": { "@clusterio/lib": "workspace:*", - "@sinclair/typebox": "^0.30.4", + "@sinclair/typebox": "catalog:", + "set-blocking": "catalog:", + "jsonwebtoken": "catalog:", + "express": "catalog:", + "fs-extra": "catalog:", + "ws": "catalog:", + "jszip": "catalog:", + "winston": "catalog:", + "winston-daily-rotate-file": "catalog:", "busboy": "^1.6.0", "compression": "^1.8.0", - "express": "^4.21.2", "finalhandler": "^1.3.1", - "fs-extra": "^11.3.0", - "jsonwebtoken": "^9.0.1", - "jszip": "^3.10.1", - "set-blocking": "^2.0.0", - "winston": "^3.17.0", - "winston-daily-rotate-file": "^4.7.1", - "ws": "^8.18.1", "yargs": "^17.7.2" }, "devDependencies": { "@clusterio/web_ui": "workspace:*", - "@swc/core": "^1.11.1", - "@types/busboy": "^1.5.0", - "@types/compression": "^1.7.2", - "@types/express": "^4.17.17", - "@types/finalhandler": "^1.2.0", - "@types/fs-extra": "^11.0.1", - "@types/jsonwebtoken": "^9.0.9", - "@types/node": "^20.17.19", - "@types/set-blocking": "^2.0.0", - "@types/ws": "^8.5.14", + "@types/jsonwebtoken": "catalog:", + "@types/set-blocking": "catalog:", + "@types/finalhandler": "catalog:", + "@types/express": "catalog:", + "@types/fs-extra": "catalog:", + "@types/node": "catalog:", + "@types/ws": "catalog:", "@types/yargs": "^17.0.33", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.29.0", - "typescript": "^5.7.3", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", - "webpack-dev-middleware": "^6.1.1", - "webpack-merge": "^5.9.0" + "@types/compression": "^1.7.2", + "@types/busboy": "^1.5.0", + "@swc/core": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-router-dom": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:", + "webpack-dev-middleware": "^6.1.1" }, "publishConfig": { "access": "public" diff --git a/packages/controller/src/BaseConnection.ts b/packages/controller/src/BaseConnection.ts index bda042aa..8efbf14c 100644 --- a/packages/controller/src/BaseConnection.ts +++ b/packages/controller/src/BaseConnection.ts @@ -78,7 +78,7 @@ export default class BaseConnection extends lib.Link { return modPack; } - getMod(mod: { name: string, version: string, sha1?: string }) { + getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) { let filename = lib.ModInfo.filename(mod.name, mod.version); let modInfo = this._controller.modStore.files.get(filename); if (!modInfo) { diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 8b6f4f28..905ef956 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -100,6 +100,8 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.ModListRequest, this.handleModListRequest.bind(this)); this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this)); this.handle(lib.ModPortalGetAllRequest, this.handleModPortalGetAllRequest.bind(this)); + this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this)); + this.handle(lib.ModDependencyResolveRequest, this.handleModDependencyResolveRequest.bind(this)); this.handle(lib.ModDeleteRequest, this.handleModDeleteRequest.bind(this)); this.handle(lib.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this)); this.handle(lib.LogQueryRequest, this.handleLogQueryRequest.bind(this)); @@ -124,7 +126,7 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this)); this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this)); this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); - this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this)); + this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this)); } validateIngress(message: lib.MessageRequest | lib.MessageEvent) { @@ -564,52 +566,181 @@ export default class ControlConnection extends BaseConnection { * Handle request to download a mod from the Factorio Mod Portal to the controller. * @param request - Request object with mod name, version, and factorioVersion. */ - async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest): Promise { - const { name, version, factorioVersion } = request; - const filename = lib.ModInfo.filename(name, version); + async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest) { + const factorioVersion = request.factorioVersion; + const toDownload = this._controller.modStore.filterInstalled(request.mods, false); - // Check if mod already exists - if (this._controller.modStore.files.has(filename)) { - logger.verbose(`Mod ${filename} already exists, skipping download.`); - return this._controller.modStore.files.get(filename)!; - } + if (toDownload.length > 0) { + // Get Factorio credentials from config + const username = this._controller.config.get("controller.factorio_username"); + const token = this._controller.config.get("controller.factorio_token"); - // Get Factorio credentials from config - const username = this._controller.config.get("controller.factorio_username"); - const token = this._controller.config.get("controller.factorio_token"); - - if (!username || !token) { - throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller."); - } - - try { - logger.info(`Downloading mod ${filename} for Factorio ${factorioVersion} from portal.`); - const modMap = new Map([[name, version]]); - await this._controller.modStore.downloadMods(modMap, username || "", token || "", factorioVersion); - - // Verify download and return ModInfo - const downloadedMod = this._controller.modStore.files.get(filename); - if (!downloadedMod) { - throw new lib.RequestError(`Failed to find mod ${filename} in store after download attempt.`); + if (!username || username === "" || !token || token === "") { + throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller."); } - logger.info(`Successfully downloaded mod ${filename}.`); - return downloadedMod; - } catch (error: any) { - logger.error(`Error downloading mod ${filename} from portal: ${error.message}`); - // Improve error message clarity - let errorMessage = `Mod portal download failed for ${name} v${version}`; - if (error instanceof lib.RequestError) { - errorMessage += `: ${error.message}`; - } else if (error.message.includes("401") || error.message.includes("Unauthorized")) { - errorMessage += ": Authentication failed. Check Factorio username/token in controller config."; - } else if (error.message.includes("404") || error.message.includes("Not Found")) { - errorMessage += ": Mod version not found on the portal."; + try { + logger.info(`Downloading ${toDownload.length} mods for Factorio ${factorioVersion} from portal.`); + await this._controller.modStore.downloadMods(toDownload, username, token, factorioVersion); + + } catch (error: any) { + logger.error(`Error downloading mods from portal: ${error.message}`); + // Improve error message clarity + let errorMessage = "Mod portal download failed"; + if (error instanceof lib.RequestError) { + errorMessage += `: ${error.message}`; + } else if (error.message.includes("401") || error.message.includes("Unauthorized")) { + errorMessage += ": Authentication failed. Check Factorio username/token in controller config."; + } else if (error.message.includes("404") || error.message.includes("Not Found")) { + errorMessage += ": Mod version not found on the portal."; + } else { + errorMessage += `: ${error.message}`; + } + throw new lib.RequestError(errorMessage); + } + } + + // Verify all mods are present + const modInfos = []; + const installedMods = [...this._controller.modStore.mods()]; + for (const mod of request.mods) { + const modInfo = installedMods.find(m => m.name === mod.name && mod.version.testVersion(m.version)); + if (!modInfo) { + throw new lib.RequestError(`Failed to find mod ${mod.name} in store after download attempt.`); + } + modInfos.push(modInfo); + } + + return modInfos; + } + + async handleModDependencyResolveRequest(request: lib.ModDependencyResolveRequest) { + const {factorioVersion, checkForUpdates} = request; + const searchQueue = [...request.mods]; + const localInfos = [...this._controller.modStore.mods()]; + + const _builtInModNames = lib.ModPack.getBuiltinModNames(factorioVersion); + const builtInModNames = new Set(_builtInModNames); + const skipSearch = new Set(_builtInModNames); + + type ModPortalModType = InstanceType["mods"][number]; + type ModPortalReleaseType = NonNullable[number]; + const dependencyRequirements = new Map(); + const optionalRequirements = new Map(); + const candidateReleases = new Map(); + const incompatible = new Set(); + const invalid = new Set(); + + const _modPortalReleases = new Map(); + async function fetchModReleases(modName: string) { + let releases = _modPortalReleases.get(modName); + if (releases === undefined) { + try { + releases = await lib.ModStore.fetchModReleases(modName); + } catch (error: any) { + if (!error.message.includes("404") && !error.message.includes("Not Found")) { + logger.error(`Error fetching mod from portal: ${error.message}`); + throw new lib.RequestError(`Dependency resolution failed: ${error.message}`); + } else { + releases = null; + } + } + } + _modPortalReleases.set(modName, releases); + return releases; + } + + while (searchQueue.length > 0) { + const mod = searchQueue.shift()!; + if (mod.incompatible) { + incompatible.add(mod.name); + continue; + } + + // Get the current version range (optionals are tracked in case they become required) + let versionRange = dependencyRequirements.get(mod.name); + if (!versionRange) { + versionRange = optionalRequirements.get(mod.name) ?? new lib.ModVersionRange(); + if (mod.required) { + dependencyRequirements.set(mod.name, versionRange); + optionalRequirements.delete(mod.name); + } else { + optionalRequirements.set(mod.name, versionRange); + } + } + + // Update the version range if needed + if (mod.version) { + versionRange.combineVersion(mod.version); + if (!versionRange.valid) { + candidateReleases.delete(mod.name); + invalid.add(mod.name); + } + } + + // Do not add dependencies for invalids, optionals, skips, or valid candidates + const existingCandidate = candidateReleases.get(mod.name); + if (invalid.has(mod.name) || optionalRequirements.has(mod.name) || skipSearch.has(mod.name) || ( + existingCandidate && versionRange.testVersion(existingCandidate.version) + )) { + continue; + } + + // Check if a local version fits the requirements + let candidate = localInfos + .filter(info => (info.name === mod.name && versionRange.testVersion(info.version))) + .reduce((max, cur) => ( + max && max.integerVersion > cur.integerVersion ? max : cur + ), undefined); + + // Check if a mod poral version fits the requirements + if (!candidate || checkForUpdates) { + const modReleases = await fetchModReleases(mod.name); + if (modReleases && modReleases.releases) { + const release = modReleases.releases + .filter(info => ( + info.info_json.factorio_version === factorioVersion + && versionRange.testVersion(info.version) + )) + .reduce((max, cur) => ( + max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur + ), undefined); + + // We only select the mod portal release if it is more recent than the local candidate + if (release && (!candidate || lib.integerFullVersion(release.version) > candidate.integerVersion)) { + candidate = lib.ModInfo.fromJSON({ + ...release.info_json, // Also includes dependences + sha1: release.sha1, + version: release.version, + name: modReleases.name, + author: modReleases.owner, + title: modReleases.title, + }); + } + } + } + + // Queue the dependencies of the new candidate + if (candidate) { + candidateReleases.set(mod.name, candidate); + for (const dep of candidate.dependencies) { + searchQueue.push(dep); + } } else { - errorMessage += `: ${error.message}`; + candidateReleases.delete(mod.name); + skipSearch.add(mod.name); } - throw new lib.RequestError(errorMessage); } + + return new lib.ModDependencyResolveRequest.Response( + [...candidateReleases.values()], + [...invalid.values(), ...[...incompatible.values()].filter(mod => ( + candidateReleases.has(mod) || builtInModNames.has(mod) + ))], + [...dependencyRequirements.keys()].filter(mod => ( + !candidateReleases.has(mod) && !incompatible.has(mod) && !invalid.has(mod) + )), + ); } async handleModDeleteRequest(request: lib.ModDeleteRequest) { @@ -1058,7 +1189,7 @@ export default class ControlConnection extends BaseConnection { if (!this._controller.config.get("controller.allow_remote_updates")) { throw new lib.RequestError("Remote updates are disabled on this machine"); } - return lib.updatePackage("@clusterio/controller"); + return await lib.updatePackage("@clusterio/controller"); } async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) { @@ -1079,9 +1210,7 @@ export default class ControlConnection extends BaseConnection { return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo( pluginInfo, this._controller.plugins.has(pluginInfo.name), - this._controller.config.get( - `${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields - ) as boolean, + this._controller.config.get(`${pluginInfo.name}.load_plugin`), )); } @@ -1094,6 +1223,10 @@ export default class ControlConnection extends BaseConnection { this.connector._socket!.clusterio_ignore_dump = true; this._controller.debugEvents.on("message", this.ws_dumper); } + + async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) { + return await this._controller.factorioVersions.get(request.maxAgeMs); + } } module.exports = ControlConnection; diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index b1ba92e2..7c366a73 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -105,6 +105,8 @@ export default class Controller { // Cache for mod portal requests modPortalCache = new Map(); + // Cache for factorio versions + factorioVersions = new lib.ValueCache(lib.fetchFactorioVersions); static async bootstrap(config: lib.ControllerConfig) { let databaseDirectory = config.get("controller.database_directory"); @@ -135,7 +137,8 @@ export default class Controller { const modPacks = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "mod-packs.json"), - lib.ModPack.fromJSON.bind(lib.ModPack) + lib.ModPack.fromJSON.bind(lib.ModPack), + this.migrateModPacks, ).bootstrap()); const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( @@ -405,7 +408,6 @@ export default class Controller { const webpackConfigs = []; if (args.dev) { - webpackConfigs.push(require("../../../webpack.config")({})); // Path outside of build } if (args.devPlugin) { @@ -550,19 +552,8 @@ export default class Controller { } requests.push(hostConnection.send(new lib.SystemInfoRequest())); } - if (!this.config.restartRequired) { - // If a restart isn't already required, then test if a new version is installed - try { - const runningVersion = this.config.get("controller.version"); - const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json")); - if (runningVersion !== packageJson.version) { - this.config.restartRequired = true; - } - } catch (err: any) { - logger.warn(`Failed to read package json:\n${err.stack ?? err.message}`); - } - } - requests.push(lib.gatherSystemInfo("controller", this.canRestart, this.config.restartRequired)); + const restartRequired = await this.checkRestartRequired(); + requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired)); const newMetrics = await Promise.all(requests); for (const metric of newMetrics) { this.systems.set(metric); @@ -572,6 +563,46 @@ export default class Controller { } } + async checkRestartRequired() { + if (this.config.restartRequired) { + return true; + } + + let packageName = "@clusterio/controller"; + try { + // First check the clusterio version + const runningVersion = this.config.get("controller.version"); + const packageJsonPath = require.resolve("@clusterio/controller/package.json"); + const packageJson = await fs.readJSON(packageJsonPath); + if (runningVersion !== packageJson.version) { + this.config.restartRequired = true; + return true; + } + + // Second check plugin versions + for (const pluginInfo of this.pluginInfos) { + if ( + !pluginInfo.controllerEntrypoint && !pluginInfo.webEntrypoint + || !this.config.get(`${pluginInfo.name}.load_plugin`) + ) { + continue; + } + + packageName = pluginInfo.npmPackage ?? pluginInfo.name; + const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json")); + const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath); + if (pluginInfo.version !== pluginPackageJson.version) { + this.config.restartRequired = true; + return true; + } + } + } catch (err: any) { + logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`); + } + + return false; + } + onAutosaveIntervalChanged() { if (this.autosaveInterval) { clearInterval(this.autosaveInterval); @@ -674,6 +705,17 @@ export default class Controller { return instance; } + static migrateModPacks(rawJson: unknown[]): Static[] { + const serialized = rawJson as Static[]; + return serialized.map(json => { + for (const mod of json.mods) { + // migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only + mod.version = lib.normaliseFullVersion(mod.version); + } + return json; + }); + } + static migrateRoles(rawJson: unknown[]): Static[] { const serialized = rawJson as Static[]; return serialized.map(json => { @@ -1128,7 +1170,7 @@ export default class Controller { logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`); } - if (!this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields)) { + if (!this.config.get(`${pluginInfo.name}.load_plugin`)) { continue; } diff --git a/packages/controller/src/InstanceInfo.ts b/packages/controller/src/InstanceInfo.ts index 36959ee6..05d63596 100644 --- a/packages/controller/src/InstanceInfo.ts +++ b/packages/controller/src/InstanceInfo.ts @@ -11,7 +11,7 @@ export default class InstanceInfo { public config: lib.InstanceConfig, public status: lib.InstanceStatus, public gamePort?: number, - public factorioVersion?: string, + public factorioVersion?: lib.TargetVersion, public updatedAtMs = 0, ) { this.config = config; @@ -23,7 +23,7 @@ export default class InstanceInfo { "status": lib.InstanceStatus, "gamePort": Type.Optional(Type.Number()), "updatedAtMs": Type.Optional(Type.Number()), - "factorioVersion": Type.Optional(Type.String()), + "factorioVersion": Type.Optional(lib.TargetVersionSchema), }); static fromJSON(json: Static, location: lib.ConfigLocation) { diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index 303059d4..adb1c716 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -109,9 +109,7 @@ function getPlugins(req: Request, res: Response) { for (let pluginInfo of req.app.locals.controller.pluginInfos) { let name = pluginInfo.name; let loaded = req.app.locals.controller.plugins.has(name); - let enabled = loaded && req.app.locals.controller.config.get( - `${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields, - ) as unknown as boolean; + let enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`); // Note: Cast through unknown is needed because load_plugin is // defined at runtime and no other fields in the controller config // currently have the boolean type. @@ -136,6 +134,12 @@ function getPlugins(req: Request, res: Response) { res.send(plugins); } +function getClusterName(req: Request, res: Response) { + const controller: Controller = req.app.locals.controller; + const clusterName = controller.config.get("controller.name"); + res.json({ name: clusterName }); +} + function validateHostToken(req: Request, res: Response, next: any) { let token = req.header("x-access-token"); if (!token) { @@ -664,6 +668,7 @@ async function uploadMod(req: Request, res: Response) { export function addRouteHandlers(app: Application) { app.get("/metrics", (req:Request, res:Response, next:any) => getMetrics(req, res, next).catch(next)); app.get("/api/plugins", getPlugins); + app.get("/api/cluster-name", getClusterName); app.put("/api/upload-export", validateHostToken, (req:Request, res:Response, next:any) => uploadExport(req, res).catch(next) diff --git a/packages/controller/webpack.config.js b/packages/controller/webpack.config.js index 25182879..44b06acb 100644 --- a/packages/controller/webpack.config.js +++ b/packages/controller/webpack.config.js @@ -5,7 +5,7 @@ const { merge } = require("webpack-merge"); const common = require("@clusterio/web_ui/webpack.common"); -module.exports = (env = {}) => merge(common(env), { +module.exports = (env = {}, argv = {}) => merge(common(env, argv), { context: __dirname, entry: "./web/index.tsx", devServer: { diff --git a/packages/create/create.js b/packages/create/create.js index 84d2ad2a..69f17e7d 100755 --- a/packages/create/create.js +++ b/packages/create/create.js @@ -689,6 +689,7 @@ async function inquirerMissingArgs(args) { } if (["standalone", "controller", "host"].includes(answers.mode)) { + if (args.hasOwnProperty("remoteNpm")) { answers.remoteNpm = args.remoteNpm; } answers = await inquirer.prompt([ { type: "confirm", @@ -753,8 +754,8 @@ async function inquirerMissingArgs(args) { async function downloadLinuxServer() { let res = await fetch("https://factorio.com/get-download/stable/headless/linux64"); - const url = new URL(res.headers.location); // get the filename of the latest factorio archive from redirected url + const url = new URL(res.url); // The res is redirects, need final url const filename = path.posix.basename(url.pathname); const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/); if (!match || !match.length) { @@ -771,10 +772,7 @@ async function downloadLinuxServer() { if (await fs.pathExists(factorioDir)) { logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`); } else { - await fs.ensureDir(tmpDir); - - // follow the redirect - res = await fetch(url.href); + await fs.emptyDir(tmpDir); logger.info("Downloading latest Factorio server release. This may take a while."); const writeStream = fs.createWriteStream(tmpArchivePath); @@ -793,8 +791,10 @@ async function downloadLinuxServer() { throw e; } - await fs.unlink(archivePath); + await fs.ensureDir("factorio"); await fs.rename(tmpFactorioDir, factorioDir); + await fs.unlink(archivePath); + await fs.rm(tmpDir); } } @@ -820,6 +820,10 @@ async function main() { .option("http-port", { nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number", }) + .option("download-headless", { + describe: "Download the latest headless release [standalone/controller] [linux only]", + type: "boolean", nargs: 0, + }) .option("host-name", { nargs: 1, describe: "Host name [host]", type: "string", }) diff --git a/packages/create/package.json b/packages/create/package.json index 29883cfa..b26a0e45 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -3,7 +3,7 @@ "description": "Installer for clusterio", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.21", + "version": "2.0.0-alpha.22b", "main": "create.js", "bin": { "clusteriocreate": "./create.js" @@ -13,11 +13,11 @@ "factorio" ], "engines": { - "node": ">=18" + "node": ">=20" }, "dependencies": { - "chalk": "^4.1.2", - "fs-extra": "^11.3.0", + "chalk": "catalog:", + "fs-extra": "catalog:", "inquirer": "^8.2.4", "yargs": "^17.7.2" }, diff --git a/packages/create/template.js b/packages/create/template.js index 02549f7d..43692e56 100644 --- a/packages/create/template.js +++ b/packages/create/template.js @@ -3,6 +3,8 @@ const { logger } = require("./logging"); const fs = require("fs-extra"); const path = require("path"); +const _package = require("./package.json"); + async function copyTemplateFile(src, dst, properties) { logger.verbose(`Writing ${dst} from template ${src}`); @@ -92,8 +94,8 @@ async function copyPluginTemplates(pluginName, templates) { const commonPath = path.resolve(__dirname, "./templates/common"); // Files included in all templates - files.set(".gitignore", path.join(commonPath, ".gitignore")); - files.set(".npmignore", path.join(commonPath, ".npmignore")); + files.set(".gitignore", path.join(commonPath, "template.gitignore")); + files.set(".npmignore", path.join(commonPath, "template.npmignore")); files.set("package.json", path.join(commonPath, "package.json")); files.set(`index.${ext}`, path.join(templatePath, `index.${ext}`)); @@ -102,6 +104,7 @@ async function copyPluginTemplates(pluginName, templates) { prepare.push("tsc --build"); files.set("tsconfig.json", path.join(templatePath, "tsconfig.json")); files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json")); + files.set("tsconfig.base.json", path.join(templatePath, "tsconfig.base.json")); if (webpack) { files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json")); } @@ -109,7 +112,7 @@ async function copyPluginTemplates(pluginName, templates) { // Files and dependences to support webpack if (webpack) { - prepare.push("webpack-cli --env production"); + prepare.push("webpack-cli --mode production"); files.set("webpack.config.js", path.join(commonPath, "webpack.config.js")); if (templates.includes("web")) { files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`)); @@ -139,7 +142,6 @@ async function copyPluginTemplates(pluginName, templates) { // Files for lua modules if (templates.includes("module")) { - files.set(".luacheckrc", path.join(commonPath, ".luacheckrc")); files.set("module/module.json", path.join(commonPath, "module/module.json")); files.set("module/control.lua", path.join(commonPath, "module/control.lua")); files.set("module/module_exports.lua", path.join(commonPath, "module/module_exports.lua")); @@ -147,7 +149,6 @@ async function copyPluginTemplates(pluginName, templates) { files.set("module/globals.lua", path.join(commonPath, "module/globals.lua")); } else { files.set(`instance.${ext}`, path.join(templatePath, `instance_empty.${ext}`)); - pluginContexts += 1; } } @@ -164,18 +165,25 @@ async function copyPluginTemplates(pluginName, templates) { // Properties that will control the replacements in the templates const properties = { - config: config, + // Weather this is ts or js typescript: !javascriptOnly, + ext: ext, + // Which templates where requested + multi_context: pluginContexts > 1, controller: templates.includes("controller"), host: templates.includes("host"), instance: templates.includes("instance"), module: templates.includes("module"), ctl: templates.includes("ctl"), web: templates.includes("web"), - multi_context: pluginContexts > 1, - plugin_name: pluginName, + // Macro flags for context requirements + webpack: webpack, + config: config, + // String values for package json + clusterio_version: _package.version, + node_version: _package.engines.node, prepare: prepare.join(" && "), - ext: ext, + plugin_name: pluginName, }; // Write all the template files diff --git a/packages/create/templates/common/.luacheckrc b/packages/create/templates/common/.luacheckrc deleted file mode 100644 index 9b043265..00000000 --- a/packages/create/templates/common/.luacheckrc +++ /dev/null @@ -1,1697 +0,0 @@ ---[[---------------------------------------------------------------------------- - LICENSE - .luacheckrc for Factorio Version 0.18.8, luacheck version 0.23.0 - This file is free and unencumbered software released into the public domain. - - Anyone is free to copy, modify, publish, use, compile, sell, or - distribute this file, either in source code form or as a compiled - binary, for any purpose, commercial or non-commercial, and by any - means. - - In jurisdictions that recognize copyright laws, the author or authors - of this file dedicate any and all copyright interest in the - software to the public domain. We make this dedication for the benefit - of the public at large and to the detriment of our heirs and - successors. We intend this dedication to be an overt act of - relinquishment in perpetuity of all present and future rights to this - software under copyright law. - - THE FILE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR - OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, - ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - OTHER DEALINGS IN THE SOFTWARE. - - For more information, please refer to -------------------------------------------------------------------------------]] -local LINE_LENGTH = false - -local IGNORE = {'21./%w+_$', '21./^_%w+$', '213/[ijk]', '213/index', '213/key'} - --- These globals are not available to the factorio API -local NOT_GLOBALS = {'coroutine', 'io', 'socket', 'dofile', 'loadfile'} - -local STD_CONTROL = 'lua52+factorio+factorio_control+stdlib+factorio_defines' -local STD_DATA = 'lua52+factorio+factorio_data+stdlib+stdlib_data+factorio_defines' - --- For Base and Core Mods -local STD_BASE_DATA = 'lua52+factorio+factorio_data+factorio_defines+factorio_base_data' -local STD_BASE_CONTROL = 'lua52+factorio+factorio_control+factorio_defines+factorio_base_control' - -do -- Assume Factorio Control Stage as Default - std = STD_CONTROL - --cache = true - not_globals = NOT_GLOBALS - ignore = IGNORE - quiet = 1 -- pass -q option - max_cyclomatic_complexity = false - codes = true - max_line_length = LINE_LENGTH - max_code_line_length = LINE_LENGTH - max_string_line_length = LINE_LENGTH - max_comment_line_length = LINE_LENGTH - - --List of files and directories to exclude - exclude_files = { - --Ignore special folders - '**/.trash/', - '**/.history/', - '**/stdlib/vendor/', - - --Ignore development mods - '**/combat-tester/', - '**/test-maker/', - '**/trailer/', - - --Ignore love Includes - '**/love/includes/' - } -end - -do -- Set default prototype files - files['**/data.lua'].std = STD_DATA - files['**/data-updates.lua'].std = STD_DATA - files['**/data-final-fixes.lua'].std = STD_DATA - files['**/settings.lua'].std = STD_DATA - files['**/settings-updates.lua'].std = STD_DATA - files['**/settings-final-fixes.lua'].std = STD_DATA - files['**/prototypes/'].std = STD_DATA - files['**/settings/'].std = STD_DATA -end - -do -- Base and Core mod files - local base_scenarios = { - std = STD_BASE_CONTROL .. '+factorio_base_scenarios+factorio_base_story', - --ignore = {'212/event', '111', '112', '113', '211', '212', '213', '311', '411', '412', '421', '422', '423', '431', '432', '512'} - ignore = {'...'} - } - files['**/base/scenarios/'] = base_scenarios - files['**/base/tutorials/'] = base_scenarios - files['**/base/campaigns/'] = base_scenarios - files['**/wip-scenario/'] = base_scenarios - - files['**/base/migrations/'] = {std = STD_BASE_CONTROL} - - files['**/core/lualib/'] = {std = STD_BASE_CONTROL} - files['**/core/lualib/util.lua'] = {globals = {'util', 'table'}, ignore = {'432/object'}} - files['**/core/lualib/silo-script.lua'] = {globals = {'silo_script'}, ignore = {'4../player'}} - files['**/core/lualib/production-score.lua'] = {globals = {'production_score', 'get_price_recursive'}, ignore = {'4../player'}} - files['**/core/lualib/story*'] = {std = '+factorio_base_story', ignore = {'42./k', '42./filter'}} - files['**/core/lualib/mod-gui.lua'] = {globals = {'mod_gui'}} - files['**/core/lualib/camera.lua'] = {globals = {'camera'}} - files['**/core/lualib/builder.lua'] = {globals = {'Builder', 'builder', 'action', 'down', 'right'}} - - files['**/core/lualib/bonus-gui-ordering/'] = {std = STD_BASE_DATA} - files['**/core/lualib/dataloader.lua'] = {globals = {'data'}} - files['**/core/lualib/circuit-connector-*'] = {std = STD_BASE_DATA..'+factorio_circuit_connector_generated'} - files['**/core/lualib/bonus-gui-ordering.lua'] = {globals = {'bonus_gui_ordering'}} - - files['**/base/prototypes/'] = {std = STD_BASE_DATA} - files['**/core/prototypes/'] = {std = STD_BASE_DATA} - files['**/core/prototypes/noise-programs.lua'] = {ignore = {'212/x', '212/y', '212/tile', '212/map'}} -end - -do -- Stdlib Files - local stdlib_control = { - std = 'lua52+factorio+factorio_control+stdlib+factorio_defines', - max_line_length = LINE_LENGTH - } - - local stdlib_data = { - std = 'lua52+factorio+factorio_data+stdlib+factorio_defines', - max_line_length = LINE_LENGTH - } - - -- Assume control stage for stdlib - files['**/stdlib/'] = stdlib_control - - -- Assume generic lua for stdlib utils - files['**/stdlib/utils/**'].std = 'lua52+stdlib' - - -- STDLIB data stage files - files['**/stdlib/data/'] = stdlib_data - - -- STDLIB Busted Spec - files['**/spec/**'] = { - globals = {'serpent', 'log', 'package.remove_stdlib'}, - std = 'lua52c+busted+factorio_defines+factorio_control+stdlib' - } - - -- Love - files['**/love/'].std = 'luajit+love+love_extra+stdlib+stdlib_data' -end - -do -- Factorio STDs-- - stds.factorio = { - --Set the read only variables - read_globals = { - -- @log@: Gives writing access to Factorio's logger instance. - "log", - -- @serpent@: Lua serializer and pretty printer. (https://github.com/pkulchenko/serpent). - "serpent", - -- @table_size@: Returns the number of elements inside an LUA table. - "table_size", - -- @lldebugger@: Provided by lua local debugger vscode extension. - lldebugger = { - fields = {'requestBreak'}, - other_fields = true, - read_only = false, - }, - -- @__DebugAdapter@: Provided by Factorio Mod Debug vscode extension. - __DebugAdapter = { - fields = {'print', 'stepIgnoreAll', 'stepIgnore', 'breakpoint'}, - other_fields = true, - read_only = false - }, - util = { - fields = { - "by_pixel", "distance", "findfirstentity", "positiontostr", "formattime", "moveposition", "oppositedirection", - "ismoduleavailable", "multiplystripes", "format_number", "increment", "color", "conditional_return", - "add_shift", "merge", "premul_color", "encode", "decode", "insert_safe", - table = { - fields = { - "compare", "deepcopy" - }, - }, - }, - }, - table = { - fields = { - "compare", "deepcopy" - }, - }, - }, - } - - stds.factorio_control = { - read_globals = { - - -- @commands@: - commands = { - fields = { - "add_command", "commands", "game_commands", "remove_command" - }, - }, - - -- @settings@: - settings = { - fields = { - "get_player_settings", - startup = {other_fields = true}, - global = {other_fields = true}, - player = {other_fields = true}, - get = {read_only = false}, -- stdlib added - get_startup = {read_only = false} -- stdlib added - }, - }, - - -- @script@: Provides an interface for registering event handlers. - -- (http://lua-api.factorio.com/latest/LuaBootstrap.html) - script = { - fields = { - "on_event", "on_nth_tick", "on_configuration_changed", "on_init", "on_load", "generate_event_name", - "raise_event", "get_event_handler", "mod_name", "get_event_order", - "is_game_in_debug_mode", "object_name", "set_event_filter", "get_event_filter", "register_metatable", - active_mods = {read_only = true, other_fields = true}, - }, - other_fields = false, - }, - - -- @remote@: Allows inter-mod communication by providing a repository of interfaces that is shared by all mods. - -- (http://lua-api.factorio.com/latest/LuaRemote.html) - remote = { - fields = { - interfaces = {read_only = false, other_fields = true}, - "add_interface", "remove_interface", "call" - }, - read_only = true, - other_fields = false, - }, - - rcon = { - fields = {'print'} - }, - - rendering = { - other_fields = false, - read_only = true, - fields = { - 'draw_animation', - 'draw_line', - 'draw_text', - 'draw_circle', - 'draw_rectangle', - 'draw_arc', - 'draw_polygon', - 'draw_sprite', - 'draw_light', - 'destroy', - 'is_font_valid', - 'is_valid', - 'get_all_ids', - 'clear', - 'get_type', - 'get_surface', - 'get_time_to_live', - 'set_time_to_live', - 'get_forces', - 'set_forces', - 'get_players', - 'set_players', - 'get_color', - 'set_color', - 'get_width', - 'set_width', - 'get_from', - 'set_from', - 'get_to', - 'set_to', - 'get_gap_amount', - 'set_gap_amount', - 'get_gap_length', - 'set_gap_length', - 'get_target', - 'set_target', - 'get_orientation', - 'set_orientation', - 'get_scale', - 'set_scale', - 'get_text', - 'set_text', - 'get_font', - 'set_font', - 'get_alignment', - 'set_alignment', - 'get_scale_with_zoom', - 'set_scale_with_zoom', - 'get_filled', - 'set_filled', - 'get_radius', - 'set_radius', - 'get_left_top', - 'set_left_top', - 'get_right_bottom', - 'set_right_bottom', - 'get_max_radius', - 'set_max_radius', - 'get_min_radius', - 'set_min_radius', - 'get_start_angle', - 'set_start_angle', - 'get_angle', - 'set_angle', - 'get_vertices', - 'set_vertices', - 'get_sprite', - 'set_sprite', - 'get_x_scale', - 'set_x_scale', - 'get_y_scale', - 'set_y_scale', - 'get_render_layer', - 'set_render_layer', - 'get_orientation_target', - 'set_orientation_target', - 'get_oriented_offset', - 'set_oriented_offset', - 'get_intensity', - 'set_intensity', - 'get_minimum_darkness', - 'set_minimum_darkness' - } - }, - - -- @game@: Main object through which most of the API is accessed. - -- It is, however, not available inside handlers registered with @script.on_load@. - -- (http://lua-api.factorio.com/latest/LuaGameScript.html) - game ={ - other_fields = false, - read_only = false, - fields = { - 'auto_save', - 'ban_player', - 'check_consistency', - 'check_prototype_translations', - 'count_pipe_groups', - 'create_force', - 'create_profiler', - 'create_random_generator', - 'create_surface', - 'decode_string', - 'delete_surface', - 'direction_to_string', - 'disable_replay', - 'disable_tips_and_tricks', - 'disable_tutorial_triggers', - 'encode_string', - 'evaluate_expression', - 'force_crc', - 'get_active_entities_count', - 'get_entity_by_tag', - 'get_filtered_achievement_prototypes', - 'get_filtered_decorative_prototypes', - 'get_filtered_entity_prototypes', - 'get_filtered_equipment_prototypes', - 'get_filtered_fluid_prototypes', - 'get_filtered_item_prototypes', - 'get_filtered_mod_setting_prototypes', - 'get_filtered_recipe_prototypes', - 'get_filtered_technology_prototypes', - 'get_filtered_tile_prototypes', - 'get_map_exchange_string', - 'get_player', - 'get_surface', - 'get_train_stops', - 'help', - 'is_demo', - 'is_multiplayer', - 'is_valid_sound_path', - 'is_valid_sprite_path', - 'json_to_table', - 'kick_player', - 'log_recipe_locale', - 'merge_forces', - 'mute_player', - 'parse_map_exchange_string', - 'play_sound', - 'print', - 'print_stack_size', - 'purge_player', - 'regenerate_entity', - 'reload_mods', - 'reload_script', - 'remove_offline_players', - 'remove_path', - 'reset_time_played', - 'save_atlas', - 'server_save', - 'set_game_state', - 'set_wait_for_screenshots_to_finish', - 'show_message_dialog', - 'table_to_json', - 'take_screenshot', - 'take_technology_screenshot', - 'unban_player', - 'unmute_player', - 'write_file', - - achievement_prototypes = {read_only = true, other_fields = true}, - active_mods = {read_only = true, other_fields = true}, - ammo_category_prototypes = {read_only = true, other_fields = true}, - autoplace_control_prototypes = {read_only = true, other_fields = true}, - backer_names = {read_only = true, other_fields = true}, - connected_players = {read_only = true, other_fields = true}, - custom_input_prototypes = {read_only = true, other_fields = true}, - damage_prototypes = {read_only = true, other_fields = true}, - decorative_prototypes = {read_only = true, other_fields = true}, - default_map_gen_settings = {read_only = true, other_fields = true}, - difficulty = {read_only = true, other_fields = true}, - difficulty_settings = {read_only = true, other_fields = true}, - entity_prototypes = {read_only = true, other_fields = true}, - equipment_category_prototypes = {read_only = true, other_fields = true}, - equipment_grid_prototypes = {read_only = true, other_fields = true}, - equipment_prototypes = {read_only = true, other_fields = true}, - finished = {read_only = true, other_fields = true}, - fluid_prototypes = {read_only = true, other_fields = true}, - forces = {read_only = true, other_fields = true}, - fuel_category_prototypes = {read_only = true, other_fields = true}, - item_group_prototypes = {read_only = true, other_fields = true}, - item_prototypes = {read_only = true, other_fields = true}, - item_subgroup_prototypes = {read_only = true, other_fields = true}, - map_settings = {read_only = true, other_fields = true}, - mod_setting_prototypes = {read_only = true, other_fields = true}, - module_category_prototypes = {read_only = true, other_fields = true}, - named_noise_expressions = {read_only = true, other_fields = true}, - noise_layer_prototypes = {read_only = true, other_fields = true}, - object_name = {read_only = true, other_fields = true}, - particle_prototypes = {read_only = true, other_fields = true}, - permissions = {read_only = true, other_fields = true}, - player = {read_only = true, other_fields = true}, - players = {read_only = true, other_fields = true}, - pollution_statistics = {read_only = true, other_fields = true}, - recipe_category_prototypes = {read_only = true, other_fields = true}, - recipe_prototypes = {read_only = true, other_fields = true}, - resource_category_prototypes = {read_only = true, other_fields = true}, - shortcut_prototypes = {read_only = true, other_fields = true}, - styles = {read_only = true, other_fields = true}, - surfaces = {read_only = true, other_fields = true}, - technology_prototypes = {read_only = true, other_fields = true}, - tick = {read_only = true, other_fields = true}, - ticks_played = {read_only = true, other_fields = true}, - tile_prototypes = {read_only = true, other_fields = true}, - trivial_smoke_prototypes = {read_only = true, other_fields = true}, - virtual_signal_prototypes = {read_only = true, other_fields = true}, - - autosave_enabled = {read_only = false, other_fields = false}, - draw_resource_selection = {read_only = false, other_fields = false}, - enemy_has_vision_on_land_mines = {read_only = false, other_fields = false}, - speed = {read_only = false, other_fields = false}, - tick_paused = {read_only = false, other_fields = false}, - ticks_to_run = {read_only = false, other_fields = false} - }, - }, - }, - - globals = { - -- @global@: The global dictionary, useful for storing data persistent across a save-load cycle. - -- Writing access is given to the mod-id field (for mod-wise saved data). - -- (http://lua-api.factorio.com/latest/Global.html) - "global", - - -- @MOD@: Keep it organized, use this variable for anything that "NEEDS" to be global for some reason. - "MOD" - }, - } - - stds.factorio_data = { - - read_globals = { - data = { - fields = { - raw = { - other_fields = true, - read_only = false - }, - "extend", "is_demo" - }, - }, - - settings = { - fields = { - "startup", "global", "player", "get", "get_startup" - }, - }, - - mods = { - other_fields = true - }, - - -- Popular mods that think they need globals! - angelsmods = { - other_fields = true - }, - - -- Popular mods that think they need globals! - bobmods = { - other_fields = true - }, - - - } - } -end - -do -- Factorio Base/Core STDs-- - stds.factorio_base_control = { - read_globals = {"silo_script", "mod_gui", "camera"} - } - - stds.factorio_base_scenarios = { - globals = { - "check_automate_science_packs_advice", "check_research_hints", "check_supplies", "manage_attacks", "all_dead", - "on_win", "difficulty_number", "init_attack_data", "handle_attacks", "count_items_in_container", "progress", "scanned", - "check_light", "check_machine_gun", "level", "story_table", - - "tightspot_prices", "tightspot_make_offer", "tightspot_init", "tightspot_get_required_balance", - "tightspot_init_level", "tightspot_init_spending_frame", "tightspot_init_progress_frame", "tightspot_update_progress", "tightspot_update_spending", - "tightspot_get_missing_to_win", "tightspot_sell_back", "tightspot_start_level", "tightspot_show_level_description", "tightspot_update_speed_label", - "map_ignore", "tightspot_check_level", "land_price", - - "transport_belt_madness_init", "transport_belt_madness_init_level", "transport_belt_madness_create_chests", "transport_belt_madness_fill_chests", - "transport_belt_madness_start_level", "map_ignore", "map_clear", "map_load", "map_save", "transport_belt_madness_show_level_description", - "transport_belt_madness_check_level", "transport_belt_madness_next_level", "transport_belt_madness_clear_level", "transport_belt_madness_contains_next_level", - - "restricted", "check_built_items", "result", "disable_combat_technologies", "apply_character_modifiers", "apply_combat_modifiers", "apply_balance", - "load_config", "starting_area_constant", "create_next_surface", "end_round", "prepare_next_round", "silo_died","choose_joining_gui", - "destroy_joining_guis", "create_random_join_gui", "create_auto_assign_gui", "create_pick_join_gui", "create_config_gui", "make_config_table", "default", - "make_team_gui", "make_team_gui_config", "add_team_button_press", "trash_team_button_press", "remove_team_from_team_table", "add_team_to_team_table", - "set_teams_from_gui", "on_team_button_press", "make_color_dropdown", "create_balance_option", "create_disable_frame", "disable_frame", "parse_disabled_items", - "set_balance_settings", "config_confirm", "parse_config_from_gui", "get_color", "roll_starting_area", "delete_roll_surfaces", "auto_assign", - "destroy_config_for_all", "prepare_map", "set_evolution_factor", "update_players_on_team_count", "random_join", "init_player_gui", - "destroy_player_gui", "objective_button_press", "admin_button_press", "admin_frame_button_press", "diplomacy_button_press", "update_diplomacy_frame", - "diplomacy_frame_button_press", "team_changed_diplomacy", "diplomacy_check_press", "get_stance", "give_inventory", "setup_teams", "disable_items_for_all", - "set_random_team", "set_diplomacy", "create_spawn_positions", "set_spawn_position", "set_team_together_spawns", "chart_starting_area_for_force_spawns", - "check_starting_area_chunks_are_generated", "check_player_color", "check_round_start", "clear_starting_area_enemies", "check_no_rush_end", "check_no_rush_players", - "finish_setup", "chart_area_for_force", "setup_start_area_copy", "update_copy_progress", "update_progress_bar", "copy_paste_starting_area_tiles", - "copy_paste_starting_area_entities", "create_silo_for_force", "setup_research", "on_chunk_generated", "get_distance_to_nearest_spawn", - "create_wall_for_force", "fpn", "give_items", "create_item_frame", "create_technologies_frame", "create_cheat_frame", "create_day_frame", - "time_modifier", "points_per_second_start", "points_per_second_level_subtract", "levels", "update_info", "get_time_left", "update_time_left", - "on_joined", "make_frame", "update_frame", "update_table", "calculate_task_item_multiplayer", "setup_config", "select_from_probability_table", - "select_inventory", "select_equipment", "select_challange_type", "save_round_statistics", "start_challenge", "create_teams", "set_areas", - "decide_player_team", "set_teams", "refresh_leaderboard", "set_player", "generate_technology_list", "generate_research_task","setup_unlocks", - "check_technology_progress", "generate_production_task", "generate_shopping_list_task", "set_gui_flow_table", "create_visibility_button", - "check_item_lists", "update_task_gui", "check_end_of_round", "end_round_gui_update", "try_to_check_victory", "update_gui", "check_start_round", - "check_start_set_areas", "check_start_setting_entities", "check_set_areas", "check_clear_areas", "check_chests", "check_chests_shopping_list", - "check_chests_production", "check_input_chests", "fill_input_chests", "check_victory", "shopping_task_finished", "calculate_force_points", - "update_research_task_table", "update_production_task_table", "update_shopping_list_task_table", "create_joined_game_gui", "pre_ending_round", - "player_ending_prompt", "update_end_timer", "update_begin_timer", "team_finished", "save_points_list", "give_force_players_points", - "update_winners_list", "set_spectator", "set_character", "give_starting_inventory", "give_equipment", "shuffle_table", "format_time", - "spairs", "fill_leaderboard", "create_grid", "simple_entities", "save_map_data", "clear_map", "create_tiles", "recreate_entities", - "map_sets", "give_points", "init_forces", "init_globals", "init_unit_settings", "check_next_wave", "next_wave", "calculate_wave_power", - "wave_end", "make_next_spawn_tick", "check_spawn_units", "get_wave_units", "spawn_units", "randomize_ore", "set_command", "command_straglers", - "unit_config", "make_next_wave_tick", "time_to_next_wave", "time_to_wave_end", "rocket_died", "unit_died", "get_bounty_price", "setup_waypoints", - "insert_items", "give_starting_equipment", "give_spawn_equipment", "next_round_button_visible", "gui_init", "create_wave_frame", "create_money_frame", - "create_upgrade_gui", "update_upgrade_listing", "upgrade_research", "get_upgrades", "get_money", "update_connected_players", "update_round_number", - "set_research", "set_recipes", "check_deconstruction", "check_blueprint_placement", "loop_entities", "experiment_items", - "setup", "story_gui_click", "clear_surface", "add_run_trains_button", "puzzle_condition", "basic_signals", - "loop_trains", "Y_offset", "ghosts_1", "ghosts_2", "required_path", "through_wall_path", "count", "check_built_real_rail", - "current_ghosts_count", "other", "rails", "set_rails", "straight_section", "late_entities", "entities", "stop", - "get_spawn_coordinate", - - --tutorials - "intermission", "create_entities_on_tick", "on_player_created", "required_count", "non_player_entities", "clear_rails", - "chest", "damage", "furnace", "init_prototypes", "build_infi_table", "junk", "update_player_tags", "time_left", "team_production", - "create_task_frame", "create_visibilty_buttons", "update_leaderboard", "in_in_area" - } - } - - stds.factorio_base_data = { - globals = { - --Style - "make_cursor_box", "make_full_cursor_box", - "default_container_padding", "default_orange_color", "default_light_orange_color", "warning_red_color", - "achievement_green_color", "achievement_tan_color", "orangebuttongraphcialset", "bluebuttongraphcialset", - "bonus_gui_ordering", "trivial_smoke", "technology_slot_base_width", "technology_slot_base_height", "default_frame_font_vertical_compensation", - - --Belts - "transport_belt_connector_frame_sprites", "transport_belt_circuit_wire_connection_point", "transport_belt_circuit_wire_max_distance", - "transport_belt_circuit_connector_sprites", "ending_patch_prototype", "basic_belt_horizontal", "basic_belt_vertical", - "basic_belt_ending_top", "basic_belt_ending_bottom", "basic_belt_ending_side", "basic_belt_starting_top", "basic_belt_starting_bottom", - "basic_belt_starting_side", "fast_belt_horizontal", "fast_belt_vertical", "fast_belt_ending_top", "fast_belt_ending_bottom", - "fast_belt_ending_side", "fast_belt_starting_top", "fast_belt_starting_bottom", "fast_belt_starting_side", "express_belt_horizontal", - "express_belt_vertical", "express_belt_ending_top", "express_belt_ending_bottom", "express_belt_ending_side", "express_belt_starting_top", - "express_belt_starting_bottom", "express_belt_starting_side", - - --Circuit Connectors - "circuit_connector_definitions", "default_circuit_wire_max_distance", "inserter_circuit_wire_max_distance", - "universal_connector_template", "belt_connector_template", "belt_frame_connector_template", "inserter_connector_template", - - --Inserter Circuit Connectors - "inserter_circuit_wire_max_distance", "inserter_default_stack_control_input_signal", - - --Sounds/beams - "make_heavy_gunshot_sounds", "make_light_gunshot_sounds", "make_laser_sounds", - - --Gun/Laser - "gun_turret_extension", "gun_turret_extension_shadow", "gun_turret_extension_mask", "gun_turret_attack", - "laser_turret_extension", "laser_turret_extension_shadow", "laser_turret_extension_mask", - - --Pipes - "pipecoverspictures", "pipepictures", "assembler2pipepictures", "assembler3pipepictures", "make_heat_pipe_pictures", - - --Combinators - "generate_arithmetic_combinator", "generate_decider_combinator", "generate_constant_combinator", - - --Rail - "destroyed_rail_pictures", "rail_pictures", "rail_pictures_internal", "standard_train_wheels", "drive_over_tie", - "rolling_stock_back_light", "rolling_stock_stand_by_light", - - --Enemies - "make_enemy_autoplace", "make_enemy_spawner_autoplace", "make_enemy_worm_autoplace", - "make_spitter_attack_animation", "make_spitter_run_animation", "make_spitter_dying_animation", - "make_spitter_attack_parameters", "make_spitter_roars", "make_spitter_dying_sounds", - "make_spawner_idle_animation", "make_spawner_die_animation", - "make_biter_run_animation", "make_biter_attack_animation", "make_biter_die_animation", - "make_biter_roars", "make_biter_dying_sounds", "make_biter_calls", - "make_worm_roars", "make_worm_dying_sounds", "make_worm_folded_animation", "make_worm_preparing_animation", - "make_worm_prepared_animation", "make_worm_attack_animation", "make_worm_die_animation", - - --Other - "tile_variations_template", "make_water_autoplace_settings", - "make_unit_melee_ammo_type", "make_trivial_smoke", "make_4way_animation_from_spritesheet", "flying_robot_sounds", - "productivitymodulelimitation", "crash_trigger", "capsule_smoke", "make_beam", "playeranimations", - "make_blood_tint", "make_shadow_tint", - - --tiles - "water_transition_template", "make_water_transition_template", "water_autoplace_settings", "water_tile_type_names", - "patch_for_inner_corner_of_transition_between_transition", - } - } - - stds.factorio_base_story = { - globals = { - "story_init_helpers", "story_update_table", "story_init", "story_update", "story_on_tick", "story_add_update", - "story_remove_update", "story_jump_to", "story_elapsed", "story_elapsed_check", "story_show_message_dialog", - "set_goal", "player_set_goal", "on_player_joined", "flash_goal", "set_info", "player_set_info", "export_entities", - "list", "recreate_entities", "entity_to_connect", "limit_camera", "find_gui_recursive", "enable_entity_export", - "add_button", "on_gui_click", "set_continue_button_style", "add_message_log", "story_add_message_log", - "player_add_message_log", "message_log_frame", "message_log_scrollpane", "message_log_close_button", - "message_log_table", "toggle_message_log_button", "toggle_objective_button", "message_log_init", - "add_gui_recursive", "add_toggle_message_log_button", "add_toggle_objective_button", "mod_gui", - "flash_message_log_button", "flash_message_log_on_tick", "story_gui_click", "story_points_by_name", "story_branches", - "player", "surface", "deconstruct_on_tick", "recreate_entities_on_tick", "flying_congrats", "story_table" - } - } - - stds.factorio_circuit_connector_generated = { - globals = { - 'default_circuit_wire_max_distance', 'circuit_connector_definitions', 'universal_connector_template', - 'belt_connector_template', 'belt_frame_connector_template', 'inserter_connector_template', 'inserter_connector_template', - 'inserter_circuit_wire_max_distance', 'inserter_default_stack_control_input_signal', 'transport_belt_connector_frame_sprites', - 'transport_belt_circuit_wire_max_distance', - } - } -end - -do -- STDLIB STDs-- - stds.stdlib = { - read_globals = {}, - globals = { - 'STDLIB', - 'prequire', - 'rawtostring', - 'traceback', - 'data_traceback', - 'inspect', - 'serpent', - 'inline_if', - 'install', - 'log', - 'concat', - 'GAME', - 'AREA', - 'POSITION', - 'TILE', - 'SURFACE', - 'CHUNK', - 'COLOR', - 'ENTITY', - 'INVENTORY', - 'RESOURCE', - 'CONFIG', - 'LOGGER', - 'QUEUE', - 'EVENT', - 'GUI', - 'PLAYER', - 'FORCE', - 'MATH', - 'STRING', - 'TABLE' - } - } - - stds.stdlib_control = {} - - stds.stdlib_data = { - globals = { - 'DATA', - 'RECIPE', - 'ITEM', - 'FLUID', - 'ENTITY', - 'TECHNOLOGY', - 'CATEGORY' - } - } -end - -do -- Love STDs - stds.love_extra = { - read_globals = { - love = { - fields = { - arg = { - fields = { - 'parseGameArguments', - 'parseOption', - 'getLow', - 'optionIndices', - 'options' - } - } - } - }, - 'coroutine', - 'io', - 'socket', - 'dofile', - 'loadfile' - }, - globals = { - love = { - fields = { - 'handlers' - } - } - } - } -end - -do -- Factorio Defines STDs-- - stds.factorio_defines = { - read_globals = { - defines = { - fields = { - alert_type = { - fields = { - 'entity_destroyed', - 'entity_under_attack', - 'not_enough_construction_robots', - 'no_material_for_construction', - 'not_enough_repair_packs', - 'turret_fire', - 'custom', - 'no_storage', - 'train_out_of_fuel', - 'fluid_mixing' - } - }, - behavior_result = { - fields = { - 'in_progress', - 'fail', - 'success', - 'deleted' - } - }, - build_check_type = { - fields = { - 'script', - 'manual', - 'ghost_place', - 'ghost_revive' - } - }, - chain_signal_state = { - fields = { - 'all_open', - 'partially_open', - 'none_open' - } - }, - chunk_generated_status = { - fields = { - 'nothing', - 'custom_tiles', - 'basic_tiles', - 'corrected_tiles', - 'tiles', - 'entities' - } - }, - circuit_condition_index = { - fields = { - 'inserter_circuit', - 'inserter_logistic', - 'lamp', - 'arithmetic_combinator', - 'decider_combinator', - 'constant_combinator', - 'offshore_pump', - 'pump' - } - }, - circuit_connector_id = { - fields = { - 'accumulator', - 'constant_combinator', - 'container', - 'programmable_speaker', - 'rail_signal', - 'rail_chain_signal', - 'roboport', - 'storage_tank', - 'wall', - 'electric_pole', - 'inserter', - 'lamp', - 'combinator_input', - 'combinator_output', - 'offshore_pump', - 'pump' - } - }, - command = { - fields = { - 'attack', - 'go_to_location', - 'compound', - 'group', - 'attack_area', - 'wander', - 'flee', - 'stop', - 'build_base' - } - }, - compound_command = { - fields = { - 'logical_and', - 'logical_or', - 'return_last' - } - }, - control_behavior = { - fields = { - inserter = { - fields = { - circuit_mode_of_operation = { - fields = { - 'enable_disable', - 'set_filters', - 'read_hand_contents', - 'set_stack_size' - } - }, - hand_read_mode = { - fields = { - 'hold', - 'pulse' - } - } - } - }, - lamp = { - fields = { - circuit_mode_of_operation = { - fields = { - 'use_colors' - } - } - } - }, - logistic_container = { - fields = { - circuit_mode_of_operation = { - fields = { - 'send_contents', - 'set_requests' - } - } - } - }, - mining_drill = { - fields = { - resource_read_mode = { - fields = { - 'this_miner', - 'entire_patch' - } - } - } - }, - roboport = { - fields = { - circuit_mode_of_operation = { - fields = { - 'read_logistics', - 'read_robot_stats' - } - } - } - }, - train_stop = { - fields = { - circuit_mode_of_operation = { - fields = { - 'enable_disable', - 'send_to_train', - 'read_from_train', - 'read_stopped_train' - } - } - } - }, - transport_belt = { - fields = { - content_read_mode = { - fields = { - 'pulse', - 'hold' - } - } - } - }, - type = { - fields = { - 'container', - 'generic_on_off', - 'inserter', - 'lamp', - 'logistic_container', - 'roboport', - 'storage_tank', - 'train_stop', - 'decider_combinator', - 'arithmetic_combinator', - 'constant_combinator', - 'transport_belt', - 'accumulator', - 'rail_signal', - 'rail_chain_signal', - 'wall', - 'mining_drill', - 'programmable_speaker' - } - } - } - }, - controllers = { - fields = { - 'ghost', - 'character', - 'god', - 'editor', - 'cutscene', - 'spectator' - } - }, - deconstruction_item = { - fields = { - entity_filter_mode = { - fields = { - 'whitelist', - 'blacklist' - } - }, - tile_filter_mode = { - fields = { - 'whitelist', - 'blacklist' - } - }, - tile_selection_mode = { - fields = { - 'normal', - 'always', - 'never', - 'only' - } - } - } - }, - difficulty = { - fields = { - 'easy', - 'normal', - 'hard' - } - }, - difficulty_settings = { - fields = { - recipe_difficulty = { - fields = { - 'normal', - 'expensive' - } - }, - technology_difficulty = { - fields = { - 'normal', - 'expensive' - } - } - } - }, - direction = { - fields = { - 'north', - 'northeast', - 'east', - 'southeast', - 'south', - 'southwest', - 'west', - 'northwest' - } - }, - distraction = { - fields = { - 'by_enemy', - 'by_anything', - 'by_damage' - } - }, - entity_status = { - fields = { - 'working', - 'no_power', - 'no_fuel', - 'no_recipe', - 'no_input_fluid', - 'no_research_in_progress', - 'no_minable_resources', - 'low_input_fluid', - 'low_power', - 'disabled_by_control_behavior', - 'disabled_by_script', - 'fluid_ingredient_shortage', - 'fluid_production_overload', - 'item_ingredient_shortage', - 'item_production_overload', - 'marked_for_deconstruction', - 'missing_required_fluid', - 'missing_science_packs', - 'waiting_for_source_items', - 'waiting_for_space_in_destination', - 'waiting_to_launch_rocket' - } - }, - events = { - fields = { - 'on_tick', - 'on_gui_click', - 'on_gui_confirmed', - 'on_gui_text_changed', - 'on_gui_checked_state_changed', - 'on_entity_died', - 'on_post_entity_died', - 'on_entity_damaged', - 'on_picked_up_item', - 'on_built_entity', - 'on_sector_scanned', - 'on_player_mined_item', - 'on_put_item', - 'on_rocket_launched', - 'on_pre_player_mined_item', - 'on_chunk_generated', - 'on_player_crafted_item', - 'on_robot_built_entity', - 'on_robot_pre_mined', - 'on_robot_mined', - 'on_research_started', - 'on_research_finished', - 'on_player_rotated_entity', - 'on_player_set_quickbar_slot', - 'on_marked_for_deconstruction', - 'on_cancelled_deconstruction', - 'on_trigger_created_entity', - 'on_trigger_fired_artillery', - 'on_train_changed_state', - 'on_player_created', - 'on_resource_depleted', - 'on_player_driving_changed_state', - 'on_force_created', - 'on_forces_merging', - 'on_player_cursor_stack_changed', - 'on_pre_entity_settings_pasted', - 'on_entity_settings_pasted', - 'on_player_main_inventory_changed', - 'on_player_armor_inventory_changed', - 'on_player_ammo_inventory_changed', - 'on_player_gun_inventory_changed', - 'on_player_placed_equipment', - 'on_player_removed_equipment', - 'on_pre_player_died', - 'on_player_died', - 'on_player_respawned', - 'on_player_joined_game', - 'on_player_left_game', - 'on_player_built_tile', - 'on_player_mined_tile', - 'on_robot_built_tile', - 'on_robot_mined_tile', - 'on_player_selected_area', - 'on_player_alt_selected_area', - 'on_player_changed_surface', - 'on_selected_entity_changed', - 'on_market_item_purchased', - 'on_player_dropped_item', - 'on_biter_base_built', - 'on_player_changed_force', - 'on_entity_renamed', - 'on_gui_selection_state_changed', - 'on_runtime_mod_setting_changed', - 'on_difficulty_settings_changed', - 'on_surface_created', - 'on_surface_deleted', - 'on_pre_surface_deleted', - 'on_player_mined_entity', - 'on_robot_mined_entity', - 'on_train_created', - 'on_gui_elem_changed', - 'on_player_setup_blueprint', - 'on_player_deconstructed_area', - 'on_player_configured_blueprint', - 'on_console_chat', - 'on_console_command', - 'on_player_removed', - 'on_pre_player_removed', - 'on_player_used_capsule', - 'script_raised_built', - 'script_raised_destroy', - 'script_raised_revive', - 'on_player_promoted', - 'on_player_demoted', - 'on_combat_robot_expired', - 'on_player_changed_position', - 'on_mod_item_opened', - 'on_gui_opened', - 'on_gui_closed', - 'on_gui_value_changed', - 'on_player_muted', - 'on_player_unmuted', - 'on_player_cheat_mode_enabled', - 'on_player_cheat_mode_disabled', - 'on_character_corpse_expired', - 'on_pre_ghost_deconstructed', - 'on_player_pipette', - 'on_player_display_resolution_changed', - 'on_player_display_scale_changed', - 'on_pre_player_crafted_item', - 'on_player_cancelled_crafting', - 'on_chunk_charted', - 'on_technology_effects_reset', - 'on_land_mine_armed', - 'on_forces_merged', - 'on_player_trash_inventory_changed', - 'on_pre_player_left_game', - 'on_pre_surface_cleared', - 'on_surface_cleared', - 'on_chunk_deleted', - 'on_pre_chunk_deleted', - 'on_train_schedule_changed', - 'on_player_banned', - 'on_player_kicked', - 'on_player_unbanned', - 'on_rocket_launch_ordered', - 'on_script_path_request_finished', - 'on_ai_command_completed', - 'on_marked_for_upgrade', - 'on_cancelled_upgrade', - 'on_player_toggled_map_editor', - 'on_entity_cloned', - 'on_area_cloned', - 'on_brush_cloned', - 'on_game_created_from_scenario', - 'on_surface_imported', - 'on_surface_renamed', - 'on_player_toggled_alt_mode', - 'on_player_repaired_entity', - 'on_player_fast_transferred', - 'on_pre_robot_exploded_cliff', - 'on_robot_exploded_cliff', - 'on_entity_spawned', - 'on_cutscene_waypoint_reached', - 'on_unit_group_created', - 'on_unit_added_to_group', - 'on_unit_removed_from_group', - 'on_unit_group_finished_gathering', - 'on_build_base_arrived', - 'on_chart_tag_added', - 'on_chart_tag_modified', - 'on_chart_tag_removed', - 'on_lua_shortcut', - 'on_gui_location_changed', - 'on_gui_selected_tab_changed', - 'on_gui_switch_state_changed', - 'on_force_cease_fire_changed', - 'on_force_friends_changed', - 'on_string_translated', - 'on_script_trigger_effect' - } - }, - flow_precision_index = { - fields = { - 'one_second', - 'one_minute', - 'ten_minutes', - 'one_hour', - 'ten_hours', - 'fifty_hours', - 'two_hundred_fifty_hours', - 'one_thousand_hours' - } - }, - group_state = { - fields = { - 'gathering', - 'moving', - 'attacking_distraction', - 'attacking_target', - 'finished', - 'pathfinding', - 'wander_in_group' - } - }, - gui_type = { - fields = { - 'entity', - 'research', - 'controller', - 'production', - 'item', - 'bonus', - 'trains', - 'achievement', - 'blueprint_library', - 'equipment', - 'logistic', - 'other_player', - 'kills', - 'permissions', - 'tutorials', - 'custom', - 'server_management', - 'player_management', - 'tile' - } - }, - input_action = { - fields = { - 'activate_copy', - 'activate_cut', - 'activate_paste', - 'add_permission_group', - 'add_train_station', - 'admin_action', - 'alt_select_area', - 'alt_select_blueprint_entities', - 'alternative_copy', - 'begin_mining', - 'begin_mining_terrain', - 'build_item', - 'build_rail', - 'build_terrain', - 'cancel_craft', - 'cancel_deconstruct', - 'cancel_new_blueprint', - 'cancel_research', - 'cancel_upgrade', - 'change_active_item_group_for_crafting', - 'change_active_item_group_for_filters', - 'change_active_quick_bar', - 'change_arithmetic_combinator_parameters', - 'change_blueprint_book_record_label', - 'change_decider_combinator_parameters', - 'change_item_label', - 'change_multiplayer_config', - 'change_picking_state', - 'change_programmable_speaker_alert_parameters', - 'change_programmable_speaker_circuit_parameters', - 'change_programmable_speaker_parameters', - 'change_riding_state', - 'change_shooting_state', - 'change_single_blueprint_record_label', - 'change_train_stop_station', - 'change_train_wait_condition', - 'change_train_wait_condition_data', - 'clean_cursor_stack', - 'clear_selected_blueprint', - 'clear_selected_deconstruction_item', - 'clear_selected_upgrade_item', - 'connect_rolling_stock', - 'copy', - 'copy_entity_settings', - 'craft', - 'create_blueprint_like', - 'cursor_split', - 'cursor_transfer', - 'custom_input', - 'cycle_blueprint_book_backwards', - 'cycle_blueprint_book_forwards', - 'deconstruct', - 'delete_blueprint_library', - 'delete_blueprint_record', - 'delete_custom_tag', - 'delete_permission_group', - 'destroy_opened_item', - 'disconnect_rolling_stock', - 'drag_train_schedule', - 'drag_train_wait_condition', - 'drop_blueprint_record', - 'drop_item', - 'drop_to_blueprint_book', - 'edit_custom_tag', - 'edit_permission_group', - 'export_blueprint', - 'fast_entity_split', - 'fast_entity_transfer', - 'go_to_train_station', - 'grab_blueprint_record', - 'gui_checked_state_changed', - 'gui_click', - 'gui_confirmed', - 'gui_elem_changed', - 'gui_location_changed', - 'gui_selected_tab_changed', - 'gui_selection_state_changed', - 'gui_switch_state_changed', - 'gui_text_changed', - 'gui_value_changed', - 'import_blueprint', - 'import_blueprint_string', - 'import_permissions_string', - 'inventory_split', - 'inventory_transfer', - 'launch_rocket', - 'lua_shortcut', - 'map_editor_action', - 'market_offer', - 'mod_settings_changed', - 'open_achievements_gui', - 'open_blueprint_library_gui', - 'open_blueprint_record', - 'open_bonus_gui', - 'open_character_gui', - 'open_equipment', - 'open_gui', - 'open_item', - 'open_kills_gui', - 'open_logistic_gui', - 'open_mod_item', - 'open_production_gui', - 'open_technology_gui', - 'open_train_gui', - 'open_train_station_gui', - 'open_trains_gui', - 'open_tutorials_gui', - 'paste_entity_settings', - 'place_equipment', - 'quick_bar_pick_slot', - 'quick_bar_set_selected_page', - 'quick_bar_set_slot', - 'remove_cables', - 'remove_train_station', - 'reset_assembling_machine', - 'rotate_entity', - 'select_area', - 'select_blueprint_entities', - 'select_entity_slot', - 'select_item', - 'select_mapper_slot', - 'select_next_valid_gun', - 'select_tile_slot', - 'set_auto_launch_rocket', - 'set_autosort_inventory', - 'set_behavior_mode', - 'set_car_weapons_control', - 'set_circuit_condition', - 'set_circuit_mode_of_operation', - 'set_deconstruction_item_tile_selection_mode', - 'set_deconstruction_item_trees_and_rocks_only', - 'set_entity_color', - 'set_entity_energy_property', - 'set_filter', - 'set_heat_interface_mode', - 'set_heat_interface_temperature', - 'set_infinity_container_filter_item', - 'set_infinity_container_remove_unfiltered_items', - 'set_infinity_pipe_filter', - 'set_inserter_max_stack_size', - 'set_inventory_bar', - 'set_logistic_filter_item', - 'set_logistic_filter_signal', - 'set_logistic_trash_filter_item', - 'set_request_from_buffers', - 'set_research_finished_stops_game', - 'set_signal', - 'set_single_blueprint_record_icon', - 'set_splitter_priority', - 'set_train_stopped', - 'setup_assembling_machine', - 'setup_blueprint', - 'setup_single_blueprint_record', - 'smart_pipette', - 'stack_split', - 'stack_transfer', - 'start_repair', - 'start_research', - 'start_walking', - 'stop_building_by_moving', - 'switch_connect_to_logistic_network', - 'switch_constant_combinator_state', - 'switch_inserter_filter_mode_state', - 'switch_power_switch_state', - 'switch_to_rename_stop_gui', - 'take_equipment', - 'toggle_deconstruction_item_entity_filter_mode', - 'toggle_deconstruction_item_tile_filter_mode', - 'toggle_driving', - 'toggle_enable_vehicle_logistics_while_moving', - 'toggle_equipment_movement_bonus', - 'toggle_map_editor', - 'toggle_personal_roboport', - 'toggle_show_entity_info', - 'translate_string', - 'undo', - 'upgrade', - 'upgrade_opened_blueprint', - 'use_artillery_remote', - 'use_item', - 'wire_dragging', - 'write_to_console' - } - }, - inventory = { - fields = { - 'fuel', - 'burnt_result', - 'chest', - 'furnace_source', - 'furnace_result', - 'furnace_modules', - 'character_main', - 'character_guns', - 'character_ammo', - 'character_armor', - 'character_vehicle', - 'character_trash', - 'god_main', - 'editor_main', - 'editor_guns', - 'editor_ammo', - 'editor_armor', - 'roboport_robot', - 'roboport_material', - 'robot_cargo', - 'robot_repair', - 'assembling_machine_input', - 'assembling_machine_output', - 'assembling_machine_modules', - 'lab_input', - 'lab_modules', - 'mining_drill_modules', - 'item_main', - 'rocket_silo_rocket', - 'rocket_silo_result', - 'rocket', - 'car_trunk', - 'car_ammo', - 'cargo_wagon', - 'turret_ammo', - 'beacon_modules', - 'character_corpse', - 'artillery_turret_ammo', - 'artillery_wagon_ammo' - } - }, - logistic_member_index = { - fields = { - 'logistic_container', - 'vehicle_storage', - 'character_requester', - 'character_storage', - 'character_provider', - 'generic_on_off_behavior' - } - }, - logistic_mode = { - fields = { - 'active_provider', - 'storage', - 'requester', - 'passive_provider', - 'buffer' - } - }, - mouse_button_type = { - fields = { - 'left', - 'right', - 'middle' - } - }, - rail_connection_direction = { - fields = { - 'left', - 'straight', - 'right' - } - }, - rail_direction = { - fields = { - 'front', - 'back' - } - }, - render_mode = { - fields = { - 'game', - 'chart', - 'chart_zoomed_in' - } - }, - rich_text_setting = { - fields = { - 'enabled', - 'disabled', - 'highlight' - } - }, - riding = { - fields = { - acceleration = { - fields = { - 'nothing', - 'accelerating', - 'braking', - 'reversing' - } - }, - direction = { - fields = { - 'left', - 'straight', - 'right' - } - } - } - }, - shooting = { - fields = { - 'not_shooting', - 'shooting_enemies', - 'shooting_selected' - } - }, - signal_state = { - fields = { - 'open', - 'closed', - 'reserved', - 'reserved_by_circuit_network' - } - }, - train_state = { - fields = { - 'on_the_path', - 'path_lost', - 'no_schedule', - 'no_path', - 'arrive_signal', - 'wait_signal', - 'arrive_station', - 'wait_station', - 'manual_control_stop', - 'manual_control' - } - }, - transport_line = { - fields = { - 'left_line', - 'right_line', - 'left_underground_line', - 'right_underground_line', - 'secondary_left_line', - 'secondary_right_line', - 'left_split_line', - 'right_split_line', - 'secondary_left_split_line', - 'secondary_right_split_line' - } - }, - wire_connection_id = { - fields = { - 'electric_pole', - 'power_switch_left', - 'power_switch_right' - } - }, - wire_type = { - fields = { - 'red', - 'green', - 'copper' - } - }, - -- Defines additional modules - color = { - other_fields = true - }, - anticolor = { - other_fields = true - }, - lightcolor = { - other_fields = true - }, - time = { - fields = { - 'second', - 'minute', - 'hour', - 'day', - 'week', - 'month', - 'year' - } - } - } - } - } - } -end - ---[[ Options - "ignore", "std", "globals", "unused_args", "self", "compat", "global", "unused", "redefined", - "unused_secondaries", "allow_defined", "allow_defined_top", "module", - "read_globals", "new_globals", "new_read_globals", "enable", "only", "not_globals", - "max_line_length", "max_code_line_length", "max_string_line_length", "max_comment_line_length", - "max_cyclomatic_complexity" ---]] - ---[[ Warnings list - -- 011 A syntax error. - -- 021 An invalid inline option. - -- 022 An unpaired inline push directive. - -- 023 An unpaired inline pop directive. - -- 111 Setting an undefined global variable. - -- 112 Mutating an undefined global variable. - -- 113 Accessing an undefined global variable. - -- 121 Setting a read-only global variable. - -- 122 Setting a read-only field of a global variable. - -- 131 Unused implicitly defined global variable. - -- 142 Setting an undefined field of a global variable. - -- 143 Accessing an undefined field of a global variable. - -- 211 Unused local variable. - -- 212 Unused argument. - -- 213 Unused loop variable. - -- 221 Local variable is accessed but never set. - -- 231 Local variable is set but never accessed. - -- 232 An argument is set but never accessed. - -- 233 Loop variable is set but never accessed. - -- 241 Local variable is mutated but never accessed. - -- 311 Value assigned to a local variable is unused. - -- 312 Value of an argument is unused. - -- 313 Value of a loop variable is unused. - -- 314 Value of a field in a table literal is unused. - -- 321 Accessing uninitialized local variable. - -- 331 Value assigned to a local variable is mutated but never accessed. - -- 341 Mutating uninitialized local variable. - -- 411 Redefining a local variable. - -- 412 Redefining an argument. - -- 413 Redefining a loop variable. - -- 421 Shadowing a local variable. - -- 422 Shadowing an argument. - -- 423 Shadowing a loop variable. - -- 431 Shadowing an upvalue. - -- 432 Shadowing an upvalue argument. - -- 433 Shadowing an upvalue loop variable. - -- 511 Unreachable code. - -- 512 Loop can be executed at most once. - -- 521 Unused label. - -- 531 Left-hand side of an assignment is too short. - -- 532 Left-hand side of an assignment is too long. - -- 541 An empty do end block. - -- 542 An empty if branch. - -- 551 An empty statement. - -- 611 A line consists of nothing but whitespace. - -- 612 A line contains trailing whitespace. - -- 613 Trailing whitespace in a string. - -- 614 Trailing whitespace in a comment. - -- 621 Inconsistent indentation (SPACE followed by TAB). - -- 631 Line is too long. ---]] diff --git a/packages/create/templates/common/module/control.lua b/packages/create/templates/common/module/control.lua index 914fcfb6..eeb480cf 100644 --- a/packages/create/templates/common/module/control.lua +++ b/packages/create/templates/common/module/control.lua @@ -1,3 +1,14 @@ +--[[ +The 'control' file within factorio is used as the entry point for mods, in Clusterio this is only a convention +You can change which files are loaded (exports event handlers) and required (no events) by Clusterio within module.json +It is highly advised to use FMTK (justarandomgeek.factoriomod-debug) when writing Lua code for factorio +Most of your code will be within this one file, but you can create more and require them + +clusterio_api exposes functions to enable communication with your InstancePlugin +compat exposes shims to simplify compatibly between factorio versions, use the following to require 2.0 +`assert(compat.version_ge("2.0.0"), "__plugin_name__ requires factorio 2.0")` +]] + local clusterio_api = require("modules/clusterio/api") local compat = require("modules/clusterio/compat") @@ -6,10 +17,12 @@ local compat = require("modules/clusterio/compat") local MyModule = { } ---- script_data is 'synced' between players, you should use your plugin name to avoid name conflicts --- The following helper function is desync safe and avoids name conflicts after being called during on_server_startup --- There are edge cases in the following events: pre player left, player left, console command, and config changed --- If you use any of these events and access globalData then make sure to call this function first +--[[ +`compat.script_data` persists between restarts and is shared between modules, therefore you should use your plugin name to avoid conflicts +The following helper function is desync safe and avoids name conflicts after being called during `clusterio_api.events.on_server_startup` +There are edge cases in the following events: pre player left, player left, console command, and config changed +If you use any of these events and access `script_data` then make sure to call this function first +]] local script_data = {} local function setup_script_data() if compat.script_data["__plugin_name__"] == nil then @@ -28,12 +41,13 @@ end --- Public methods should be available though your top level module table function MyModule.foo() - game.print("foo") + game.print("foo: " .. (script_data.counter or 0)) end --- Private methods should be local to the file, this will prevent others from calling it local function bar() - game.print("bar") + script_data.counter = (script_data.counter or 0) + 1 + game.print("bar: " .. script_data.counter) end --- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_init @@ -61,7 +75,7 @@ local function on_nth_tick_300() bar() end ---- Factorio events are accessible through defines.events, you can have one handler per event per module +--- Factorio events are accessible through defines.events, you can have one handler per event per file local e = defines.events local events = { [clusterio_api.events.on_server_startup] = on_server_startup, @@ -73,7 +87,7 @@ local on_nth_tick = { [300] = on_nth_tick_300, } ---- Always return the top level module table from control, this is how clusterio will access your event handlers +--- Always return the top level module table from control, this is how Clusterio will access your event handlers MyModule.events = events --- @package MyModule.on_nth_tick = on_nth_tick --- @package return MyModule diff --git a/packages/create/templates/common/module/globals.lua b/packages/create/templates/common/module/globals.lua index b4d0bea0..8153cb67 100644 --- a/packages/create/templates/common/module/globals.lua +++ b/packages/create/templates/common/module/globals.lua @@ -1,3 +1,9 @@ ---- Expose any globals here, typically just your control file if you want to access it through rcon or commands --- luacheck: globals __plugin_name__ +--[[ +It is best practice to not expose any globals because all modules share a global environment +However, sometimes you need globals, for example to access functions within rcon commands +Therefore, we advise that this should be the only file in your module to expose globals +Typically this would be your control file as shown in the example below +]] + +-- Access using `/sc __plugin_name__.foo()` __plugin_name__ = require("modules/__plugin_name__/control") diff --git a/packages/create/templates/common/module/module_exports.lua b/packages/create/templates/common/module/module_exports.lua index 3642b397..b50a7ea0 100644 --- a/packages/create/templates/common/module/module_exports.lua +++ b/packages/create/templates/common/module/module_exports.lua @@ -1,4 +1,8 @@ ---- Can contain anything you want to allow other plugins to have access to, this example exposes the control file ---- If you only export a single file then it may be better to rename that file to module_exports and save the redirection --- Access the exports from other modules using require("modules/__plugin_name__") +--[[ +It is typical for a module to have a control file as the entry point, although this is not a requirement +Therefore, Clusterio allows you to specify a single default export that can be accessed by other modules +You are not required to specify any default export, but note that a module can still require yours directly +]] + +-- Access using `local __plugin_name__ = require("modules/__plugin_name__")` return require("modules/__plugin_name__/control") diff --git a/packages/create/templates/common/package.json b/packages/create/templates/common/package.json index ada0a72b..0e01b9d7 100644 --- a/packages/create/templates/common/package.json +++ b/packages/create/templates/common/package.json @@ -12,10 +12,22 @@ "prepare": "__prepare__" }, "engines": { - "node": ">=18" + "node": "__node_version__" }, "peerDependencies": { - "@clusterio/lib": "workspace:*" +//%if controller + "@clusterio/controller": "^__clusterio_version__", +//%endif +//%if webpack + "@clusterio/web_ui": "^__clusterio_version__", +//%endif +//%if host + "@clusterio/host": "^__clusterio_version__", +//%endif +//%if ctl + "@clusterio/ctl": "^__clusterio_version__", +//%endif + "@clusterio/lib": "^__clusterio_version__" }, "devDependencies": { //%// Typescript and type declarations @@ -32,13 +44,24 @@ "react": "^18.2.0", "react-dom": "^18.2.0", //%endif -//%if web | controller // The controller uses webpack even when there is no plugin code +//%if webpack "webpack": "^5.88.2", "webpack-cli": "^5.1.4", "webpack-merge": "^5.9.0", - "@clusterio/web_ui": "workspace:*", //%endif - "@clusterio/lib": "workspace:*" +//%if controller + "@clusterio/controller": "^__clusterio_version__", +//%endif +//%if webpack + "@clusterio/web_ui": "^__clusterio_version__", +//%endif +//%if host + "@clusterio/host": "^__clusterio_version__", +//%endif +//%if ctl + "@clusterio/ctl": "^__clusterio_version__", +//%endif + "@clusterio/lib": "^__clusterio_version__" }, "dependencies": { "@sinclair/typebox": "^0.30.4" diff --git a/packages/create/templates/common/.gitignore b/packages/create/templates/common/template.gitignore similarity index 52% rename from packages/create/templates/common/.gitignore rename to packages/create/templates/common/template.gitignore index 1a993212..b9470778 100644 --- a/packages/create/templates/common/.gitignore +++ b/packages/create/templates/common/template.gitignore @@ -1,3 +1,2 @@ -dist/ node_modules/ -package-lock.json +dist/ diff --git a/packages/create/templates/common/.npmignore b/packages/create/templates/common/template.npmignore similarity index 67% rename from packages/create/templates/common/.npmignore rename to packages/create/templates/common/template.npmignore index fbac3cb2..1c0ed619 100644 --- a/packages/create/templates/common/.npmignore +++ b/packages/create/templates/common/template.npmignore @@ -1,3 +1,3 @@ webpack.config.js -dist/web/build dist/browser +dist/web diff --git a/packages/create/templates/common/webpack.config.js b/packages/create/templates/common/webpack.config.js index bf153d04..78216580 100644 --- a/packages/create/templates/common/webpack.config.js +++ b/packages/create/templates/common/webpack.config.js @@ -5,7 +5,7 @@ const { merge } = require("webpack-merge"); const common = require("@clusterio/web_ui/webpack.common"); -module.exports = (env = {}) => merge(common(env), { +module.exports = (env = {}, argv = {}) => merge(common(env, argv), { context: __dirname, entry: "./web/index.__ext__x", output: { @@ -18,9 +18,7 @@ module.exports = (env = {}) => merge(common(env), { exposes: { "./": "./index.__ext__", "./package.json": "./package.json", -//%if web "./web": "./web/index.__ext__x", -//%endif }, shared: { "@clusterio/lib": { import: false }, diff --git a/packages/create/templates/plugin-js/controller.js b/packages/create/templates/plugin-js/controller.js index b578d248..fae78fdc 100644 --- a/packages/create/templates/plugin-js/controller.js +++ b/packages/create/templates/plugin-js/controller.js @@ -1,7 +1,7 @@ "use strict"; const lib = require("@clusterio/lib"); const { BaseControllerPlugin } = require("@clusterio/controller"); -//%if multi_context // Subscribing requires multi context +//%if multi_context // Messages requires multi context const { PluginExampleEvent, PluginExampleRequest, @@ -9,7 +9,7 @@ const { //%if controller & web // Subscribing requires web content and the controller ExampleSubscribableUpdate, ExampleSubscribableValue, //%endif -//%if multi_context // Subscribing requires multi context +//%if multi_context // Messages requires multi context } = require("./messages"); //%endif @@ -26,7 +26,8 @@ class ControllerPlugin extends BaseControllerPlugin { //%endif //%if controller & web // Subscribing requires web content and the controller this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this)); - this.exampleDatabase = new Map(); // If needed, replace with loading from database file + // If needed, replace with loading from database file such as lib.Datastore + this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]); //%endif //%if multi_context // Subscribing requires multi context } @@ -70,6 +71,7 @@ class ControllerPlugin extends BaseControllerPlugin { //%if controller & web // Subscribing requires web content and the controller async handleExampleSubscription(request) { + this.logger.info(JSON.stringify(request)); const values = [...this.exampleDatabase.values()].filter( value => value.updatedAtMs > request.lastRequestTimeMs, ); diff --git a/packages/create/templates/plugin-js/index.js b/packages/create/templates/plugin-js/index.js index edfa6a0e..d773d4c4 100644 --- a/packages/create/templates/plugin-js/index.js +++ b/packages/create/templates/plugin-js/index.js @@ -7,12 +7,14 @@ lib.definePermission({ name: "__plugin_name__.example.permission.event", title: "Example permission event", description: "Example Description. Event. Change me in index.js", + grantByDefault: true, }); lib.definePermission({ name: "__plugin_name__.example.permission.request", title: "Example permission request", description: "Example Description. Request. Change me in index.js", + grantByDefault: true, }); //%endif //%if controller & web // Subscribing requires web content and the controller @@ -21,6 +23,7 @@ lib.definePermission({ name: "__plugin_name__.example.permission.subscribe", title: "Example permission subscribe", description: "Example Description. Subscribe. Change me in index.js", + grantByDefault: true, }); //%endif //%if web @@ -29,6 +32,7 @@ lib.definePermission({ name: "__plugin_name__.page.view", title: "Example page view permission", description: "Example Description. View. Change me in index.js", + grantByDefault: true, }); //%endif diff --git a/packages/create/templates/plugin-js/instance.js b/packages/create/templates/plugin-js/instance.js index 6993aa1d..41175938 100644 --- a/packages/create/templates/plugin-js/instance.js +++ b/packages/create/templates/plugin-js/instance.js @@ -24,18 +24,26 @@ class InstancePlugin extends BaseInstancePlugin { } async onStart() { + // Called once rcon becomes available this.logger.info("instance::onStart"); } async onStop() { + // Called during normal exits before rcon becomes unavailable this.logger.info("instance::onStop"); } + onExit() { + // Called during all exits, including crashes and init failures, rcon is not available + this.logger.info("instance::onExit"); + } + + async onPlayerEvent(event) { this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`); //%if module if (this.instance.status === "running") { - this.sendRcon("/sc __plugin_name__.foo()"); + await this.sendRcon("/sc __plugin_name__.foo()"); } //%endif } diff --git a/packages/create/templates/plugin-js/web/plugin.jsx b/packages/create/templates/plugin-js/web/plugin.jsx index 8dc03690..7a67c8f6 100644 --- a/packages/create/templates/plugin-js/web/plugin.jsx +++ b/packages/create/templates/plugin-js/web/plugin.jsx @@ -12,7 +12,7 @@ import React, { import { BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler, } from "@clusterio/web_ui"; -//%if multi_context // Subscribing requires multi context +//%if multi_context // Messages requires multi context import { PluginExampleEvent, PluginExampleRequest, @@ -20,7 +20,7 @@ import { //%if controller // Subscribing requires web content and the controller ExampleSubscribableUpdate, ExampleSubscribableValue, //%endif -//%if multi_context // Subscribing requires multi context +//%if multi_context // Messages requires multi context } from "../messages"; //%endif @@ -35,9 +35,9 @@ function MyTemplatePage() { return - //%if controller // Subscribing requires web content and the controller +//%if controller // Subscribing requires web content and the controller Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])} - //%endif +//%endif ; } @@ -57,7 +57,7 @@ export class WebPlugin extends BaseWebPlugin { permission: "__plugin_name__.example.permission.subscribe", //%endif //%if !controller - permission: undefined, // "__plugin_name__.example.permission.request", + permission: "__plugin_name__.example.permission.view", //%endif content: , }, diff --git a/packages/create/templates/plugin-ts/controller.ts b/packages/create/templates/plugin-ts/controller.ts index cca31956..450b54dd 100644 --- a/packages/create/templates/plugin-ts/controller.ts +++ b/packages/create/templates/plugin-ts/controller.ts @@ -1,6 +1,6 @@ import * as lib from "@clusterio/lib"; import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller"; -//%if multi_context // Subscribing requires multi context +//%if multi_context // Messages requires multi context import { PluginExampleEvent, PluginExampleRequest, @@ -8,7 +8,7 @@ import { //%if controller & web // Subscribing requires web content and the controller ExampleSubscribableUpdate, ExampleSubscribableValue, //%endif -//%if multi_context // Subscribing requires multi context +//%if multi_context // Messages requires multi context } from "./messages"; //%endif @@ -25,7 +25,8 @@ export class ControllerPlugin extends BaseControllerPlugin { //%endif //%if controller & web // Subscribing requires web content and the controller this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this)); - this.exampleDatabase = new Map(); // If needed, replace with loading from database file + // If needed, replace with loading from database file such as lib.Datastore + this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]); //%endif //%if multi_context // Subscribing requires multi context } @@ -69,6 +70,7 @@ export class ControllerPlugin extends BaseControllerPlugin { //%if controller & web // Subscribing requires web content and the controller async handleExampleSubscription(request: lib.SubscriptionRequest) { + this.logger.info(JSON.stringify(request)); const values = [...this.exampleDatabase.values()].filter( value => value.updatedAtMs > request.lastRequestTimeMs, ); diff --git a/packages/create/templates/plugin-ts/index.ts b/packages/create/templates/plugin-ts/index.ts index 8ef46604..539a66bc 100644 --- a/packages/create/templates/plugin-ts/index.ts +++ b/packages/create/templates/plugin-ts/index.ts @@ -6,12 +6,14 @@ lib.definePermission({ name: "__plugin_name__.example.permission.event", title: "Example permission event", description: "Example Description. Event. Change me in index.ts", + grantByDefault: true, }); lib.definePermission({ name: "__plugin_name__.example.permission.request", title: "Example permission request", description: "Example Description. Request. Change me in index.ts", + grantByDefault: true, }); //%endif //%if controller & web // Subscribing requires web content and the controller @@ -20,6 +22,7 @@ lib.definePermission({ name: "__plugin_name__.example.permission.subscribe", title: "Example permission subscribe", description: "Example Description. Subscribe. Change me in index.ts", + grantByDefault: true, }); //%endif //%if web @@ -28,6 +31,7 @@ lib.definePermission({ name: "__plugin_name__.page.view", title: "Example page view permission", description: "Example Description. View. Change me in index.ts", + grantByDefault: true, }); //%endif //%if config diff --git a/packages/create/templates/plugin-ts/instance.ts b/packages/create/templates/plugin-ts/instance.ts index 21af1189..133efa6f 100644 --- a/packages/create/templates/plugin-ts/instance.ts +++ b/packages/create/templates/plugin-ts/instance.ts @@ -31,18 +31,25 @@ export class InstancePlugin extends BaseInstancePlugin { } async onStart() { + // Called once rcon becomes available this.logger.info("instance::onStart"); } async onStop() { + // Called during normal exits before rcon becomes unavailable this.logger.info("instance::onStop"); } + onExit() { + // Called during all exits, including crashes and init failures, rcon is not available + this.logger.info("instance::onExit"); + } + async onPlayerEvent(event: lib.PlayerEvent) { this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`); //%if module if (this.instance.status === "running") { - this.sendRcon("/sc __plugin_name__.foo()"); + await this.sendRcon("/sc __plugin_name__.foo()"); } //%endif } diff --git a/packages/create/templates/plugin-ts/tsconfig.base.json b/packages/create/templates/plugin-ts/tsconfig.base.json new file mode 100644 index 00000000..dd597d2d --- /dev/null +++ b/packages/create/templates/plugin-ts/tsconfig.base.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "incremental": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + }, +} diff --git a/packages/create/templates/plugin-ts/tsconfig.browser.json b/packages/create/templates/plugin-ts/tsconfig.browser.json index 325fd71d..fe5100ca 100644 --- a/packages/create/templates/plugin-ts/tsconfig.browser.json +++ b/packages/create/templates/plugin-ts/tsconfig.browser.json @@ -1,8 +1,13 @@ { - "extends": "../../tsconfig.browser.json", - "references": [ - { "path": "../../packages/lib/tsconfig.browser.json" }, - { "path": "../../packages/web_ui/tsconfig.browser.json" }, - ], + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["dom", "es2023"], + "module": "es2022", + "moduleResolution": "bundler", + "target": "es2022", + "jsx": "react-jsx", + "noEmitOnError": true, + "outDir": "${configDir}/dist/browser", + }, "include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ], } diff --git a/packages/create/templates/plugin-ts/tsconfig.node.json b/packages/create/templates/plugin-ts/tsconfig.node.json index ce0e013f..53333178 100644 --- a/packages/create/templates/plugin-ts/tsconfig.node.json +++ b/packages/create/templates/plugin-ts/tsconfig.node.json @@ -1,8 +1,13 @@ { - "extends": "../../tsconfig.node.json", - "references": [ - { "path": "../../packages/lib/tsconfig.node.json" }, - ], + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["es2023"], + "module": "node16", + "moduleResolution": "node16", + "target": "es2022", + "forceConsistentCasingInFileNames": true, + "outDir": "${configDir}/dist/node" + }, "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], } diff --git a/packages/create/templates/plugin-ts/web/plugin.tsx b/packages/create/templates/plugin-ts/web/plugin.tsx index c8b830fe..d4d9d150 100644 --- a/packages/create/templates/plugin-ts/web/plugin.tsx +++ b/packages/create/templates/plugin-ts/web/plugin.tsx @@ -12,7 +12,7 @@ import React, { import { BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler, } from "@clusterio/web_ui"; -//%if multi_context // Subscribing requires multi context +//%if multi_context // Messages requires multi context import { PluginExampleEvent, PluginExampleRequest, @@ -20,7 +20,7 @@ import { //%if controller & web // Subscribing requires web content and the controller ExampleSubscribableUpdate, ExampleSubscribableValue, //%endif -//%if multi_context // Subscribing requires multi context +//%if multi_context // Messages requires multi context } from "../messages"; //%endif @@ -35,9 +35,9 @@ function MyTemplatePage() { return - //%if controller // Subscribing requires web content and the controller +//%if controller // Subscribing requires web content and the controller Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])} - //%endif +//%endif ; } @@ -57,7 +57,7 @@ export class WebPlugin extends BaseWebPlugin { permission: "__plugin_name__.example.permission.subscribe", //%endif //%if !controller - permission: undefined, // "__plugin_name__.example.permission.request", + permission: "__plugin_name__.example.permission.view", //%endif content: , }, diff --git a/packages/ctl/ctl.ts b/packages/ctl/ctl.ts index bafb43af..793dd1ab 100755 --- a/packages/ctl/ctl.ts +++ b/packages/ctl/ctl.ts @@ -189,6 +189,7 @@ export async function initialize( default: "plugin-list.json", type: "string", }) + .option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false }) .command("plugin", "Manage available plugins", lib.pluginCommand) .command("control-config", "Manage Control config", lib.configCommand) .wrap(parser.terminalWidth()) @@ -234,8 +235,14 @@ export async function initialize( .parse(argv) as CtlArguments ; - let controlConfig; const controlConfigPath = args.config; + const controlConfigLockPath = `${controlConfigPath}.lock`; + if (args.bypassLockFile) { + await fs.unlink(controlConfigLockPath).catch(() => {}); // ignore error, file might not exist + } + + let controlConfig; + const controlConfigLock = new lib.LockFile(controlConfigLockPath); logger.verbose(`Loading config from ${controlConfigPath}`); try { controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath); @@ -257,7 +264,7 @@ export async function initialize( // Handle the control-config command before trying to connect. if (args._[0] === "control-config") { - await lib.handleConfigCommand(args, controlConfig); + await lib.handleConfigCommand(args, controlConfig, controlConfigLock); return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false }; } diff --git a/packages/ctl/package.json b/packages/ctl/package.json index 0a8f002d..af4819ed 100644 --- a/packages/ctl/package.json +++ b/packages/ctl/package.json @@ -3,7 +3,7 @@ "description": "Clusterio command line management tool", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.21", + "version": "2.0.0-alpha.22", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build", @@ -17,25 +17,25 @@ "factorio" ], "engines": { - "node": ">=18" + "node": ">=20" }, "dependencies": { "@clusterio/lib": "workspace:*", + "set-blocking": "catalog:", + "fs-extra": "catalog:", + "winston": "catalog:", + "phin": "catalog:", "as-table": "^1.0.55", - "fs-extra": "^11.3.0", - "phin": "^3.7.0", - "set-blocking": "^2.0.0", - "winston": "^3.17.0", "yargs": "^17.7.2" }, "publishConfig": { "access": "public" }, "devDependencies": { - "@types/fs-extra": "^11.0.1", - "@types/node": "^20.17.19", - "@types/set-blocking": "^2.0.0", + "@types/fs-extra": "catalog:", + "@types/node": "catalog:", + "@types/set-blocking": "catalog:", "@types/yargs": "^17.0.33", - "typescript": "^5.7.3" + "typescript": "catalog:" } } diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index 59be29b0..142e26d0 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -1362,7 +1362,7 @@ function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) { if (!version) { throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1"); } - if (!/^\d+\.\d+\.\d+$/.test(version)) { + if (!lib.isFullVersion(version)) { throw new lib.CommandError("version must match the format digit.digit.digit"); } if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) { @@ -1415,8 +1415,8 @@ modPackCommands.add(new lib.Command({ modPack.name = args.name; if (args.description) { modPack.description = args.description; } if (args.factorioVersion) { - if (!/^\d+\.\d+\.\d+?$/.test(args.factorioVersion)) { - throw new lib.CommandError("factorio-version must match the format digit.digit.digit"); + if (!lib.isPartialVersion(args.factorioVersion)) { + throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]"); } modPack.factorioVersion = args.factorioVersion; } @@ -1496,8 +1496,8 @@ modPackCommands.add(new lib.Command({ if (args.name) { modPack.name = args.name; } if (args.description) { modPack.description = args.description; } if (args.factorioVersion) { - if (!/^\d+\.\d+\.\d+?$/.test(args.factorioVersion)) { - throw new lib.CommandError("factorio-version must match the format digit.digit.digit"); + if (!lib.isPartialVersion(args.factorioVersion)) { + throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]"); } modPack.factorioVersion = args.factorioVersion; } @@ -1551,6 +1551,9 @@ modCommands.add(new lib.Command({ yargs.positional("mod-version", { describe: "Version of the mod", type: "string" }); }], handler: async function(args: { name: string, modVersion: string }, control: Control) { + if (!lib.isFullVersion(args.modVersion)) { + throw new lib.CommandError("mod-version must match format digit.digit.digit"); + } let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion)); for (let [field, value] of Object.entries(modInfo)) { if (value instanceof Array) { @@ -1638,7 +1641,7 @@ modCommands.add(new lib.Command({ ) { let response = await control.send(new lib.ModSearchRequest( args.query, - args.factorioVersion, + lib.normaliseApiVersion(args.factorioVersion as any), args.page, args.pageSize, args.sort, @@ -1717,6 +1720,9 @@ modCommands.add(new lib.Command({ yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" }); }], handler: async function(args: { name: string, modVersion: string }, control: Control) { + if (!lib.isFullVersion(args.modVersion)) { + throw new lib.CommandError("mod-version must match format digit.digit.digit"); + } let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion)); let url = new URL(control.config.get("control.controller_url")!); @@ -1757,6 +1763,9 @@ modCommands.add(new lib.Command({ yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" }); }], handler: async function(args: { name: string, modVersion: string }, control: Control) { + if (!lib.isFullVersion(args.modVersion)) { + throw new lib.CommandError("mod-version must match format digit.digit.digit"); + } await control.send(new lib.ModDeleteRequest(args.name, args.modVersion)); }, })); diff --git a/packages/host/host.ts b/packages/host/host.ts index 6a3b1a3f..c3f539a5 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -113,6 +113,7 @@ async function startHost() { default: "plugin-list.json", type: "string", }) + .option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false }) .command("plugin", "Manage available plugins", lib.pluginCommand) .command("config", "Manage Host config", lib.configCommand) .command("run", "Run host", yargs => { @@ -167,8 +168,14 @@ async function startHost() { lib.registerPluginMessages(pluginInfos); lib.addPluginConfigFields(pluginInfos); - let hostConfig; const hostConfigPath = args.config; + const hostConfigLockPath = `${hostConfigPath}.lock`; + if (args.bypassLockFile) { + await fs.unlink(hostConfigLockPath).catch(() => {}); // ignore error, file might not exist + } + + let hostConfig; + const hostConfigLock = new lib.LockFile(hostConfigLockPath); logger.info(`Loading config from ${hostConfigPath}`); try { hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath); @@ -186,10 +193,13 @@ async function startHost() { hostConfig.set("host.version", version); // Allows tracking last loaded version if (command === "config") { - await lib.handleConfigCommand(args, hostConfig); + await lib.handleConfigCommand(args, hostConfig, hostConfigLock); return; } + // From this point on the host should run + await hostConfigLock.acquire(); + // If we get here the command was run await fs.ensureDir(hostConfig.get("host.instances_directory")); await fs.ensureDir(hostConfig.get("host.mods_directory")); diff --git a/packages/host/modules/clusterio/serialize.lua b/packages/host/modules/clusterio/serialize.lua index 375fbe8d..e1013f58 100644 --- a/packages/host/modules/clusterio/serialize.lua +++ b/packages/host/modules/clusterio/serialize.lua @@ -70,7 +70,6 @@ function serialize.serialize_equipment_grid(grid) if equipment.to_be_removed then entry.t = equipment.to_be_removed end - log(entry) table.insert(serialized, entry) end end diff --git a/packages/host/package.json b/packages/host/package.json index 2808b282..638f1501 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -3,10 +3,11 @@ "description": "Implementation of Clusterio host server", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.21", + "version": "2.0.0-alpha.22b", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build", + "build-mod": "node ../lib/build_mod.js --source-dir ./lua/clusterio_lib", "test": "echo \"Error: no test specified\" && exit 1" }, "bin": { @@ -17,33 +18,33 @@ "factorio" ], "engines": { - "node": ">=18" + "node": ">=20" }, "dependencies": { "@clusterio/lib": "workspace:*", - "@sinclair/typebox": "^0.30.4", - "fs-extra": "^11.3.0", + "@sinclair/typebox": "catalog:", + "set-blocking": "catalog:", + "fs-extra": "catalog:", + "jszip": "catalog:", + "phin": "catalog:", + "winston": "catalog:", + "winston-daily-rotate-file": "catalog:", "jimp": "^0.22.8", - "jszip": "^3.10.1", - "phin": "^3.7.0", "pidusage": "^3.0.2", "rcon-client": "^4.2.5", "semver": "^7.7.1", - "set-blocking": "^2.0.0", - "winston": "^3.17.0", - "winston-daily-rotate-file": "^4.7.1", "yargs": "^17.7.2" }, "publishConfig": { "access": "public" }, "devDependencies": { - "@types/fs-extra": "^11.0.1", - "@types/node": "^20.17.19", + "@types/node": "catalog:", + "@types/fs-extra": "catalog:", + "@types/set-blocking": "catalog:", "@types/pidusage": "^2.0.2", "@types/semver": "^7.5.0", - "@types/set-blocking": "^2.0.0", "@types/yargs": "^17.0.33", - "typescript": "^5.7.3" + "typescript": "catalog:" } } diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 839fac5d..f3061f29 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -410,7 +410,7 @@ export default class Host extends lib.Link { for (let pluginInfo of this.pluginInfos) { if ( !pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint - || !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields) + || !this.config.get(`${pluginInfo.name}.load_plugin`) ) { continue; } @@ -693,7 +693,8 @@ export default class Host extends lib.Link { instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId), instanceConnection ? instanceConnection.instance.server.version - : instanceInfo.config.get("factorio.version"), + // TODO: factorio.version is not validated + : instanceInfo.config.get("factorio.version") as lib.TargetVersion, ) ); @@ -805,19 +806,48 @@ export default class Host extends lib.Link { } async handleSystemInfoRequest() { - if (!this.config.restartRequired) { - // If a restart isn't already required, then test if a new version is installed - try { - const runningVersion = this.config.get("host.version"); - const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json")); - if (runningVersion !== packageJson.version) { - this.config.restartRequired = true; - } - } catch (err: any) { - logger.warn(`Failed to read package json:\n${err.stack ?? err.message}`); - } + const restartRequired = await this.checkRestartRequired(); + return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired); + } + + async checkRestartRequired() { + if (this.config.restartRequired) { + return true; } - return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, this.config.restartRequired); + + let packageName = "@clusterio/host"; + try { + // First check the clusterio version + const runningVersion = this.config.get("host.version"); + const packageJsonPath = require.resolve("@clusterio/host/package.json"); + const packageJson = await fs.readJSON(packageJsonPath); + if (runningVersion !== packageJson.version) { + this.config.restartRequired = true; + return true; + } + + // Second check plugin versions + for (const pluginInfo of this.pluginInfos) { + if ( + !pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint + || !this.config.get(`${pluginInfo.name}.load_plugin`) + ) { + continue; + } + + packageName = pluginInfo.npmPackage ?? pluginInfo.name; + const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json")); + const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath); + if (pluginInfo.version !== pluginPackageJson.version) { + this.config.restartRequired = true; + return true; + } + } + } catch (err: any) { + logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`); + } + + return false; } async handleHostMetricsRequest() { @@ -1066,7 +1096,7 @@ export default class Host extends lib.Link { if (!this.config.get("host.allow_remote_updates")) { throw new lib.RequestError("Remote updates are disabled on this machine"); } - return lib.updatePackage("@clusterio/host"); + return await lib.updatePackage("@clusterio/host"); } async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) { @@ -1087,7 +1117,7 @@ export default class Host extends lib.Link { return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo( pluginInfo, this.plugins.has(pluginInfo.name), - this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields) as boolean, + this.config.get(`${pluginInfo.name}.load_plugin`), )); } diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 0a47c0b2..88cf50ae 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -210,7 +210,8 @@ export default class Instance extends lib.Link { let serverOptions = { logger: this.logger, - version: this.config.get("factorio.version"), + // TODO: factorio.version is not validated + version: this.config.get("factorio.version") as lib.TargetVersion, executablePath: this.config.get("factorio.executable_path") ?? undefined, gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id), rconPort: this.config.get("factorio.rcon_port") ?? undefined, @@ -553,7 +554,8 @@ end`.replace(/\r?\n/g, " "); this.id, status, this.server.gamePort, - status === "running"? this.server.version : this.config.get("factorio.version"), + // TODO: factorio.version is not validated + status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion, ), ); } @@ -651,7 +653,7 @@ end`.replace(/\r?\n/g, " "); if ( !pluginInfo.instanceEntrypoint || !this._host.serverPlugins.has(pluginInfo.name) - || !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.InstanceConfigFields) + || !this.config.get(`${pluginInfo.name}.load_plugin`) ) { continue; } @@ -960,10 +962,15 @@ end`.replace(/\r?\n/g, " "); this._loadedSave = saveName; await this.server.start(saveName); - if (this.config.get("factorio.enable_save_patching") && this.config.get("factorio.enable_script_commands")) { + if (this.config.get("factorio.enable_script_commands")) { await this.server.disableAchievements(); - await this.updateInstanceData(); + } + + if (this.config.get("factorio.enable_save_patching")) { this._watchPlayerPromote(); + if (this.config.get("factorio.enable_script_commands")) { + await this.updateInstanceData(); + } } else { this._watchServerLogActions(); } diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index 479037c9..dc45d101 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) { * @returns Array with path to data dir and version found. * @internal */ -async function findVersion(factorioDir: string, targetVersion: string) { +async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion): Promise<[string, lib.FullVersion]> { // There are two supported setups: having the factorio dir be the actual // install directory, and having the factorio dir be a folder containing @@ -87,8 +87,10 @@ async function findVersion(factorioDir: string, targetVersion: string) { let simpleVersion = await getVersion(path.join(factorioDir, "data", "changelog.txt")); if (simpleVersion !== null) { - if (simpleVersion === targetVersion || targetVersion === "latest") { - return [path.join(factorioDir, "data"), simpleVersion]; + if (simpleVersion === targetVersion || simpleVersion.startsWith(targetVersion) || targetVersion === "latest") { + if (lib.isFullVersion(simpleVersion)) { + return [path.join(factorioDir, "data"), simpleVersion]; + } } throw new Error( @@ -96,14 +98,14 @@ async function findVersion(factorioDir: string, targetVersion: string) { ); } - let versions = new Map(); + let versions = new Map(); for (let entry of await fs.readdir(factorioDir, { withFileTypes: true })) { if (!entry.isDirectory()) { continue; } let version = await getVersion(path.join(factorioDir, entry.name, "data", "changelog.txt")); - if (version === null) { + if (version === null || !lib.isFullVersion(version)) { continue; } @@ -118,9 +120,17 @@ async function findVersion(factorioDir: string, targetVersion: string) { throw new Error(`Unable to find any Factorio install in ${factorioDir}`); } + if (lib.isPartialVersion(targetVersion)) { + const sorted = [...versions.keys()].sort(versionOrder); + const latest = sorted.find(version => version.startsWith(targetVersion)); + if (latest) { + return [path.join(factorioDir, versions.get(latest)!, "data"), latest]; + } + } + if (targetVersion === "latest") { - let latest = [...versions.keys()].sort(versionOrder)[0]; - return [path.join(factorioDir, versions.get(latest), "data"), latest]; + const latest = [...versions.keys()].sort(versionOrder)[0]; + return [path.join(factorioDir, versions.get(latest)!, "data"), latest]; } throw new Error(`Unable to find Factorio version ${targetVersion}`); @@ -423,7 +433,7 @@ export interface FactorioServerOptions { * Version of Factorio to use. Can also be the string "latest" to use * the latest version found in `factorioDir`. */ - version?: string, + version?: lib.TargetVersion, /** Path to executable to invoke when starting the server */ executablePath?: string; /** UDP port to host game on. */ @@ -523,7 +533,7 @@ export class FactorioServer extends events.EventEmitter { _writeDir: string; // Resolved in init - _version: string | null = null; + _version: lib.FullVersion | null = null; _dataDir: string | null = null; // Due to inconsistencies in the factorio api, we must manually watch the whitelist @@ -532,7 +542,7 @@ export class FactorioServer extends events.EventEmitter { _whitelist = new Set(); _logger: lib.Logger; - _targetVersion: string; + _targetVersion: lib.TargetVersion; _state: "new" | "init" | "create" | "running" | "stopping" = "new"; _server: child_process.ChildProcessWithoutNullStreams | null = null; _rconClient: Rcon | null= null; @@ -866,7 +876,7 @@ export class FactorioServer extends events.EventEmitter { * "latest" (the default) was specified as `factorioVersion` to the constructor. * This will be undefined before the server is initialized, or if init failed. */ - get version(): string | undefined { + get version(): lib.FullVersion | undefined { return this._version ?? undefined; } diff --git a/packages/lib/browser.ts b/packages/lib/browser.ts index 31fd837f..58c0debf 100644 --- a/packages/lib/browser.ts +++ b/packages/lib/browser.ts @@ -15,3 +15,4 @@ export * from "./src/subscriptions"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as RateLimiter } from "./src/RateLimiter"; +export { default as ValueCache } from "./src/ValueCache"; diff --git a/packages/lib/build_mod.js b/packages/lib/build_mod.js index eee00d8d..30cc4326 100644 --- a/packages/lib/build_mod.js +++ b/packages/lib/build_mod.js @@ -115,9 +115,21 @@ async function main() { "clean": { describe: "Remove previous builds", type: "boolean", default: false }, "build": { describe: "Build mod(s)", type: "boolean", default: true }, "pack": { describe: "Pack into zip file", type: "boolean", default: true }, - "source-dir": { describe: "Path to mod source files", nargs: 1, type: "string", demandOption: true }, - "output-dir": { describe: "Path to output built mod(s)", nargs: 1, type: "string", default: "dist" }, "factorio-version": { describe: "Override factorio_version", type: "string" }, + "source-dir": { + describe: "Path to mod source files", + type: "string", + nargs: 1, + normalize: true, + demandOption: true, + }, + "output-dir": { + describe: "Path to output built mod(s)", + type: "string", + nargs: 1, + normalize: true, + default: "dist/factorio", + }, }) .strict() .argv diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 36e0d492..96a4da80 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -9,6 +9,7 @@ export * from "./src/config"; export * from "./src/data"; export * from "./src/database"; export * from "./src/errors"; +export * from "./src/external"; export * from "./src/factorio"; export * from "./src/file_ops"; export * from "./src/hash"; @@ -31,7 +32,9 @@ export * from "./src/subscriptions"; export * from "./src/datastore"; export * from "./src/load_plugin_list"; export * from "./src/rce_ops"; +export * from "./src/LockFile"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as ModStore } from "./src/ModStore"; export { default as RateLimiter } from "./src/RateLimiter"; +export { default as ValueCache } from "./src/ValueCache"; diff --git a/packages/lib/package.json b/packages/lib/package.json index 89cd13ae..74e4f560 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -1,7 +1,7 @@ { "name": "@clusterio/lib", "description": "Shared library for Clusterio", - "version": "2.0.0-alpha.21", + "version": "2.0.0-alpha.22b", "repository": "https://github.com/clusterio/clusterio", "license": "MIT", "scripts": { @@ -18,30 +18,30 @@ "node": ">=12" }, "dependencies": { - "@sinclair/typebox": "^0.30.4", - "ajv": "^8.17.1", - "chalk": "^4.1.2", + "@sinclair/typebox": "catalog:", + "set-blocking": "catalog:", + "fs-extra": "catalog:", + "jszip": "catalog:", + "chalk": "catalog:", + "ws": "catalog:", + "winston": "catalog:", + "winston-transport": "catalog:", "fast-deep-equal": "^3.1.3", - "fs-extra": "^11.3.0", - "jszip": "^3.10.1", - "klaw": "^4.1.0", - "set-blocking": "^2.0.0", "triple-beam": "^1.4.1", - "winston": "^3.17.0", - "winston-transport": "^4.9.0", - "ws": "^8.18.1", + "ajv": "^8.17.1", + "klaw": "^4.1.0", "yargs": "^17.7.2" }, "publishConfig": { "access": "public" }, "devDependencies": { - "@types/fs-extra": "^11.0.1", - "@types/node": "^20.17.19", - "@types/set-blocking": "^2.0.0", + "@types/fs-extra": "catalog:", + "@types/node": "catalog:", + "@types/ws": "catalog:", + "@types/set-blocking": "catalog:", "@types/triple-beam": "^1.3.2", - "@types/ws": "^8.5.14", "@types/yargs": "^17.0.33", - "typescript": "^5.7.3" + "typescript": "catalog:" } } diff --git a/packages/lib/src/LockFile.ts b/packages/lib/src/LockFile.ts new file mode 100644 index 00000000..5cb6ecce --- /dev/null +++ b/packages/lib/src/LockFile.ts @@ -0,0 +1,163 @@ +import { FileHandle, open, unlink, readFile } from "fs/promises"; +import { unlinkSync } from "fs"; +import assert from "assert"; + +/** + * Error thrown when the lock file already exists. + */ +export class LockFileExistsError extends Error { + constructor( + readonly filePath: string + ) { + super(`Cannot acquire lock, a valid lock file exists at ${filePath}`); + this.name = "LockFileExistsError"; + } +} + +/** + * Error thrown when the acquire or release called during an invalid state. + */ +export class LockFileBusyError extends Error { + constructor(message: string) { + super(message); + this.name = "LockFileBusyError"; + } +} + +type LockState = "idle" | "acquiring" | "acquired" | "releasing"; + +/** + * Cross-platform lock file utility for Node.js. + */ +export class LockFile { + private handle: FileHandle | null = null; + private state: LockState = "idle"; + + constructor( + private readonly filePath: string + ) {} + + /** + * Acquire the lock file. + * Throws LockFileExistsError if already locked. + * Throws LockFileBusyError if not in idle state. + */ + async acquire(): Promise { + if (this.state !== "idle") { + throw new LockFileBusyError(`Cannot acquire lock, current state is "${this.state}"`); + } + + this.state = "acquiring"; + + if (await this.isStale()) { + await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed + } + + try { + this.handle = await open(this.filePath, "wx"); + await this.handle.write(`${process.pid}\n`); + this.attachExitHandlers(); + this.state = "acquired"; + } catch (err: any) { + this.state = "idle"; + if (err?.code === "EEXIST") { + throw new LockFileExistsError(this.filePath); + } + throw err; + } + } + + /** + * Release the lock file. + * Throws LockFileBusyError if not in acquired state. + */ + async release(): Promise { + if (this.state !== "acquired") { + throw new LockFileBusyError(`Cannot release lock, current state is "${this.state}"`); + } + + this.state = "releasing"; + this.detachExitHandlers(); + + // Ignored: file may have already been removed by another process + try { await this.handle?.close(); } catch {} + try { await unlink(this.filePath); } catch {} + + this.handle = null; + this.state = "idle"; + } + + /** + * Check whether the lock is currently held by this process. + * True only if the lock is acquired and the PID in the file + * matches the current process. + */ + async isHeld(): Promise { + if (this.state !== "acquired") { + return false; + } + + try { + const pidInLock = Number(await readFile(this.filePath, "utf8")); + return pidInLock === process.pid; + } catch { + // Ignored: file may not exist or be unreadable, so lock is not held + return false; + } + } + + /** + * Determine whether an existing lock is stale. + */ + async isStale(): Promise { + try { + const pidInLock = Number(await readFile(this.filePath, "utf8")); + if (!Number.isFinite(pidInLock)) { return true; } + // signal 0 is a special case to test if a process exists + process.kill(pidInLock, 0); + return false; + } catch { + return true; + } + } + + /** + * Attach process handlers, called when transitioning to "acquired" + */ + private attachExitHandlers(): void { + assert(this.state === "acquiring"); + process.on("SIGINT", this.handleExit); + process.on("SIGTERM", this.handleExit); + process.on("SIGHUP", this.handleExit); + process.on("uncaughtException", this.handleExit); + // eslint-disable-next-line node/no-sync + process.on("exit", this.handleExitSync); + } + + /** + * Detach process handlers, called when transitioning to "idle" + */ + private async detachExitHandlers(): Promise { + assert(this.state === "releasing"); + process.off("SIGINT", this.handleExit); + process.off("SIGTERM", this.handleExit); + process.off("SIGHUP", this.handleExit); + process.off("uncaughtException", this.handleExit); + // eslint-disable-next-line node/no-sync + process.off("exit", this.handleExitSync); + } + + // Event handler for async signals / exceptions + private handleExit = async (): Promise => { + await this.release(); + }; + + // Event handler for synchronous process exit + private handleExitSync = (): void => { + try { + unlinkSync(this.filePath); + } catch (err) { + // Ignored: file may have already been removed by another process + } + }; +} diff --git a/packages/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts index 84d03154..13a94be6 100644 --- a/packages/lib/src/ModStore.ts +++ b/packages/lib/src/ModStore.ts @@ -3,10 +3,10 @@ import path from "path"; import events from "events"; import { Static } from "@sinclair/typebox"; import { logger } from "./logging"; -import { ModInfo, ModPack } from "./data"; import { safeOutputFile } from "./file_ops"; import { Writable } from "stream"; -import { ModPortalReleaseSchema, ModPortalDetailsSchema } from "./data/messages_mod"; +import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod"; +import { ModInfo, ModPack, FullVersion, ApiVersion, ModVersionEquality } from "./data"; export interface ModStoreEvents { /** A stored mod was created, updated or deleted */ @@ -45,7 +45,7 @@ export default class ModStore extends events.EventEmitter { super(); } - getMod(name: string, version: string, sha1?: string) { + getMod(name: string, version: FullVersion, sha1?: string) { const mod = this.files.get(ModInfo.filename(name, version)); if (!mod || sha1 && sha1 !== mod.sha1) { return undefined; @@ -87,11 +87,11 @@ export default class ModStore extends events.EventEmitter { this.emit("change", modInfo); } - async loadMod(name: string, version: string) { + async loadMod(name: string, version: FullVersion) { return await this.loadFile(ModInfo.filename(name, version)); } - async deleteMod(name: string, version: string) { + async deleteMod(name: string, version: FullVersion) { return await this.deleteFile(ModInfo.filename(name, version)); } @@ -155,10 +155,46 @@ export default class ModStore extends events.EventEmitter { return new this(modsDirectory, files); } - private async downloadModFromReleases(releases: Array>, version: string, + /** + * Stream a url's content into a file. + * + * @param url - Web path to stream from. + * @param filePath - File path to stream to. + */ + private static async downloadFile(url: string | URL, filePath: string) { + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); + } + + if (!response.body) { + throw new Error("Response body is missing."); + } + + const fileStream = fs.createWriteStream(filePath); + const writer = Writable.toWeb(fileStream); + await response.body.pipeTo(writer); + } catch (error) { + logger.error("Error downloading file:", error); + throw error; + } + } + + /** + * Download a matching version from a selection of available versions. + * + * @param releases - Array of mod releases taken from the mod portal. + * @param version - The required version to install. + * @param username - Factorio username to download using. + * @param token - Factorio token to download using. + */ + private async downloadModFromReleases( + releases: Array>, version: ModVersionEquality, username: string, token: string ) { - const selectedRelease = releases.find(release => release.version === version); + const selectedRelease = releases.find(release => version.testVersion(release.version)); if (selectedRelease === undefined) { logger.warn(`Mod release matching version ${version} not found.`); @@ -190,96 +226,83 @@ export default class ModStore extends events.EventEmitter { } } + /** + * Downloads all provided mods, does not perform chunking or filtering of builtins + * + * @param mods - An array of mods to download + * @param username - Factorio username used for auth to download mods + * @param token - Factorio token used for auth to download mods + * @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions) + */ private async downloadModsChunk( - modNames: string[], modVersions: string[], username: string, token: string, factorioVersion: string + mods: ModNameVersionPair[], + username: string, token: string, + factorioVersion: ApiVersion, ) { const url = new URL("https://mods.factorio.com/api/mods"); url.searchParams.set("page_size", "max"); url.searchParams.set("version", factorioVersion); const response = await fetch(url, { method: "POST", - body: new URLSearchParams({ namelist: modNames.join(",") }), + body: new URLSearchParams({ namelist: mods.map(m => m.name).join(",") }), }); + if (response.status !== 200) { throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`); } - const mods = (await response.json() as ModsInfoResponse).results; - await Promise.all(mods.map((mod) => { + const modReleases = (await response.json() as ModsInfoResponse).results; + await Promise.all(modReleases.map((mod) => { + const modVersion = mods.find(m => m.name === mod.name)!.version; if (mod.releases) { - return this.downloadModFromReleases(mod.releases, - modVersions[modNames.indexOf(mod.name)], username, token); + return this.downloadModFromReleases(mod.releases, modVersion, username, token); } else if (mod.latest_release) { - return this.downloadModFromReleases([mod.latest_release], - modVersions[modNames.indexOf(mod.name)], username, token); + return this.downloadModFromReleases([mod.latest_release], modVersion, username, token); } throw new Error(`Mod ${mod.name} has no releases or latest_release`); })); } - /** + * Downloads all provided mods to the mod dir. * - * downloads mods to the mod dir, will not download mods for which are already in the mod directory - * - * @param modMap a map of what mods to download and what version to download - * @param username factorio username used for auth to download mods - * @param token factorio token used for auth to download mods - * @param factorioVersion factorio version to filter for (mods are not guaranteed to work between Middle versions) + * Handles chunking to prevent errors due to URL length. + * Will re-download if mod is already present, if this is undesired use filterInstalled first. * + * @param mods - An array of mods to download. + * @param username - Factorio username used for auth to download mods. + * @param token - Factorio token used for auth to download mods. + * @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions) */ - async downloadMods(modMap: Map, username: string, token: string, factorioVersion: string = "1.1") { + async downloadMods( + mods: ModNameVersionPair[], username: string, token: string, factorioVersion: ApiVersion = "1.1" + ) { const chunkSize = 500; - let futures: Promise[] = []; - modMap = new Map(modMap); - - // Truncate factorioVersion to major.minor (e.g., "1.1.100" -> "1.1") for API/builtin checks - const versionParts = factorioVersion.split("."); - if (versionParts.length > 2) { - factorioVersion = versionParts.slice(0, 2).join("."); - } - + const futures: Promise[] = []; const builtInMods = new Set(ModPack.getBuiltinModNames(factorioVersion)); + mods = mods.filter(mod => !(builtInMods.has(mod.name) || mod.name === "core")); - // get rid of mods already downloaded, built-in, or core - modMap.forEach((modVersion, modName, map) => { - const filename = `${modName}_${modVersion}.zip`; - // Filter existing, built-in from getBuiltinModNames, and explicitly filter "core" - if (this.files.has(filename) || builtInMods.has(modName) || modName === "core") { - map.delete(modName); - } - }); - - const modNames = Array.from(modMap.keys()); - const modVersions = Array.from(modMap.values()); - for (let i = 0; i < modMap.size; i += chunkSize) { + for (let i = 0; i < mods.length; i += chunkSize) { futures.push( - this.downloadModsChunk(modNames.slice(i, i + chunkSize), modVersions.slice(i, i + chunkSize), - username, token, factorioVersion) + this.downloadModsChunk(mods.slice(i, i + chunkSize), username, token, factorioVersion) ); } + await Promise.all(futures); } - static async downloadFile(url: string | URL, filePath: string) { - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); - } - - if (!response.body) { - throw new Error("Response body is missing."); - } - - const fileStream = fs.createWriteStream(filePath); - const writer = Writable.toWeb(fileStream); - await response.body.pipeTo(writer); - } catch (error) { - logger.error("Error downloading file:", error); - throw error; - } + /** + * Filter an array of mods based on if they are installed. + * + * @param mods - Array of mods to filer. + * @param installed - `true` to keep installed mods, `false` to keep uninstalled mods. + * @returns Filtered array of mods based on install state. + */ + filterInstalled(mods: ModNameVersionPair[], installed: boolean) { + const installedMods = Array.from(this.mods()); + return mods.filter(mod => ( + installed === installedMods.some(m => mod.name === m.name && mod.version.testVersion(m.version)) + )); } /** @@ -332,4 +355,19 @@ export default class ModStore extends events.EventEmitter { logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`); return allMods; } + + /** + * Fetch the full release list for a mod including dependencies. + * + * @param modName - Name of the mod to fetch the releases of + * @returns Mod portal response + */ + static async fetchModReleases(modName: string) { + const url = `https://mods.factorio.com/api/mods/${modName}/full`; + const response = await fetch(url); + if (!response.ok) { + throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`); + } + return await response.json() as ModsInfoResponse["results"][number]; + } } diff --git a/packages/lib/src/ValueCache.ts b/packages/lib/src/ValueCache.ts new file mode 100644 index 00000000..5b8947de --- /dev/null +++ b/packages/lib/src/ValueCache.ts @@ -0,0 +1,64 @@ + +/** + * Represents an async function which can fetch a value for a ValueCache + */ +export interface ValueCacheFetch { + (): Promise; +} + +/** + * In-memory single value cache + * + * Stores the last time a value was fetched and measures staleness + * Concurrent calls will share the same in-flight fetch + */ +export default class ValueCache { + private ongoingFetch: Promise | null = null; + private timestamp: number | null = null; + private value: T | null = null; // Only valid when timestamp is not null + + /** + * Create a new in memory single value cache + * + * @param fetch A function that fetches and returns a fresh value + */ + constructor( + private readonly fetch: ValueCacheFetch, + ) {} + + /** + * Get the cached value, will fetch a fresh value if stale or missing + * + * @param maxAgeMs Maximum allowed cache age in milliseconds. + * Defaults to 0, meaning it will always fetch a new value. + * @throws TypeError if maxAgeMs is invalid + * @throws Propagates errors from underlying fetch method + */ + get(maxAgeMs: number = 0): Promise { + if (maxAgeMs < 0) { + throw new TypeError("maxAgeMs must be a non-negative number"); + } + + const now = Date.now(); + + if (this.timestamp && now - this.timestamp < maxAgeMs) { + return Promise.resolve(this.value as T); + } + + if (this.ongoingFetch) { + return this.ongoingFetch; + } + + this.ongoingFetch = this.fetch() + .then(value => { + this.timestamp = Date.now(); + this.value = value; + return value; + }) + .finally(() => { + this.ongoingFetch = null; + }); + + return this.ongoingFetch; + } +} diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index 138220fe..18d30609 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -35,6 +35,7 @@ export interface ControllerConfigFields { "controller.allow_remote_updates": boolean; "controller.allow_plugin_updates": boolean; "controller.allow_plugin_install": boolean; + [key: `${string}.load_plugin`]: boolean; } /** @@ -267,6 +268,7 @@ export interface HostConfigFields { "host.allow_remote_updates": boolean; "host.allow_plugin_updates": boolean; "host.allow_plugin_install": boolean; + [key: `${string}.load_plugin`]: boolean; } /** @@ -399,6 +401,7 @@ export interface InstanceConfigFields { "instance.assigned_host": number | null; "instance.auto_start": boolean; "instance.exclude_from_start_all": boolean; + [key: `${string}.load_plugin`]: boolean; "factorio.version": string; "factorio.executable_path": string | null; @@ -476,6 +479,7 @@ export class InstanceConfig extends classes.Config { restartRequired: true, type: "string", initialValue: "latest", + inputComponent: "target_version", }, "factorio.executable_path": { description: diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index e29db8d3..df97831a 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -5,9 +5,128 @@ import { Type, Static } from "@sinclair/typebox"; import * as libHash from "../hash"; import * as libSchema from "../schema"; import { findRoot } from "../zip_ops"; +import { ModRecord } from "./ModPack"; -import { integerModVersion, integerFactorioVersion, modVersionRegExp } from "./version"; +import { + ApiVersion, + ApiVersionSchema, + FullVersion, FullVersionSchema, integerFullVersion, + integerPartialVersion, + ModVersionEquality, +} from "./version"; +type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required"; + +export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version"; +const UnsatisfiedSeverity: Record = { + "incompatible": 1, "missing_dependency": 2, "wrong_version": 3, +}; + +const lettersRegex = /^[a-zA-Z]+$/; + +export class ModDependency { + public type: ModDependencyType; + public name: string; + public version: ModVersionEquality | undefined; + + static getTypeFromPrefix(prefix: string): ModDependencyType { + switch (prefix) { + case "!": + return "incompatible"; + case "?": + return "optional"; + case "(?)": + return "hidden"; + case "~": + return "unordered"; + case "": + return "required"; + default: + throw new Error(`Invalid dependency prefix: ${prefix}`); + } + } + + constructor ( + public specification: string, + ) { + const parts = specification.split(" "); + + // Fix mods which include spaces in their name, joins consecutive words together + let index = 0; + while (index < parts.length - 1) { + const current = parts[index]; + const next = parts[index + 1]; + if (lettersRegex.test(current) && lettersRegex.test(next)) { + parts[index] = `${current} ${next}`; + parts.splice(index + 1, 1); + } else { + index += 1; + } + } + + // Parse the dependency specification + switch (parts.length) { + case 1: + this.type = "required"; + this.name = parts[0]; + break; + case 2: + this.type = ModDependency.getTypeFromPrefix(parts[0]); + this.name = parts[1]; + break; + case 3: + this.type = "required"; + this.name = parts[0]; + this.version = ModVersionEquality.fromParts(parts[1], parts[2]); + break; + case 4: + this.type = ModDependency.getTypeFromPrefix(parts[0]); + this.name = parts[1]; + this.version = ModVersionEquality.fromParts(parts[2], parts[3]); + break; + default: + throw new Error(`Invalid dependency specification: ${specification}`); + } + } + + checkUnsatisfiedReason(mods: (ModInfo | ModRecord)[]): ModDependencyUnsatisfiedReason | undefined { + const mod = mods.find(m => m.name === this.name); + if (mod === undefined) { + return ["unordered", "required"].includes(this.type) ? "missing_dependency" : undefined; + } else if (this.type === "incompatible") { + return "incompatible"; + } else if (this.version && !this.version.testVersion(mod.version)) { + return "wrong_version"; + } + return undefined; + } + + isSatisfied(mods: (ModInfo | ModRecord)[]) { + return this.checkUnsatisfiedReason(mods) === undefined; + } + + get incompatible() { + return this.type === "incompatible"; + } + + get required() { + return this.type === "unordered" || this.type === "required"; + } + + get optional() { + return this.type === "hidden" || this.type === "optional"; + } + + static jsonSchema = Type.String(); + + static fromJSON(json: Static) { + return new this(json); + } + + toJSON() { + return this.specification; + } +} /** * Info about a mod available on the controller. @@ -35,13 +154,13 @@ export default class ModInfo { * Version of the mod. * Sourced from info.json. */ - version = ""; + version = "0.0.0" as FullVersion; /** * Integer representation of the version */ get integerVersion() { - return integerModVersion(this.version); + return integerFullVersion(this.version); } /** @@ -78,21 +197,38 @@ export default class ModInfo { * Major version of Factorio this mod supports. * Sourced from info.json. */ - factorioVersion = "0.12"; + factorioVersion = "0.12" as "0.12" | ApiVersion; /** * Integer representation of the factorioVersion * @type {number} */ get integerFactorioVersion() { - return integerFactorioVersion(this.factorioVersion); + return integerPartialVersion(this.factorioVersion); } /** - * Dependiences for this mod. + * Dependencies for this mod. * Sourced from info.json. */ - dependencies = ["base"]; + dependencies = [new ModDependency("base")]; + + /** + * Dependencies for this mod. + * As they would be represented in info.json. + */ + get dependencySpecifications() { + return this.dependencies.map(d => d.specification); + } + + checkDependencySatisfaction(mods: (ModInfo | ModRecord)[]) { + return this.dependencies + .map(d => d.checkUnsatisfiedReason(mods)) + .filter((v): v is ModDependencyUnsatisfiedReason => Boolean(v)) + .reduce((max, current) => ( + !max || UnsatisfiedSeverity[max] > UnsatisfiedSeverity[current] ? current : max + ), undefined); + } /** * Expected name of zip file containing this mod. @@ -107,7 +243,7 @@ export default class ModInfo { * @param version - Mod's version * @returns string containing {name}_{version}.zip */ - static filename(name: string, version: string) { + static filename(name: string, version: FullVersion) { return `${name}_${version}.zip`; } @@ -137,13 +273,13 @@ export default class ModInfo { // Content of info.json found in mod files static infoJsonSchema = Type.Object({ "name": Type.String(), - "version": Type.String(), + "version": FullVersionSchema, "title": Type.String(), "author": Type.String(), "contact": Type.Optional(Type.String()), "homepage": Type.Optional(Type.String()), "description": Type.Optional(Type.String()), - "factorio_version": Type.Optional(Type.String()), + "factorio_version": Type.Optional(ApiVersionSchema), "dependencies": Type.Optional(Type.Array(Type.String())), }); @@ -172,7 +308,7 @@ export default class ModInfo { if (json.homepage) { modInfo.homepage = json.homepage; } if (json.description) { modInfo.description = json.description; } if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; } - if (json.dependencies) { modInfo.dependencies = json.dependencies; } + if (json.dependencies) { modInfo.dependencies = json.dependencies.map(d => new ModDependency(d)); } // Additional data if (json.size) { modInfo.size = json.size; } @@ -195,8 +331,8 @@ export default class ModInfo { if (this.homepage) { json.homepage = this.homepage; } if (this.description) { json.description = this.description; } if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion; } - if (this.dependencies.length !== 1 || this.dependencies[0] !== "base") { - json.dependencies = this.dependencies; + if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") { + json.dependencies = this.dependencySpecifications; } if (this.size) { json.size = this.size; } if (this.mtimeMs) { json.mtime_ms = this.mtimeMs; } @@ -227,10 +363,6 @@ export default class ModInfo { throw new Error("Mod's info.json is not valid"); } - if (!modVersionRegExp.test(modInfo.version)) { - throw new Error(`Mod's version (${modInfo.version}) is invalid`); - } - const stat = await fs.stat(modPath); return this.fromJSON({ ...modInfo, diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index 74030b49..df3adf7d 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -5,9 +5,15 @@ import * as libSchema from "../schema"; import * as libLuaTools from "../lua_tools"; import ExportManifest from "./ExportManifest"; -import { integerFactorioVersion } from "./version"; import type { Logger } from "../logging"; +import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo"; + +import { + PartialVersion, PartialVersionSchema, integerPartialVersion, + FullVersion, FullVersionSchema, normaliseFullVersion, +} from "./version"; + export const ModSettingColor = Type.Object({ r: Type.Number(), @@ -40,17 +46,21 @@ export interface ModRecord { /** if mod is to be loaded. */ enabled: boolean, /** version of the mod. */ - version: string, + version: FullVersion, /** SHA1 hash of the zip file. */ sha1?: string, /** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */ - error?: "missing"|"bad_checksum", + error?: "missing" | "bad_checksum", + /** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an warning type. */ + warning?: ModDependencyUnsatisfiedReason | "wrong_factorio_version", + /** Used inside packages\web_ui\src\components\ModPackViewPage.tsx when there is no error. */ + info?: ModInfo, } const ModRecordJsonSchema = Type.Object({ "name": Type.String(), "enabled": Type.Boolean(), - "version": Type.String(), + "version": FullVersionSchema, "sha1": Type.Optional(Type.String()), }); @@ -82,13 +92,13 @@ export default class ModPack { /** * Version of Factorio this mod pack is for */ - factorioVersion = "1.1.0"; + factorioVersion = "1.1.0" as PartialVersion; /** * Integer representation of the factorioVersion */ get integerFactorioVersion() { - return integerFactorioVersion(this.factorioVersion); + return integerPartialVersion(this.factorioVersion); } /** @@ -136,7 +146,7 @@ export default class ModPack { "id": Type.Optional(Type.Integer()), "name": Type.String(), "description": Type.String(), - "factorio_version": Type.String(), + "factorio_version": PartialVersionSchema, "mods": Type.Array(ModRecordJsonSchema), "settings": Type.Object({ "startup": ModSettingsJsonSchema, @@ -385,39 +395,40 @@ export default class ModPack { /** Array of default mod packs which should exist on a newly installed cluster */ static defaultModPacks = [ - ...["0.17", "0.18", "1.0", "1.1", "2.0"] + ...(["0.17", "0.18", "1.0", "1.1", "2.0"] as const) .map(version => this.fromJSON({ name: `Base Game ${version}`, description: `Factorio ${version} with no extra mods.`, factorio_version: version, } as any)), - ...["2.0"] + ...(["2.0"] as const) .map(version => this.fromJSON({ name: `Space Age ${version}`, description: `Factorio ${version} with Space Age expansion.`, factorio_version: version, mods: [ - { name: "elevated-rails", enabled: true, version }, - { name: "quality", enabled: true, version }, - { name: "space-age", enabled: true, version }, + { name: "elevated-rails", enabled: true, version: normaliseFullVersion(version) }, + { name: "quality", enabled: true, version: normaliseFullVersion(version) }, + { name: "space-age", enabled: true, version: normaliseFullVersion(version) }, ], } as any)), ]; /** - * Returns an array of ModInfo containing all mods included in factorio + * Returns an array of ModRecord containing all mods included in factorio * * @param factorioVersion - Factorio version to get the default mods for * @return Built in mods for the given version */ - static getBuiltinMods(factorioVersion: string) { + static getBuiltinMods(factorioVersion: PartialVersion) { + factorioVersion = normaliseFullVersion(factorioVersion); let defaultMods: ModRecord[] = [ // "core" not included because core cannot be disabled { name: "base", enabled: true, version: factorioVersion }, ]; - const integerVersion = integerFactorioVersion(factorioVersion); - if (integerVersion >= integerFactorioVersion("1.2")) { + const integerVersion = integerPartialVersion(factorioVersion); + if (integerVersion >= integerPartialVersion("1.2")) { defaultMods = defaultMods.concat([ { name: "elevated-rails", enabled: false, version: factorioVersion }, { name: "quality", enabled: false, version: factorioVersion }, @@ -428,7 +439,7 @@ export default class ModPack { return defaultMods; } - static getBuiltinModNames(factorioVersion: string) { + static getBuiltinModNames(factorioVersion: PartialVersion) { return this.getBuiltinMods(factorioVersion).map(builtinMod => builtinMod.name); } } diff --git a/packages/lib/src/data/index.ts b/packages/lib/src/data/index.ts index cacc1211..5adf08e6 100644 --- a/packages/lib/src/data/index.ts +++ b/packages/lib/src/data/index.ts @@ -4,7 +4,7 @@ * @author Hornwitser */ export { default as ExportManifest } from "./ExportManifest"; -export { default as ModInfo } from "./ModInfo"; +export { default as ModInfo, ModDependency } from "./ModInfo"; export { ModSettingColor } from "./ModPack"; export type { ModSetting, ModRecord } from "./ModPack"; export { default as ModPack } from "./ModPack"; diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index dfcc368c..aa4232a5 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -2,6 +2,7 @@ import { Type, Static } from "@sinclair/typebox"; import { JsonString, StringEnum, StringKey, plainJson } from "./composites"; import { levels } from "../logging"; import { ControllerConfig, HostConfig } from "../config"; +import { ExternalFactorioVersionSchema } from "../external"; export class ControllerStopRequest { declare ["constructor"]: typeof ControllerStopRequest; @@ -391,3 +392,25 @@ export class DebugWsMessageEvent { return new this(json.direction, json.content); } } + +export class FactorioVersionsRequest { + declare ["constructor"]: typeof FactorioVersionsRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.external.get_factorio_versions" as const; + + constructor( + public maxAgeMs: number = 5 * 60 * 1000, // Default 5 minutes + ) {} + + static jsonSchema = Type.Object({ + "maxAgeMs": Type.Number(), + }); + + static fromJSON(json: Static) { + return new this(json.maxAgeMs); + } + + static Response = plainJson(Type.Array(ExternalFactorioVersionSchema)); +} diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index 08119562..a0a251d2 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -5,6 +5,7 @@ import { InstanceConfig } from "../config"; import type { IControllerUser } from "./User"; import type { MessageRequest } from "./messages_core"; import { CollectorResultSerialized } from "../prometheus"; +import { TargetVersion, TargetVersionSchema, PartialVersion, PartialVersionSchema } from "./version"; export const InstanceStatus = StringEnum([ @@ -42,7 +43,7 @@ export class InstanceDetails { public assignedHost: number | undefined, public gamePort: number | undefined, public status: InstanceStatus, - public factorioVersion: string | undefined, + public factorioVersion: TargetVersion | undefined, /** Millisecond Unix timestamp this entry was last updated at */ public updatedAtMs = 0, public excludeFromStartAll = false, @@ -57,7 +58,7 @@ export class InstanceDetails { "unknown", "unassigned", "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", "deleted", ]), - "factorioVersion": Type.Optional(Type.String()), + "factorioVersion": Type.Optional(TargetVersionSchema), "updatedAtMs": Type.Optional(Type.Number()), "excludeFromStartAll": Type.Optional(Type.Boolean()), }); @@ -744,8 +745,8 @@ export class HostInstanceUpdate { constructor( public config: Static, public status: InstanceStatus, - public gamePort: undefined | number, - public factorioVersion: undefined | string, + public gamePort: number | undefined, + public factorioVersion: PartialVersion | undefined, ) { } static jsonSchema = Type.Object({ @@ -754,7 +755,7 @@ export class HostInstanceUpdate { "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", ]), "gamePort": Type.Optional(Type.Number()), - "factorioVersion": Type.Optional(Type.String()), + "factorioVersion": Type.Optional(PartialVersionSchema), }); static fromJSON(json: Static) { @@ -852,7 +853,7 @@ export class InstanceStatusChangedEvent { public instanceId: number, public status: InstanceStatus, public gamePort?: number, - public factorioVersion?: string, + public factorioVersion?: TargetVersion, ) { } static jsonSchema = Type.Object({ @@ -861,7 +862,7 @@ export class InstanceStatusChangedEvent { "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", ]), "gamePort": Type.Optional(Type.Integer()), - "factorioVersion": Type.Optional(Type.String()), + "factorioVersion": Type.Optional(TargetVersionSchema), }); static fromJSON(json: Static) { diff --git a/packages/lib/src/data/messages_mod.ts b/packages/lib/src/data/messages_mod.ts index b5a4d17a..dc147b3d 100644 --- a/packages/lib/src/data/messages_mod.ts +++ b/packages/lib/src/data/messages_mod.ts @@ -1,8 +1,14 @@ import { Type, Static } from "@sinclair/typebox"; -import ModInfo from "./ModInfo"; +import ModInfo, { ModDependency } from "./ModInfo"; import ModPack from "./ModPack"; import { JsonString, jsonArray } from "./composites"; +import { + FullVersion, FullVersionSchema, + ApiVersion, ApiVersionSchema, normaliseApiVersion, + ModVersionEquality, +} from "./version"; + export class ModPackGetRequest { declare ["constructor"]: typeof ModPackGetRequest; @@ -113,13 +119,13 @@ export class ModGetRequest { constructor( public name: string, - public version: string, + public version: FullVersion, public sha1?: string, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": Type.String(), + "version": FullVersionSchema, "sha1": Type.Optional(Type.String()), }); @@ -148,7 +154,7 @@ export class ModSearchRequest { constructor( public query: string, - public factorioVersion: string, + public factorioVersion: ApiVersion, public page: number, public pageSize?: number, public sort?: string, @@ -157,7 +163,7 @@ export class ModSearchRequest { static jsonSchema = Type.Object({ "query": Type.String(), - "factorioVersion": Type.String(), + "factorioVersion": ApiVersionSchema, "page": Type.Integer(), "pageSize": Type.Optional(Type.Integer()), "sort": Type.Optional(Type.String()), @@ -199,9 +205,9 @@ export class ModSearchRequest { // Define the structure for the latest release info from the portal export const ModPortalReleaseSchema = Type.Object({ - version: Type.String(), + version: FullVersionSchema, // Match the structure from ModStore's ModRelease/ModDetails - info_json: Type.Object({ factorio_version: Type.String() }), + info_json: Type.Object({ factorio_version: ApiVersionSchema }), released_at: Type.String(), // ISO 8601 date string download_url: Type.String(), file_name: Type.String(), @@ -229,20 +235,13 @@ export class ModPortalGetAllRequest { static dst = "controller" as const; static permission = "core.mod.search_portal" as const; - // Define allowed Factorio versions - static allowedVersions = [ - Type.Literal("0.13"), Type.Literal("0.14"), Type.Literal("0.15"), - Type.Literal("0.16"), Type.Literal("0.17"), Type.Literal("0.18"), - Type.Literal("1.0"), Type.Literal("1.1"), Type.Literal("2.0"), - ] as const; - constructor( - public factorioVersion: Static, + public factorioVersion: ApiVersion, public hide_deprecated?: boolean, ) { } static jsonSchema = Type.Object({ - "factorioVersion": Type.Union([...this.allowedVersions]), // Use Union for validation + "factorioVersion": ApiVersionSchema, "hide_deprecated": Type.Optional(Type.Boolean()), }); @@ -277,13 +276,13 @@ export class ModDownloadRequest { constructor( public name: string, - public version: string, + public version: FullVersion, public sha1?: string, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": Type.String(), + "version": FullVersionSchema, "sha1": Type.Optional(Type.String()), }); @@ -303,12 +302,12 @@ export class ModDeleteRequest { constructor( public name: string, - public version: string, + public version: FullVersion, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": Type.String(), + "version": FullVersionSchema, }); static fromJSON(json: Static) { @@ -356,6 +355,16 @@ export class ModUpdatesEvent { } } +export interface ModNameVersionPair { + name: string, + version: ModVersionEquality, +} + +export const ModNameVersionPairSchema = Type.Object({ + "name": Type.String(), + "version": ModVersionEquality.jsonSchema, +}); + /** * Request mods to be downloaded from the Factorio mod portal to the controller. * @@ -363,8 +372,7 @@ export class ModUpdatesEvent { * Requires Factorio credentials to be configured on the controller if the portal * requires authentication for downloads. * - * @param name - Name of the mod to download. - * @param version - Version of the mod to download. + * @param mods - Array of mods to be downloaded. * @param factorioVersion - Factorio version context for the download. */ export class ModPortalDownloadRequest { @@ -373,21 +381,93 @@ export class ModPortalDownloadRequest { static src = "control" as const; static dst = "controller" as const; static permission = "core.mod.download_from_portal" as const; - static Response = ModInfo; + static Response = jsonArray(ModInfo); constructor( - public name: string, - public version: string, - public factorioVersion: string, + public mods: ModNameVersionPair[], + public factorioVersion: ApiVersion, ) { } static jsonSchema = Type.Object({ - "name": Type.String(), - "version": Type.String(), - "factorioVersion": Type.String(), + "mods": Type.Array(ModNameVersionPairSchema), + "factorioVersion": ApiVersionSchema, }); static fromJSON(json: Static) { - return new this(json.name, json.version, json.factorioVersion); + return new this( + json.mods.map(mod => ({ + name: mod.name, version: ModVersionEquality.fromJSON(mod.version), + })), + json.factorioVersion, + ); } } + +/** + * Request mods dependencies to be resolved using the Factorio mod portal. + * + * @param mods - Array of dependencies to resolve. + * @param factorioVersion - Factorio version context for resolution. + */ +export class ModDependencyResolveRequest { + declare ["constructor"]: typeof ModDependencyResolveRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.mod.search_portal" as const; + + constructor( + public mods: ModDependency[], + public factorioVersion: ApiVersion, + public checkForUpdates: boolean = false, + ) { } + + static jsonSchema = Type.Object({ + "mods": Type.Array(ModDependency.jsonSchema), + "factorioVersion": ApiVersionSchema, + "checkForUpdates": Type.Boolean(), + }); + + static fromJSON(json: Static) { + return new this(json.mods.map(spec => new ModDependency(spec)), json.factorioVersion, json.checkForUpdates); + } + + static fromModPack(modPack: ModPack, checkForUpdates?: boolean) { + const equality = checkForUpdates ? ">=" : "="; + return new this( + [...modPack.mods.values()] + .map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)), + normaliseApiVersion(modPack.factorioVersion), + checkForUpdates, + ); + } + + static fromModPackEnabled(modPack: ModPack, checkForUpdates?: boolean) { + const equality = checkForUpdates ? ">=" : "="; + return new this( + [...modPack.mods.values()] + .filter(mod => mod.enabled) + .map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)), + normaliseApiVersion(modPack.factorioVersion), + checkForUpdates, + ); + } + + static Response = class ModDependencyResolveResponse { + constructor( + public dependencies: ModInfo[], + public incompatible: string[], + public missing: string[], + ) {} + + static jsonSchema = Type.Object({ + "dependencies": Type.Array(ModInfo.jsonSchema), + "incompatible": Type.Array(Type.String()), + "missing": Type.Array(Type.String()), + }); + + static fromJSON(json: Static) { + return new this(json.dependencies.map(d => ModInfo.fromJSON(d)), json.incompatible, json.missing); + } + }; +} diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts index bd79bece..882992b2 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -1,33 +1,241 @@ // Helper functions for dealing with factorio version numbers +import { Static, Type } from "@sinclair/typebox"; /** - * Integer representation of a mod version + * Integer representation of a version string. * * Suitable for sorting and comparing version numbers which may have different * number of leading zeros in them. - * @param version - string representation of version. - * @returns numeric representation. */ -export function integerModVersion(version: string) { +export type IntegerVersion = number & { [integerVersionSymbol]: void }; +declare const integerVersionSymbol: unique symbol; + +/** + * Allowed version equalities as defined within mod info json + * https://lua-api.factorio.com/latest/auxiliary/mod-structure.html#Dependency + */ +const versionEqualities = ["<", "<=", "=", ">=", ">"] as const; +export const VersionEqualitySchema = Type.Union(versionEqualities.map(v => Type.Literal(v))); +export type VersionEquality = Static; + +export function isVersionEquality(input: string): input is VersionEquality { + return versionEqualities.includes(input as any); +} + +/** + * Matches valid factorio versions accepts by the web API + * https://wiki.factorio.com/Mod_portal_API#/api/mods (see version enum) + */ +export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"] as const; +export const ApiVersionSchema = Type.Union(ApiVersions.map(v => Type.Literal(v))); +export type ApiVersion = Static; + +export function isApiVersion(input: string): input is ApiVersion { + return ApiVersions.includes(input as any); +} + +export function normaliseApiVersion(version: PartialVersion) { + const parts = version.split("."); + const apiVersion = `${parts[0]}.${parts[1]}`; + if (!isApiVersion(apiVersion)) { + throw new Error(`Factorio version not supported by mod portal: ${apiVersion}`); + } + return apiVersion; +} + +/** + * Matches valid mod versions, where all parts are specified. + */ +const fullVersionRegExp = /^\d+\.\d+\.\d+$/; +export type FullVersion = `${number}.${number}.${number}`; +export const FullVersionSchema = Type.Unsafe( + Type.String({ pattern: fullVersionRegExp.source }) +); + +export function isFullVersion(input: string): input is FullVersion { + return fullVersionRegExp.test(input); +} + +export function integerFullVersion(version: FullVersion) { const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10)); - return major * 0x100000000 + minor * 0x10000 + sub; // Can't use bitwise here because this is 48-bits. + // Can't use bitwise here because this is 48-bits. + return major * 0x100000000 + minor * 0x10000 + sub as IntegerVersion; +} + +export function normaliseFullVersion(version: PartialVersion) { + const parts = version.split("."); + return (parts.length === 3 ? version : `${version}.0`) as FullVersion; } /** - * Integer representation of a factorio version - * - * Suitable for sorting and comparing version numbers which may have different - * number of leading zeros in them. - * @param version - string representation of version. - * @returns numeric representation. + * Matches valid factorio versions and mod dependencies specifications where 2 or 3 parts are specified. */ -export function integerFactorioVersion(version: string) { - const [main, major, minor] = version.split(".").map(n => Number.parseInt(n, 10)); - return main * 0x100000000 + major * 0x10000 + (minor || 0); // Can't use bitwise here because this is 48-bits. +const partialVersionRegExp = /^\d+\.\d+(?:\.\d+)?$/; +export type PartialVersion = FullVersion | `${number}.${number}`; +export const PartialVersionSchema = Type.Unsafe( + Type.String({ pattern: partialVersionRegExp.source }) +); + +export function isPartialVersion(input: string): input is PartialVersion { + return partialVersionRegExp.test(input); +} + +export function integerPartialVersion(version: PartialVersion) { + const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10)); + // Can't use bitwise here because this is 48-bits. sub is optional and defaults to 0. + return major * 0x100000000 + minor * 0x10000 + (sub || 0) as IntegerVersion; } /** - * Matches valid mod versions. + * Matches valid factorio target versions, this is the same as partial but also accepts "latest". */ -export const modVersionRegExp = /^\d+\.\d+\.\d+$/; +export type TargetVersion = PartialVersion | "latest"; +export const TargetVersionSchema = Type.Union([ + PartialVersionSchema, + Type.Literal("latest"), +]); + +export function isTargetVersion(input: string): input is TargetVersion { + return input === "latest" || partialVersionRegExp.test(input); +} + +/** + * Represents a mod version paired with an equality which can be tested against + */ +export class ModVersionEquality { + public integerVersion: IntegerVersion; + + constructor( + public equality: VersionEquality, + public version: PartialVersion, + ) { + this.integerVersion = integerPartialVersion(version); + if (!isVersionEquality(this.equality)) { + throw new Error("Invalid equality"); + } + if (!isPartialVersion(this.version)) { + throw new Error("Invalid version"); + } + } + + testIntegerVersion(other: IntegerVersion) { + switch (this.equality) { + case "<": + return other < this.integerVersion; + case "<=": + return other <= this.integerVersion; + case "=": + return other === this.integerVersion; + case ">=": + return other >= this.integerVersion; + case ">": + return other > this.integerVersion; + default: + throw new Error("unreachable"); + } + } + + testVersion(version: PartialVersion) { + return this.testIntegerVersion(integerPartialVersion(version)); + } + + toString() { + return `${this.equality} ${this.version}`; + } + + static fromString(version: string) { + const parts = version.split(" "); + return parts.length === 1 ? this.fromParts("=", parts[0]) : this.fromParts(parts[0], parts[1]); + } + + static fromParts(equality: string, version: string) { + if (!isVersionEquality(equality)) { + throw new Error(`Unknown version equality: ${equality}`); + } + if (!isPartialVersion(version)) { + throw new Error(`Malformed version string: ${version}`); + } + return new this(equality, version); + } + + static jsonSchema = Type.String(); + + static fromJSON(json: Static) { + return this.fromString(json); + } + + toJSON() { + return this.toString(); + } +} + +/** + * Represents a range of mod versions which can be tested against + */ +export class ModVersionRange { + constructor( + public minVersion = new ModVersionEquality(">=", "0.0.0"), + public maxVersion = new ModVersionEquality("<=", "65535.65535.65535"), + ) { + if (minVersion.equality === "<" || minVersion.equality === "<=") { + throw new Error("Minimum version can not use < or <="); + } + if (maxVersion.equality === ">" || maxVersion.equality === ">=") { + throw new Error("Maximum version can not use > or >="); + } + } + + get valid() { + return this.minVersion.testIntegerVersion(this.maxVersion.integerVersion) + && this.maxVersion.testIntegerVersion(this.minVersion.integerVersion); + } + + invalidate() { + this.maxVersion = new ModVersionEquality(">", "0.0.0"); + } + + testIntegerVersion(other: IntegerVersion) { + return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other); + } + + testVersion(version: PartialVersion) { + return this.testIntegerVersion(integerPartialVersion(version)); + } + + combineVersion(other: ModVersionEquality) { + switch (other.equality) { + case "<": + if (other.integerVersion <= this.maxVersion.integerVersion) { + this.maxVersion = other; + } + break; + case "<=": + if (other.integerVersion < this.maxVersion.integerVersion) { + this.maxVersion = other; + } + break; + case "=": + if (this.testIntegerVersion(other.integerVersion)) { + this.minVersion = other; + this.maxVersion = other; + } else { + // Make invalid as it could not be satisfied + this.invalidate(); + } + break; + case ">=": + if (other.integerVersion > this.minVersion.integerVersion) { + this.minVersion = other; + } + break; + case ">": + if (other.integerVersion >= this.minVersion.integerVersion) { + this.minVersion = other; + } + break; + default: + throw new Error("unreachable"); + } + } +} diff --git a/packages/lib/src/external/FactorioVersions.ts b/packages/lib/src/external/FactorioVersions.ts new file mode 100644 index 00000000..b9041fc0 --- /dev/null +++ b/packages/lib/src/external/FactorioVersions.ts @@ -0,0 +1,69 @@ +import { Static, Type } from "@sinclair/typebox"; +import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data"; + +const ARCHIVE_URL = "https://factorio.com/download/archive"; + +/** + * Represents the parsed result of a factorio version + */ +export const ExternalFactorioVersionSchema = Type.Object({ + stable: Type.Boolean(), + version: FullVersionSchema, + headlessUrl: Type.String(), +}); + +export type ExternalFactorioVersion = Static + +/** + * Fetch all factorio versions that support headless + * + * @returns A list of all factorio versions and their download URL + * @throws Network errors and non-ok status responses + */ +export async function fetchFactorioVersions(): Promise { + let res; + + try { + res = await fetch(ARCHIVE_URL); + } catch (err) { + throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: ${err}`); + } + + if (!res.ok) { + throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: HTTP ${res.status} ${res.statusText}`); + } + + const html = await res.text(); + return parseFactorioVersions(html); +} + +/** + * Parse all versions from the factorio archive webpage + * + * @param html The HTML content of the archive page + * @returns A list of all factorio versions and their download URL + */ +function parseFactorioVersions(html: string): ExternalFactorioVersion[] { + const versions = new Map(); + + // Match 2.0.72 + // eslint-disable-next-line max-len + const anchorRegex = /]*class="[^"]*version-button-(?stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?\d+\.\d+.\d+)"[^>]*>/g; + + let match: RegExpExecArray | null; + const minimumMultiplayerVersion = integerFullVersion("0.12.35"); + while ((match = anchorRegex.exec(html)) !== null) { + const kind = match.groups?.kind; + const version = match.groups?.version; + + if (kind && version && isFullVersion(version) && integerFullVersion(version) >= minimumMultiplayerVersion) { + versions.set(version, { + version: version, + stable: kind === "stable", + headlessUrl: `www.factorio.com/get-download/${version}/headless/linux64`, + }); + } + } + + return [...versions.values()]; +} diff --git a/packages/lib/src/external/index.ts b/packages/lib/src/external/index.ts new file mode 100644 index 00000000..92cc1f2e --- /dev/null +++ b/packages/lib/src/external/index.ts @@ -0,0 +1,5 @@ +/** + * Library for interfacing with external apis + * @module lib/external + */ +export * from "./FactorioVersions"; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 65505bac..299cf3ae 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -35,6 +35,7 @@ export const dataClasses: (RequestClass | EventClass) controller.SystemInfoUpdateEvent, controller.DebugDumpWsRequest, controller.DebugWsMessageEvent, + controller.FactorioVersionsRequest, subscriptions.SubscriptionRequest, @@ -108,6 +109,7 @@ export const dataClasses: (RequestClass | EventClass) mod.ModPackUpdatesEvent, mod.ModUpdatesEvent, mod.ModPortalDownloadRequest, + mod.ModDependencyResolveRequest, role.PermissionListRequest, role.RoleListRequest, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index fb7395b9..0507a2a4 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -568,3 +568,9 @@ definePermission({ title: "Dump WebSocket", description: "Dump all WebSocket communicatation from the controller.", }); + +definePermission({ + name: "core.external.get_factorio_versions", + title: "Get factorio versions", + description: "Get list of all factorio versions from Wube", +}); diff --git a/packages/lib/src/plugin_loader.ts b/packages/lib/src/plugin_loader.ts index 45279946..115aef46 100644 --- a/packages/lib/src/plugin_loader.ts +++ b/packages/lib/src/plugin_loader.ts @@ -4,6 +4,7 @@ * @module lib/plugin_loader */ import path from "path"; +import fs from "fs/promises"; import * as libErrors from "./errors"; import * as libPlugin from "./plugin"; import { logger } from "./logging"; @@ -29,8 +30,13 @@ export async function loadPluginInfos(pluginList: Map) { // Check if plugin path exists, otherwise remove it try { require.resolve(pluginPath); - } catch (err) { - logger.error(`Plugin path ${pluginPath} does not exist, not loading ${pluginName}`); + } catch { + let errMsg = `Plugin path ${pluginPath} does not exist`; + try { + await fs.access(pluginPath, fs.constants.F_OK); + errMsg = `Plugin path ${pluginPath} missing index or main file`; + } catch {} + logger.error(`${errMsg}, not loading ${pluginName}`); pluginList.delete(pluginName); continue; } diff --git a/packages/lib/src/rce_ops.ts b/packages/lib/src/rce_ops.ts index f9195ae4..b5ad5be5 100644 --- a/packages/lib/src/rce_ops.ts +++ b/packages/lib/src/rce_ops.ts @@ -6,7 +6,7 @@ import { RequestError } from "./errors"; import { PluginNodeEnvInfo } from "./plugin"; const execAsync = util.promisify(exec); -async function isDev() { +function isDev() { // dev: /packages/lib/dist/src/rce_ops.js // prod: /node_modules/@clusterio/lib/dist/src/rce_ops.js return __dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio" diff --git a/packages/lib/src/shared_commands.ts b/packages/lib/src/shared_commands.ts index e65f925f..d9f6eb09 100644 --- a/packages/lib/src/shared_commands.ts +++ b/packages/lib/src/shared_commands.ts @@ -8,6 +8,7 @@ import * as libConfig from "./config"; import * as libFileOps from "./file_ops"; import { logger } from "./logging"; import * as libHelpers from "./helpers"; +import { LockFile } from "./LockFile"; function print(...content: any[]) { @@ -130,11 +131,12 @@ export function configCommand(yargs: any) { * * @param args - yargs args object. * @param instance - Config instance. - * @param configPath - Path to configuration file. + * @param lockFile - Lockfile required if attempting to write */ export async function handleConfigCommand( args: Record, instance: libConfig.Config, + lockFile: LockFile, ) { let command = (args._ as string[])[1]; @@ -155,6 +157,8 @@ export async function handleConfigCommand( } } else if (command === "set") { + await lockFile.acquire(); + if (args.stdin) { args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); @@ -172,5 +176,7 @@ export async function handleConfigCommand( throw err; } } + + await lockFile.release(); } } diff --git a/packages/lib/src/subscriptions.ts b/packages/lib/src/subscriptions.ts index f8f64c80..1ece2bd0 100644 --- a/packages/lib/src/subscriptions.ts +++ b/packages/lib/src/subscriptions.ts @@ -195,25 +195,19 @@ export class SubscriptionController { throw new Error(`Event ${event.eventName} is not a registered as subscribable`); } if (event.subscribe === false) { - // Unsubscribe logic const subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src)); if (!subscriber) { return false; } - if (event.filters === undefined) { + if (event.filters === undefined || subscriber.filters === undefined) { // Remove entire subscription for this address eventData.subscriptions = eventData.subscriptions.filter(sub => !sub.dst.addressedTo(src)); } else { const filtersToRemove = Array.isArray(event.filters) ? event.filters : [event.filters]; - if (subscriber.filters === undefined) { - // Was subscribed to all, remove subscription entirely - eventData.subscriptions = eventData.subscriptions.filter(sub => !sub.dst.addressedTo(src)); - } else { - subscriber.filters = subscriber.filters.filter(f => !filtersToRemove.includes(f)); - if (subscriber.filters.length === 0) { - // No filters left, remove subscription - eventData.subscriptions = eventData.subscriptions.filter(sub => sub !== subscriber); - } + subscriber.filters = subscriber.filters.filter(f => !filtersToRemove.includes(f)); + if (subscriber.filters.length === 0) { + // No filters left, remove subscription + eventData.subscriptions = eventData.subscriptions.filter(sub => sub !== subscriber); } } } else { diff --git a/packages/lib/tsconfig.browser.json b/packages/lib/tsconfig.browser.json index 059b1ce6..05554ac8 100644 --- a/packages/lib/tsconfig.browser.json +++ b/packages/lib/tsconfig.browser.json @@ -6,6 +6,7 @@ "./src/api.ts", "./src/config/*.ts", "./src/data/*.ts", + "./src/external/*.ts", "./src/datastore.ts", "./src/errors.ts", "./src/factorio/exchange_string.ts", @@ -25,6 +26,7 @@ "./src/ExponentialBackoff.ts", "./src/RateLimiter.ts", "./src/TypedEventEmitter.ts", + "./src/ValueCache.ts", ], "compilerOptions": { "rootDir": ".", diff --git a/packages/web_ui/package.json b/packages/web_ui/package.json index ea3f0ba1..7cfe8161 100644 --- a/packages/web_ui/package.json +++ b/packages/web_ui/package.json @@ -3,7 +3,7 @@ "description": "Clusterio web interface implementation", "author": "Hornwitser", "license": "MIT", - "version": "2.0.0-alpha.21", + "version": "2.0.0-alpha.22b", "browser": "src/index.ts", "main": "dist/browser/src/index.js", "scripts": { @@ -21,7 +21,7 @@ "@ant-design/icons": "^5.6.1", "@clusterio/lib": "workspace:*", "@sinclair/typebox": "^0.30.4", - "@swc/core": "^1.11.1", + "@swc/core": "^1.13.3", "antd": "^5.24.2", "assert": "^2.0.0", "browserify-zlib": "^0.2.0", diff --git a/packages/web_ui/src/bootstrap.tsx b/packages/web_ui/src/bootstrap.tsx index 36c018f4..3b64bbd5 100644 --- a/packages/web_ui/src/bootstrap.tsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -9,6 +9,8 @@ import App from "./components/App"; import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin"; import InputRole from "./components/InputRole"; import InputModPack from "./components/InputModPack"; +import InputTargetVersion from "./components/InputTargetVersion"; +import InputPartialVersion from "./components/InputPartialVersion"; import { Control, ControlConnector } from "./util/websocket"; const { ConsoleTransport, WebConsoleFormat, logger } = lib; @@ -109,6 +111,8 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con function inputComponentsFromPlugins(plugins: Map) { const inputComponents: Record = { + "partial_version": InputPartialVersion, + "target_version": InputTargetVersion, "mod_pack": InputModPack, "role": InputRole, }; diff --git a/packages/web_ui/src/components/ControllerPage.tsx b/packages/web_ui/src/components/ControllerPage.tsx index 9e241bb5..4a65b415 100644 --- a/packages/web_ui/src/components/ControllerPage.tsx +++ b/packages/web_ui/src/components/ControllerPage.tsx @@ -34,9 +34,10 @@ function ControllerControlButton({ canRestart, restartRequired }: ControllerCont actions.push({ key: "restart", label: ( - - Restart {restartRequired ? : undefined} - + !restartRequired ? "Restart" : + + Restart + ), onClick: () => { control.send( @@ -81,6 +82,7 @@ export default function ControllerPage() { const [systems] = useSystems(); const system = systems.get("controller"); const [maxLevel, setMaxLevel] = useState("info"); + const canShowNpm = hasNpmButtonPermission(true); return { - hasNpmButtonPermission(true) + canShowNpm && } } diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index 93374cfa..f7eddcf8 100644 --- a/packages/web_ui/src/components/HostViewPage.tsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -37,9 +37,10 @@ function HostControlButton({ hostId, canRestart, restartRequired }: HostControlB actions.push({ key: "restart", label: ( - - Restart {restartRequired ? : undefined} - + !restartRequired ? "Restart" : + + Restart + ), onClick: () => { control.sendTo( @@ -90,6 +91,7 @@ export default function HostViewPage() { const system = systems.get(hostId); const [host, synced] = useHost(hostId); const [maxLevel, setMaxLevel] = useState("info"); + const canShowNpm = hasNpmButtonPermission(false); const hostInstances = new Map([...instances].filter(([_id, instance]) => instance.assignedHost === hostId)); let nav = [{ name: "Hosts", path: "/hosts" }, { name: host?.name ?? String(hostId) }]; @@ -104,7 +106,7 @@ export default function HostViewPage() { ; } - let hostButtons = + let hostButtons = { account.hasPermission("core.host.revoke_token") @@ -124,10 +126,9 @@ export default function HostViewPage() { } - { - hasNpmButtonPermission(false) - && - } + {canShowNpm && ( + + )} ; return diff --git a/packages/web_ui/src/components/InputPartialVersion.tsx b/packages/web_ui/src/components/InputPartialVersion.tsx new file mode 100644 index 00000000..250b5de6 --- /dev/null +++ b/packages/web_ui/src/components/InputPartialVersion.tsx @@ -0,0 +1,68 @@ +import React, { useContext, useEffect, useState } from "react"; +import { TreeSelect } from "antd"; + +import * as lib from "@clusterio/lib"; +import { InputComponentProps } from "../BaseWebPlugin"; +import { useAccount } from "../model/account"; +import ControlContext from "./ControlContext"; + +const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false }; + +export default function InputPartialVersion ( + props: Omit & { + fieldDefinition?: lib.FieldDefinition; + className?: string; + } +) { + const fieldDefinition = props.fieldDefinition || defaultFieldDefinition; + const [versions, setVersions] = useState(lib.ApiVersions); + const control = useContext(ControlContext); + const account = useAccount(); + + useEffect(() => { + (async () => { + if (account.hasPermission("core.external.get_factorio_versions")) { + const res = await control.factorioVersions.get(5 * 60 * 1000); + setVersions(res.map(v => v.version)); + } + })(); + }, [control]); + + // Split the versions into groups based on major minor + const groups = new Map(); + + for (const version of versions) { + const [major, minor] = version.split("."); + const key = `${major}.${minor}` as lib.PartialVersion; + + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key)!.push(version); + } + + // Construct the tree to be disabled + const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => ( + { + title: majorMinor, + value: majorMinor, + key: majorMinor, + children: patchVersions.map((v) => ({ + title: v, + value: v, + key: v, + })), + } + )); + + return props.onChange(value ?? null)} + value={props.value} + treeData={tree} + allowClear={fieldDefinition.optional} + className={props.className} + disabled={props.disabled} + />; +} diff --git a/packages/web_ui/src/components/InputTargetVersion.tsx b/packages/web_ui/src/components/InputTargetVersion.tsx new file mode 100644 index 00000000..3e52d33d --- /dev/null +++ b/packages/web_ui/src/components/InputTargetVersion.tsx @@ -0,0 +1,71 @@ +import React, { useContext, useEffect, useState } from "react"; +import { TreeSelect } from "antd"; + +import * as lib from "@clusterio/lib"; +import { InputComponentProps } from "../BaseWebPlugin"; +import { useAccount } from "../model/account"; +import ControlContext from "./ControlContext"; + +const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false }; + +export default function InputTargetVersion ( + props: Omit & { + fieldDefinition?: lib.FieldDefinition; + className?: string; + } +) { + const fieldDefinition = props.fieldDefinition || defaultFieldDefinition; + const [versions, setVersions] = useState(lib.ApiVersions); + const control = useContext(ControlContext); + const account = useAccount(); + + useEffect(() => { + (async () => { + if (account.hasPermission("core.external.get_factorio_versions")) { + const res = await control.factorioVersions.get(5 * 60 * 1000); + setVersions(res.map(v => v.version)); + } + })(); + }, [control]); + + // Split the versions into groups based on major minor + const groups = new Map(); + + for (const version of versions) { + const [major, minor] = version.split("."); + const key = `${major}.${minor}` as lib.PartialVersion; + + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key)!.push(version); + } + + // Construct the tree to be disabled + const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => ( + { + title: majorMinor, + value: majorMinor, + key: majorMinor, + children: patchVersions.map((v) => ({ + title: v, + value: v, + key: v, + })), + } + )); + + // Add "latest" option for TargetVersion + tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] }); + + return props.onChange(value ?? null)} + value={props.value} + treeData={tree} + allowClear={fieldDefinition.optional} + className={props.className} + disabled={props.disabled} + />; +} diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx index fe299f00..46b75f9d 100644 --- a/packages/web_ui/src/components/InstanceList.tsx +++ b/packages/web_ui/src/components/InstanceList.tsx @@ -1,12 +1,14 @@ import React from "react"; import { useNavigate } from "react-router-dom"; -import { message, Button, Table } from "antd"; +import { message, Button, Table, Space } from "antd"; import CopyOutlined from "@ant-design/icons/CopyOutlined"; import type { SizeType } from "antd/es/config-provider/SizeContext"; import type { ColumnsType } from "antd/es/table"; import { useAccount } from "../model/account"; +import { useSystems } from "../model/system"; import { useHosts } from "../model/host"; +import { RestartRequired } from "./system_metrics"; import InstanceStatusTag from "./InstanceStatusTag"; import InstanceControlButton, { InstanceControlButtonPermissions } from "./InstanceControlButton"; import * as lib from "@clusterio/lib"; @@ -25,6 +27,7 @@ export default function InstanceList(props: InstanceListProps) { let account = useAccount(); let navigate = useNavigate(); let [hosts] = useHosts(); + const [systems] = useSystems(); function hostName(hostId?: number) { if (hostId === undefined) { @@ -34,10 +37,10 @@ export default function InstanceList(props: InstanceListProps) { } function integerFactorioVersionOrDefault(instance: lib.InstanceDetails) { - if (instance.factorioVersion === undefined) { + if (instance.factorioVersion === undefined || instance.factorioVersion === "latest") { return -1; } - return lib.integerFactorioVersion(instance.factorioVersion); + return lib.integerPartialVersion(instance.factorioVersion); } let columns: ColumnsType = [ @@ -50,12 +53,15 @@ export default function InstanceList(props: InstanceListProps) { { title: "Assigned Host", key: "assignedHost", - render: (_, instance) => e.stopPropagation()} - > - {hostName(instance.assignedHost)} - , + render: (_, instance) => + e.stopPropagation()} + > + {hostName(instance.assignedHost)} + + + , sorter: (a, b) => strcmp(hostName(a.assignedHost), hostName(b.assignedHost)), responsive: ["sm"], }, diff --git a/packages/web_ui/src/components/InstanceViewPage.tsx b/packages/web_ui/src/components/InstanceViewPage.tsx index ec1b28d6..69b5055c 100644 --- a/packages/web_ui/src/components/InstanceViewPage.tsx +++ b/packages/web_ui/src/components/InstanceViewPage.tsx @@ -141,7 +141,7 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) { } }, }; - return + return { account.hasAnyPermission(...InstanceControlButtonPermissions) && diff --git a/packages/web_ui/src/components/LoginForm.tsx b/packages/web_ui/src/components/LoginForm.tsx index e0d8ceb5..2fd64d3c 100644 --- a/packages/web_ui/src/components/LoginForm.tsx +++ b/packages/web_ui/src/components/LoginForm.tsx @@ -1,7 +1,8 @@ -import React, { Fragment, useContext } from "react"; +import React, { Fragment, useContext, useEffect, useState } from "react"; import { Button, Card, Divider, Form, Image, Input, Space, Typography } from "antd"; import LockOutlined from "@ant-design/icons/LockOutlined"; +import notify from "../util/notify"; import logo from "../images/logo.png"; import ControlContext from "./ControlContext"; @@ -36,6 +37,23 @@ function TokenAuth(props: SetTokenProps) { export default function LoginForm(props: SetTokenProps) { let plugins = useContext(ControlContext).plugins; + const [clusterName, setClusterName] = useState("Clusterio"); + + useEffect(() => { + (async () => { + try { + let response = await fetch(`${webRoot}api/cluster-name`); + if (response.ok) { + let data = await response.json(); + if (data.name) { + setClusterName(data.name); + } + } + } catch (err) { + notify("Failed to load cluster name"); + } + })(); + }, []); let loginForms = []; for (let plugin of plugins.values()) { @@ -50,7 +68,7 @@ export default function LoginForm(props: SetTokenProps) { return -

Clusterio

+

{clusterName}

Log in to the cluster using one of the methods below.
@@ -60,4 +78,3 @@ export default function LoginForm(props: SetTokenProps) { )}
; } - diff --git a/packages/web_ui/src/components/ModDetails.tsx b/packages/web_ui/src/components/ModDetails.tsx index 973480fb..1403bf6c 100644 --- a/packages/web_ui/src/components/ModDetails.tsx +++ b/packages/web_ui/src/components/ModDetails.tsx @@ -1,6 +1,9 @@ import React, { Fragment } from "react"; -import { Descriptions, Grid, Typography } from "antd"; +import { Descriptions, Grid, Tooltip, Typography, Space } from "antd"; import ExclamationCircleOutlined from "@ant-design/icons/ExclamationCircleOutlined"; +import FileUnknownOutlined from "@ant-design/icons/FileUnknownOutlined"; +import FileExclamationOutlined from "@ant-design/icons/FileExclamationOutlined"; +import FileSyncOutlined from "@ant-design/icons/FileSyncOutlined"; import * as lib from "@clusterio/lib"; @@ -8,11 +11,25 @@ const { useBreakpoint } = Grid; type ModDetailsProps = { mod: T; + mods?: lib.ModRecord[]; actions?: (mod: T) => React.JSX.Element; }; + export default function ModDetails(props: ModDetailsProps) { let screens = useBreakpoint(); - const mod: Partial & Partial = props.mod; + let mod: Partial & Partial = props.mod; + + if ("info" in mod) { + // Add the mod info if present in a mod record + mod = { ...mod, ...mod.info }; + } + + const depWarnings = new Map(); + if (mod.dependencies && props.mods) { + for (const dependency of mod.dependencies) { + depWarnings.set(dependency.name, dependency.checkUnsatisfiedReason(props.mods)); + } + } return (props: {Boolean(mod.homepage) && {mod.homepage}} {mod.name} {Boolean(mod.factorioVersion) - && {mod.factorioVersion} + && + + {mod.warning === "wrong_factorio_version" && + {" "} + } + {mod.factorioVersion} + + } {Boolean(mod.filename) && {mod.filename}} {mod.size !== undefined @@ -62,8 +86,22 @@ export default function ModDetails(props: {mod.dependencies && mod.dependencies.length && {mod.dependencies - .filter(e => !e.startsWith("(?)")) - .map((e, i) => {e}
) + .filter(e => e.type !== "hidden") + .map((e, i) => + + {depWarnings.get(e.name) === "incompatible" && + {" "} + } + {depWarnings.get(e.name) === "missing_dependency" && + {" "} + } + {depWarnings.get(e.name) === "wrong_version" && + {" "} + } + {e.specification} + +
+
) }
} diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index fd41acb2..fbf75bdf 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -1,19 +1,19 @@ import React, { Fragment, memo, useCallback, useEffect, useContext, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import { FieldData } from "rc-field-form/lib/interface"; + import { Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination, Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip, - TablePaginationConfig, + TablePaginationConfig, Alert, notification, } from "antd"; -import { FieldData } from "rc-field-form/lib/interface"; + +import { + ExportOutlined, FileUnknownOutlined, FileExclamationOutlined, FileSyncOutlined, + CloseOutlined, DeleteOutlined, ToolOutlined, PlusOutlined, CloudSyncOutlined, CloudDownloadOutlined, +} from "@ant-design/icons"; import type { SorterResult, FilterValue, TableCurrentDataSource } from "antd/es/table/interface"; -import ExportOutlined from "@ant-design/icons/ExportOutlined"; -import FileUnknownOutlined from "@ant-design/icons/FileUnknownOutlined"; -import FileExclamationOutlined from "@ant-design/icons/FileExclamationOutlined"; -import CloseOutlined from "@ant-design/icons/CloseOutlined"; -import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; -import PlusOutlined from "@ant-design/icons/PlusOutlined"; import * as lib from "@clusterio/lib"; import ControlContext from "./ControlContext"; @@ -26,18 +26,23 @@ import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import SectionHeader from "./SectionHeader"; import ModDetails from "./ModDetails"; +import InputPartialVersion from "./InputPartialVersion"; const { logger } = lib; -const { Paragraph, Text } = Typography; +const { Text } = Typography; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; - type ModChange = { - type: "name" | "description" | "factorioVersion", + type: "name" | "description" name?: never, scope?: never, value: string, + } | { + type: "factorioVersion", + name?: never, + scope?: never, + value: lib.PartialVersion, } | { type: "settings.set" | "settings.delete", name: string, @@ -47,7 +52,7 @@ type ModChange = type: "mods.set" | "mods.delete", name: string, scope?: never, - value: lib.ModRecord | lib.ModInfo, + value: lib.ModRecord, } ; @@ -71,28 +76,38 @@ function SearchModsTable(props: SearchModsTableProps) { const [modResultPageSize, setModResultPageSize] = useState(10); const [modResultCount, setModResultCount] = useState(2); const [modResultSelectedVersion, setModResultSelectedVersion] = useState>(new Map()); + const [factorioVersion, setFactorioVersion] = useState(null); - const factorioVersion = props.modPack.factorioVersion.split(".").slice(0, 2).join("."); + // Get a valid factorio version + useEffect(() => { + try { + setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion)); + } catch (err) { + setFactorioVersion(null); + } + }, [props.modPack.factorioVersion]); useEffect(() => { let canceled = false; - control.send(new lib.ModSearchRequest( - searchText, - factorioVersion, - modResultPage, - modResultPageSize, - modResultSort, - modResultSortOrder, - )).then(response => { - if (canceled) { - return; - } - // In React < v18 this causes 3 renders and a partial state necessitating the use - // of || 0 on modResultSelectedVersion.get() calls. Remove when updating React. - setModResults(response.results); - setModResultSelectedVersion(new Map(response.results.map(({ name, versions }) => [name, 0]))); - setModResultCount(response.resultCount); - }); + if (factorioVersion) { + control.send(new lib.ModSearchRequest( + searchText, + factorioVersion, + modResultPage, + modResultPageSize, + modResultSort, + modResultSortOrder, + )).then(response => { + if (canceled) { + return; + } + // In React < v18 this causes 3 renders and a partial state necessitating the use + // of || 0 on modResultSelectedVersion.get() calls. Remove when updating React. + setModResults(response.results); + setModResultSelectedVersion(new Map(response.results.map(({ name, versions }) => [name, 0]))); + setModResultCount(response.resultCount); + }); + } return () => { canceled = true; @@ -239,19 +254,260 @@ function SearchModsTable(props: SearchModsTableProps) { ; } +type DownloadDependenciesProps = { + disabled?: boolean; + checkForUpdates?: boolean; + modPack: lib.ModPack; + mods: lib.ModRecord[]; + onChange: (change: ModChange) => void; + builtInModNames: string[]; +} +function DownloadDependenciesButton(props: DownloadDependenciesProps) { + const control = useContext(ControlContext); + const [modInfos] = useMods(); + + // State for the modal and dependencies + const [open, setOpen] = useState(false); + const [missing, setMissing] = useState([]); + const [builtins, setBuiltins] = useState([]); + const [incompatible, setIncompatible] = useState([]); + const [factorioVersion, setFactorioVersion] = useState(null); + + // State for all mods fetched from backend + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [allMods, setAllMods] = useState([]); + const [mods, setMods] = useState([]); + + // Get a valid factorio version + useEffect(() => { + try { + setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion)); + } catch (err) { + setError(new Error(`Invalid factorio version: ${props.modPack.factorioVersion}`)); + setFactorioVersion(null); + } + }, [props.modPack.factorioVersion]); + + // Fetch all mods from backend when modal opens or version changes + useEffect(() => { + if (!open || !factorioVersion) { + // Clear if closed or no version + setMods([]); + setAllMods([]); + setMissing([]); + setBuiltins([]); + setIncompatible([]); + return; + } + + setLoading(true); + setError(null); + let canceled = false; + + control.send( + lib.ModDependencyResolveRequest.fromModPackEnabled(props.modPack, props.checkForUpdates) + ).then(response => { + if (canceled) { return; } + setMods(response.dependencies.filter(dep => !modInfos.has(`${dep.name}_${dep.version}`))); + setIncompatible(response.incompatible); + setAllMods(response.dependencies); + setMissing(response.missing.filter(name => !props.builtInModNames.includes(name))); + setBuiltins(response.missing.filter(name => props.builtInModNames.includes(name))); + setLoading(false); + }).catch(err => { + if (canceled) { return; } + notifyErrorHandler("Error fetching mods dependencies")(err); + setError(err); + setMods([]); + setAllMods([]); + setMissing([]); + setBuiltins([]); + setIncompatible([]); + setLoading(false); + }); + + // eslint-disable-next-line consistent-return + return () => { + canceled = true; + }; + // Re-fetch when modal opens or factorio version changes + }, [open, factorioVersion, control]); + + // Function to handle download from Mod Portal to Controller + function handleControllerDownload() { + if (!factorioVersion || !mods) { + return; + } + + // Enable any required builtins + for (const name of builtins) { + props.onChange({ + type: "mods.set", + name: name, + value: { ...props.modPack.mods.get(name)!, enabled: true }, + }); + } + + // Disable any incompatible mods present + for (const name of incompatible) { + const modRecord = props.modPack.mods.get(name); + if (modRecord) { + props.onChange({ + type: "mods.set", + name: name, + value: { ...modRecord, enabled: false }, + }); + } + } + + // Add all dependencies to the mod pack + for (const mod of allMods) { + props.onChange({ + type: "mods.set", + name: mod.name, + value: { + name: mod.name, + version: mod.version, + sha1: mod.sha1, + enabled: true, + }, + }); + } + + // Download the missing dependencies + control.send( + new lib.ModPortalDownloadRequest( + mods.map(mod => ({ + name: mod.name, version: new lib.ModVersionEquality("=", mod.version), + })), + factorioVersion, + ) + ).then(() => { + notification.success({ + message: "Download complete", + description: `${mods.length} mods have been downloaded to the controller.`, + }); + }).catch( + notifyErrorHandler("Error starting mod download") + ); + }; + + return <> + {props.checkForUpdates && || } + { setOpen(false); }} + width={1000} + footer={[ + , + , + ]} + > + {/* Display error message if fetching failed */} + {error && } + + {/* Display warning message if dependencies could not be found */} + {!loading && missing.length > 0 && + {missing.map(name =>
  • {name}
  • )} + } + type="warning" + showIcon + style={{ marginBottom: 16 }} + />} + + {/* Display warning message if dependencies are incompatible */} + {!loading && incompatible.length > 0 && + {incompatible.map(name =>
  • {name}
  • )} + } + type="warning" + showIcon + style={{ marginBottom: 16 }} + />} + + record.name} + loading={loading} + pagination={{ + defaultPageSize: 10, + showSizeChanger: true, + pageSizeOptions: ["10", "20", "50", "100"], + showTotal: (total: number) => ( + props.checkForUpdates ? `${total} Updates` : `${total} Missing Dependencies` + ), + }} + columns={[ + { + title: "Name", + dataIndex: "name", + key: "name", + sorter: true, + }, + { + title: "Title", + dataIndex: "title", + key: "title", + sorter: true, + }, + { + title: "Author", + dataIndex: "author", + key: "author", + sorter: true, + }, + { + title: "Selected Version", + dataIndex: "version", + key: "version", + }, + ]} + /> + + ; +} + type ModsTableProps = { modPack: lib.ModPack; changes: ModChange[]; onChange: (change: ModChange) => void; onRevert: (change: ModChange) => void; - builtInMods: string[]; + builtInModNames: string[]; }; function ModsTable(props: ModsTableProps) { - const [showAddMods, setShowAddMods] = useState(false); - const [modsMap] = useMods(); + const account = useAccount(); + const [modInfos] = useMods(); - const deletedMods: Map = new Map(); - const changedMods: Map = new Map(); + const [showAddMods, setShowAddMods] = useState(false); + + const deletedMods: Map = new Map(); + const changedMods: Map = new Map(); for (let change of props.changes) { if (change.name) { if (props.modPack.mods.has(change.name)) { @@ -265,34 +521,119 @@ function ModsTable(props: ModsTableProps) { } const mods = [...props.modPack.mods.values(), ...deletedMods.values()].map( - (mod: lib.ModRecord|lib.ModInfo): lib.ModInfo|lib.ModRecord => { - if (props.builtInMods.includes(mod.name)) { + (mod: lib.ModRecord): lib.ModRecord => { + if (props.builtInModNames.includes(mod.name)) { return { ...mod, - enabled: (mod as any).enabled ?? false, + enabled: mod.enabled ?? false, }; } - const candidate = modsMap.get(`${mod.name}_${mod.version}`); - if (!candidate) { + const modInfo = modInfos.get(`${mod.name}_${mod.version}`); + if (!modInfo) { return { ...mod, - enabled: (mod as any).enabled ?? false, + enabled: mod.enabled ?? false, error: "missing", }; - } else if (mod.sha1 && candidate.sha1 !== mod.sha1) { + } else if (mod.sha1 && modInfo.sha1 !== mod.sha1) { return { ...mod, - enabled: (mod as any).enabled ?? false, + enabled: mod.enabled ?? false, error: "bad_checksum", }; } - return candidate; + return { + ...mod, + enabled: mod.enabled ?? false, + info: modInfo, + }; } ); - function actions(mod: lib.ModRecord|lib.ModInfo) { + for (const mod of mods) { + if (mod.enabled && mod.info) { + mod.warning = mod.info.checkDependencySatisfaction(mods.filter(m => m.enabled)); + try { + const packFactorioVersion = lib.normaliseApiVersion(props.modPack.factorioVersion); + const modFactorioVersion = lib.normaliseApiVersion(mod.info.factorioVersion); + if (packFactorioVersion !== modFactorioVersion) { + mod.warning = "wrong_factorio_version"; + } + } catch { + // Hit if the mod or mod pack version is not a valid api version + mod.warning = "wrong_factorio_version"; + } + } + } + + async function fixDependencyIssues(mod: lib.ModRecord) { + if (!mod.info) { + throw new Error("Mod record has no info"); // Should be unreachable + } + if (mod.info.factorioVersion === "0.12") { + throw new Error("Factorio version 0.12 not supported by mod portal"); + } + + let hasFailure = false; + const enabledMods = mods.filter(m => m.enabled); + const modInfoArray = [...modInfos.values()]; + for (const dependency of mod.info.dependencies) { + const reason = dependency.checkUnsatisfiedReason(enabledMods); + if (reason === undefined) { + continue; // No issues for this dependency + } + + if (reason === "incompatible") { + // Disable the incompatible mod + props.onChange({ + type: "mods.set", + name: dependency.name, + value: { ...props.modPack.mods.get(dependency.name)!, enabled: false }, + }); + continue; + } + + if (props.builtInModNames.includes(dependency.name)) { + // If builtin mod then enable it + props.onChange({ + type: "mods.set", + name: dependency.name, + value: { ...props.modPack.mods.get(dependency.name)!, enabled: true }, + }); + continue; + } + + // Find a suitable version to add + const candidate = modInfoArray + .filter(info => (info.name === dependency.name && info.factorioVersion === mod.info!.factorioVersion + && (!dependency.version || dependency.version.testVersion(info.version)) + )) + .reduce((max, cur) => ( + max && max.integerVersion > cur.integerVersion ? max : cur + ), undefined); + + if (candidate) { + props.onChange({ + type: "mods.set", + name: candidate.name, + value: { + name: candidate.name, + version: candidate.version, + sha1: candidate.sha1, + enabled: true, + }, + }); + } else { + hasFailure = true; + } + } + + return hasFailure; + } + + function actions(mod: lib.ModRecord) { return - {!deletedMods.has(mod.name) && !props.builtInMods.includes(mod.name) && { props.onChange({ @@ -311,13 +652,52 @@ function ModsTable(props: ModsTableProps) { }); }} >revert} + {(mod.warning && mod.warning !== "wrong_factorio_version") && { + if (await fixDependencyIssues(mod)) { + notify("Failed to automatically fix all issues", "warning"); + } + }} + >fix issues} ; } return <> + extra={ + + {account.hasAllPermission("core.mod.search_portal", "core.mod.download_from_portal") + && m.enabled + && (m.error || m.warning && ["wrong_version", "missing_dependency"].includes(m.warning)) + )} + />} + {account.hasAllPermission("core.mod.search_portal", "core.mod.download_from_portal") + && } } /> @@ -361,14 +741,26 @@ function ModsTable(props: ModsTableProps) { { title: "Name", key: "name", - render: (_, mod: any) => <> + render: (_, mod: lib.ModRecord) => <> {mod.error === "missing" && {" "} } - {mod.error === "bad_checksum" && + {mod.error === "bad_checksum" && {" "} } - {mod.title || mod.name} + {mod.warning === "incompatible" && + {" "} + } + {mod.warning === "missing_dependency" && + {" "} + } + {mod.warning === "wrong_version" && + {" "} + } + {mod.warning === "wrong_factorio_version" && + {" "} + } + {mod.info?.title || mod.name} , defaultSortOrder: "ascend", sorter: (a, b) => strcmp(a.name, b.name), @@ -386,7 +778,7 @@ function ModsTable(props: ModsTableProps) { }, ]} expandable={{ - expandedRowRender: mod => , + expandedRowRender: mod => m.enabled)} actions={actions} />, expandedRowClassName: () => "no-expanded-padding", }} dataSource={mods} @@ -897,7 +1289,9 @@ export default function ModPackViewPage() { message: "Must be an a.b or a.b.c version number.", }]} > - form.setFieldValue("factorioVersion", value)} className={hasChange(changes, { type: "factorioVersion" }) ? "changed" : ""} /> @@ -908,9 +1302,7 @@ export default function ModPackViewPage() { changes={changes} onChange={pushChange} onRevert={revertChange} - builtInMods={ - lib.ModPack.getBuiltinMods(modifiedModPack.factorioVersion).map(builtinMod => builtinMod.name) - } + builtInModNames={lib.ModPack.getBuiltinModNames(modifiedModPack.factorioVersion)} /> ; - // Type alias for the mod structure derived from the library response type ModPortalModType = InstanceType["mods"][number]; @@ -132,7 +129,10 @@ function CreateModPackButton() { message: "Must be an a.b or a.b.c version number.", }]} > - + form.setFieldValue("factorioVersion", value)} + /> @@ -145,7 +145,7 @@ function SearchModsButton() { const [open, setOpen] = useState(false); const [form] = Form.useForm(); const [searchText, setSearchText] = useState(""); - const [factorioVersion, setFactorioVersion] = useState("2.0"); + const [factorioVersion, setFactorioVersion] = useState("2.0"); // State for all mods fetched from backend const [allMods, setAllMods] = useState([]); @@ -162,22 +162,21 @@ function SearchModsButton() { const handleControllerDownload = ( modName: string, modTitle: string | undefined, - modVersion: string | undefined, - portalFactorioVersion: FactorioVersion, + modVersion: lib.FullVersion | undefined, + portalFactorioVersion: lib.ApiVersion, ) => { if (modVersion) { control.send( new lib.ModPortalDownloadRequest( - modName, - modVersion, + [{ name: modName, version: new lib.ModVersionEquality("=", modVersion)}], portalFactorioVersion ) ).then(() => { notification.success({ - message: "Download started", + message: "Download complete", description: `${modTitle || modName} v${ modVersion - } is being downloaded to the controller.`, + } has been downloaded to the controller.`, }); }).catch( notifyErrorHandler("Error starting mod download") @@ -270,7 +269,7 @@ function SearchModsButton() { // Update search text and factorio version from form const handleSearch = (changedValues: any, allValues: any) => { const nameValue = allValues.name; - const versionValue = allValues.factorioVersion as FactorioVersion | undefined; + const versionValue = allValues.factorioVersion as lib.ApiVersion | undefined; // Only trigger state updates if values actually changed if (nameValue !== searchText) { @@ -351,12 +350,9 @@ function SearchModsButton() { label="Factorio Version" > @@ -569,7 +565,7 @@ export default function ModsPage() { + extra={ {account.hasPermission("core.mod_pack.create") && } {account.hasPermission("core.mod_pack.create") && } } @@ -602,7 +598,7 @@ export default function ModsPage() { }, })} /> - + {uploadButton} } /> diff --git a/packages/web_ui/src/components/NpmButton.tsx b/packages/web_ui/src/components/NpmButton.tsx index 019bb884..8696cac1 100644 --- a/packages/web_ui/src/components/NpmButton.tsx +++ b/packages/web_ui/src/components/NpmButton.tsx @@ -187,7 +187,7 @@ export function NpmButton(props: { target: lib.AddressShorthand, canRestart?: bo { formAction !== undefined && account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.restart`) - && + && } diff --git a/packages/web_ui/src/components/RoleViewPage.tsx b/packages/web_ui/src/components/RoleViewPage.tsx index e94bad85..29c9526d 100644 --- a/packages/web_ui/src/components/RoleViewPage.tsx +++ b/packages/web_ui/src/components/RoleViewPage.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useContext, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { Button, Checkbox, Form, Input, Popconfirm, Spin } from "antd"; +import { Button, Checkbox, Form, Input, Popconfirm, Space, Spin } from "antd"; import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; import * as lib from "@clusterio/lib"; @@ -71,7 +71,7 @@ export default function RoleViewPage() { > + extra={ {canUpdate && } {account.hasPermission("core.role.delete") && } - } + } /> diff --git a/packages/web_ui/src/components/SavesList.tsx b/packages/web_ui/src/components/SavesList.tsx index 0ec02355..9089335a 100644 --- a/packages/web_ui/src/components/SavesList.tsx +++ b/packages/web_ui/src/components/SavesList.tsx @@ -345,7 +345,7 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) { }; return
    - + {account.hasPermission("core.instance.save.upload") && Upload save diff --git a/packages/web_ui/src/components/UserViewPage.tsx b/packages/web_ui/src/components/UserViewPage.tsx index 878d309c..694f1bf8 100644 --- a/packages/web_ui/src/components/UserViewPage.tsx +++ b/packages/web_ui/src/components/UserViewPage.tsx @@ -106,7 +106,7 @@ export default function UserViewPage() { {user.isBanned && Banned} } - extra={<> + extra={ {account.hasPermission("core.user.revoke_token") && ( account.name === userName || account.hasPermission("core.user.revoke_other_token") ) && } - } + } />
    user.name} pagination={defaultPagination} size={size} + scroll={{ x: "max-content" }} onRow={(user) => ({ onClick: (event) => { if ((event.target as HTMLElement).closest("a")) { diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index 62363569..dbd02325 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -69,6 +69,9 @@ export class Control extends lib.Link { users = new lib.EventSubscriber(lib.UserUpdatesEvent, this); roles = new lib.EventSubscriber(lib.RoleUpdatesEvent, this); + /** Cache for factorio versions to avoid repeat calls to the controller */ + factorioVersions = new lib.ValueCache(this.requestFactorioVersions.bind(this)); + declare connector: ControlConnector; constructor( @@ -105,6 +108,10 @@ export class Control extends lib.Link { this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this)); } + requestFactorioVersions() { + return this.send(new lib.FactorioVersionsRequest()); + } + async handleAccountUpdateEvent(event: lib.AccountUpdateEvent) { this.accountRoles = event.roles??null; this.emitAccountUpdate(); diff --git a/packages/web_ui/webpack.common.js b/packages/web_ui/webpack.common.js index 964c37a9..30bd8e99 100644 --- a/packages/web_ui/webpack.common.js +++ b/packages/web_ui/webpack.common.js @@ -4,9 +4,10 @@ const { CleanWebpackPlugin } = require("clean-webpack-plugin"); const { WebpackManifestPlugin } = require("webpack-manifest-plugin"); const TerserPlugin = require("terser-webpack-plugin"); -module.exports = (env = {}) => ({ - mode: env.production ? "production" : "development", - devtool: env.production ? "source-map" : "eval-source-map", +module.exports = (env = {}, argv = {}) => ({ + mode: argv.mode ?? "development", + devtool: argv.mode === "production" ? "source-map" : "eval-source-map", + cache: { type: "filesystem" }, performance: { maxAssetSize: 2**21, maxEntrypointSize: 2**21, @@ -34,7 +35,7 @@ module.exports = (env = {}) => ({ new webpack.NormalModuleReplacementPlugin( /@ant-design\/icons\/[A-Z]/, resource => { - resource.request = resource.request.replace(/@ant-design\/icons/, "$&/es/icons"); + resource.request = resource.request.replace("@ant-design\/icons", "$&/es/icons"); } ), ], @@ -60,7 +61,7 @@ module.exports = (env = {}) => ({ { loader: require.resolve("css-loader"), options: { - sourceMap: !env.production, + sourceMap: argv.mode !== "production", }, }, ], @@ -138,9 +139,7 @@ module.exports = (env = {}) => ({ moduleIds: "deterministic", minimizer: [ new TerserPlugin({ - // TerserPlugin.swcMinify spams log with "false" after bump to @swc/core@1.11.1 - // https://github.com/clusterio/clusterio/issues/737 - // minify: TerserPlugin.swcMinify, + minify: TerserPlugin.swcMinify, terserOptions: { compress: { keep_classnames: true, diff --git a/plugins/global_chat/package.json b/plugins/global_chat/package.json index b756dddc..14f0f38d 100644 --- a/plugins/global_chat/package.json +++ b/plugins/global_chat/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-global_chat", - "version": "2.0.0-alpha.21", + "version": "2.0.0-alpha.22b", "description": "Clusterio plugin forwarding between Factorio servers", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", @@ -13,32 +13,37 @@ "factorio" ], "scripts": { - "prepare": "tsc --build && webpack-cli --env production", + "prepare": "tsc --build && webpack-cli --mode production", "test": "echo \"Error: run tests from root\" && exit 1" }, "bugs": { "url": "https://github.com/clusterio/clusterio/issues" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { + "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", + "@clusterio/ctl": "workspace:*", "@clusterio/lib": "workspace:*" }, + "dependencies": { + "@sinclair/typebox": "catalog:" + }, "devDependencies": { - "@clusterio/lib": "workspace:*", "@clusterio/web_ui": "workspace:*", - "@types/node": "^20.17.19", - "@types/react": "^18.3.18", - "typescript": "^5.7.3", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", - "webpack-merge": "^5.9.0" + "@clusterio/host": "workspace:*", + "@clusterio/ctl": "workspace:*", + "@clusterio/lib": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" }, "publishConfig": { "access": "public" - }, - "dependencies": { - "@sinclair/typebox": "^0.30.4" } } diff --git a/plugins/global_chat/tsconfig.node.json b/plugins/global_chat/tsconfig.node.json index ce0e013f..24bc1d0a 100644 --- a/plugins/global_chat/tsconfig.node.json +++ b/plugins/global_chat/tsconfig.node.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.node.json", "references": [ { "path": "../../packages/lib/tsconfig.node.json" }, + { "path": "../../packages/host/tsconfig.node.json" }, + { "path": "../../packages/ctl/tsconfig.node.json" }, ], "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], diff --git a/plugins/global_chat/webpack.config.js b/plugins/global_chat/webpack.config.js index 39150485..43021033 100644 --- a/plugins/global_chat/webpack.config.js +++ b/plugins/global_chat/webpack.config.js @@ -5,7 +5,7 @@ const { merge } = require("webpack-merge"); const common = require("@clusterio/web_ui/webpack.common"); -module.exports = (env = {}) => merge(common(env), { +module.exports = (env = {}, argv = {}) => merge(common(env, argv), { context: __dirname, entry: "./web/index.tsx", output: { diff --git a/plugins/inventory_sync/package.json b/plugins/inventory_sync/package.json index 45bfe454..73e69188 100644 --- a/plugins/inventory_sync/package.json +++ b/plugins/inventory_sync/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-inventory_sync", - "version": "2.0.0-alpha.21", + "version": "2.0.0-alpha.22b", "description": "Clusterio plugin forwarding between Factorio servers", "author": "Danielv ", "homepage": "https://github.com/clusterio/factorioClusterio#readme", @@ -13,35 +13,40 @@ "factorio" ], "scripts": { - "prepare": "tsc --build && webpack-cli --env production", + "prepare": "tsc --build && webpack-cli --mode production", "test": "echo \"Error: run tests from root\" && exit 1" }, "bugs": { "url": "https://github.com/clusterio/factorioClusterio/issues" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { + "@clusterio/controller": "workspace:*", + "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*" }, "dependencies": { - "@sinclair/typebox": "^0.30.4", - "fs-extra": "^8.1.0" + "@sinclair/typebox": "catalog:", + "fs-extra": "catalog:" }, "devDependencies": { - "@clusterio/lib": "workspace:*", + "@clusterio/controller": "workspace:*", "@clusterio/web_ui": "workspace:*", - "@types/fs-extra": "^11.0.1", - "@types/node": "^20.17.19", - "@types/react": "^18.3.18", - "antd": "^5.24.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "typescript": "^5.7.3", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", - "webpack-merge": "^5.9.0" + "@clusterio/host": "workspace:*", + "@clusterio/lib": "workspace:*", + "@types/fs-extra": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "antd": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" }, "publishConfig": { "access": "public" diff --git a/plugins/inventory_sync/tsconfig.node.json b/plugins/inventory_sync/tsconfig.node.json index ce0e013f..55d31e46 100644 --- a/plugins/inventory_sync/tsconfig.node.json +++ b/plugins/inventory_sync/tsconfig.node.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.node.json", "references": [ { "path": "../../packages/lib/tsconfig.node.json" }, + { "path": "../../packages/host/tsconfig.node.json" }, + { "path": "../../packages/controller/tsconfig.node.json" }, ], "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], diff --git a/plugins/inventory_sync/webpack.config.js b/plugins/inventory_sync/webpack.config.js index 6f4df3b1..8d2f2cc2 100644 --- a/plugins/inventory_sync/webpack.config.js +++ b/plugins/inventory_sync/webpack.config.js @@ -5,7 +5,7 @@ const { merge } = require("webpack-merge"); const common = require("@clusterio/web_ui/webpack.common"); -module.exports = (env = {}) => merge(common(env), { +module.exports = (env = {}, argv = {}) => merge(common(env, argv), { context: __dirname, entry: "./web/index.tsx", output: { diff --git a/plugins/player_auth/package.json b/plugins/player_auth/package.json index 6d15d483..358f04c4 100644 --- a/plugins/player_auth/package.json +++ b/plugins/player_auth/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-player_auth", - "version": "2.0.0-alpha.21", + "version": "2.0.0-alpha.22b", "description": "Clusterio plugin authenticating logged in players to the web interface", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", @@ -13,37 +13,42 @@ "factorio" ], "scripts": { - "prepare": "tsc --build && webpack-cli --env production", + "prepare": "tsc --build && webpack-cli --mode production", "test": "echo \"Error: run tests from root\" && exit 1" }, "bugs": { "url": "https://github.com/clusterio/clusterio/issues" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { + "@clusterio/controller": "workspace:*", + "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*" }, "dependencies": { - "@sinclair/typebox": "^0.30.4", - "express": "^4.21.2", - "jsonwebtoken": "^9.0.1" + "@sinclair/typebox": "catalog:", + "jsonwebtoken": "catalog:", + "express": "catalog:" }, "devDependencies": { - "@clusterio/lib": "workspace:*", + "@clusterio/controller": "workspace:*", "@clusterio/web_ui": "workspace:*", - "@types/express": "^4.17.17", - "@types/jsonwebtoken": "^9.0.9", - "@types/node": "^20.17.19", - "@types/react": "^18.3.18", - "antd": "^5.24.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "typescript": "^5.7.3", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", - "webpack-merge": "^5.9.0" + "@clusterio/host": "workspace:*", + "@clusterio/lib": "workspace:*", + "@types/express": "catalog:", + "@types/jsonwebtoken": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "antd": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" }, "publishConfig": { "access": "public" diff --git a/plugins/player_auth/tsconfig.node.json b/plugins/player_auth/tsconfig.node.json index ce0e013f..55d31e46 100644 --- a/plugins/player_auth/tsconfig.node.json +++ b/plugins/player_auth/tsconfig.node.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.node.json", "references": [ { "path": "../../packages/lib/tsconfig.node.json" }, + { "path": "../../packages/host/tsconfig.node.json" }, + { "path": "../../packages/controller/tsconfig.node.json" }, ], "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], diff --git a/plugins/player_auth/webpack.config.js b/plugins/player_auth/webpack.config.js index af39fcba..a31faea1 100644 --- a/plugins/player_auth/webpack.config.js +++ b/plugins/player_auth/webpack.config.js @@ -5,7 +5,7 @@ const { merge } = require("webpack-merge"); const common = require("@clusterio/web_ui/webpack.common"); -module.exports = (env = {}) => merge(common(env), { +module.exports = (env = {}, argv = {}) => merge(common(env, argv), { context: __dirname, entry: "./web/index.tsx", output: { diff --git a/plugins/research_sync/package.json b/plugins/research_sync/package.json index 9c17a038..29704b02 100644 --- a/plugins/research_sync/package.json +++ b/plugins/research_sync/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-research_sync", - "version": "2.0.0-alpha.21", + "version": "2.0.0-alpha.22b", "description": "Clusterio plugin for synchronizing research between Factorio servers", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", @@ -13,32 +13,37 @@ "factorio" ], "scripts": { - "prepare": "tsc --build && webpack-cli --env production", + "prepare": "tsc --build && webpack-cli --mode production", "test": "echo \"Error: run tests from root\" && exit 1" }, "bugs": { "url": "https://github.com/clusterio/clusterio/issues" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { + "@clusterio/controller": "workspace:*", + "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*" }, "dependencies": { - "@sinclair/typebox": "^0.30.4", - "fs-extra": "^8.1.0" + "@sinclair/typebox": "catalog:", + "fs-extra": "catalog:" }, "devDependencies": { - "@clusterio/lib": "workspace:*", + "@clusterio/controller": "workspace:*", "@clusterio/web_ui": "workspace:*", - "@types/fs-extra": "^11.0.1", - "@types/node": "^20.17.19", - "@types/react": "^18.3.18", - "typescript": "^5.7.3", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", - "webpack-merge": "^5.9.0" + "@clusterio/host": "workspace:*", + "@clusterio/lib": "workspace:*", + "@types/fs-extra": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" }, "publishConfig": { "access": "public" diff --git a/plugins/research_sync/tsconfig.node.json b/plugins/research_sync/tsconfig.node.json index ce0e013f..55d31e46 100644 --- a/plugins/research_sync/tsconfig.node.json +++ b/plugins/research_sync/tsconfig.node.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.node.json", "references": [ { "path": "../../packages/lib/tsconfig.node.json" }, + { "path": "../../packages/host/tsconfig.node.json" }, + { "path": "../../packages/controller/tsconfig.node.json" }, ], "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], diff --git a/plugins/research_sync/webpack.config.js b/plugins/research_sync/webpack.config.js index 52c861de..e5a4d931 100644 --- a/plugins/research_sync/webpack.config.js +++ b/plugins/research_sync/webpack.config.js @@ -5,7 +5,7 @@ const { merge } = require("webpack-merge"); const common = require("@clusterio/web_ui/webpack.common"); -module.exports = (env = {}) => merge(common(env), { +module.exports = (env = {}, argv = {}) => merge(common(env, argv), { context: __dirname, entry: "./web/index.tsx", output: { diff --git a/plugins/statistics_exporter/package.json b/plugins/statistics_exporter/package.json index e954e803..683cf207 100644 --- a/plugins/statistics_exporter/package.json +++ b/plugins/statistics_exporter/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-statistics_exporter", - "version": "2.0.0-alpha.21", + "version": "2.0.0-alpha.22b", "description": "Clusterio plugin for gathering statistics from Factorio servers", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", @@ -13,27 +13,30 @@ "factorio" ], "scripts": { - "prepare": "tsc --build && webpack-cli --env production", + "prepare": "tsc --build && webpack-cli --mode production", "test": "echo \"Error: run tests from root\" && exit 1" }, "bugs": { "url": "https://github.com/clusterio/clusterio/issues" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { + "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*" }, "devDependencies": { - "@clusterio/lib": "workspace:*", "@clusterio/web_ui": "workspace:*", - "@types/node": "^20.17.19", - "@types/react": "^18.3.18", - "typescript": "^5.7.3", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", - "webpack-merge": "^5.9.0" + "@clusterio/host": "workspace:*", + "@clusterio/lib": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" }, "publishConfig": { "access": "public" diff --git a/plugins/statistics_exporter/tsconfig.node.json b/plugins/statistics_exporter/tsconfig.node.json index ce0e013f..48c9d359 100644 --- a/plugins/statistics_exporter/tsconfig.node.json +++ b/plugins/statistics_exporter/tsconfig.node.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.node.json", "references": [ { "path": "../../packages/lib/tsconfig.node.json" }, + { "path": "../../packages/host/tsconfig.node.json" }, ], "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], diff --git a/plugins/statistics_exporter/webpack.config.js b/plugins/statistics_exporter/webpack.config.js index 1e25f204..bdbd99e2 100644 --- a/plugins/statistics_exporter/webpack.config.js +++ b/plugins/statistics_exporter/webpack.config.js @@ -5,7 +5,7 @@ const { merge } = require("webpack-merge"); const common = require("@clusterio/web_ui/webpack.common"); -module.exports = (env = {}) => merge(common(env), { +module.exports = (env = {}, argv = {}) => merge(common(env, argv), { context: __dirname, entry: "./web/index.tsx", output: { diff --git a/plugins/subspace_storage/instance.ts b/plugins/subspace_storage/instance.ts index e1d6edfa..77cfd818 100644 --- a/plugins/subspace_storage/instance.ts +++ b/plugins/subspace_storage/instance.ts @@ -14,6 +14,8 @@ type IpcItems = [name: string, count: number, quality: string][]; export class InstancePlugin extends BaseInstancePlugin { pendingTasks!: Set; pingId?: ReturnType; + timeUpdateId?: ReturnType; + cachedInventoryItems: Item[] = []; unexpectedError(err: Error) { this.logger.error(`Unexpected error:\n${err.stack}`); @@ -54,18 +56,37 @@ export class InstancePlugin extends BaseInstancePlugin { }, 5000); let items = await this.instance.sendTo("controller", new GetStorageRequest()); - // TODO Diff with dump of invdata produce minimal command to sync - let itemsJson = lib.escapeString(JSON.stringify(items)); - await this.sendRcon(`/sc __subspace_storage__ UpdateInvData("${itemsJson}", true)`, true); + + // Cache items for periodic time updates + this.cachedInventoryItems = items; + + // Ensure a payload with updated time is sent every second + this.timeUpdateId = setInterval(() => { + if (this.instance.status !== "running") { + return; + } + + const payloadItems = [ + ...this.cachedInventoryItems, + new Item("signal-unixtime", Math.floor(Date.now() / 1000), "normal"), + ]; + const payloadJson = lib.escapeString(JSON.stringify(payloadItems)); + const task = this.sendRcon(`/sc __subspace_storage__ UpdateInvData("${payloadJson}")`, true) + .catch(err => this.unexpectedError(err)); + this.pendingTasks.add(task); + task.finally(() => { this.pendingTasks.delete(task); }); + }, 1000); } async onStop() { clearInterval(this.pingId); + clearInterval(this.timeUpdateId); await Promise.all(this.pendingTasks); } onExit() { clearInterval(this.pingId); + clearInterval(this.timeUpdateId); } // provide items -------------------------------------------------------------- @@ -114,14 +135,7 @@ export class InstancePlugin extends BaseInstancePlugin { if (this.instance.status !== "running") { return; } - let items = event.items; - - // XXX this should be moved to instance/clusterio api - items.push(new Item("signal-unixtime", Math.floor(Date.now()/1000), "normal")); - - let itemsJson = lib.escapeString(JSON.stringify(items)); - let task = this.sendRcon(`/sc __subspace_storage__ UpdateInvData("${itemsJson}")`, true); - this.pendingTasks.add(task); - await task.finally(() => { this.pendingTasks.delete(task); }); + // Cache latest inventory from controller + this.cachedInventoryItems = event.items; } } diff --git a/plugins/subspace_storage/package.json b/plugins/subspace_storage/package.json index 0d00d8ae..63187fe0 100644 --- a/plugins/subspace_storage/package.json +++ b/plugins/subspace_storage/package.json @@ -1,6 +1,6 @@ { "name": "@clusterio/plugin-subspace_storage", - "version": "2.0.0-alpha.21", + "version": "2.0.0-alpha.22b", "description": "Clusterio plugin for sharing storage between Factorio servers", "author": "Hornwitser ", "homepage": "https://github.com/clusterio/clusterio#readme", @@ -13,36 +13,41 @@ "factorio" ], "scripts": { - "prepare": "tsc --build && webpack-cli --env production", + "prepare": "tsc --build && webpack-cli --mode production", "test": "echo \"Error: run tests from root\" && exit 1" }, "bugs": { "url": "https://github.com/clusterio/clusterio/issues" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { + "@clusterio/controller": "workspace:*", + "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*" }, "dependencies": { - "@sinclair/typebox": "^0.30.4", - "fs-extra": "^8.1.0" + "@sinclair/typebox": "catalog:", + "fs-extra": "catalog:" }, "devDependencies": { - "@clusterio/lib": "workspace:*", + "@clusterio/controller": "workspace:*", "@clusterio/web_ui": "workspace:*", - "@types/express": "^4.17.17", - "@types/fs-extra": "^11.0.1", - "@types/node": "^20.17.19", - "@types/react": "^18.3.18", - "antd": "^5.24.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "typescript": "^5.7.3", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", - "webpack-merge": "^5.9.0" + "@clusterio/host": "workspace:*", + "@clusterio/lib": "workspace:*", + "@types/express": "catalog:", + "@types/fs-extra": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "antd": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" }, "publishConfig": { "access": "public" diff --git a/plugins/subspace_storage/tsconfig.node.json b/plugins/subspace_storage/tsconfig.node.json index ce0e013f..55d31e46 100644 --- a/plugins/subspace_storage/tsconfig.node.json +++ b/plugins/subspace_storage/tsconfig.node.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.node.json", "references": [ { "path": "../../packages/lib/tsconfig.node.json" }, + { "path": "../../packages/host/tsconfig.node.json" }, + { "path": "../../packages/controller/tsconfig.node.json" }, ], "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], diff --git a/plugins/subspace_storage/webpack.config.js b/plugins/subspace_storage/webpack.config.js index ae97b74c..ca1c7440 100644 --- a/plugins/subspace_storage/webpack.config.js +++ b/plugins/subspace_storage/webpack.config.js @@ -5,7 +5,7 @@ const { merge } = require("webpack-merge"); const common = require("@clusterio/web_ui/webpack.common"); -module.exports = (env = {}) => merge(common(env), { +module.exports = (env = {}, argv = {}) => merge(common(env, argv), { context: __dirname, entry: "./web/index.tsx", output: { diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fd9fdcd3..29bc79e4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,37 @@ packages: - "external_plugins/*" - "external_plugins/*/*" - "test/" + +catalog: + "@types/node": ^20.17.19 + "@types/fs-extra": ^11.0.1 + "@types/express": ^4.17.17 + "@types/finalhandler": ^1.2.0 + "@types/jsonwebtoken": ^9.0.9 + "@types/set-blocking": ^2.0.0 + "@types/react": ^18.3.18 + "@types/ws": ^8.5.14 + "@sinclair/typebox": ^0.30.4 + "@swc/core": ^1.13.3 + "antd": ^5.24.2 + "react": ^18.2.0 + "react-dom": ^18.2.0 + "react-router-dom": ^6.29.0 + "typescript": ^5.7.3 + "webpack": ^5.98.0 + "webpack-cli": ^5.1.4 + "webpack-merge": ^5.9.0 + "fs-extra": ^8.1.0 + "express": ^5.1.0 + "winston": ^3.17.0 + "winston-transport": ^4.9.0 + "winston-daily-rotate-file": ^4.7.1 + "jsonwebtoken": ^9.0.2 + "set-blocking": ^2.0.0 + "jszip": ^3.10.1 + "chalk": ^4.1.2 + "phin": ^3.7.0 + "ws": ^8.18.1 + +catalogMode: strict +linkWorkspacePackages: true diff --git a/publish.js b/publish.js new file mode 100644 index 00000000..447a1610 --- /dev/null +++ b/publish.js @@ -0,0 +1,134 @@ +/** + * Publish any mods that need to be published + */ + +"use strict"; +const FormData = require("form-data"); +const fs = require("fs-extra"); +const yargs = require("yargs"); + +let DRY = false; + +const modReleaseCache = new Map(); +/** + * Gets a list of all releases for a mod + */ +async function requestModReleases(mod) { + const cache = modReleaseCache.get(mod); + if (cache) { + return cache; + } + + // eslint-disable-next-line no-console + console.log(`Requesting current releases of ${mod}`); + + const response = await fetch(`https://mods.factorio.com/api/mods/${mod}`); + if (!response.ok) { + throw new Error(`Failed request to mod portal api: ${response.statusText}\n${await response.text()}`); + } + + const json = await response.json(); + modReleaseCache.set(mod, json.releases); + return json.releases; +} + +/** + * Upload a mod to the mod portal + */ +async function uploadModRelease(file, name) { + // eslint-disable-next-line no-console + console.log(`Uploading new release ${file}`); + if (DRY) { + return; + } + + const headers = { + Authorization: `Bearer ${process.env.FACTORIO_TOKEN}`, + }; + + // Start the upload process by requesting an upload url + const initFormData = new FormData(); + initFormData.append("mod", name); + + const initResponse = await fetch("https://mods.factorio.com/api/v2/mods/releases/init_upload", { + method: "POST", + headers: headers, + body: initFormData, + }); + if (!initResponse.ok) { + throw new Error(`Failed init mod upload: ${initResponse.statusText}\n${await initResponse.text()}`); + } + + // Upload the new version of the mod + const uploadUrl = (await initResponse.json()).upload_url; + const uploadFormData = new FormData(); + uploadFormData.append("file", fs.createReadStream(file)); + + const uploadResponse = await fetch(uploadUrl, { + method: "POST", + headers: headers, + body: uploadFormData, + }); + if (!uploadResponse.ok) { + throw new Error(`Failed mod upload: ${uploadResponse.statusText}\n${await uploadResponse.text()}`); + } +} + +const regexModZip = /(\w+)_\d+.\d+.\d+\.zip/; +/** + * Publishes all mods that require publishing + */ +async function publishMods(dir) { + const files = await fs.readdir(dir); + const mods = files + .map(file => regexModZip.exec(file)) + .filter(file => Boolean(file)); + + const uploading = []; + for (const mod of mods) { + const [file, name] = mod; + const releases = await requestModReleases(name); + if (!releases.some(release => release.file_name === file)) { + uploading.push(uploadModRelease(file, name)); + } else { + // eslint-disable-next-line no-console + console.log(`Skipped ${file}`); + } + } + + await Promise.all(uploading); +} + +/** + * Main function for this script + */ +async function main() { + await yargs(process.argv.slice(2)) + .scriptName("publish") + .option("dry-run", { + alias: "dry", + nargs: 0, + describe: "Will not publish anything", + default: false, + type: "boolean", + }) + .command(["factorio ", "$0 "], "Publish factorio mods", yargs => { + yargs + .positional("mods-dir", { + describe: "Path to packed mods to publish", + normalize: true, + type: "string", + }) + .demandOption("mods-dir", "A path to the mods dir must be specified"); + }, async argv => { + DRY = argv.dry; + publishMods(argv.modsDir); + }) + .strict() + .parse(); +} + +// Run main if started from command line +if (module === require.main) { + main(); +} diff --git a/test/common.js b/test/common.js new file mode 100644 index 00000000..96397548 --- /dev/null +++ b/test/common.js @@ -0,0 +1,35 @@ +"use strict"; +const assert = require("assert").strict; + +/** + * Generate a flat array of tests from a matrix of inputs. + * + * @template {any[][]} T + * @param {T} arrays - A list of arrays representing the different values for each argument + * @returns {Array<{ [K in keyof T]: T[K][number] }>} + * An array of tuples, each containing one value from each input array. + */ +function testMatrix(...arrays) { + return arrays.reduce((acc, curr) => acc.flatMap(a => curr.map(b => [...a, b])), [[]]); +} + +/** + * Test that a class is round trip json serialisable across multiple test cases. + * + * @template {any[]} T - Constructor arguments + * @param {{new(...args: T): object}} Class - The class which has toJSON and fromJSON methods. + * @param {T[]} tests - The tests inputs to pass to the class constructor. + */ +function testRoundTripJsonSerialisable(Class, tests) { + for (const test of tests) { + const original = new Class(...test); + const serialised = JSON.stringify(original); + const reconstructed = Class.fromJSON(JSON.parse(serialised)); + assert.deepEqual(reconstructed, original, JSON.stringify(test)); + } +} + +module.exports = { + testMatrix, + testRoundTripJsonSerialisable, +}; diff --git a/test/controller/Controller.js b/test/controller/Controller.js index f3d7e7ab..d9e68be2 100644 --- a/test/controller/Controller.js +++ b/test/controller/Controller.js @@ -1,9 +1,14 @@ "use strict"; +const path = require("node:path"); const assert = require("assert").strict; const { Controller, HostInfo, InstanceInfo } = require("@clusterio/controller"); -const { ControllerConfig, Address, RequestError, InstanceConfig, SystemInfo, Role } = require("@clusterio/lib"); const { EventEmitter } = require("stream"); +const { + ControllerConfig, Address, RequestError, + InstanceConfig, SystemInfo, Role, ModPack, +} = require("@clusterio/lib"); + class MockEvent {} class MockInstanceConfig extends EventEmitter { @@ -27,8 +32,10 @@ describe("controller/src/Controller", function() { describe("class Controller", function() { /** @type {Controller} */ let controller, mockInstanceConfig; + const controllerVersion = require("@clusterio/controller/package.json").version; before(async function() { - controller = new Controller({}, [], new ControllerConfig("controller")); + const controllerConfig = new ControllerConfig("controller", { "controller.version": controllerVersion }); + controller = new Controller({}, [], controllerConfig); mockInstanceConfig = new MockInstanceConfig(new Map([ ["instance.id", 100], ["instance.name", "test"], ["factorio.settings", []], ])); @@ -233,6 +240,62 @@ describe("controller/src/Controller", function() { assert(instanceInfo.updatedAtMs === 0, "updatedAtMs incremented"); }); }); + describe(".checkRestartRequired()", function() { + beforeEach(function() { + // Would not be needed if controller setup was beforeEach + controller.config.restartRequired = false; + controller.config.set("controller.version", controllerVersion); + controller.config.set("global_chat.load_plugin", true); + controller.pluginInfos[0] = { + name: "global_chat", + requirePath: path.dirname(require.resolve("@clusterio/controller/package.json")), + version: controllerVersion, + }; + }); + it("returns false when no changes are present", async function() { + controller.pluginInfos[0].webEntrypoint = true; + controller.pluginInfos[0].controllerEntrypoint = true; + const result = await controller.checkRestartRequired(); + assert.equal(result, false); + assert.equal(controller.config.restartRequired, false); + }); + it("returns false when an unloaded plugin version changes", async function() { + controller.pluginInfos[0].version = "0.0.0"; + controller.pluginInfos[0].webEntrypoint = true; + controller.pluginInfos[0].controllerEntrypoint = true; + controller.config.set("global_chat.load_plugin", false); + controller.config.restartRequired = false; // Setting load_plugin requires a restart + const result = await controller.checkRestartRequired(); + assert.equal(result, false); + assert.equal(controller.config.restartRequired, false); + }); + it("returns true when the config flag is set", async function() { + controller.config.restartRequired = true; + const result = await controller.checkRestartRequired(); + assert.equal(result, true); + assert.equal(controller.config.restartRequired, true); + }); + it("returns true when clusterio version changes", async function() { + controller.config.set("controller.version", "0.0.0"); + const result = await controller.checkRestartRequired(); + assert.equal(result, true); + assert.equal(controller.config.restartRequired, true); + }); + it("returns true when a controller plugin version changes", async function() { + controller.pluginInfos[0].version = "0.0.0"; + controller.pluginInfos[0].controllerEntrypoint = true; + const result = await controller.checkRestartRequired(); + assert.equal(result, true); + assert.equal(controller.config.restartRequired, true); + }); + it("returns true when a web plugin version changes", async function() { + controller.pluginInfos[0].version = "0.0.0"; + controller.pluginInfos[0].webEntrypoint = true; + const result = await controller.checkRestartRequired(); + assert.equal(result, true); + assert.equal(controller.config.restartRequired, true); + }); + }); }); describe("migrations", function() { describe("SystemInfo", function() { @@ -314,6 +377,48 @@ describe("controller/src/Controller", function() { ]); }); }); + describe("ModPack", function() { + it("migrates builtin mod versions to X.Y.Z", function() { + const modPacks = Controller.migrateModPacks([{ + id: 0, + name: "name", + factorio_version: "2.0", + mods: [ + { name: "base", version: "2.0" }, + { name: "my-mod", version: "1.0.0" }, + ], + }]); + assert.deepEqual(modPacks, [{ + id: 0, + name: "name", + factorio_version: "2.0", + mods: [ + { name: "base", version: "2.0.0" }, + { name: "my-mod", version: "1.0.0" }, + ], + }]); + }); + it("does nothing for upto date data", function() { + const modPack = ModPack.fromJSON({ + id: 0, + name: "name", + factorio_version: "2.0", + mods: [ + { name: "base", version: "2.0.0" }, + { name: "my-mod", version: "1.0.0" }, + ], + }); + const jsonString = JSON.stringify(modPack); + const modPackJson = JSON.parse(jsonString); + const modPackJsonCopy = JSON.parse(jsonString); + const result = Controller.migrateModPacks([ + modPackJson, modPackJson, modPackJson, + ]); + assert.deepEqual(result, [ + modPackJsonCopy, modPackJsonCopy, modPackJsonCopy, + ]); + }); + }); describe("Role", function() { it("migrates renamed permissions", function() { // Alpha 17 const result = Controller.migrateRoles([ diff --git a/test/controller/routes.js b/test/controller/routes.js index 06d605e6..ddccd692 100644 --- a/test/controller/routes.js +++ b/test/controller/routes.js @@ -74,6 +74,19 @@ describe("controller/src/routes", function() { assert.equal(await responses[1].text(), "test content"); }); }); + describe("/api/cluster-name", function() { + let endpoint; + beforeEach(function() { + endpoint = `http://localhost:${port}/api/cluster-name`; + }); + it("should return the cluster name from config", async function() { + controller.mockConfigEntries.set("controller.name", "Test Cluster"); + let response = await fetch(endpoint); + assert.equal(response.status, 200); + let data = await response.json(); + assert.deepEqual(data, { name: "Test Cluster" }); + }); + }); describe("/api/upload-save", function() { let endpoint; beforeEach(function() { diff --git a/test/file/html/factorio-download-archive.html b/test/file/html/factorio-download-archive.html new file mode 100644 index 00000000..3f5520ee --- /dev/null +++ b/test/file/html/factorio-download-archive.html @@ -0,0 +1,1583 @@ + + + + Download old versions | Factorio + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + +
    + +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    + +
    +
    +
    + + + + +
    + + + + + + +
    +
    +

    Old version archive

    + + See latest releases + +
    + +
    +

    + On this page you can download old versions of Factorio. They will not be as polished and up-to-date as the latest release! +

    +
    + Please choose a version. +
    +
    + + + + +
    +
    + + + 2.0.73 + + + + 2.0.72 + + + + 2.0.71 + + + + 2.0.70 + + + + 2.0.69 + + + + 2.0.68 + + + + 2.0.67 + + + + 2.0.66 + + + + 2.0.65 + + + + 2.0.64 + + + + 2.0.63 + + + + 2.0.62 + + + + 2.0.61 + + + + 2.0.60 + + + + 2.0.59 + + + + 2.0.58 + + + + 2.0.57 + + + + 2.0.56 + + + + 2.0.55 + + + + 2.0.54 + + + + 2.0.53 + + + + 2.0.52 + + + + 2.0.51 + + + + 2.0.50 + + + + 2.0.49 + + + + 2.0.48 + + + + 2.0.47 + + + + 2.0.46 + + + + 2.0.45 + + + + 2.0.44 + + + + 2.0.43 + + + + 2.0.42 + + + + 2.0.41 + + + + 2.0.40 + + + + 2.0.39 + + + + 2.0.38 + + + + 2.0.37 + + + + 2.0.36 + + + + 2.0.35 + + + + 2.0.34 + + + + 2.0.33 + + + + 2.0.32 + + + + 2.0.31 + + + + 2.0.30 + + + + 2.0.29 + + + + 2.0.28 + + + + 2.0.27 + + + + 2.0.26 + + + + 2.0.25 + + + + 2.0.24 + + + + 2.0.23 + + + + 2.0.22 + + + + 2.0.21 + + + + 2.0.20 + + + + 2.0.19 + + + + 2.0.18 + + + + 2.0.17 + + + + 2.0.16 + + + + 2.0.15 + + + + 2.0.14 + + + + 2.0.13 + + + + 2.0.12 + + + + 2.0.11 + + + + 2.0.10 + + + + 2.0.9 + + + + 2.0.8 + + + + 2.0.7 + + + + 1.1.110 + + + + 1.1.109 + + + + 1.1.108 + + + + 1.1.107 + + + + 1.1.106 + + + + 1.1.105 + + + + 1.1.104 + + + + 1.1.103 + + + + 1.1.102 + + + + 1.1.101 + + + + 1.1.100 + + + + 1.1.99 + + + + 1.1.98 + + + + 1.1.97 + + + + 1.1.96 + + + + 1.1.95 + + + + 1.1.94 + + + + 1.1.93 + + + + 1.1.92 + + + + 1.1.91 + + + + 1.1.90 + + + + 1.1.89 + + + + 1.1.88 + + + + 1.1.87 + + + + 1.1.86 + + + + 1.1.69 + + + + 1.1.60 + + + + 1.1.59 + + + + 1.1.58 + + + + 1.1.57 + + + + 1.0.0 + + + + 0.17.79 + + + + 0.16.51 + + + + 0.15.40 + + + + 0.15.36 + + + + 0.14.23 + + + + 0.13.20 + + + + 0.12.35 + + + + 0.11.22 + + + + 0.10.12 + + + + 0.9.8 + + + + 0.8.8 + + + + 0.7.5 + + + + 0.6.4 + + +
    +
    + + +
    + + + +
    +
    + + + + + + + + + + diff --git a/test/host.js b/test/host.js index 54fd8ffe..fe50b04d 100644 --- a/test/host.js +++ b/test/host.js @@ -6,6 +6,7 @@ const lib = require("@clusterio/lib"); const Host = require("@clusterio/host/dist/node/src/Host").default; const Instance = require("@clusterio/host/dist/node/src/Instance").default; const { _discoverInstances } = require("@clusterio/host/dist/node/src/Host"); +const { HostConnector } = require("@clusterio/host/dist/node/host"); describe("Host testing", function() { describe("discoverInstances()", function() { @@ -115,5 +116,63 @@ describe("Host testing", function() { ]); }); }); + describe(".checkRestartRequired()", function() { + let host; + beforeEach(function() { + // This can be used more generally, but i did not want to interfere with handleSyncUserListsEvent + const hostVersion = require("@clusterio/host/package.json").version; + const pluginInfos = [{ + name: "global_chat", + requirePath: path.dirname(require.resolve("@clusterio/host/package.json")), + version: hostVersion, + }]; + const hostConfig = new lib.HostConfig("host", { "host.version": hostVersion }); + const hostConnector = new HostConnector(hostConfig, undefined, pluginInfos); + host = new Host(hostConnector, hostConfig, undefined, pluginInfos); + }); + it("returns false when no changes are present", async function() { + host.pluginInfos[0].hostEntrypoint = true; + host.pluginInfos[0].instanceEntrypoint = true; + const result = await host.checkRestartRequired(); + assert.equal(result, false); + assert.equal(host.config.restartRequired, false); + }); + it("returns false when an unloaded plugin version changes", async function() { + host.pluginInfos[0].version = "0.0.0"; + host.pluginInfos[0].hostEntrypoint = true; + host.pluginInfos[0].instanceEntrypoint = true; + host.config.set("global_chat.load_plugin", false); + host.config.restartRequired = false; // Setting load_plugin requires a restart + const result = await host.checkRestartRequired(); + assert.equal(result, false); + assert.equal(host.config.restartRequired, false); + }); + it("returns true when the config flag is set", async function() { + host.config.restartRequired = true; + const result = await host.checkRestartRequired(); + assert.equal(result, true); + assert.equal(host.config.restartRequired, true); + }); + it("returns true when clusterio version changes", async function() { + host.config.set("host.version", "0.0.0"); + const result = await host.checkRestartRequired(); + assert.equal(result, true); + assert.equal(host.config.restartRequired, true); + }); + it("returns true when a host plugin version changes", async function() { + host.pluginInfos[0].version = "0.0.0"; + host.pluginInfos[0].hostEntrypoint = true; + const result = await host.checkRestartRequired(); + assert.equal(result, true); + assert.equal(host.config.restartRequired, true); + }); + it("returns true when an instance plugin version changes", async function() { + host.pluginInfos[0].version = "0.0.0"; + host.pluginInfos[0].instanceEntrypoint = true; + const result = await host.checkRestartRequired(); + assert.equal(result, true); + assert.equal(host.config.restartRequired, true); + }); + }); }); }); diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 43fe317c..3fe1805f 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -13,8 +13,8 @@ const { wait } = lib; const testStrings = require("../lib/factorio/test_strings"); const { TestControl, TestControlConnector, url, controlToken, slowTest, - exec, execCtl, execCtlProcess, execHost, sendRcon, getControl, - spawnNode, instancesDir, factorioDir, + execCtl, execCtlProcess, execController, execHost, sendRcon, getControl, + spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath, } = require("./index"); @@ -109,16 +109,59 @@ describe("Integration of Clusterio", function() { describe("clusteriocontroller", function() { describe("bootstrap generate-user-token", function() { it("work for existing user", async function() { - await exec("node ../../packages/controller bootstrap generate-user-token test"); + await execController("bootstrap generate-user-token test"); }); it("fails if user does not exist", async function() { await assert.rejects( - exec("node ../../packages/controller bootstrap generate-user-token invalid") + execController("bootstrap generate-user-token invalid") ); }); }); + describe("bootstrap create-admin", function() { + it("should refuse to modify the database files when locked", async function() { + await assert.rejects( + execController("bootstrap create-admin BootstrapAdminTest") + ); + }); + it("should modify the database files when locked if bypass option is given", async function() { + const lockFilePath = `${controllerConfigPath}.lock`; + await fs.copyFile(lockFilePath, "temp.lock"); + try { + await execController("bootstrap create-admin BootstrapAdminTest2 --bypass-lock-file"); + } finally { + await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted + } + + const json = await fs.readJSON(path.join(databaseDir, "users.json")); + assert.equal(Object.values(json).some(user => user.name === "BootstrapAdminTest2"), true); + }); + }); + + describe("config", function() { + it("can read the config file", async function() { + await execController("config list"); + }); + it("should refuse to modify the config file while locked", async function() { + await assert.rejects( + execController("config set controller.name ConfigEditTest") + ); + }); + it("should modify the config file while locked if bypass option is given", async function() { + const lockFilePath = `${controllerConfigPath}.lock`; + await fs.copyFile(lockFilePath, "temp.lock"); + try { + await execController("config set controller.name ConfigEditTest2 --bypass-lock-file"); + } finally { + await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted + } + + const json = await fs.readJSON(path.join(controllerConfigPath)); + assert.equal(json["controller.name"], "ConfigEditTest2"); + }); + }); + describe("run", function() { it("should handle resume of an active connection", async function() { slowTest(this); @@ -143,6 +186,38 @@ describe("Integration of Clusterio", function() { await events.once(connectorB, "resume"); await connectorB.close(1000, ""); }); + it("should refuse to start a second process while locked", async function() { + await assert.rejects(execController("run")); + }); + it("should start a second process while locked if bypass option is given", async function() { + slowTest(this); + const lockFilePath = `${controllerConfigPath}.lock`; + await fs.copyFile(lockFilePath, "temp.lock"); + try { + await execController("run --bypass-lock-file"); + } catch (err) { + assert.equal(/Error: Server listening failed/.test(err.stderr), true); + } + await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted + }); + it("should refuse to start when no users are loaded", async function() { + const dir = path.join("temp", "test", "empty_controller"); + await fs.emptyDir(dir); + await assert.rejects(execController("run", { cwd: dir })); + }); + it("should start when no users are loaded if bypass option given", async function() { + slowTest(this); + const dir = path.join("temp", "test", "empty_controller"); + await fs.emptyDir(dir); + const child = await spawnNode( + "altController", + "../../../packages/controller run --no-check-user-count", + /Started controller/, + { cwd: dir }, + ); + assert.ok(child.kill()); + assert.equal(child.killed, true); + }); }); describe("queryLogRequestHandler", function() { @@ -245,6 +320,67 @@ describe("Integration of Clusterio", function() { await stopAltHost(hostProcess); } }); + describe("config", function() { + it("can read the config file", async function() { + await execHost("config list"); + }); + it("should refuse to modify the config file while locked", async function() { + slowTest(this); + let hostProcess; // Alt host used since the process could get killed + const config = "alt-host-config.json"; + try { + hostProcess = await startAltHost(); + await assert.rejects( + execHost(`--config ${config} config set host.name ConfigEditTest`) + ); + } finally { + await stopAltHost(hostProcess); + } + }); + it("should modify the config file while locked if bypass option is given", async function() { + slowTest(this); + let hostProcess; // Alt host used since the process could get killed + const config = "alt-host-config.json"; + try { + hostProcess = await startAltHost(); + await execHost(`--config ${config} config set host.name ConfigEditTest2 --bypass-lock-file`); + } finally { + await stopAltHost(hostProcess); + } + + const json = await fs.readJSON(path.join("temp", "test", config)); + assert.equal(json["host.name"], "ConfigEditTest2"); + }); + }); + describe("run", function() { + it("should refuse to start a second process while locked", async function() { + slowTest(this); + let hostProcess; // Alt host used since the process could get killed + const config = "alt-host-config.json"; + try { + hostProcess = await startAltHost(); + await assert.rejects( + execHost(`--config ${config} run`) + ); + } finally { + await stopAltHost(hostProcess); + } + }); + it("should start a second process while locked if bypass option is given", async function() { + slowTest(this); + let hostProcessA, hostProcessB; // Alt host used since the process could get killed + const config = "alt-host-config.json"; + try { + hostProcessA = await startAltHost(); + hostProcessB = await spawnNode( + "alt-host-b:", `../../packages/host run --config ${config} --bypass-lock-file`, /Started host/ + ); + } finally { + await stopAltHost(hostProcessA); + await stopAltHost(hostProcessB); + } + }); + }); }); describe("clusterioctl", function() { @@ -951,7 +1087,7 @@ describe("Integration of Clusterio", function() { await execCtl("instance start 44"); const instance = (await getInstances()).get(44); - const isV2 = lib.integerFactorioVersion(instance.factorioVersion) > lib.integerFactorioVersion("2.0.0"); + const isV2 = lib.integerPartialVersion(instance.factorioVersion) > lib.integerPartialVersion("2.0"); const exchangeString = (isV2 ? testStrings.modified_v2 : testStrings.modified).replace(/[\n\r]+/g, ""); const args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString}"`; await execCtl("instance stop 44"); diff --git a/test/integration/index.js b/test/integration/index.js index ec8e56bb..ba72efd6 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -119,15 +119,14 @@ class TestHostConnector extends lib.WebSocketClientConnector { } } -// Mark that this test takes a lot of time, or depeneds on a test -// that takes a lot of time. +// Mark that this test takes a lot of time, or depends on a test that takes a lot of time. function slowTest(test) { if (process.env.FAST_TEST) { test.skip(); } - test.timeout(20000); + test.timeout(30000); } async function get(urlPath) { @@ -176,29 +175,31 @@ let controllerConfigPath = path.join("temp", "test", "config-controller.json"); let hostConfigPath = path.join("temp", "test", "config-host.json"); let controlConfigPath = path.join("temp", "test", "config-control.json"); -async function exec(command, options = {}) { +function exec(command, options = {}) { // Uncomment to show commands run in tests // console.log(command); options = { cwd: path.join("temp", "test"), ...options }; - return await util.promisify(child_process.exec)(command, options); + return util.promisify(child_process.exec)(command, options); } -async function execController(...args) { +function execController(...args) { args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`; - return await exec(...args); + return exec(...args); } -async function execHost(...args) { +function execHost(...args) { args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`; - return await exec(...args); + return exec(...args); } -async function execCtlProcess(...args) { +function execCtlProcess(...args) { args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`; - return await exec(...args); + return exec(...args); } +let inExecCtl = false; async function execCtl(command) { + inExecCtl = true; process.chdir("temp/test"); try { const initArgs = await initializeCtl(command, control.plugins, true); @@ -211,9 +212,17 @@ async function execCtl(command) { await targetCommand.run(initArgs.args, control); } finally { process.chdir("../.."); + inExecCtl = false; } } +afterEach(function() { + if (inExecCtl) { + // This is needed in case a test times out and never runs the finally block + process.chdir("../.."); + } +}); + async function sendRcon(instanceId, command) { return await control.sendTo({ instanceId }, new lib.InstanceSendRconRequest(command)); } @@ -222,7 +231,8 @@ function getControl() { return control; } -function spawn(name, cmd, waitFor) { +/** @returns {Promise} */ +function spawn(name, cmd, waitFor, options = {}) { const silent = process.env.SILENT_TEST; const bootstrap = !controllerProcess || !hostProcess; @@ -235,7 +245,7 @@ function spawn(name, cmd, waitFor) { return new Promise((resolve, reject) => { log(cmd); let parts = cmd.split(" "); - let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test") }); + let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test"), ...options }); let stdout = new LineSplitter({ readableObjectMode: true }); let stderr = new LineSplitter({ readableObjectMode: true }); let onDataOut = line => { @@ -259,8 +269,8 @@ function spawn(name, cmd, waitFor) { }); } -async function spawnNode(name, cmd, waitFor) { - return await spawn(name, `node --enable-source-maps ${cmd}`, waitFor); +function spawnNode(name, cmd, waitFor, options) { + return spawn(name, `node --enable-source-maps ${cmd}`, waitFor, options); } before(async function() { @@ -388,6 +398,7 @@ module.exports = { TestControl, TestControlConnector, TestHostConnector, + execController, execCtlProcess, slowTest, get, diff --git a/test/integration/instance.js b/test/integration/instance.js index eb4e617b..fbc58a5c 100644 --- a/test/integration/instance.js +++ b/test/integration/instance.js @@ -2,12 +2,14 @@ const assert = require("assert").strict; const lib = require("@clusterio/lib"); +const { testMatrix } = require("../common"); const { slowTest, exec, execCtl, execCtlProcess, sendRcon, getControl, } = require("./index"); const instId = 48; const instName = "Integration"; +const instSetConfig = `instance config set ${instName}`; const instAltId = 49; const instAltName = "IntegrationAlt"; @@ -53,7 +55,6 @@ describe("Clusterio Instance", function() { // Exclude alt test instance from start-all to prevent interference await execCtl(`instance config set ${instAltName} instance.exclude_from_start_all true`); await execCtl(`instance start ${instAltName}`); - await sendRcon(instAltId, "/sc disable achievements"); }); after(async function() { this.timeout(20000); @@ -62,25 +63,42 @@ describe("Clusterio Instance", function() { await execCtl(`instance stop ${instAltName}`); await execCtl(`instance delete ${instAltName}`); }); - for (const savePatchingEnabled of [true, false]) { - describe(`Integration ${savePatchingEnabled ? "with" : "without"} save patching`, function() { + for (const [savePatchingEnabled, scriptCommandsEnabled] of testMatrix([true, false], [true, false])) { + const strSavePatching = `save patching: ${savePatchingEnabled ? "Enabled" : "Disabled"}`; + const strScriptCommands = `script commands: ${scriptCommandsEnabled ? "Enabled" : "Disabled"}`; + async function allowSendingScriptCommands() { + if (!scriptCommandsEnabled) { + // Enable command to prevent "Attempted to use script command when disabled" + // This works because we only disable achievements on startup, this will break if we make it live update + await execCtl(`${instSetConfig} factorio.enable_script_commands true`); + } + } + describe(`Integration - ${strSavePatching}, ${strScriptCommands}`, function() { before(async function() { this.timeout(20000); // Create and start a patched save - await execCtl(`instance config set ${instName} factorio.enable_save_patching ${savePatchingEnabled}`); - await execCtl(`instance save create ${instName} ${savePatchingEnabled ? "patched" : "unpatched"}`); - await execCtl(`instance start ${instName} --save ${savePatchingEnabled ? "patched" : "unpatched"}.zip`); - await sendRcon(instId, "/sc disable achievements"); + await execCtl(`${instSetConfig} factorio.enable_save_patching ${savePatchingEnabled}`); + await execCtl(`${instSetConfig} factorio.enable_script_commands ${scriptCommandsEnabled}`); + await execCtl(`instance save create ${instName}`); + await execCtl(`instance start ${instName}`); }); after(async function() { // Stop the instance await execCtl(`instance stop ${instName}`); }); - describe("player event", function() { + describe("Disable achievements", function() { + it("should only disable achievements when script commands are enabled", async function() { + await allowSendingScriptCommands(); + const res = await sendRcon(instId, "/sc rcon.print('Disabled')"); + assert.equal(res === "Disabled\n", scriptCommandsEnabled); + }); + }); + describe("Player events", function() { if (savePatchingEnabled) { // IPC expects save patching to be enabled it("should do nothing for an unknown type", async function() { await assert.rejects(getUser("DoesNotExist"), "User should not exist"); + await allowSendingScriptCommands(); await sendRcon(instId, `/sc ${requireApi} api.send_json("player_event",` + "{ type='invalid type', name='DoesNotExist' })" @@ -88,6 +106,7 @@ describe("Clusterio Instance", function() { await assert.rejects(getUser("DoesNotExist"), "User was created"); }); it("should respond to a player join and leave event", async function() { + await allowSendingScriptCommands(); await sendRcon(instId, `/sc ${requireApi} api.send_json("player_event",` + "{ type='join', name='JoiningPlayer' })" @@ -103,7 +122,7 @@ describe("Clusterio Instance", function() { }); } it("should respond to a player ban and unban event", async function() { - await execCtl(`instance config set ${instName} factorio.sync_banlist bidirectional`); + await execCtl(`${instSetConfig} factorio.sync_banlist bidirectional`); await sendRcon(instId, "/ban BannedPlayer"); await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms const userBanned = await getUser("BannedPlayer"); @@ -120,7 +139,7 @@ describe("Clusterio Instance", function() { }); it("should respond to a player promote and demote event", async function() { // Because there is no admin list command this test will be different to the other two - await execCtl(`instance config set ${instName} factorio.sync_adminlist bidirectional`); + await execCtl(`${instSetConfig} factorio.sync_adminlist bidirectional`); await sendRcon(instId, "/promote AdminPlayer"); await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms const userPromote = await getUser("AdminPlayer"); @@ -142,7 +161,7 @@ describe("Clusterio Instance", function() { ); }); it("should respond to a player whitelist add and remove event", async function() { - await execCtl(`instance config set ${instName} factorio.sync_whitelist bidirectional`); + await execCtl(`${instSetConfig} factorio.sync_whitelist bidirectional`); await sendRcon(instId, "/whitelist add WhitelistPlayer"); await lib.wait(30); // Propagation time, had issues with 15ms in ci so i made it 30ms const userAdded = await getUser("WhitelistPlayer"); diff --git a/test/lib/LockFile.test.js b/test/lib/LockFile.test.js new file mode 100644 index 00000000..6bceeb06 --- /dev/null +++ b/test/lib/LockFile.test.js @@ -0,0 +1,133 @@ +/* eslint-disable node/no-sync */ +"use strict"; +const assert = require("assert").strict; +const fs = require("fs-extra"); +const path = require("path"); + +const { LockFile } = require("@clusterio/lib"); + +const baseDir = path.join("temp", "test", "lock_file"); + +describe("class LockFile", function () { + /** @type {string} */ + let filePath; + /** @type {LockFile} */ + let lock; + + before(async function () { + await fs.ensureDir(baseDir); + }); + + beforeEach(function() { + filePath = path.join(baseDir, `${Date.now()}-${Math.random()}.lock`); + lock = new LockFile(filePath); + }); + + afterEach(async function () { + await fs.unlink(filePath).catch(() => {}); + }); + + describe("acquire()", function () { + it("should create the lock file when acquiring", async function () { + await lock.acquire(); + assert.ok(fs.existsSync(filePath)); + }); + + it("should throw LockFileBusyError if called when not idle", async function () { + await lock.acquire(); + await assert.rejects(lock.acquire(), { name: "LockFileBusyError" }); + }); + + it("should remove stale lock file and acquire it", async function () { + await fs.writeFile(filePath, "999999\n"); // stale lock + await lock.acquire(); + assert.ok(fs.existsSync(filePath)); + }); + + it("should throw if existing lock file is not stale", async function () { + await fs.writeFile(filePath, `${process.pid}\n`); + await assert.rejects(lock.acquire(), { name: "LockFileExistsError" }); + }); + }); + + describe("release()", function () { + it("should remove the lock file when releasing", async function () { + await lock.acquire(); + await lock.release(); + assert.ok(!fs.existsSync(filePath)); + }); + + it("should throw LockFileBusyError if called when not acquired", async function () { + await assert.rejects(lock.release(), { name: "LockFileBusyError" }); + }); + }); + + describe("isHeld()", function () { + it("should return true only when lock file contains current PID", async function () { + await lock.acquire(); + const held = await lock.isHeld(); + assert.equal(held, true); + }); + + it("should return false when lock file does not exist", async function () { + const held = await lock.isHeld(); + assert.equal(held, false); + }); + + it("should return false if lock file contains different PID", async function () { + await fs.writeFile(filePath, "999999\n"); // simulate another process + const held = await lock.isHeld(); + assert.equal(held, false); + }); + + it("should return false if lock file was deleted while held", async function() { + await lock.acquire(); + await fs.unlink(filePath); + const held = await lock.isHeld(); + assert.equal(held, false); + }); + }); + + describe("isStale()", function () { + it("should return true if lock file does not exist", async function () { + const stale = await lock.isStale(); + assert.equal(stale, true); + }); + + it("should return true for invalid PID in lock file", async function () { + await fs.writeFile(filePath, "abc\n"); // non-numeric PID + const stale = await lock.isStale(); + assert.equal(stale, true); + }); + + it("should return true for non-existent process PID", async function () { + await fs.writeFile(filePath, "999999\n"); // assume PID does not exist + const stale = await lock.isStale(); + assert.equal(stale, true); + }); + + it("should return false for current process PID", async function () { + await fs.writeFile(filePath, `${process.pid}\n`); + const stale = await lock.isStale(); + assert.equal(stale, false); + }); + }); + + describe("attach/detach exit handlers", function () { + it("should attach exit handler on acquire and detach on release", async function () { + await lock.acquire(); + const listenersBefore = process.listeners("exit").slice(); + assert.ok(listenersBefore.includes(lock.handleExitSync)); + + await lock.release(); + const listenersAfter = process.listeners("exit").slice(); + assert.ok(!listenersAfter.includes(lock.handleExitSync)); + }); + }); + + describe("handleExitSync()", function () { + it("should not throw if file already removed", function () { + assert.doesNotThrow(() => lock.handleExitSync()); // simulate non-existent file + }); + }); +}); diff --git a/test/lib/ModStore.test.js b/test/lib/ModStore.test.js index 75c2d1c9..b1d733b1 100644 --- a/test/lib/ModStore.test.js +++ b/test/lib/ModStore.test.js @@ -7,7 +7,7 @@ const JSZip = require("jszip"); // Added for creating mock zips // Capture native fetch before any potential mocks are applied const nativeFetch = global.fetch; -const { ModStore, ModInfo } = require("@clusterio/lib"); // Adjust path based on compiled output +const { ModStore, ModInfo, ModVersionEquality } = require("@clusterio/lib"); // Adjust path based on compiled output const MODS_DIR = path.join("temp", "test", "mod_store", "mods"); const CACHE_FILE = path.join(MODS_DIR, "mod-info-cache.json"); @@ -148,7 +148,7 @@ describe("lib/ModStore", function () { assert.equal(modInfo.name, modName); assert.equal(modInfo.version, modVersion); // Check if the dependency was updated (indicating re-read) - assert(modInfo.dependencies.includes("? updated-dep")); + assert(modInfo.dependencySpecifications.includes("? updated-dep")); }); it("should handle corrupted zip files gracefully", async function () { @@ -271,7 +271,7 @@ describe("lib/ModStore", function () { }); // Helper to create the mock fetch function for the Factorio Mod Portal API - function createMockPortalApiFetch(modsToDownloadMap, fetchCallsArray = null) { + function createMockPortalApiFetch(modsToDownload, fetchCallsArray = null) { return async (url, options) => { const urlString = url.toString(); if (fetchCallsArray) { @@ -285,11 +285,12 @@ describe("lib/ModStore", function () { requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean); } const results = requestedModNames.map(name => { - const requestedVersion = modsToDownloadMap.get(name); - if (!requestedVersion) { + const requestedMod = modsToDownload.find(mod => mod.name === name); + if (!requestedMod) { // Use assert or throw for clearer test failure messages assert.fail(`[Test Mock ERROR] Mock fetch received unexpected mod name: ${name}`); } + const requestedVersion = requestedMod.version.version; return { name: name, latest_release: { @@ -309,7 +310,7 @@ describe("lib/ModStore", function () { } it("should download and add a single mod", async function () { - const modsToDownload = new Map([["download-mod-1", "1.1.0"]]); + const modsToDownload = [{ name: "download-mod-1", version: ModVersionEquality.fromString("1.1.0") }]; // Define fetch mock specific to this test global.fetch = createMockPortalApiFetch(modsToDownload); @@ -327,10 +328,10 @@ describe("lib/ModStore", function () { }); it("should download multiple mods", async function () { - const modsToDownload = new Map([ - ["multi-mod-a", "1.0.0"], - ["multi-mod-b", "2.0.0"], - ]); + const modsToDownload = [ + { name: "multi-mod-a", version: ModVersionEquality.fromString("1.0.0") }, + { name: "multi-mod-b", version: ModVersionEquality.fromString("2.0.0") }, + ]; // Define fetch mock specific to this test global.fetch = createMockPortalApiFetch(modsToDownload); @@ -343,43 +344,34 @@ describe("lib/ModStore", function () { assert(await fs.pathExists(path.join(MODS_DIR, "multi-mod-a_1.0.0.zip"))); assert(await fs.pathExists(path.join(MODS_DIR, "multi-mod-b_2.0.0.zip"))); }); + it("should throw when no releases available", async function () { + const modsToDownload = [{ name: "download-mod-1", version: ModVersionEquality.fromString("1.1.0") }]; - it("should skip already downloaded mods", async function () { - const existingModName = "existing-mod"; - const existingModVersion = "1.0.0"; - const existingFilename = `${existingModName}_${existingModVersion}.zip`; - await createMockModZip(path.join(MODS_DIR, existingFilename), existingModName, existingModVersion); - modStore = await ModStore.fromDirectory(MODS_DIR); - assert.equal(modStore.files.size, 1); + // Define fetch mock specific to this test + global.fetch = async (url, options) => { + const urlString = url.toString(); + if (urlString.startsWith("https://mods.factorio.com/api/mods") && options?.method === "POST") { + const body = options.body; + let requestedModNames = []; + if (body instanceof URLSearchParams) { + requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean); + } + const results = requestedModNames.map(name => ({ name: name })); + return { ok: true, status: 200, json: async () => ({ results: results, pagination: {} }) }; + } + // Fallback for unhandled fetches in this test + return { ok: false, status: 404, statusText: `Test Mock 404: ${urlString}` }; + }; - const modsToDownload = new Map([ - [existingModName, existingModVersion], // Already present - ["new-mod", "0.5.0"], // New mod - ]); - - let fetchCalls = []; - // Define fetch mock specific to this test (wraps original for tracking) - global.fetch = createMockPortalApiFetch(modsToDownload, fetchCalls); - - await modStore.downloadMods(modsToDownload, username, token, factorioVersion); - - assert.equal(modStore.files.size, 2, "Should have 2 mods total"); - assert(modStore.files.has(existingFilename)); - assert(modStore.files.has("new-mod_0.5.0.zip")); - assert(await fs.pathExists(path.join(MODS_DIR, "new-mod_0.5.0.zip"))); - - const postCall = fetchCalls.find(call => call.options?.method === "POST"); - assert(postCall, "POST call to API should have happened"); - const requestedMods = (postCall.options.body.get("namelist") || "").split(","); - assert.deepEqual(requestedMods, ["new-mod"], "Only new-mod should be in the namelist"); + await assert.rejects(modStore.downloadMods(modsToDownload, username, token, factorioVersion)); }); it("should skip built-in mods", async function () { - const modsToDownload = new Map([ - ["base", "1.1.0"], // Built-in - ["core", "1.1.0"], // Built-in - ["another-new-mod", "1.0.0"], // Not built-in - ]); + const modsToDownload = [ + { name: "base", version: ModVersionEquality.fromString("1.1.0") }, // Built-in + { name: "core", version: ModVersionEquality.fromString("1.1.0") }, // Built-in + { name: "another-new-mod", version: ModVersionEquality.fromString("1.0.0") }, // Not built-in + ]; let fetchCalls = []; // Define fetch mock specific to this test @@ -396,51 +388,8 @@ describe("lib/ModStore", function () { assert.deepEqual(requestedMods, ["another-new-mod"], "Only non-builtin mod should be in the namelist"); }); - it("should handle Factorio version formatting (e.g., 1.1.100 -> 1.1)", async function () { - const modsToDownload = new Map([["version-test-mod", "1.0.0"]]); - let capturedApiUrl = null; - - // Define fetch mock specific to this test - const testFetch = async (url, options) => { - const urlString = url.toString(); - if (urlString.startsWith("https://mods.factorio.com/api/mods") && options?.method === "POST") { - capturedApiUrl = urlString; // Capture URL - const body = options.body; - let requestedModNames = []; - if (body instanceof URLSearchParams) { - requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean); - } - const results = requestedModNames.map(name => { - const requestedVersion = modsToDownload.get(name); - if (!requestedVersion) { - throw new Error(`[Test Mock ERROR] No version for ${name}`); - } - return { - name: name, - latest_release: { - download_url: `/download/${name}/${requestedVersion}`, - file_name: `${name}_${requestedVersion}.zip`, - info_json: { factorio_version: factorioVersion }, - version: requestedVersion, - sha1: `sha1_${name}_${requestedVersion}`, - }, - }; - }); - return { ok: true, status: 200, json: async () => ({ results: results, pagination: {} }) }; - } - return { ok: false, status: 404, statusText: `Test Mock 404: ${urlString}` }; - }; - global.fetch = testFetch; - - await modStore.downloadMods(modsToDownload, username, token, "1.1.100"); // Use full version - - assert(capturedApiUrl, "Fetch to API mods should have been called"); - const apiUrlParams = new URL(capturedApiUrl).searchParams; - assert.equal(apiUrlParams.get("version"), "1.1", "Factorio version should be truncated in API call"); - }); - it("should handle API error during mod info fetch", async function () { - const modsToDownload = new Map([["api-error-mod", "1.0.0"]]); + const modsToDownload = [{ name: "api-error-mod", version: ModVersionEquality.fromString("1.0.0") }]; // Define fetch mock specific to this test (simulates API error) global.fetch = async (url, options) => { @@ -463,11 +412,11 @@ describe("lib/ModStore", function () { }); it("should handle download error for a specific mod", async function () { - const modsToDownload = new Map([ - ["good-mod", "1.0.0"], // This one should succeed - ["bad-download-mod", "1.0.0"], // This one will fail - ["another-good-mod", "1.0.0"], // This one should also succeed - ]); + const modsToDownload = [ + { name: "good-mod", version: ModVersionEquality.fromString("1.0.0") }, // Success + { name: "bad-download-mod", version: ModVersionEquality.fromString("1.0.0") }, // Fail + { name: "another-good-mod", version: ModVersionEquality.fromString("1.0.0") }, // Success + ]; // Mock ModStore.downloadFile to fail for the specific mod const originalDSF = ModStore.downloadFile; @@ -496,10 +445,11 @@ describe("lib/ModStore", function () { requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean); } const results = requestedModNames.map(name => { - const requestedVersion = modsToDownload.get(name); - if (!requestedVersion) { + const requestedMod = modsToDownload.find(mod => mod.name === name); + if (!requestedMod) { throw new Error(`[Test Mock ERROR] No version for ${name}`); } + const requestedVersion = requestedMod.version.version; return { name: name, latest_release: { @@ -526,12 +476,12 @@ describe("lib/ModStore", function () { }); it("should download mods in chunks", async function () { - const modsToDownload = new Map([ - ["chunk-mod-1", "1.0.0"], - ["chunk-mod-2", "1.0.0"], - ["chunk-mod-3", "1.0.0"], - ["chunk-mod-4", "1.0.0"], - ]); + const modsToDownload = [ + { name: "chunk-mod-1", version: ModVersionEquality.fromString("1.0.0") }, + { name: "chunk-mod-2", version: ModVersionEquality.fromString("1.0.0") }, + { name: "chunk-mod-3", version: ModVersionEquality.fromString("1.0.0") }, + { name: "chunk-mod-4", version: ModVersionEquality.fromString("1.0.0") }, + ]; let postCalls = 0; // Define fetch mock specific to this test @@ -545,10 +495,11 @@ describe("lib/ModStore", function () { requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean); } const results = requestedModNames.map(name => { - const requestedVersion = modsToDownload.get(name); - if (!requestedVersion) { + const requestedMod = modsToDownload.find(mod => mod.name === name); + if (!requestedMod) { throw new Error(`[Test Mock ERROR] No version for ${name}`); } + const requestedVersion = requestedMod.version.version; return { name: name, latest_release: { @@ -570,7 +521,7 @@ describe("lib/ModStore", function () { // Assuming chunkSize is 500, 4 mods should be 1 chunk/POST call assert.equal(postCalls, 1, "Should make 1 POST call for 4 mods"); - assert.equal(modStore.files.size, modsToDownload.size, "All chunked mods should be downloaded"); + assert.equal(modStore.files.size, modsToDownload.length, "All chunked mods should be downloaded"); assert(modStore.files.has("chunk-mod-1_1.0.0.zip")); assert(modStore.files.has("chunk-mod-4_1.0.0.zip")); }); @@ -843,6 +794,40 @@ describe("lib/ModStore", function () { }); }); + describe("fetchModReleases (static)", function() { + // Keep track of the original fetch for the live test + let originalFetch; + before(function() { + originalFetch = global.fetch; + }); + after(function() { + global.fetch = originalFetch; // Restore original fetch after this suite + }); + + it("should fetch from the correct api url (mock)", async function() { + global.fetch = async function(url) { + assert.equal(url, "https://mods.factorio.com/api/mods/my-mod/full"); + return { ok: true, status: 200, json: async () => ({ name: "my-mod" }) }; + }; + + const result = await ModStore.fetchModReleases("my-mod"); + assert.equal(result.name, "my-mod"); + }); + it("should throw an error when the fetch fails (mock)", async function() { + global.fetch = async function(url) { + assert.equal(url, "https://mods.factorio.com/api/mods/my-mod/full"); + return { ok: false, status: 400, json: async () => ({ name: "my-mod" }) }; + }; + + await assert.rejects(ModStore.fetchModReleases("my-mod")); + }); + it("should fetch from the correct api url (live)", async function() { + global.fetch = nativeFetch; + const result = await ModStore.fetchModReleases("clusterio_lib"); + assert(result.releases && result.releases.length > 1, "Failed to fetch releases"); + }); + }); + // === Instance Methods === describe("Instance Methods", function () { let modStore; @@ -1004,5 +989,26 @@ describe("lib/ModStore", function () { assert.equal(eventModInfo, modInfo, "Event should pass the added ModInfo"); }); }); + + describe("filterInstalled", function() { + it("should filter out installed mods", function() { + const mods = [ + { name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) }, + { name: "not-installed", version: ModVersionEquality.fromString("1.0.0") }, + ]; + assert.deepEqual(modStore.filterInstalled(mods, false), [ + { name: "not-installed", version: ModVersionEquality.fromString("1.0.0") }, + ]); + }); + it("should filter out uninstalled mods", function() { + const mods = [ + { name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) }, + { name: "not-installed", version: ModVersionEquality.fromString("1.0.0") }, + ]; + assert.deepEqual(modStore.filterInstalled(mods, true), [ + { name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) }, + ]); + }); + }); }); }); diff --git a/test/lib/ValueCache.test.js b/test/lib/ValueCache.test.js new file mode 100644 index 00000000..b75094c3 --- /dev/null +++ b/test/lib/ValueCache.test.js @@ -0,0 +1,127 @@ +const assert = require("assert").strict; +const { ValueCache, wait } = require("@clusterio/lib"); + +describe("ValueCache", function () { + describe("constructor", function () { + it("constructs with a fetch function", function() { + const fetcher = async () => 42; + const cache = new ValueCache(fetcher); + assert.ok(cache); + }); + }); + + describe("get", function() { + it("throws TypeError for negative maxAgeMs", async function() { + const fetcher = async () => 42; + const cache = new ValueCache(fetcher); + + assert.throws(() => cache.get(-1), err => { + assert.equal(err instanceof TypeError, true); + assert.match(String(err.message), /maxAgeMs/); + return true; + }); + }); + it("uses a default of 0 for maxAgeMs", async function() { + let callCount = 0; + const fetcher = async () => { + callCount += 1; + return callCount; + }; + + const cache = new ValueCache(fetcher); + + const v1 = await cache.get(); + const v2 = await cache.get(); + + assert.equal(v1, 1); + assert.equal(v2, 2); + assert.equal(callCount, 2); + }); + it("returns cached value when within maxAgeMs", async function() { + let callCount = 0; + const fetcher = async () => { + callCount += 1; + return callCount; + }; + + const cache = new ValueCache(fetcher); + + const v1 = await cache.get(1000); + const v2 = await cache.get(1000); + + assert.equal(v1, 1); + assert.equal(v2, 1); + assert.equal(callCount, 1); + }); + it("refreshes the cache when older than maxAgeMs", async function() { + let callCount = 0; + const fetcher = async () => { + callCount += 1; + return callCount; + }; + + const cache = new ValueCache(fetcher); + + const v1 = await cache.get(50); + const v2 = await cache.get(1000); // Use cached value + await wait(100); + const v3 = await cache.get(50); // Stale after 100ms wait + const v4 = await cache.get(1000); // Use cached value + + assert.equal(v1, 1); + assert.equal(v2, 1); + assert.equal(v3, 2); + assert.equal(v4, 2); + assert.equal(callCount, 2); + }); + it("shares in-flight fetch between concurrent callers", async function() { + let callCount = 0; + let resolveFetcher; + const fetcher = () => ( + new Promise(resolve => { + callCount += 1; + resolveFetcher = resolve; + }) + ); + + const cache = new ValueCache(fetcher); + + const p1 = cache.get(1000); + const p2 = cache.get(1000); + + assert.equal(callCount, 1); + + resolveFetcher(42); + const [v1, v2] = await Promise.all([p1, p2]); + + assert.equal(v1, 42); + assert.equal(v2, 42); + assert.equal(callCount, 1); + }); + it("propagates errors from the fetcher and clears ongoing fetch", async function() { + let callCount = 0; + const error = new Error("fetch failed"); + const fetcher = async () => { + callCount += 1; + throw error; + }; + + const cache = new ValueCache(fetcher); + + await assert.rejects(cache.get(), err => { + assert.equal(err, error); + return true; + }); + + assert.equal(callCount, 1); + + // After a failure, a new attempt to fetch should be made + await assert.rejects(cache.get(), err => { + assert.equal(err, error); + return true; + }); + + assert.equal(callCount, 2); + }); + }); +}); diff --git a/test/lib/data/ModInfo.js b/test/lib/data/ModInfo.js index 9e8d6c65..1868a432 100644 --- a/test/lib/data/ModInfo.js +++ b/test/lib/data/ModInfo.js @@ -5,10 +5,226 @@ const path = require("path"); const lib = require("@clusterio/lib"); const libBuildMod = require("@clusterio/lib/build_mod"); -const { ModInfo } = lib; +const { ModInfo, ModDependency } = lib; +const { testMatrix, testRoundTripJsonSerialisable } = require("../../common"); describe("lib/data/ModInfo", function() { + describe("class ModDependency", function() { + describe(".getTypeFromPrefix()", function() { + it("should not throw for valid prefixes", function() { + assert.equal(ModDependency.getTypeFromPrefix("!"), "incompatible"); + assert.equal(ModDependency.getTypeFromPrefix("?"), "optional"); + assert.equal(ModDependency.getTypeFromPrefix("(?)"), "hidden"); + assert.equal(ModDependency.getTypeFromPrefix("~"), "unordered"); + assert.equal(ModDependency.getTypeFromPrefix(""), "required"); + }); + it("should throw for unknown prefixes", function() { + assert.throws(() => ModDependency.getTypeFromPrefix("invalid")); + }); + }); + describe("constructor", function() { + it("should accept specifications of name only", function() { + const dependency = new ModDependency("my-mod"); + assert.equal(dependency.name, "my-mod"); + assert.equal(dependency.type, "required"); + assert.equal(dependency.version, undefined); + }); + it("should accept specifications of name and prefix", function() { + const dependency = new ModDependency("? my-mod"); + assert.equal(dependency.name, "my-mod"); + assert.equal(dependency.type, "optional"); + assert.equal(dependency.version, undefined); + }); + it("should accept specifications of name and version", function() { + const dependency = new ModDependency("my-mod >= 1.2.3"); + assert.equal(dependency.name, "my-mod"); + assert.equal(dependency.type, "required"); + assert.notEqual(dependency.version, undefined); + assert.equal(dependency.version.equality, ">="); + assert.equal(dependency.version.integerVersion, lib.integerFullVersion("1.2.3")); + }); + it("should accept specifications of name, prefix and version", function() { + const dependency = new ModDependency("? my-mod >= 1.2.3"); + assert.equal(dependency.name, "my-mod"); + assert.equal(dependency.type, "optional"); + assert.notEqual(dependency.version, undefined); + assert.equal(dependency.version.equality, ">="); + assert.equal(dependency.version.integerVersion, lib.integerFullVersion("1.2.3")); + }); + it("should throw if no version equality is given", function() { + assert.throws(() => new ModDependency("my-mod 1.2.3")); + assert.throws(() => new ModDependency("? my-mod 1.2.3")); + }); + it("should throw if invalid specification given", function() { + assert.throws(() => new ModDependency("? ? my-mod = 1.2.3")); + }); + it("should throw if name contains spaces", function() { + // Apparently there are some legacy mods which include spaces + // Such as https://mods.factorio.com/mod/Explosive%20Excavation + this.skip(); + assert.throws(() => new ModDependency("my mod")); + assert.throws(() => new ModDependency("? my mod")); + assert.throws(() => new ModDependency("? my mod >= 1.2.3")); + }); + it("should not error when the name contains spaces", function() { + const dependencyReq = new ModDependency("my mod"); + assert.equal(dependencyReq.name, "my mod"); + assert.equal(dependencyReq.type, "required"); + + const dependencyOpt = new ModDependency("? my mod"); + assert.equal(dependencyOpt.name, "my mod"); + assert.equal(dependencyOpt.type, "optional"); + + const dependencyVer = new ModDependency("my mod >= 1.2.3"); + assert.equal(dependencyVer.name, "my mod"); + assert.equal(dependencyVer.type, "required"); + assert.equal(dependencyVer.version.version, "1.2.3"); + + const dependencyOptVer = new ModDependency("? my mod >= 1.2.3"); + assert.equal(dependencyOptVer.name, "my mod"); + assert.equal(dependencyOptVer.type, "optional"); + assert.equal(dependencyOptVer.version.version, "1.2.3"); + }); + it("should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(ModDependency, testMatrix( + ["my-mod", "? my-mod", "! my-mod", "my-mod > 1.0.0", "~ my-mod <= 2.0.0"], // Specification + )); + }); + }); + describe("checkUnsatisfiedReason()", function() { + const mods = [ + ModInfo.fromJSON({ name: "my-mod", version: "1.0.0" }), + ]; + it("should be undefined for incompatible being missing", function() { + const dependency = new ModDependency("! not-present"); + assert.equal(dependency.checkUnsatisfiedReason(mods), undefined); + }); + it("should give reason for incompatible being present", function() { + const dependency = new ModDependency("! my-mod"); + assert.equal(dependency.checkUnsatisfiedReason(mods), "incompatible"); + }); + it("should undefined for optional / hidden being missing", function() { + const dependencyOptional = new ModDependency("? not-present"); + assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined); + const dependencyHidden = new ModDependency("(?) not-present"); + assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined); + }); + it("should undefined for optional / hidden being present", function() { + const dependencyOptional = new ModDependency("? my-mod"); + assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined); + const dependencyHidden = new ModDependency("(?) my-mod"); + assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined); + }); + it("should give reason for optional / hidden being present but wrong version", function() { + const dependencyOptional = new ModDependency("? my-mod > 2.0.0"); + assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "wrong_version"); + const dependencyHidden = new ModDependency("(?) my-mod > 2.0.0"); + assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "wrong_version"); + }); + it("should undefined for unordered / required being present", function() { + const dependencyOptional = new ModDependency("~ my-mod"); + assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined); + const dependencyHidden = new ModDependency("my-mod"); + assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined); + }); + it("should give reason for unordered / required being present but wrong version", function() { + const dependencyOptional = new ModDependency("~ my-mod > 2.0.0"); + assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "wrong_version"); + const dependencyHidden = new ModDependency("my-mod > 2.0.0"); + assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "wrong_version"); + }); + it("should give reason for unordered / required being missing", function() { + const dependencyOptional = new ModDependency("~ not-present"); + assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "missing_dependency"); + const dependencyHidden = new ModDependency("not-present"); + assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "missing_dependency"); + }); + }); + describe("isSatisfied()", function() { + const mods = [ + ModInfo.fromJSON({ name: "my-mod", version: "1.0.0" }), + ]; + it("should pass for incompatible being missing", function() { + const dependency = new ModDependency("! not-present"); + assert.equal(dependency.isSatisfied(mods), true); + }); + it("should fail for incompatible being present", function() { + const dependency = new ModDependency("! my-mod"); + assert.equal(dependency.isSatisfied(mods), false); + }); + it("should pass for optional / hidden being missing", function() { + const dependencyOptional = new ModDependency("? not-present"); + assert.equal(dependencyOptional.isSatisfied(mods), true); + const dependencyHidden = new ModDependency("(?) not-present"); + assert.equal(dependencyHidden.isSatisfied(mods), true); + }); + it("should pass for optional / hidden being present", function() { + const dependencyOptional = new ModDependency("? my-mod"); + assert.equal(dependencyOptional.isSatisfied(mods), true); + const dependencyHidden = new ModDependency("(?) my-mod"); + assert.equal(dependencyHidden.isSatisfied(mods), true); + }); + it("should fail for optional / hidden being present but wrong version", function() { + const dependencyOptional = new ModDependency("? my-mod > 2.0.0"); + assert.equal(dependencyOptional.isSatisfied(mods), false); + const dependencyHidden = new ModDependency("(?) my-mod > 2.0.0"); + assert.equal(dependencyHidden.isSatisfied(mods), false); + }); + it("should pass for unordered / required being present", function() { + const dependencyOptional = new ModDependency("~ my-mod"); + assert.equal(dependencyOptional.isSatisfied(mods), true); + const dependencyHidden = new ModDependency("my-mod"); + assert.equal(dependencyHidden.isSatisfied(mods), true); + }); + it("should fail for unordered / required being present but wrong version", function() { + const dependencyOptional = new ModDependency("~ my-mod > 2.0.0"); + assert.equal(dependencyOptional.isSatisfied(mods), false); + const dependencyHidden = new ModDependency("my-mod > 2.0.0"); + assert.equal(dependencyHidden.isSatisfied(mods), false); + }); + it("should fail for unordered / required being missing", function() { + const dependencyOptional = new ModDependency("~ not-present"); + assert.equal(dependencyOptional.isSatisfied(mods), false); + const dependencyHidden = new ModDependency("not-present"); + assert.equal(dependencyHidden.isSatisfied(mods), false); + }); + }); + describe("incompatible", function() { + it("should return true iff the type is incompatible", function() { + const dependencyIncompatible = new ModDependency("! my-mod"); + assert.equal(dependencyIncompatible.incompatible, true); + for (const test of ["my-mod", "? my-mod", "(?) my-mod", "~ my-mod"]) { + const dependency = new ModDependency(test); + assert.equal(dependency.incompatible, false, test); + } + }); + }); + describe("required", function() { + it("should return true iff the dependency is required", function() { + for (const test of ["my-mod", "~ my-mod"]) { + const dependency = new ModDependency(test); + assert.equal(dependency.required, true, test); + } + for (const test of ["! my-mod", "? my-mod", "(?) my-mod"]) { + const dependency = new ModDependency(test); + assert.equal(dependency.required, false, test); + } + }); + }); + describe("optional", function() { + it("should return true iff the dependency is optional", function() { + for (const test of ["? my-mod", "(?) my-mod"]) { + const dependency = new ModDependency(test); + assert.equal(dependency.optional, true, test); + } + for (const test of ["! my-mod", "my-mod", "~ my-mod"]) { + const dependency = new ModDependency(test); + assert.equal(dependency.optional, false, test); + } + }); + }); + }); describe("class ModInfo", function() { it("should round trip serialize", function() { const validate = lib.compile(ModInfo.jsonSchema); @@ -70,7 +286,16 @@ describe("lib/data/ModInfo", function() { factorioMods.sort((a, b) => a.integerFactorioVersion - b.integerFactorioVersion); assert.deepEqual(factorioMods.map(mod => mod.factorioVersion), sortedVersions); }); - + it("should provide the original string specifications for dependencies", function() { + const mod = ModInfo.fromJSON({ dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"] }); + assert.deepEqual(mod.dependencySpecifications, ["UltraMod", "SuperLib >= 1.00", "! bad-mod"]); + }); + it("should parse the provided dependency specifications", function() { + const mod = ModInfo.fromJSON({ dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"] }); + assert.deepEqual(mod.dependencies, + ["UltraMod", "SuperLib >= 1.00", "! bad-mod"].map(dep => new ModDependency(dep)) + ); + }); it("should load from a mod zip file", async function() { await libBuildMod.build({ build: true, @@ -100,5 +325,23 @@ describe("lib/data/ModInfo", function() { }) ); }); + describe("checkDependencySatisfaction()", function() { + it("should return undefined when satisfied", function() { + const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] }); + assert.equal(mod.checkDependencySatisfaction([{ name: "b", version: "1.0.0" }]), undefined); + }); + it("should return incompatible when there is an incompatibility", function() { + const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] }); + assert.equal(mod.checkDependencySatisfaction([{ name: "a", version: "1.0.0" }]), "incompatible"); + }); + it("should return missing dependency when missing but no incompatibly", function() { + const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] }); + assert.equal(mod.checkDependencySatisfaction([{ name: "c", version: "1.0.0" }]), "missing_dependency"); + }); + it("should return wrong version when wrong but not missing or incompatible", function() { + const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] }); + assert.equal(mod.checkDependencySatisfaction([{ name: "b", version: "2.0.0" }]), "wrong_version"); + }); + }); }); }); diff --git a/test/lib/data/ModPack.js b/test/lib/data/ModPack.js index b63b9794..5000845b 100644 --- a/test/lib/data/ModPack.js +++ b/test/lib/data/ModPack.js @@ -397,16 +397,16 @@ describe("lib/data/ModPack", function() { it("should work with versions before 2.0", function() { const builtinMods = ModPack.getBuiltinMods("1.1"); assert.deepEqual(builtinMods, [ - { name: "base", enabled: true, version: "1.1" }, + { name: "base", enabled: true, version: "1.1.0" }, ]); }); it("should work with versions after 2.0", function() { const builtinMods = ModPack.getBuiltinMods("2.0"); assert.deepEqual(builtinMods, [ - { name: "base", enabled: true, version: "2.0" }, - { name: "elevated-rails", enabled: false, version: "2.0" }, - { name: "quality", enabled: false, version: "2.0" }, - { name: "space-age", enabled: false, version: "2.0" }, + { name: "base", enabled: true, version: "2.0.0" }, + { name: "elevated-rails", enabled: false, version: "2.0.0" }, + { name: "quality", enabled: false, version: "2.0.0" }, + { name: "space-age", enabled: false, version: "2.0.0" }, ]); }); }); diff --git a/test/lib/data/version.test.js b/test/lib/data/version.test.js new file mode 100644 index 00000000..39cf0e03 --- /dev/null +++ b/test/lib/data/version.test.js @@ -0,0 +1,515 @@ +"use strict"; +const assert = require("assert").strict; +const lib = require("@clusterio/lib"); + +const { testMatrix, testRoundTripJsonSerialisable } = require("../../common"); + +describe("lib/data/version", function() { + describe("isVersionEquality()", function() { + it("should correctly validate input strings", function() { + const valid = ["<", "<=", "=", ">=", ">"]; + for (const test of valid) { + assert.equal(lib.isVersionEquality(test), true, test); + } + const invalid = ["==", "!=", "<>", ""]; + for (const test of invalid) { + assert.equal(lib.isVersionEquality(test), false, test); + } + }); + }); + describe("isApiVersion()", function() { + it("should correctly validate input strings", function() { + const valid = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"]; + for (const test of valid) { + assert.equal(lib.isApiVersion(test), true, test); + } + const invalid = ["0.12", "0.19", "1.2", "2.1", "1.0.0", "1.0.0.0", "0", "", "latest"]; + for (const test of invalid) { + assert.equal(lib.isApiVersion(test), false, test); + } + }); + }); + describe("normaliseApiVersion()", function() { + it("should normalise accepted versions", function() { + assert.equal(lib.normaliseApiVersion("0.13"), "0.13"); + assert.equal(lib.normaliseApiVersion("0.13.0"), "0.13"); + assert.equal(lib.normaliseApiVersion("0.13.1"), "0.13"); + assert.equal(lib.normaliseApiVersion("1.1"), "1.1"); + assert.equal(lib.normaliseApiVersion("1.1.0"), "1.1"); + assert.equal(lib.normaliseApiVersion("1.1.1"), "1.1"); + assert.equal(lib.normaliseApiVersion("2.0"), "2.0"); + assert.equal(lib.normaliseApiVersion("2.0.0"), "2.0"); + assert.equal(lib.normaliseApiVersion("2.0.1"), "2.0"); + assert.equal(lib.normaliseApiVersion("1.0.0.0"), "1.0"); + }); + it("should throw for invalid versions", function() { + const invalid = ["0.12", "0", "", "latest"]; + for (const test of invalid) { + assert.throws(() => lib.normaliseApiVersion(test), undefined, test); + } + }); + }); + describe("isFullVersion()", function() { + it("should correctly validate input strings", function() { + const valid = ["0.12.0", "0.13.1", "0.17.99", "00.000.0001", "1.1.110"]; + for (const test of valid) { + assert.equal(lib.isFullVersion(test), true, test); + } + const invalid = ["0.12", "0.19", "1.0", "1.0.0.0", "0", "", "latest"]; + for (const test of invalid) { + assert.equal(lib.isFullVersion(test), false, test); + } + }); + }); + describe("integerFullVersion()", function() { + it("should sort versions lexicographically", function() { + let unsortedVersions = ["1.0.0", "1.1.0", "0.1.0", "3.0.0", "1.2.0", "0.3.1", "0.3.3", "2.1.1", "0.0.1"]; + let sortedVersions = ["0.0.1", "0.1.0", "0.3.1", "0.3.3", "1.0.0", "1.1.0", "1.2.0", "2.1.1", "3.0.0"]; + unsortedVersions.sort((a, b) => lib.integerFullVersion(a) - lib.integerFullVersion(b)); + assert.deepEqual(unsortedVersions, sortedVersions); + }); + }); + describe("normaliseFullVersion()", function() { + it("should normalise accepted versions", function() { + assert.equal(lib.normaliseFullVersion("0.13"), "0.13.0"); + assert.equal(lib.normaliseFullVersion("0.13.0"), "0.13.0"); + assert.equal(lib.normaliseFullVersion("0.13.1"), "0.13.1"); + assert.equal(lib.normaliseFullVersion("1.1"), "1.1.0"); + assert.equal(lib.normaliseFullVersion("1.1.0"), "1.1.0"); + assert.equal(lib.normaliseFullVersion("1.1.1"), "1.1.1"); + assert.equal(lib.normaliseFullVersion("2.0"), "2.0.0"); + assert.equal(lib.normaliseFullVersion("2.0.0"), "2.0.0"); + assert.equal(lib.normaliseFullVersion("2.0.1"), "2.0.1"); + }); + }); + describe("isPartialVersion()", function() { + it("should correctly validate input strings", function() { + const valid = [ + "0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0", + "0.12.0", "0.13.1", "0.17.99", "00.000.0001", "1.1.110", + ]; + for (const test of valid) { + assert.equal(lib.isPartialVersion(test), true, test); + } + const invalid = ["1.0.0.0", "0", "", "latest"]; + for (const test of invalid) { + assert.equal(lib.isPartialVersion(test), false, test); + } + }); + }); + describe("integerPartialVersion()", function() { + it("should sort versions lexicographically", function() { + let unsortedVersions = ["1.0", "1.1", "0.1", "3.0", "1.2", "0.3", "2.1"]; + let sortedVersions = ["0.1", "0.3", "1.0", "1.1", "1.2", "2.1", "3.0"]; + unsortedVersions.sort((a, b) => lib.integerPartialVersion(a) - lib.integerPartialVersion(b)); + assert.deepEqual(unsortedVersions, sortedVersions); + }); + }); + describe("isTargetVersion()", function() { + it("should correctly validate input strings", function() { + const valid = [ + "0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0", + "0.12.0", "0.13.1", "0.17.99", "00.000.0001", "1.1.110", "latest", + ]; + for (const test of valid) { + assert.equal(lib.isTargetVersion(test), true, test); + } + const invalid = ["1.0.0.0", "0", ""]; + for (const test of invalid) { + assert.equal(lib.isTargetVersion(test), false, test); + } + }); + }); + describe("class ModVersionEquality", function() { + const tests = testMatrix( + ["<", "<=", "=", ">=", ">"], + ["0.18", "1.0", "1.0.0", "1.1.1"], + ); + describe("constructor", function() { + it("should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(lib.ModVersionEquality, tests); + }); + it("should be constructible", function() { + for (const [eq, ver] of tests) { + const test = `${eq} ${ver}`; + const version = new lib.ModVersionEquality(eq, ver); + assert.equal(version.equality, eq, test); + assert.equal(version.version, ver, test); + assert.equal(version.integerVersion, lib.integerPartialVersion(ver), test); + } + }); + it("should throw when given an invalid equality", function() { + assert.throws(() => new lib.ModVersionEquality("==", "1.0.0")); + }); + it("should throw when given an invalid version", function() { + assert.throws(() => new lib.ModVersionEquality("=", "1.0.0.0")); + }); + }); + describe("testIntegerVersion()", function() { + it("should test for less than", function() { + const version = new lib.ModVersionEquality("<", "0.0.100"); + assert.equal(version.testIntegerVersion(90), true); + assert.equal(version.testIntegerVersion(100), false); + assert.equal(version.testIntegerVersion(110), false); + }); + it("should test for less than equal", function() { + const version = new lib.ModVersionEquality("<=", "0.0.100"); + assert.equal(version.testIntegerVersion(90), true); + assert.equal(version.testIntegerVersion(100), true); + assert.equal(version.testIntegerVersion(110), false); + }); + it("should test for equal", function() { + const version = new lib.ModVersionEquality("=", "0.0.100"); + assert.equal(version.testIntegerVersion(90), false); + assert.equal(version.testIntegerVersion(100), true); + assert.equal(version.testIntegerVersion(110), false); + }); + it("should test for greater than equal", function() { + const version = new lib.ModVersionEquality(">=", "0.0.100"); + assert.equal(version.testIntegerVersion(90), false); + assert.equal(version.testIntegerVersion(100), true); + assert.equal(version.testIntegerVersion(110), true); + }); + it("should test for greater than", function() { + const version = new lib.ModVersionEquality(">", "0.0.100"); + assert.equal(version.testIntegerVersion(90), false); + assert.equal(version.testIntegerVersion(100), false); + assert.equal(version.testIntegerVersion(110), true); + }); + it("should throw if the equality is invalid (unreachable)", function() { + const version = new lib.ModVersionEquality(">", "0.0.100"); + version.equality = "=="; + assert.throws(() => version.testIntegerVersion(100)); + }); + }); + describe("testVersion()", function() { + it("should test for less than", function() { + const version = new lib.ModVersionEquality("<", "1.1.1"); + assert.equal(version.testVersion("1.0"), true); + assert.equal(version.testVersion("1.1"), true); + assert.equal(version.testVersion("1.1.1"), false); + assert.equal(version.testVersion("1.1.2"), false); + assert.equal(version.testVersion("1.2"), false); + }); + it("should test for less than equal", function() { + const version = new lib.ModVersionEquality("<=", "1.1.1"); + assert.equal(version.testVersion("1.0"), true); + assert.equal(version.testVersion("1.1"), true); + assert.equal(version.testVersion("1.1.1"), true); + assert.equal(version.testVersion("1.1.2"), false); + assert.equal(version.testVersion("1.2"), false); + }); + it("should test for equal", function() { + const version = new lib.ModVersionEquality("=", "1.1.1"); + assert.equal(version.testVersion("1.0"), false); + assert.equal(version.testVersion("1.1"), false); + assert.equal(version.testVersion("1.1.1"), true); + assert.equal(version.testVersion("1.1.2"), false); + assert.equal(version.testVersion("1.2"), false); + }); + it("should test for greater than equal", function() { + const version = new lib.ModVersionEquality(">=", "1.1.1"); + assert.equal(version.testVersion("1.0"), false); + assert.equal(version.testVersion("1.1"), false); + assert.equal(version.testVersion("1.1.1"), true); + assert.equal(version.testVersion("1.1.2"), true); + assert.equal(version.testVersion("1.2"), true); + }); + it("should test for greater than", function() { + const version = new lib.ModVersionEquality(">", "1.1.1"); + assert.equal(version.testVersion("1.0"), false); + assert.equal(version.testVersion("1.1"), false); + assert.equal(version.testVersion("1.1.1"), false); + assert.equal(version.testVersion("1.1.2"), true); + assert.equal(version.testVersion("1.2"), true); + }); + it("should throw if the equality is invalid (unreachable)", function() { + const version = new lib.ModVersionEquality(">", "1.1.1"); + version.equality = "=="; + assert.throws(() => version.testInteger("1.0.0")); + }); + }); + describe("toString()", function() { + it("should be convertible to string", function() { + for (const [eq, ver] of tests) { + const expected = `${eq} ${ver}`; + const version = new lib.ModVersionEquality(eq, ver); + assert.equal(version.toString(), expected); + } + }); + }); + describe("fromString()", function() { + it("should be constructable from string", function() { + for (const [eq, ver] of tests) { + const test = `${eq} ${ver}`; + const version = lib.ModVersionEquality.fromString(test); + assert.equal(version.equality, eq, test); + assert.equal(version.version, ver, test); + assert.equal(version.integerVersion, lib.integerPartialVersion(ver), test); + } + }); + it("should error for invalid equalities", function() { + const equalities = ["==", "!=", "<>", ""]; + for (const eq of equalities) { + const test = `${eq} 1.0.0`; + assert.throws(() => lib.ModVersionEquality.fromString(test), undefined, test); + } + }); + it("should error for invalid versions", function() { + const versions = ["1.0.0.0", "0", "", "latest"]; + for (const ver of versions) { + const test = `= ${ver}`; + assert.throws(() => lib.ModVersionEquality.fromString(test), undefined, test); + } + }); + }); + describe("fromParts()", function() { + it("should be constructable from parts", function() { + for (const [eq, ver] of tests) { + const test = `${eq} ${ver}`; + const version = lib.ModVersionEquality.fromParts(eq, ver); + assert.equal(version.equality, eq, test); + assert.equal(version.version, ver, test); + assert.equal(version.integerVersion, lib.integerPartialVersion(ver), test); + } + }); + it("should error for invalid equalities", function() { + const equalities = ["==", "!=", "<>", ""]; + for (const eq of equalities) { + assert.throws(() => lib.ModVersionEquality.fromParts(eq, "1.0.0"), undefined, eq); + } + }); + it("should error for invalid versions", function() { + const versions = ["1.0.0.0", "0", "", "latest"]; + for (const ver of versions) { + assert.throws(() => lib.ModVersionEquality.fromParts("=", ver), undefined, ver); + } + }); + }); + }); + describe("class ModVersionRange", function() { + describe("constructor", function() { + it("should be constructable", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + assert.equal(range.minVersion.equality, ">="); + assert.equal(range.minVersion.version, "1.0.0"); + assert.equal(range.maxVersion.equality, "<"); + assert.equal(range.maxVersion.version, "2.0.0"); + }); + it("should be default constructable", function() { + const range = new lib.ModVersionRange(); + assert.equal(range.minVersion.equality, ">="); + assert.equal(range.minVersion.version, "0.0.0"); + assert.equal(range.maxVersion.equality, "<="); + assert.equal(range.maxVersion.version, "65535.65535.65535"); + }); + it("should throw if invalid equality is given for min version", function() { + assert.throws(() => new lib.ModVersionRange( + new lib.ModVersionEquality("<", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + )); + assert.throws(() => new lib.ModVersionRange( + new lib.ModVersionEquality("<=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + )); + }); + it("should throw if invalid equality is given for max version", function() { + assert.throws(() => new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality(">", "2.0.0"), + )); + assert.throws(() => new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality(">=", "2.0.0"), + )); + }); + }); + describe("valid", function() { + it("should be true for valid ranges", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + assert.equal(range.valid, true); + + const defaultRange = new lib.ModVersionRange(); + assert.equal(defaultRange.valid, true); + }); + it("should be false for invalid ranges", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "2.0.0"), + new lib.ModVersionEquality("<", "1.0.0"), + ); + assert.equal(range.valid, false); + + const rangeImpossible = new lib.ModVersionRange( + new lib.ModVersionEquality("=", "1.0.0"), + new lib.ModVersionEquality("=", "2.0.0"), + ); + assert.equal(rangeImpossible.valid, false); + }); + }); + describe("invalidate()", function() { + it("should result in the range becoming invalid", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + range.invalidate(); + assert.equal(range.valid, false); + + const defaultRange = new lib.ModVersionRange(); + defaultRange.invalidate(); + assert.equal(defaultRange.valid, false); + }); + }); + describe("testIntegerVersion()", function() { + it("should return true when in range", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "0.0.100"), + new lib.ModVersionEquality("<", "0.0.200"), + ); + assert.equal(range.testIntegerVersion(100), true); + assert.equal(range.testIntegerVersion(199), true); + }); + it("should return false when out of range", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "0.0.100"), + new lib.ModVersionEquality("<", "0.0.200"), + ); + assert.equal(range.testIntegerVersion(99), false); + assert.equal(range.testIntegerVersion(200), false); + }); + it("should handle exact matches", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality("=", "0.0.100"), + new lib.ModVersionEquality("=", "0.0.100"), + ); + assert.equal(range.testIntegerVersion(99), false); + assert.equal(range.testIntegerVersion(100), true); + assert.equal(range.testIntegerVersion(101), false); + }); + }); + describe("testVersion()", function() { + it("should return true when in range", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + assert.equal(range.testVersion("1.0.0"), true); + assert.equal(range.testVersion("1.9.9"), true); + }); + it("should return false when out of range", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + assert.equal(range.testVersion("0.9.9"), false); + assert.equal(range.testVersion("2.0.0"), false); + }); + it("should handle exact matches", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality("=", "1.0.0"), + new lib.ModVersionEquality("=", "1.0.0"), + ); + assert.equal(range.testVersion("0.9.9"), false); + assert.equal(range.testVersion("1.0.0"), true); + assert.equal(range.testVersion("1.0.1"), false); + }); + }); + describe("combineVersion()", function() { + it("should handle merge with less than", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + + assert.equal(range.testVersion("1.1"), true); + assert.equal(range.testVersion("1.5"), true); + range.combineVersion(new lib.ModVersionEquality("<", "1.2")); + assert.equal(range.testVersion("1.1"), true); + assert.equal(range.testVersion("1.5"), false); + + assert.equal(range.valid, true); + range.combineVersion(new lib.ModVersionEquality("<", "0.5")); + assert.equal(range.valid, false); + }); + it("should handle merge with less than equal", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + + assert.equal(range.testVersion("1.2"), true); + assert.equal(range.testVersion("1.5"), true); + range.combineVersion(new lib.ModVersionEquality("<=", "1.2")); + assert.equal(range.testVersion("1.2"), true); + assert.equal(range.testVersion("1.5"), false); + + assert.equal(range.valid, true); + range.combineVersion(new lib.ModVersionEquality("<=", "0.5")); + assert.equal(range.valid, false); + }); + it("should handle merge with equal", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + + assert.equal(range.testVersion("1.2"), true); + assert.equal(range.testVersion("1.5"), true); + range.combineVersion(new lib.ModVersionEquality("=", "1.2")); + assert.equal(range.testVersion("1.2"), true); + assert.equal(range.testVersion("1.5"), false); + + assert.equal(range.valid, true); + range.combineVersion(new lib.ModVersionEquality("=", "0.5")); + assert.equal(range.valid, false); + }); + it("should handle merge with greater than equal", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + + assert.equal(range.testVersion("1.1"), true); + assert.equal(range.testVersion("1.5"), true); + range.combineVersion(new lib.ModVersionEquality(">=", "1.2")); + assert.equal(range.testVersion("1.1"), false); + assert.equal(range.testVersion("1.5"), true); + + assert.equal(range.valid, true); + range.combineVersion(new lib.ModVersionEquality(">=", "2.5")); + assert.equal(range.valid, false); + }); + it("should handle merge with greater than", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + + assert.equal(range.testVersion("1.2"), true); + assert.equal(range.testVersion("1.5"), true); + range.combineVersion(new lib.ModVersionEquality(">", "1.2")); + assert.equal(range.testVersion("1.2"), false); + assert.equal(range.testVersion("1.5"), true); + + assert.equal(range.valid, true); + range.combineVersion(new lib.ModVersionEquality(">", "2.5")); + assert.equal(range.valid, false); + }); + it("should throw if the equality is invalid (unreachable)", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + const version = new lib.ModVersionEquality(">", "1.2"); + version.equality = "=="; + assert.throws(() => range.combineVersion(version)); + }); + }); + }); +}); diff --git a/test/lib/external/FactorioVersions.test.js b/test/lib/external/FactorioVersions.test.js new file mode 100644 index 00000000..32b86dad --- /dev/null +++ b/test/lib/external/FactorioVersions.test.js @@ -0,0 +1,134 @@ +const assert = require("assert").strict; +const { readFile } = require("fs-extra"); + +const { fetchFactorioVersions } = require("@clusterio/lib"); +const { slowTest } = require("../../integration"); + +describe("FactorioVersions", function() { + describe("fetchFactorioVersions", function() { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = undefined; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + function mockFetch(html) { + const mockResponse = { + ok: true, + status: 200, + statusText: "OK", + text: async () => html, + }; + + global.fetch = async () => mockResponse; + } + + it("fetches and parsed versions with stable / experimental flags", async function() { + mockFetch(` + + + + 2.0.73 + + + + + 2.0.72 + + + + `); + + const versions = await fetchFactorioVersions(); + assert.deepEqual(versions, [ + { + stable: false, + version: "2.0.73", + headlessUrl: "www.factorio.com/get-download/2.0.73/headless/linux64", + }, + { + stable: true, + version: "2.0.72", + headlessUrl: "www.factorio.com/get-download/2.0.72/headless/linux64", + }, + ]); + }); + it("deduplicates versions by last occurrence", async function() { + mockFetch(` + + + + 2.0.73 + + + + + 2.0.73 + + + + `); + + const versions = await fetchFactorioVersions(); + assert.deepEqual(versions, [ + { + stable: true, + version: "2.0.73", + headlessUrl: "www.factorio.com/get-download/2.0.73/headless/linux64", + }, + ]); + }); + it("thorws on HTTP error status", async function() { + const mockResponse = { + ok: false, + status: 500, + statusText: "Server Error", + text: async () => "", + }; + + global.fetch = async () => mockResponse; + + await assert.rejects(fetchFactorioVersions(), /HTTP 500 Server Error/); + }); + it("thorws on network error status", async function() { + global.fetch = async () => { + throw new Error("Network Error"); + }; + + await assert.rejects(fetchFactorioVersions(), /Network Error/); + }); + it("parses versions from saved html file", async function() { + mockFetch(await readFile("test/file/html/factorio-download-archive.html")); + + const versions = await fetchFactorioVersions(); + assert.equal(versions.length, 105); + assert.equal(versions.filter(v => v.stable).length, 48); + assert.equal(versions.filter(v => !v.stable).length, 57); + }); + it("parses versions from live api", async function() { + slowTest(this); + global.fetch = originalFetch; + + const versions = await fetchFactorioVersions(); + assert.ok(versions.length >= 105); + assert.ok(versions.filter(v => v.stable).length >= 48); + assert.ok(versions.filter(v => !v.stable).length >= 57); + }); + }); +}); diff --git a/test/lib/plugin_loader.js b/test/lib/plugin_loader.js index 308e3716..f8e275e7 100644 --- a/test/lib/plugin_loader.js +++ b/test/lib/plugin_loader.js @@ -5,7 +5,6 @@ const path = require("path"); const lib = require("@clusterio/lib"); const { BaseControllerPlugin } = require("@clusterio/controller"); -const { escapeRegExp } = lib; describe("lib/plugin_loader", function() { @@ -34,7 +33,10 @@ describe("lib/plugin_loader", function() { }); it("should ignore missing plugins", async function() { - const result = await lib.loadPluginInfos(new Map([["missing", missingPlugin]]), []); + const result = await lib.loadPluginInfos(new Map([ + ["missing", path.resolve(missingPlugin)], + ["no_index", path.resolve(baseDir)], + ]), []); assert.deepEqual(result, []); }); it("should load test plugin", async function() { @@ -46,7 +48,6 @@ describe("lib/plugin_loader", function() { it("should reject on broken plugin", async function() { let brokenMessage; try { - require(path.resolve(brokenPlugin)); } catch (err) { brokenMessage = err.message; diff --git a/test/lib/subscriptions.js b/test/lib/subscriptions.js index a548a862..d8748f28 100644 --- a/test/lib/subscriptions.js +++ b/test/lib/subscriptions.js @@ -347,11 +347,13 @@ describe("lib/subscriptions", function() { subscriptions.broadcast(new RegisteredEvent(), "foo"); await onceConnectorSend(0); + assert.equal(getLink(0).connector.sentMessages.length, before0 + 1); assert.equal(getLink(1).connector.sentMessages.length, before1); subscriptions.broadcast(new RegisteredEvent(), "bar"); await onceConnectorSend(1); assert.equal(getLink(0).connector.sentMessages.length, before0 + 1); + assert.equal(getLink(1).connector.sentMessages.length, before1 + 1); const beforeBaz0 = getLink(0).connector.sentMessages.length; const beforeBaz1 = getLink(1).connector.sentMessages.length; @@ -371,12 +373,18 @@ describe("lib/subscriptions", function() { getLink(1), reqBaz, connectorSetupDate[1].dst, connectorSetupDate[1].src ); + const before0 = getLink(0).connector.sentMessages.length; + const before1 = getLink(1).connector.sentMessages.length; + subscriptions.broadcast(new RegisteredEvent(), "foo"); await onceConnectorSend(0); - assertNoEvent(1); + assert.equal(getLink(0).connector.sentMessages.length, before0 + 1); + assert.equal(getLink(1).connector.sentMessages.length, before1); subscriptions.broadcast(new RegisteredEvent(), "baz"); await Promise.all([onceConnectorSend(0), onceConnectorSend(1)]); + assert.equal(getLink(0).connector.sentMessages.length, before0 + 2); + assert.equal(getLink(1).connector.sentMessages.length, before1 + 1); }); it("should merge multiple filter subscriptions for the same subscriber", async function() { @@ -389,14 +397,19 @@ describe("lib/subscriptions", function() { getLink(0), reqBar, connectorSetupDate[0].dst, connectorSetupDate[0].src ); + const before0 = getLink(0).connector.sentMessages.length; + subscriptions.broadcast(new RegisteredEvent(), "alpha"); await onceConnectorSend(0); - const before = getLink(0).connector.sentMessages.length; + assert.equal(getLink(0).connector.sentMessages.length, before0 + 1); + subscriptions.broadcast(new RegisteredEvent(), "beta"); await onceConnectorSend(0); + assert.equal(getLink(0).connector.sentMessages.length, before0 + 2); + subscriptions.broadcast(new RegisteredEvent(), "gamma"); await new Promise(resolve => setImmediate(resolve)); - assert.equal(getLink(0).connector.sentMessages.length, before + 1); + assert.equal(getLink(0).connector.sentMessages.length, before0 + 2); }); it("should reset to all when subscribing with no filters after having filters", async function() { @@ -424,12 +437,15 @@ describe("lib/subscriptions", function() { getLink(0), unsubFoo, connectorSetupDate[0].dst, connectorSetupDate[0].src ); + const before0 = getLink(0).connector.sentMessages.length; + subscriptions.broadcast(new RegisteredEvent(), "foo"); await new Promise(resolve => setImmediate(resolve)); - const before = getLink(0).connector.sentMessages.length; + assert.equal(getLink(0).connector.sentMessages.length, before0); + subscriptions.broadcast(new RegisteredEvent(), "bar"); await onceConnectorSend(0); - assert.equal(getLink(0).connector.sentMessages.length, before + 1); + assert.equal(getLink(0).connector.sentMessages.length, before0 + 1); }); it("should remove entire subscription when last filter is unsubscribed", async function() { diff --git a/test/messages/controller.test.js b/test/messages/controller.test.js index 1e345475..7b735e9b 100644 --- a/test/messages/controller.test.js +++ b/test/messages/controller.test.js @@ -34,4 +34,44 @@ describe("messages/controller", function() { ); }); }); + + describe("External", function() { + it("runs", async function() { + let callCount = 0; + const versions = [ + { version: "1.2.3", stable: true, headlessUrl: "path/to/download" }, + { version: "1.2.4", stable: false, headlessUrl: "path/to/download" }, + ]; + + // Mock the cache to avoid needless fetches + controller.factorioVersions = new lib.ValueCache(async () => { + callCount += 1; + return versions; + }); + + // First call should result in a fetch + const versions1 = await controlConnection.handleFactorioVersionsRequest( + new lib.FactorioVersionsRequest() + ); + + assert.deepEqual(versions1, versions); + assert.equal(callCount, 1); + + // Second call should not fetch due to default maxAgeMs being greater than 0 + const versions2 = await controlConnection.handleFactorioVersionsRequest( + new lib.FactorioVersionsRequest() + ); + + assert.deepEqual(versions2, versions); + assert.equal(callCount, 1); + + // Setting maxAgeMs to 0 should result in a new fetch + const versions3 = await controlConnection.handleFactorioVersionsRequest( + new lib.FactorioVersionsRequest(0) + ); + + assert.deepEqual(versions3, versions); + assert.equal(callCount, 2); + }); + }); }); diff --git a/test/messages/mod.test.js b/test/messages/mod.test.js new file mode 100644 index 00000000..42b0d787 --- /dev/null +++ b/test/messages/mod.test.js @@ -0,0 +1,462 @@ +"use strict"; +const assert = require("assert").strict; + +const lib = require("@clusterio/lib"); +const { ModDependencyResolveRequest, ModDependency, ModInfo } = lib; + +const { testMatrix, testRoundTripJsonSerialisable } = require("../common"); + +const { Controller, ControlConnection, ControllerUser } = require("@clusterio/controller"); +const { slowTest } = require("../integration"); + +describe("messages/mod", function() { + /** @type {Controller} */ + let controller; + /** @type {ControlConnection} */ + let controlConnection; + /** @type {Map} */ + let ModReleases = new Map(); + + /** + * @param {string} name + * @param {string} version + * @param {string} factorioVersion + * @param {string[]} dependencies + */ + function setPortalModRelease(name, version, factorioVersion, dependencies) { + ModReleases.set(name, { + name: name, + releases: [{ + version: version, + info_json: { + factorio_version: factorioVersion, + dependencies: ["base", ...dependencies], + }, + }], + }); + } + + const _ModStore_fetchModReleases = lib.ModStore.fetchModReleases; + + after(function() { + lib.ModStore.fetchModReleases = _ModStore_fetchModReleases; + }); + + beforeEach(function() { + ModReleases = new Map(); + lib.ModStore.fetchModReleases = function(modName) { + const mod = ModReleases.get(modName); + if (mod) { + return mod; + } + throw new Error("Mock 404"); + }; + + const controllerConfig = new lib.ControllerConfig("controller"); + const connection = new lib.VirtualConnector( + lib.Address.fromShorthand("controller"), + lib.Address.fromShorthand({ controlId: 1 }), + ); + controller = new Controller(lib.logger, [], controllerConfig); + const user = new ControllerUser(controller.userManager, undefined, "test"); + controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1); + }); + + describe("ModDependencyResolveRequest", function() { + describe("static", function() { + const factorioVersions = ["1.0", "1.1", "2.0"]; + const tests = testMatrix( + [ // mods + [], + [new ModDependency("foo")], + [new ModDependency("? foo"), new ModDependency("bar > 2.0.5")], + ], + factorioVersions, // factorioVersion + [undefined, true, false], + ); + it("should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(ModDependencyResolveRequest, tests); + }); + it("should be construable", function() { + for (const [mods, factorioVersion, checkForUpdates] of tests) { + const request = new ModDependencyResolveRequest(mods, factorioVersion, checkForUpdates); + assert.equal(request.factorioVersion, factorioVersion); + assert.equal(request.checkForUpdates, checkForUpdates ?? false); + assert.equal(request.mods, mods); + } + }); + it("should be constructed from a ModPack", function() { + for (const factorioVersion of factorioVersions) { + const mods = [ + { name: "foo", version: "1.0.0", enabled: true }, + { name: "bar", version: "2.0.0", enabled: false }, + ]; + const modPack = lib.ModPack.fromJSON({ mods: mods, factorio_version: factorioVersion }); + const request = ModDependencyResolveRequest.fromModPack(modPack); + + assert.equal(request.factorioVersion, factorioVersion, factorioVersion); + assert.equal(request.mods.length, modPack.mods.size, factorioVersion); + assert.deepEqual( + request.mods.find(m => m.name === "foo"), new ModDependency("foo = 1.0.0"), factorioVersion); + assert.deepEqual( + request.mods.find(m => m.name === "bar"), new ModDependency("bar = 2.0.0"), factorioVersion); + } + }); + it("should be constructed from a ModPack (enabled only)", function() { + for (const factorioVersion of factorioVersions) { + const mods = [ + { name: "foo", version: "1.0.0", enabled: true }, + { name: "bar", version: "2.0.0", enabled: false }, + ]; + const modPack = lib.ModPack.fromJSON({ mods: mods, factorio_version: factorioVersion }); + const request = ModDependencyResolveRequest.fromModPackEnabled(modPack); + + assert.equal(request.factorioVersion, factorioVersion); + assert.equal( + request.mods.length, [...modPack.mods.values()].filter(m => m.enabled).length, factorioVersion); + assert.deepEqual( + request.mods.find(m => m.name === "foo"), new ModDependency("foo = 1.0.0"), factorioVersion); + assert.deepEqual( + request.mods.find(m => m.name === "bar"), undefined, factorioVersion); + } + }); + it("should be constructed from a ModPack (check for updates)", function() { + for (const factorioVersion of factorioVersions) { + const mods = [ + { name: "foo", version: "1.0.0", enabled: true }, + { name: "bar", version: "2.0.0", enabled: false }, + ]; + const modPack = lib.ModPack.fromJSON({ mods: mods, factorio_version: factorioVersion }); + const request = ModDependencyResolveRequest.fromModPack(modPack, true); + + assert.equal(request.factorioVersion, factorioVersion, factorioVersion); + assert.equal(request.mods.length, modPack.mods.size, factorioVersion); + assert.deepEqual( + request.mods.find(m => m.name === "foo"), new ModDependency("foo >= 1.0.0"), factorioVersion); + assert.deepEqual( + request.mods.find(m => m.name === "bar"), new ModDependency("bar >= 2.0.0"), factorioVersion); + } + }); + it("should be constructed from a ModPack (enabled only) (check for updates)", function() { + for (const factorioVersion of factorioVersions) { + const mods = [ + { name: "foo", version: "1.0.0", enabled: true }, + { name: "bar", version: "2.0.0", enabled: false }, + ]; + const modPack = lib.ModPack.fromJSON({ mods: mods, factorio_version: factorioVersion }); + const request = ModDependencyResolveRequest.fromModPackEnabled(modPack, true); + + assert.equal(request.factorioVersion, factorioVersion); + assert.equal( + request.mods.length, [...modPack.mods.values()].filter(m => m.enabled).length, factorioVersion); + assert.deepEqual( + request.mods.find(m => m.name === "foo"), new ModDependency("foo >= 1.0.0"), factorioVersion); + assert.deepEqual( + request.mods.find(m => m.name === "bar"), undefined, factorioVersion); + } + }); + }); + describe("response", function() { + const tests = testMatrix( + [ // dependencies + [], + [ + ModInfo.fromJSON({ name: "foo", version: "1.0.0" }), + ], + [ + ModInfo.fromJSON({ name: "foo", version: "1.0.0" }), + ModInfo.fromJSON({ name: "bar", version: "2.0.0" }), + ], + ], + [[], ["foo-i"], ["foo-i", "bar-i"]], // incompatible + [[], ["foo-m"], ["foo-m", "bar-m"]], // missing + ); + it("should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(ModDependencyResolveRequest.Response, tests); + }); + it("should be construable", function() { + for (const [dependencies, incompatible, missing] of tests) { + const test = JSON.stringify([dependencies, missing, incompatible]); + const request = new ModDependencyResolveRequest.Response(dependencies, incompatible, missing); + assert.deepEqual(request.dependencies, dependencies, test); + assert.deepEqual(request.incompatible, incompatible, test); + assert.deepEqual(request.missing, missing, test); + } + }); + }); + describe("handle", function() { + const factorioVersions = ["1.0", "1.1", "2.0"]; + it("resolves dependencies", async function() { + for (const factorioVersion of factorioVersions) { + setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "bar < 2.0.0", "? baz"]); + setPortalModRelease("foo", "1.0.0", factorioVersion, ["foo-dep"]); + setPortalModRelease("foo-dep", "1.0.0", factorioVersion, []); + setPortalModRelease("bar", "1.0.0", factorioVersion, []); + setPortalModRelease("baz", "1.0.0", factorioVersion, []); + + setPortalModRelease("root-2", "1.0.0", factorioVersion, ["foo", "bar-2"]); + setPortalModRelease("bar-2", "1.0.0", factorioVersion, []); + + const result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest( + [new ModDependency("root"), new ModDependency("root-2")], + factorioVersion, + ) + ); + + assert.deepEqual(result.missing, ["base"]); + assert.deepEqual(result.incompatible, []); + + const expectedIds = [ + "root_1.0.0", "foo_1.0.0", "foo-dep_1.0.0", "bar_1.0.0", + "root-2_1.0.0", "bar-2_1.0.0", + ]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + it("highlights dependencies with no suitable version", async function() { + for (const factorioVersion of factorioVersions) { + setPortalModRelease("root", "1.0.0", factorioVersion, ["bar < 2.0.0"]); + setPortalModRelease("bar", "2.0.0", factorioVersion, []); + + const result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) + ); + + assert.deepEqual(result.missing, ["base", "bar"]); + assert.deepEqual(result.incompatible, []); + + assert.equal(result.dependencies.length, 1); + assert.equal(result.dependencies[0].id, "root_1.0.0"); + } + }); + it("highlights dependencies with no releases", async function() { + for (const factorioVersion of factorioVersions) { + setPortalModRelease("root", "1.0.0", factorioVersion, ["foo"]); + + const result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) + ); + + assert.deepEqual(result.missing, ["base", "foo"]); + assert.deepEqual(result.incompatible, []); + + assert.equal(result.dependencies.length, 1); + assert.equal(result.dependencies[0].id, "root_1.0.0"); + } + }); + it("highlights dependencies which are incompatible", async function() { + for (const factorioVersion of factorioVersions) { + setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "! foo-dep"]); + setPortalModRelease("foo", "1.0.0", factorioVersion, ["foo-dep"]); + setPortalModRelease("foo-dep", "1.0.0", factorioVersion, []); + + const result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) + ); + + assert.deepEqual(result.missing, ["base"]); + assert.deepEqual(result.incompatible, ["foo-dep"]); + + const expectedIds = ["root_1.0.0", "foo_1.0.0", "foo-dep_1.0.0"]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + it("highlights dependencies with conflicting versions", async function() { + for (const factorioVersion of factorioVersions) { + setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "bar"]); + setPortalModRelease("foo", "1.0.0", factorioVersion, ["dep > 2.0.0"]); + setPortalModRelease("bar", "1.0.0", factorioVersion, ["dep < 1.5.0"]); + + const result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) + ); + + assert.deepEqual(result.missing, ["base"]); + assert.deepEqual(result.incompatible, ["dep"]); + + const expectedIds = ["root_1.0.0", "foo_1.0.0", "bar_1.0.0"]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + it("highlights incompatible builtin mods", async function() { + for (const factorioVersion of factorioVersions) { + setPortalModRelease("root", "1.0.0", factorioVersion, ["foo"]); + setPortalModRelease("foo", "1.0.0", factorioVersion, ["! base"]); + + const result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) + ); + + assert.deepEqual(result.missing, []); + assert.deepEqual(result.incompatible, ["base"]); + + const expectedIds = ["root_1.0.0", "foo_1.0.0"]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + it("does not ignore optional dependencies when required by another", async function() { + for (const factorioVersion of factorioVersions) { + setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "? baz"]); + setPortalModRelease("foo", "1.0.0", factorioVersion, ["baz"]); + setPortalModRelease("baz", "1.0.0", factorioVersion, []); + + const result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) + ); + + assert.deepEqual(result.missing, ["base"]); + assert.deepEqual(result.incompatible, []); + + const expectedIds = ["root_1.0.0", "foo_1.0.0", "baz_1.0.0"]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + it("prefers the locally installed version if matching", async function() { + controller.modStore.addMod(lib.ModInfo.fromJSON({ name: "foo", version: "1.0.0" })); + for (const factorioVersion of factorioVersions) { + setPortalModRelease("root", "1.0.0", factorioVersion, ["foo"]); + setPortalModRelease("foo", "2.0.0", factorioVersion, []); + + const result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) + ); + + assert.deepEqual(result.missing, ["base"]); + assert.deepEqual(result.incompatible, []); + + const expectedIds = ["root_1.0.0", "foo_1.0.0"]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + it("prefers the mod portal version when checking for updates", async function() { + controller.modStore.addMod(lib.ModInfo.fromJSON({ name: "foo", version: "1.0.0" })); + for (const factorioVersion of factorioVersions) { + setPortalModRelease("root", "1.0.0", factorioVersion, ["foo"]); + setPortalModRelease("foo", "2.0.0", factorioVersion, []); + + const result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion, true) + ); + + assert.deepEqual(result.missing, ["base"]); + assert.deepEqual(result.incompatible, []); + + const expectedIds = ["root_1.0.0", "foo_2.0.0"]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + it("prefers the latest mod portal version when using the mod portal", async function() { + for (const factorioVersion of factorioVersions) { + setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "bar < 2.0.0"]); + + ModReleases.set("foo", { + name: "foo", + releases: [{ + version: "1.0.0", info_json: { factorio_version: factorioVersion, dependencies: [] }, + }, { + version: "2.0.0", info_json: { factorio_version: factorioVersion, dependencies: [] }, + }], + }); + + ModReleases.set("bar", { + name: "bar", + releases: [{ + version: "1.0.0", info_json: { factorio_version: factorioVersion, dependencies: [] }, + }, { + version: "1.5.0", info_json: { factorio_version: factorioVersion, dependencies: [] }, + }, { + version: "2.0.0", info_json: { factorio_version: factorioVersion, dependencies: [] }, + }], + }); + + const result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) + ); + + assert.deepEqual(result.missing, ["base"]); + assert.deepEqual(result.incompatible, []); + + const expectedIds = ["root_1.0.0", "foo_2.0.0", "bar_1.5.0"]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + it("rejects when a network error occurs", async function() { + lib.ModStore.fetchModReleases = function() { + throw new Error("Mock network error"); + }; + + for (const factorioVersion of factorioVersions) { + await assert.rejects(controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) + )); + } + }); + it("resolves dependencies (live)", async function() { + this.timeout(60000); // Increase timeout to 60 seconds for live API call + lib.ModStore.fetchModReleases = _ModStore_fetchModReleases; + + const result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("pymodpack = 3.0.0")], "2.0") + ); + + assert.deepEqual(result.missing, ["base"]); + assert.deepEqual(result.incompatible, ["space-age"]); + + const expectedNames = [ // Can't use ids as the versions may change + "pymodpack", "pypostprocessing", "pyalternativeenergy", "pyalternativeenergygraphics", + "pyalienlife", "pyalienlifegraphics", "pyalienlifegraphics2", "pyalienlifegraphics3", + "pycoalprocessing", "pycoalprocessinggraphics", "pyfusionenergy", "pyfusionenergygraphics", + "pypetroleumhandling", "pypetroleumhandlinggraphics", "pyrawores", "pyraworesgraphics", + "pyhightech", "pyhightechgraphics", "pyindustry", "pyindustrygraphics", + ]; + + const depNames = new Set(result.dependencies.map(mod => mod.name)); + assert.equal(depNames.size, expectedNames.length); + for (const modName of expectedNames) { + assert(depNames.has(modName), `Missing ${modName}`); + } + }); + }); + }); +});