mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
99
Commits
2.1
...
plugin-minimap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ddf3fe34f | ||
|
|
17480aafff | ||
|
|
387a9425ce | ||
|
|
550ea22724 | ||
|
|
9375c462af | ||
|
|
e28b4121e3 | ||
|
|
a1e8d009ff | ||
|
|
7c6dd1ff66 | ||
|
|
bb74b615a7 | ||
|
|
c3136532b8 | ||
|
|
245ceb14fa | ||
|
|
5ff7d7ee42 | ||
|
|
35575a93a3 | ||
|
|
57b50048bc | ||
|
|
923316b44d | ||
|
|
81fcea8398 | ||
|
|
3f8ef85987 | ||
|
|
78fc90d7a0 | ||
|
|
ebbc14ba71 | ||
|
|
3be1d637c8 | ||
|
|
3d9c682d7c | ||
|
|
b0fd90d273 | ||
|
|
73ab8fc4f3 | ||
|
|
2eedddf17f | ||
|
|
9061d2aa92 | ||
|
|
3932ac2cae | ||
|
|
107201e6e5 | ||
|
|
a7ab63ba3c | ||
|
|
ec0c068d44 | ||
|
|
264a32431e | ||
|
|
01f6397983 | ||
|
|
979d961618 | ||
|
|
6489fa4048 | ||
|
|
22850b7cd0 | ||
|
|
f65c8fb9a0 | ||
|
|
3232da71e2 | ||
|
|
c9fc4f1c8f | ||
|
|
0c56c56f74 | ||
|
|
2928b9b7c5 | ||
|
|
1fc6971278 | ||
|
|
2679851d66 | ||
|
|
ab86cd2b1c | ||
|
|
02fce63e11 | ||
|
|
8981a018b0 | ||
|
|
bbf5df6d01 | ||
|
|
6f68cf74a7 | ||
|
|
07f501a67a | ||
|
|
4901d76ebe | ||
|
|
438ccbdc7f | ||
|
|
d6c5430e0c | ||
|
|
d48b731ea4 | ||
|
|
bccb6527a7 | ||
|
|
00cf0f59ec | ||
|
|
38d5fdfdba | ||
|
|
ea144863f9 | ||
|
|
48437a5f6c | ||
|
|
70ef8a60ad | ||
|
|
c4d4cb3afd | ||
|
|
093b71045b | ||
|
|
a36e7bddca | ||
|
|
3737659cc3 | ||
|
|
5ba6056848 | ||
|
|
89b6d5aa13 | ||
|
|
630a0d3898 | ||
|
|
047d3c22f5 | ||
|
|
57f89de746 | ||
|
|
14f69cda11 | ||
|
|
2bce506d96 | ||
|
|
ae7a1744ad | ||
|
|
ab8321ff4b | ||
|
|
c5c26bfad1 | ||
|
|
1c06611cca | ||
|
|
7db716eceb | ||
|
|
3b7fd164af | ||
|
|
cf2efb1380 | ||
|
|
fc466ec799 | ||
|
|
f5aeaff256 | ||
|
|
6ce4d0d20b | ||
|
|
0785798b7d | ||
|
|
9dc5f8ff37 | ||
|
|
be21f22707 | ||
|
|
0d294479b3 | ||
|
|
626188dd16 | ||
|
|
a516b6b129 | ||
|
|
2dcc92c96b | ||
|
|
015fd2c30b | ||
|
|
3f06bd0993 | ||
|
|
cfc1b7687e | ||
|
|
285254e3bc | ||
|
|
b70c7bd84f | ||
|
|
3bf7a84de4 | ||
|
|
063e622721 | ||
|
|
5b9f650946 | ||
|
|
c62eae0009 | ||
|
|
e9a4627118 | ||
|
|
6cbd3229f6 | ||
|
|
f7658e1118 | ||
|
|
f31fb535df | ||
|
|
75b2dac371 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"files": [],
|
||||
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"watch": "tsc --build --watch",
|
||||
@@ -27,7 +28,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
|
||||
@@ -127,7 +127,8 @@ void new lib.Gauge(
|
||||
|
||||
async function handleBootstrapCommand(
|
||||
args: any,
|
||||
controllerConfig: lib.ControllerConfig
|
||||
controllerConfig: lib.ControllerConfig,
|
||||
controllerConfigLock: lib.LockFile,
|
||||
): Promise<void> {
|
||||
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<InitializeParameters> {
|
||||
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<InitializeParameters> {
|
||||
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<InitializeParameters> {
|
||||
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<InitializeParameters> {
|
||||
}
|
||||
|
||||
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 <username>`");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let secondSigint = false;
|
||||
process.on("SIGINT", () => {
|
||||
if (secondSigint) {
|
||||
|
||||
@@ -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.13.3",
|
||||
"@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"
|
||||
|
||||
@@ -126,6 +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.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
|
||||
}
|
||||
|
||||
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
|
||||
@@ -1222,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;
|
||||
@@ -105,6 +105,8 @@ export default class Controller {
|
||||
|
||||
// Cache for mod portal requests
|
||||
modPortalCache = new Map<string, { timestamp: number, data: any[] }>();
|
||||
// Cache for factorio versions
|
||||
factorioVersions = new lib.ValueCache(lib.fetchFactorioVersions);
|
||||
|
||||
static async bootstrap(config: lib.ControllerConfig) {
|
||||
let databaseDirectory = config.get("controller.database_directory");
|
||||
|
||||
@@ -754,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) {
|
||||
@@ -772,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);
|
||||
@@ -794,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -821,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",
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,18 @@
|
||||
"node": "__node_version__"
|
||||
},
|
||||
"peerDependencies": {
|
||||
//%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": {
|
||||
@@ -36,7 +48,18 @@
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-merge": "^5.9.0",
|
||||
//%endif
|
||||
//%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__"
|
||||
},
|
||||
|
||||
+9
-2
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -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"));
|
||||
|
||||
+12
-12
@@ -18,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:"
|
||||
}
|
||||
}
|
||||
@@ -962,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();
|
||||
}
|
||||
|
||||
@@ -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: lib.TargetVersion) {
|
||||
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: lib.TargetVersion
|
||||
|
||||
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: lib.TargetVersion
|
||||
);
|
||||
}
|
||||
|
||||
let versions = new Map();
|
||||
let versions = new Map<lib.FullVersion, string>();
|
||||
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: lib.TargetVersion
|
||||
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}`);
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
+15
-15
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> => {
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
|
||||
/**
|
||||
* Represents an async function which can fetch a value for a ValueCache
|
||||
*/
|
||||
export interface ValueCacheFetch<T> {
|
||||
(): Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T> {
|
||||
private ongoingFetch: Promise<T> | 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<T>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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<T> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -479,6 +479,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "latest",
|
||||
inputComponent: "target_version",
|
||||
},
|
||||
"factorio.executable_path": {
|
||||
description:
|
||||
|
||||
@@ -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<typeof this.jsonSchema>) {
|
||||
return new this(json.maxAgeMs);
|
||||
}
|
||||
|
||||
static Response = plainJson(Type.Array(ExternalFactorioVersionSchema));
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data/version";
|
||||
|
||||
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<typeof ExternalFactorioVersionSchema>
|
||||
|
||||
/**
|
||||
* 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<ExternalFactorioVersion[]> {
|
||||
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<string, ExternalFactorioVersion>();
|
||||
|
||||
// Match <a class=" slot-button-inline version-button-stable " href="/download/archive/2.0.72">2.0.72</a>
|
||||
// eslint-disable-next-line max-len
|
||||
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\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()];
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Library for interfacing with external apis
|
||||
* @module lib/external
|
||||
*/
|
||||
export * from "./FactorioVersions";
|
||||
@@ -35,6 +35,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
controller.SystemInfoUpdateEvent,
|
||||
controller.DebugDumpWsRequest,
|
||||
controller.DebugWsMessageEvent,
|
||||
controller.FactorioVersionsRequest,
|
||||
|
||||
subscriptions.SubscriptionRequest,
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -275,6 +275,9 @@ function keyToLabels(key: string) {
|
||||
if (key !== "") {
|
||||
for (let pair of key.split(",")) {
|
||||
let [name, value] = pair.split("=", 2);
|
||||
if (!name || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
labels.set(name, value
|
||||
.slice(1, -1)
|
||||
.replace(/\\"/g, "\"")
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
instance: libConfig.Config<any>,
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,6 @@ 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;
|
||||
|
||||
@@ -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": ".",
|
||||
|
||||
@@ -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<string, BaseWebPlugin>) {
|
||||
const inputComponents: Record<string, InputComponent> = {
|
||||
"partial_version": InputPartialVersion,
|
||||
"target_version": InputTargetVersion,
|
||||
"mod_pack": InputModPack,
|
||||
"role": InputRole,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { TreeSelect } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { 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<InputComponentProps, "fieldDefinition"> & {
|
||||
fieldDefinition?: lib.FieldDefinition;
|
||||
className?: string;
|
||||
}
|
||||
) {
|
||||
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
|
||||
const [versions, setVersions] = useState<readonly lib.PartialVersion[]>(lib.ApiVersions);
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const hasPermission = account.hasPermission("core.external.get_factorio_versions");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (hasPermission) {
|
||||
const res = await control.factorioVersions.get(5 * 60 * 1000);
|
||||
setVersions(res.map(v => v.version));
|
||||
}
|
||||
})();
|
||||
}, [control, hasPermission]);
|
||||
|
||||
// Split the versions into groups based on major minor
|
||||
const groups = new Map<lib.PartialVersion, lib.PartialVersion[]>();
|
||||
|
||||
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]) => {
|
||||
const groupKey = `group-${majorMinor}`;
|
||||
|
||||
return {
|
||||
title: majorMinor,
|
||||
value: majorMinor,
|
||||
key: groupKey,
|
||||
children: patchVersions.map((v) => ({
|
||||
title: v,
|
||||
value: v,
|
||||
key: v,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return <TreeSelect
|
||||
showSearch
|
||||
style={{ minWidth: 175 }}
|
||||
onChange={(value) => props.onChange(value ?? null)}
|
||||
value={props.value}
|
||||
treeData={tree}
|
||||
allowClear={fieldDefinition.optional}
|
||||
className={props.className}
|
||||
disabled={props.disabled}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { TreeSelect } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { 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<InputComponentProps, "fieldDefinition"> & {
|
||||
fieldDefinition?: lib.FieldDefinition;
|
||||
className?: string;
|
||||
}
|
||||
) {
|
||||
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
|
||||
const [versions, setVersions] = useState<readonly lib.TargetVersion[]>(lib.ApiVersions);
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const hasPermission = account.hasPermission("core.external.get_factorio_versions");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (hasPermission) {
|
||||
const res = await control.factorioVersions.get(5 * 60 * 1000);
|
||||
setVersions(res.map(v => v.version));
|
||||
}
|
||||
})();
|
||||
}, [control, hasPermission]);
|
||||
|
||||
// Split the versions into groups based on major minor
|
||||
const groups = new Map<lib.TargetVersion, lib.TargetVersion[]>();
|
||||
|
||||
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]) => {
|
||||
const groupKey = `group-${majorMinor}`;
|
||||
|
||||
return {
|
||||
title: majorMinor,
|
||||
value: majorMinor,
|
||||
key: groupKey,
|
||||
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 <TreeSelect
|
||||
showSearch
|
||||
style={{ minWidth: 175 }}
|
||||
onChange={(value) => props.onChange(value ?? null)}
|
||||
value={props.value}
|
||||
treeData={tree}
|
||||
allowClear={fieldDefinition.optional}
|
||||
className={props.className}
|
||||
disabled={props.disabled}
|
||||
/>;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import ModDetails from "./ModDetails";
|
||||
import InputPartialVersion from "./InputPartialVersion";
|
||||
|
||||
const { logger } = lib;
|
||||
const { Text } = Typography;
|
||||
@@ -1288,7 +1289,9 @@ export default function ModPackViewPage() {
|
||||
message: "Must be an a.b or a.b.c version number.",
|
||||
}]}
|
||||
>
|
||||
<Input
|
||||
<InputPartialVersion
|
||||
value={form.getFieldValue("factorioVersion")}
|
||||
onChange={(value: any) => form.setFieldValue("factorioVersion", value)}
|
||||
className={hasChange(changes, { type: "factorioVersion" }) ? "changed" : ""}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -21,6 +21,7 @@ import SectionHeader from "./SectionHeader";
|
||||
import ModDetails from "./ModDetails";
|
||||
import { Dropzone } from "./Dropzone";
|
||||
import UploadButton from "./UploadButton";
|
||||
import InputPartialVersion from "./InputPartialVersion";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
@@ -128,7 +129,10 @@ function CreateModPackButton() {
|
||||
message: "Must be an a.b or a.b.c version number.",
|
||||
}]}
|
||||
>
|
||||
<Input />
|
||||
<InputPartialVersion
|
||||
value={form.getFieldValue("factorioVersion")}
|
||||
onChange={(value) => form.setFieldValue("factorioVersion", value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -20,25 +20,30 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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/*"],
|
||||
|
||||
@@ -20,28 +20,33 @@
|
||||
"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"
|
||||
|
||||
@@ -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/*"],
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.ts
|
||||
!*.d.ts
|
||||
@@ -0,0 +1,40 @@
|
||||
# Clusterio Minimap Plugin
|
||||
|
||||
A Clusterio plugin that provides interactive minimaps of Factorio instances with real-time tile and entity data visualization.
|
||||
|
||||
## Features
|
||||
- Live updating terrain map in web UI
|
||||
- Recipe overlay showing active crafting machines
|
||||
- Timelapse mode for viewing historical changes
|
||||
- Chart tag visualization and management
|
||||
- Per instance, force and surface support
|
||||
- Works when instances are offline
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[Tile Data Storage](./docs/tile_data_storage.md)** - Binary format for terrain visualization data
|
||||
- **[Recipe Overlay](./docs/minimap_recipe_overlay.md)** - Compact storage for crafting machine recipes
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The plugin consists of three main data streams:
|
||||
|
||||
### Terrain Data
|
||||
- Factorio sends RGB565 data for 32×32 chunks
|
||||
- Controller queues chunks and generates incremental pixel changes
|
||||
- Data stored in append-only `.bin` files for efficient timelapse replay
|
||||
- Web UI renders live updates and historical data
|
||||
|
||||
### Recipe Data
|
||||
- Tracks which recipe each crafting machine is running
|
||||
- Compact binary format with per-tile dictionaries (~8x smaller than JSON)
|
||||
- Live streaming of recipe changes with timeline support
|
||||
|
||||
### Chart Tags
|
||||
- User-created map markers with text and icons
|
||||
- JSONL storage format for simplicity
|
||||
- Real-time synchronization across all connected clients
|
||||
|
||||
## Usage
|
||||
|
||||
Access the minimap through the Clusterio web interface. Select an instance to view its terrain, toggle recipe overlays, enable chart tags, or switch to timelapse mode to review historical changes.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,88 @@
|
||||
# Crafting-Recipe Overlay for Minimap & Timelapse
|
||||
|
||||
## Overview
|
||||
Records which recipe every crafting machine is running, streams live changes to web clients, and replays them frame-perfectly in timelapse mode. The solution:
|
||||
|
||||
* Works for all forces & surfaces
|
||||
* Scales to millions of events using compact binary storage
|
||||
* Allows cheap frustum-culling using 256×256 map tiles
|
||||
* Uses append-only storage so historical data is never rewritten
|
||||
|
||||
## Architecture
|
||||
|
||||
### Event Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Lua as Factorio Lua (minimap.lua)
|
||||
participant Host as Host/Instance plugin (instance.ts)
|
||||
participant Ctl as Controller plugin (controller.ts)
|
||||
participant UI as Web clients
|
||||
|
||||
Lua->>Host: ipc-minimap:recipe_data (JSON)
|
||||
Host->>Ctl: RecipeDataEvent
|
||||
Ctl-->>UI: RecipeDataEvent (broadcast)
|
||||
UI->>Ctl: GetRawRecipeTileRequest (HTTP / cluster bus)
|
||||
Ctl-->>UI: Raw recipe tile (.recipes)
|
||||
```
|
||||
|
||||
### Messages
|
||||
| Message | Direction | Purpose |
|
||||
| ---------------------------------- | --------------------------- | --------------------------- |
|
||||
| `RecipeDataEvent` | instance → controller → web | Single recipe change (live) |
|
||||
| `GetRawRecipeTileRequest/Response` | web → controller | Fetches binary recipe tile |
|
||||
|
||||
## Storage Format
|
||||
|
||||
### Tile Organization
|
||||
Uses same 256×256 world-unit tiles as terrain data.
|
||||
Filename: `<instance>_<surface>_<force>_<tileX>_<tileY>.recipes`
|
||||
Stored in: `database_directory/minimap_recipe_tiles/`
|
||||
|
||||
### Binary Record Format (append-only)
|
||||
All numbers **big-endian**.
|
||||
|
||||
| Bytes | Field | Description |
|
||||
| ------------------- | ---------------- | --------------------------------------------- |
|
||||
| 1 | `type` | `0=DictEntry`, `1=SetRecipe`, `2=ClearRecipe` |
|
||||
| **DictEntry (0)** | |
|
||||
| 2 | `index` (uint16) | Dictionary ID (0-65535) |
|
||||
| 1 | `len` (uint8) | UTF-8 byte length |
|
||||
| n | name bytes | Recipe name string |
|
||||
| **SetRecipe (1)** | |
|
||||
| 4 | `sec` (uint32) | ⌊tick / 60⌋ timestamp |
|
||||
| 1 | `px` (uint8) | X position in tile (0-255) |
|
||||
| 1 | `py` (uint8) | Y position in tile (0-255) |
|
||||
| 2 | `index` (uint16) | Dictionary ID |
|
||||
| **ClearRecipe (2)** | |
|
||||
| 4 | `sec` (uint32) | ⌊tick / 60⌋ timestamp |
|
||||
| 1 | `px` (uint8) | X position in tile (0-255) |
|
||||
| 1 | `py` (uint8) | Y position in tile (0-255) |
|
||||
|
||||
Dictionary entries are written only when a previously unseen recipe name is required for that tile.
|
||||
|
||||
### Space Efficiency
|
||||
- ~8x more compact than JSON format
|
||||
- Dictionary compression eliminates recipe name repetition
|
||||
- Per-tile dictionaries keep indices small (uint16 sufficient)
|
||||
|
||||
## Playback
|
||||
|
||||
### Live Mode
|
||||
- `RecipeDataEvent` broadcasts like `TileDataEvent`
|
||||
- Web UI maintains `Map<posKey, recipeName>` for instant icon updates
|
||||
- Icons render using Factorio sprite metadata
|
||||
|
||||
### Timelapse Mode
|
||||
- Downloads `.recipes` files via `GetRawRecipeTileRequest`
|
||||
- Parses binary format using shared `parseRecipeTileBinary()` utility
|
||||
- Applies Set/Clear records up to selected timeline tick
|
||||
- Integrates with existing terrain timelapse system
|
||||
|
||||
## Scalability
|
||||
| Metric | Estimate |
|
||||
| ------------------------------ | ----------------------------- |
|
||||
| Assemblers per large factory | ~50,000 |
|
||||
| Avg recipe changes per machine | ~5 lifetime |
|
||||
| Bytes per Set/Clear record | 8 bytes |
|
||||
| Total storage per instance | ~2 MB |
|
||||
| Max recipes per tile | <1,000 (well under 65K limit) |
|
||||
@@ -0,0 +1,111 @@
|
||||
# Player Position Tracking
|
||||
|
||||
## Overview
|
||||
Tracks player positions across the map, streams live movements to web clients, and stores compressed historical data for timeline playback.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Event Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Lua as Factorio Lua (minimap.lua)
|
||||
participant Host as Host/Instance plugin (instance.ts)
|
||||
participant Ctl as Controller plugin (controller.ts)
|
||||
participant UI as Web clients
|
||||
|
||||
Lua->>Host: ipc-minimap:player_position (JSON)
|
||||
Host->>Ctl: PlayerPositionEvent
|
||||
Ctl-->>UI: PlayerPositionEvent (broadcast)
|
||||
UI->>Ctl: GetPlayerPathRequest (HTTP / cluster bus)
|
||||
Ctl-->>UI: compressed path data (.positions)
|
||||
```
|
||||
|
||||
### Messages
|
||||
| Message | Direction | Purpose |
|
||||
| ------------------------------- | --------------------------- | ----------------------- |
|
||||
| `PlayerPositionEvent` | instance → controller → web | Live position update |
|
||||
| `GetPlayerPathRequest/Response` | web → controller | Fetches historical path |
|
||||
|
||||
## Storage Format
|
||||
|
||||
### Coordinate Encoding
|
||||
- **Map size**: 2m × 2m (2 000 000 × 2 000 000 tiles)
|
||||
- **Resolution**: 1 tile
|
||||
- **Encoding**: 24-bit signed integers per coordinate (±8,388,608 tiles)
|
||||
- **Storage**: 3 bytes per coordinate (x_tiles, y_tiles) (24 bits)
|
||||
|
||||
### Binary Record Format (append-only)
|
||||
|
||||
| Bytes | Field | Description |
|
||||
| -------------------- | -------------------- | ---------------------------------------------- |
|
||||
| 1 | `type` | `0=Position`, `1=SessionStart`, `2=SessionEnd` |
|
||||
| **Position (0)** | | |
|
||||
| 4 | `t_sec` (uint32) | Seconds since epoch |
|
||||
| 4 | `sec` (uint32) | Seconds since map started (ticks / 60⌋) |
|
||||
| 3 | `x_tiles` (int24) | X coordinate in tiles |
|
||||
| 3 | `y_tiles` (int24) | Y coordinate in tiles |
|
||||
| 2 | `player_id` (uint16) | Player dictionary ID |
|
||||
| **SessionStart (1)** | | |
|
||||
| 4 | `t_ms` (uint32) | Session start time |
|
||||
| 2 | `player_id` (uint16) | Player dictionary ID |
|
||||
| 1 | `name_len` (uint8) | UTF-8 byte length of player name |
|
||||
| n | `name` bytes | Player name string |
|
||||
| **SessionEnd (2)** | | |
|
||||
| 4 | `t_ms` (uint32) | Session end time |
|
||||
| 2 | `player_id` (uint16) | Player dictionary ID |
|
||||
|
||||
### File Organization
|
||||
- Filename: `<instance>_<surface>.positions`
|
||||
- Location: `database_directory/minimap_player_positions/`
|
||||
- Format: Append-only binary records
|
||||
|
||||
### Compression Strategy
|
||||
1. **Dictionary compression**: Player names stored once per session
|
||||
2. **Temporal compression**: Modified Ramer-Douglas-Peucker algorithm
|
||||
- ε = 15cm maximum deviation
|
||||
- Min Δt = 5s when stationary
|
||||
- Linear interpolation for playback
|
||||
3. **Coordinate packing**: 24-bit integers instead of 32-bit floats
|
||||
|
||||
## Streaming Protocol
|
||||
|
||||
### Live Updates
|
||||
- **Trigger**: Position change ≥1 tile OR 250ms timeout
|
||||
|
||||
### Message Format (WebSocket)
|
||||
```typescript
|
||||
interface PlayerPositionEvent {
|
||||
playerId: string;
|
||||
instanceId: string;
|
||||
surface: string;
|
||||
x: number; // tiles
|
||||
y: number; // tiles
|
||||
sec: number; // seconds since map started (⌊tick / 60⌋)
|
||||
}
|
||||
```
|
||||
|
||||
## Playback System
|
||||
|
||||
### Live Mode
|
||||
- Web UI maintains per-player tween buffer (last 2 points)
|
||||
- Canvas interpolation every animation frame
|
||||
- Smooth movement without server round-trips
|
||||
|
||||
### Timeline Mode
|
||||
- When opening the timeline, the client downloads the compressed path data for the player
|
||||
- The client then interpolates the path for display
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Saving Process
|
||||
```
|
||||
1. Queue position updates in positionBuffer
|
||||
2. Every 250ms, process queued positions:
|
||||
- Check movement threshold
|
||||
- Broadcast live updates
|
||||
- Buffer for compression
|
||||
3. After session end:
|
||||
- Run RDP downsampling
|
||||
- Write compressed records
|
||||
- Update session metadata
|
||||
```
|
||||
@@ -0,0 +1,118 @@
|
||||
# Minimap Tile Data Storage Format
|
||||
|
||||
## Overview
|
||||
The minimap plugin stores terrain visualization data in a compact binary format optimized for both live updates and historical timelapse playback. The system uses append-only tiles that capture pixel changes over time.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant L as Factorio Lua
|
||||
participant H as Host Plugin
|
||||
participant C as Controller
|
||||
participant W as Web UI
|
||||
|
||||
L->>H: chart_data (32x32 chunk)
|
||||
H->>C: TileDataEvent
|
||||
C->>W: TileDataEvent (broadcast)
|
||||
W->>C: GetRawTileRequest
|
||||
C->>W: tile data (.bin)
|
||||
```
|
||||
|
||||
### Storage Organization
|
||||
- **Tile size**: 256×256 pixels (8×8 chunks of 32×32 each)
|
||||
- **Filename**: `<instance>_<surface>_<force>_<tileX>_<tileY>.bin`
|
||||
- **Location**: `database_directory/minimap_tiles/`
|
||||
- **Format**: Append-only binary records
|
||||
|
||||
## Binary Format
|
||||
|
||||
### Color Encoding
|
||||
Map colors use **RGB565** format (Factorio's internal representation):
|
||||
- 5 bits red, 6 bits green, 5 bits blue
|
||||
- 16 bits (2 bytes) per pixel
|
||||
|
||||
### Record Types
|
||||
All timestamps use `⌊tick / 60⌋` (seconds) as uint32.
|
||||
|
||||
| Bytes | Field | Description |
|
||||
| ----- | ------ | --------------------------- |
|
||||
| 1 | `type` | `1=Chunk`, `2=PixelChanges` |
|
||||
|
||||
#### Chunk Record (Type 1)
|
||||
Initial exploration data for a 32×32 area.
|
||||
|
||||
| Bytes | Field | Description |
|
||||
| ----- | ----------------- | -------------------------------- |
|
||||
| 4 | `tick` (uint32) | ⌊tick / 60⌋ timestamp |
|
||||
| 1 | `coords` (uint8) | `(chunkX << 4) \| chunkY` |
|
||||
| 2 | `length` (uint16) | Compressed data length |
|
||||
| n | `data` | Deflate-compressed RGB565 pixels |
|
||||
|
||||
- **Chunk coordinates**: 4-bit X and Y (0-7 within tile)
|
||||
- **Data**: 32×32×2 bytes (2048 bytes uncompressed)
|
||||
- **Compression**: Deflate (zlib)
|
||||
|
||||
#### Pixel Changes Record (Type 2)
|
||||
Incremental updates for pixels sharing the same color transition.
|
||||
|
||||
| Bytes | Field | Description |
|
||||
| ----- | ------------------- | --------------------------- |
|
||||
| 4 | `tick` (uint32) | ⌊tick / 60⌋ timestamp |
|
||||
| 2 | `count` (uint16) | Number of pixels changed |
|
||||
| 2 | `new_color` (uint16)| New RGB565 value |
|
||||
| 2 | `old_color` (uint16)| Previous RGB565 value |
|
||||
|
||||
**Per pixel coordinate (2 bytes each):**
|
||||
| Bytes | Field | Description |
|
||||
| ----- | ----------- | ---------------------------- |
|
||||
| 1 | `x` (uint8) | X coordinate in tile (0-255) |
|
||||
| 1 | `y` (uint8) | Y coordinate in tile (0-255) |
|
||||
|
||||
**Note**: Multiple Type 2 records may be generated for a single tick if pixels have different color transitions. Each record groups pixels that share the same `old_color` -> `new_color` transition.
|
||||
|
||||
## Processing Strategy
|
||||
|
||||
### Live Updates
|
||||
1. **Exploration**: New chunks sent as Type 1 records
|
||||
2. **Changes**: Individual pixel updates grouped by color transition as Type 2 records
|
||||
3. **Optimization**: Pixels with identical color changes are batched together
|
||||
|
||||
### Storage Efficiency
|
||||
- **Chunks**: ~100-500 bytes after compression (vs 2048 uncompressed)
|
||||
- **Pixels**: 2 bytes per coordinate + 11-byte header per color group (vs 6 bytes per pixel in old format)
|
||||
- **Timeline**: Old color values enable fast rewind without recalculation
|
||||
- **Compression**: Grouping by color transitions provides significant space savings when many pixels change to the same colors
|
||||
|
||||
### Timelapse Playback
|
||||
1. **Forward**: Apply chunks and pixel changes up to target tick
|
||||
2. **Backward**: Revert pixel changes using old_color values
|
||||
3. **Chunks**: Revert to black when rewinding past exploration time
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Saving Process
|
||||
```
|
||||
1. Queue chunk data in chunkSavingQueue
|
||||
2. Every 5 seconds, process queued chunks:
|
||||
- Load existing tile (if exists)
|
||||
- Compare new chunk pixels with current tile state
|
||||
- Group pixel differences by color transition
|
||||
- Generate compressed pixel change records
|
||||
- Append new data to tile file (never modify existing)
|
||||
```
|
||||
|
||||
### Web Client Rendering
|
||||
```
|
||||
1. Request tile data with optional maxTick parameter
|
||||
2. Parse binary records sequentially
|
||||
3. Apply chunks and pixel changes up to target time
|
||||
4. Convert RGB565 to RGBA for Canvas rendering
|
||||
5. Overlay live broadcasts for real-time updates
|
||||
```
|
||||
|
||||
### Cross-Platform Compatibility
|
||||
- **Node.js**: Uses Buffer with native endian operations
|
||||
- **Browser**: Uses Uint8Array with manual bit manipulation
|
||||
- **Shared utilities**: `tile-utils.ts` handles platform differences
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import {
|
||||
TileDataEvent,
|
||||
ChartTagDataEvent,
|
||||
PlayerPositionEvent,
|
||||
PlayerSessionEndEvent,
|
||||
GetRawTileRequest,
|
||||
GetChartTagsRequest,
|
||||
GetPlayerPathRequest,
|
||||
RecipeDataEvent,
|
||||
GetRawRecipeTileRequest,
|
||||
ClearMinimapSurfaceDataRequest,
|
||||
ClearMinimapDataRequest,
|
||||
} from "./messages";
|
||||
|
||||
// Define permissions
|
||||
lib.definePermission({
|
||||
name: "minimap.view",
|
||||
title: "View Minimap",
|
||||
description: "View the interactive minimap of Factorio instances.",
|
||||
grantByDefault: true,
|
||||
});
|
||||
lib.definePermission({
|
||||
name: "minimap.manage",
|
||||
title: "Manage Minimap Data",
|
||||
description: "Delete cached minimap data through the web interface.",
|
||||
});
|
||||
|
||||
export const plugin: lib.PluginDeclaration = {
|
||||
name: "minimap",
|
||||
title: "Minimap",
|
||||
description: "Provides interactive minimaps of Factorio instances with real-time tile and entity data.",
|
||||
instanceEntrypoint: "dist/node/instance",
|
||||
controllerEntrypoint: "dist/node/controller",
|
||||
webEntrypoint: "./web",
|
||||
|
||||
messages: [
|
||||
TileDataEvent,
|
||||
ChartTagDataEvent,
|
||||
PlayerPositionEvent,
|
||||
PlayerSessionEndEvent,
|
||||
RecipeDataEvent,
|
||||
GetRawTileRequest,
|
||||
GetChartTagsRequest,
|
||||
GetPlayerPathRequest,
|
||||
GetRawRecipeTileRequest,
|
||||
ClearMinimapSurfaceDataRequest,
|
||||
ClearMinimapDataRequest,
|
||||
],
|
||||
routes: ["/minimap"],
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseInstancePlugin } from "@clusterio/host";
|
||||
import {
|
||||
TileDataEvent,
|
||||
ChartData,
|
||||
ChartTagDataEvent,
|
||||
ChartTagData,
|
||||
RecipeDataEvent,
|
||||
RecipeData,
|
||||
PlayerPositionEvent,
|
||||
PlayerData,
|
||||
PlayerSessionEndEvent,
|
||||
} from "./messages";
|
||||
|
||||
interface TileDataIpc {
|
||||
type: "chart";
|
||||
data: ChartData[];
|
||||
position: [number, number];
|
||||
tick: number;
|
||||
}
|
||||
|
||||
interface ChartTagDataIpc {
|
||||
tag_number: number;
|
||||
start_tick: number | null;
|
||||
end_tick: number | null;
|
||||
force: string;
|
||||
surface: string;
|
||||
position: [number, number];
|
||||
text: string;
|
||||
icon: string | null;
|
||||
last_user: string | null;
|
||||
}
|
||||
|
||||
interface RecipeDataIpc {
|
||||
tag_number?: number
|
||||
start_tick: number | null
|
||||
end_tick: number | null
|
||||
surface: string
|
||||
force: string
|
||||
position: [number, number]
|
||||
recipe: string | null
|
||||
icon?: { type?: string, name?: string, quality?: string }
|
||||
}
|
||||
|
||||
interface PlayerPositionIpc {
|
||||
player_name: string;
|
||||
surface: string;
|
||||
x: number;
|
||||
y: number;
|
||||
sec: number;
|
||||
}
|
||||
|
||||
interface PlayerSessionEndIpc {
|
||||
player_name: string;
|
||||
surface: string;
|
||||
}
|
||||
|
||||
export class InstancePlugin extends BaseInstancePlugin {
|
||||
pendingTileUpdates = new Map<string, Set<[number, number, [number, number, number, number]]>>();
|
||||
|
||||
async init() {
|
||||
if (!this.instance.config.get("factorio.enable_script_commands")) {
|
||||
throw new Error("minimap plugin requires script commands to be enabled.");
|
||||
}
|
||||
|
||||
// Listen for tile data from the Lua module
|
||||
this.instance.server.on("ipc-minimap:tile_data", (data: TileDataIpc) => {
|
||||
this.handleTileDataFromLua(data).catch(err => this.logger.error(
|
||||
`Error handling tile data from Lua:\n${err}`
|
||||
));
|
||||
});
|
||||
|
||||
// Listen for chart tag data from the Lua module
|
||||
this.instance.server.on("ipc-minimap:chart_tag_data", (data: ChartTagDataIpc) => {
|
||||
this.handleChartTagDataFromLua(data).catch(err => this.logger.error(
|
||||
`Error handling chart tag data from Lua:\n${err}`
|
||||
));
|
||||
});
|
||||
|
||||
// Listen for recipe data from Lua module
|
||||
this.instance.server.on("ipc-minimap:recipe_data", (data: RecipeDataIpc) => {
|
||||
this.handleRecipeDataFromLua(data).catch(err => this.logger.error(
|
||||
`Error handling recipe data from Lua:\n${err}`
|
||||
));
|
||||
});
|
||||
|
||||
// Listen for player position data from Lua module
|
||||
this.instance.server.on("ipc-minimap:player_position", (data: PlayerPositionIpc) => {
|
||||
this.handlePlayerPositionFromLua(data).catch(err => this.logger.error(
|
||||
`Error handling player position data from Lua:\n${err}`
|
||||
));
|
||||
});
|
||||
|
||||
// Listen for player session end data from Lua module
|
||||
this.instance.server.on("ipc-minimap:player_session_end", (data: PlayerSessionEndIpc) => {
|
||||
this.handlePlayerSessionEndFromLua(data).catch(err => this.logger.error(
|
||||
`Error handling player session end data from Lua:\n${err}`
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
async handleTileDataFromLua(data: TileDataIpc) {
|
||||
try {
|
||||
const { type, data: rawData, position, tick } = data;
|
||||
|
||||
if (type === "chart") {
|
||||
// Handle new chart data format
|
||||
const chartData = rawData as ChartData[];
|
||||
|
||||
if (!chartData || !Array.isArray(chartData)) {
|
||||
this.logger.error("Invalid chart data received");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send chart data to controller
|
||||
const TileDataEvents = chartData.map(chart => new TileDataEvent(
|
||||
this.instance.config.get("instance.id"),
|
||||
chart.surface,
|
||||
chart.force,
|
||||
position[0],
|
||||
position[1],
|
||||
tick,
|
||||
chart
|
||||
));
|
||||
|
||||
TileDataEvents.map(event => this.instance.sendTo("controller", event));
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to process tile data from Lua: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async handleChartTagDataFromLua(data: ChartTagDataIpc) {
|
||||
try {
|
||||
// Validate the chart tag data
|
||||
if (!data || typeof data.tag_number !== "number") {
|
||||
this.logger.error("Invalid chart tag data received");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create chart tag data event
|
||||
const chartTagEvent = new ChartTagDataEvent(
|
||||
this.instance.config.get("instance.id"),
|
||||
data as ChartTagData
|
||||
);
|
||||
|
||||
// Send to controller
|
||||
this.instance.sendTo("controller", chartTagEvent);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to process chart tag data from Lua: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async handleRecipeDataFromLua(data: RecipeDataIpc) {
|
||||
try {
|
||||
if (!data) {
|
||||
this.logger.error("Invalid recipe data received");
|
||||
return;
|
||||
}
|
||||
|
||||
const recipeData: RecipeData = {
|
||||
start_tick: data.start_tick ?? undefined,
|
||||
end_tick: data.end_tick ?? undefined,
|
||||
surface: data.surface,
|
||||
force: data.force,
|
||||
position: data.position,
|
||||
recipe: data.recipe ?? undefined,
|
||||
icon: data.icon ?? undefined,
|
||||
};
|
||||
|
||||
const event = new RecipeDataEvent(
|
||||
this.instance.config.get("instance.id"),
|
||||
recipeData
|
||||
);
|
||||
|
||||
this.instance.sendTo("controller", event);
|
||||
} catch (err: unknown) {
|
||||
this.logger.error(`Failed to process recipe data from Lua: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async handlePlayerPositionFromLua(data: PlayerPositionIpc) {
|
||||
try {
|
||||
if (!data) {
|
||||
this.logger.error("Invalid player position data received");
|
||||
return;
|
||||
}
|
||||
|
||||
const playerData: PlayerData = {
|
||||
player_name: data.player_name,
|
||||
surface: data.surface,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
sec: data.sec,
|
||||
};
|
||||
|
||||
const event = new PlayerPositionEvent(
|
||||
this.instance.config.get("instance.id"),
|
||||
playerData
|
||||
);
|
||||
this.instance.sendTo("controller", event);
|
||||
} catch (err: unknown) {
|
||||
this.logger.error(`Failed to process player position data from Lua: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async handlePlayerSessionEndFromLua(data: PlayerSessionEndIpc) {
|
||||
try {
|
||||
if (!data) {
|
||||
this.logger.error("Invalid player session end data received");
|
||||
return;
|
||||
}
|
||||
|
||||
const event = new PlayerSessionEndEvent(
|
||||
this.instance.config.get("instance.id"),
|
||||
{
|
||||
player_name: data.player_name,
|
||||
surface: data.surface,
|
||||
}
|
||||
);
|
||||
this.instance.sendTo("controller", event);
|
||||
} catch (err: unknown) {
|
||||
this.logger.error(`Failed to process player session end data from Lua: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
// Define the chart data structure
|
||||
export const ChartDataSchema = Type.Object({
|
||||
surface: Type.String(),
|
||||
force: Type.String(),
|
||||
chart_data: Type.String(),
|
||||
});
|
||||
|
||||
export type ChartData = Static<typeof ChartDataSchema>;
|
||||
|
||||
// Define the SignalID structure (based on Factorio API)
|
||||
export const SignalIDSchema = Type.Object({
|
||||
type: Type.Optional(Type.String()), // SignalIDType, nil when reading for "item" type
|
||||
name: Type.Optional(Type.String()), // Name of the prototype
|
||||
quality: Type.Optional(Type.String()), // Quality name, defaults to "normal"
|
||||
});
|
||||
|
||||
export type SignalID = Static<typeof SignalIDSchema>;
|
||||
|
||||
// Define the chart tag data structure
|
||||
export const ChartTagDataSchema = Type.Object({
|
||||
tag_number: Type.Number(),
|
||||
start_tick: Type.Optional(Type.Number()),
|
||||
end_tick: Type.Optional(Type.Number()),
|
||||
force: Type.String(),
|
||||
surface: Type.String(),
|
||||
position: Type.Tuple([Type.Number(), Type.Number()]),
|
||||
text: Type.String(),
|
||||
icon: Type.Optional(SignalIDSchema),
|
||||
last_user: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
export type ChartTagData = Static<typeof ChartTagDataSchema>;
|
||||
|
||||
export const RecipeDataSchema = Type.Object({
|
||||
start_tick: Type.Optional(Type.Number()),
|
||||
end_tick: Type.Optional(Type.Number()),
|
||||
surface: Type.String(),
|
||||
force: Type.String(),
|
||||
position: Type.Tuple([Type.Number(), Type.Number()]),
|
||||
recipe: Type.Optional(Type.String()),
|
||||
icon: Type.Optional(SignalIDSchema),
|
||||
});
|
||||
|
||||
export type RecipeData = Static<typeof RecipeDataSchema>;
|
||||
|
||||
export const PlayerDataSchema = Type.Object({
|
||||
player_name: Type.String(),
|
||||
surface: Type.String(),
|
||||
x: Type.Number(), // tiles
|
||||
y: Type.Number(), // tiles
|
||||
sec: Type.Number(), // seconds since map started (⌊tick / 60⌋)
|
||||
});
|
||||
|
||||
export type PlayerData = Static<typeof PlayerDataSchema>;
|
||||
|
||||
export const PlayerSessionEndDataSchema = Type.Object({
|
||||
player_name: Type.String(),
|
||||
surface: Type.String(),
|
||||
});
|
||||
|
||||
export type PlayerSessionEndData = Static<typeof PlayerSessionEndDataSchema>;
|
||||
|
||||
/**
|
||||
* PlayerPositionEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients
|
||||
*
|
||||
* Contains real-time player position data for live tracking and movement visualization.
|
||||
* When sent from instance to controller: triggers storage in compressed binary format.
|
||||
* When sent from controller to web clients: enables real-time player position updates.
|
||||
*
|
||||
* Data flow:
|
||||
* - Lua module -> Instance plugin -> Controller plugin (for persistence)
|
||||
* - Controller plugin -> Web UI plugin -> Canvas renderer (for live updates)
|
||||
*/
|
||||
export class PlayerPositionEvent {
|
||||
declare ["constructor"]: typeof PlayerPositionEvent;
|
||||
static type = "event" as const;
|
||||
static src = ["instance", "controller"] as const;
|
||||
static dst = ["controller", "control"] as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = null;
|
||||
|
||||
constructor(
|
||||
public instance_id: number,
|
||||
public player_data: PlayerData,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
"player_data": PlayerDataSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof PlayerPositionEvent.jsonSchema>) {
|
||||
return new this(
|
||||
json.instance_id,
|
||||
json.player_data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PlayerSessionEndEvent: Instance -> Controller
|
||||
*
|
||||
* Notifies controller that a player session has ended (disconnect).
|
||||
*/
|
||||
export class PlayerSessionEndEvent {
|
||||
declare ["constructor"]: typeof PlayerSessionEndEvent;
|
||||
static type = "event" as const;
|
||||
static src = ["instance"] as const;
|
||||
static dst = ["controller"] as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = null;
|
||||
|
||||
constructor(
|
||||
public instance_id: number,
|
||||
public session_data: PlayerSessionEndData,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
"session_data": PlayerSessionEndDataSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof PlayerSessionEndEvent.jsonSchema>) {
|
||||
return new this(
|
||||
json.instance_id,
|
||||
json.session_data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TileDataEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients
|
||||
*
|
||||
* Contains compressed RGB565 chart data from Factorio's internal mapping system.
|
||||
* When sent from instance to controller: triggers storage in persistent tile files.
|
||||
* When sent from controller to web clients: enables real-time minimap updates.
|
||||
*
|
||||
* Data flow:
|
||||
* - Lua module -> Instance plugin -> Controller plugin (for persistence)
|
||||
* - Controller plugin -> Web UI plugin -> Canvas renderer (for live updates)
|
||||
*/
|
||||
export class TileDataEvent {
|
||||
declare ["constructor"]: typeof TileDataEvent;
|
||||
static type = "event" as const;
|
||||
static src = ["instance", "controller"] as const;
|
||||
static dst = ["controller", "control"] as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = null;
|
||||
|
||||
constructor(
|
||||
public instance_id: number,
|
||||
public surface: string,
|
||||
public force: string,
|
||||
public x: number,
|
||||
public y: number,
|
||||
public tick: number,
|
||||
public chunk: ChartData,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
"surface": Type.String(),
|
||||
"force": Type.String(),
|
||||
"x": Type.Number(),
|
||||
"y": Type.Number(),
|
||||
"tick": Type.Number(),
|
||||
"chunk": ChartDataSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof TileDataEvent.jsonSchema>) {
|
||||
return new this(
|
||||
json.instance_id,
|
||||
json.surface,
|
||||
json.force,
|
||||
json.x,
|
||||
json.y,
|
||||
json.tick,
|
||||
json.chunk
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ChartTagDataEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients
|
||||
*
|
||||
* Contains chart tag information from Factorio's map tags.
|
||||
* When sent from instance to controller: triggers storage for tag history and timelapse.
|
||||
* When sent from controller to web clients: enables real-time tag updates.
|
||||
*
|
||||
* Data flow:
|
||||
* - Lua module -> Instance plugin -> Controller plugin (for persistence)
|
||||
* - Controller plugin -> Web UI plugin -> Tag renderer (for live updates)
|
||||
*/
|
||||
export class ChartTagDataEvent {
|
||||
declare ["constructor"]: typeof ChartTagDataEvent;
|
||||
static type = "event" as const;
|
||||
static src = ["instance", "controller"] as const;
|
||||
static dst = ["controller", "control"] as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = null;
|
||||
|
||||
constructor(
|
||||
public instance_id: number,
|
||||
public tag_data: ChartTagData,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
"tag_data": ChartTagDataSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof ChartTagDataEvent.jsonSchema>) {
|
||||
return new this(
|
||||
json.instance_id,
|
||||
json.tag_data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class RecipeDataEvent {
|
||||
declare ["constructor"]: typeof RecipeDataEvent;
|
||||
static type = "event" as const;
|
||||
static src = ["instance", "controller"] as const;
|
||||
static dst = ["controller", "control"] as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = null;
|
||||
|
||||
constructor(
|
||||
public instance_id: number,
|
||||
public recipe_data: RecipeData,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
"recipe_data": RecipeDataSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof RecipeDataEvent.jsonSchema>) {
|
||||
return new this(
|
||||
json.instance_id,
|
||||
json.recipe_data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GetRawTileRequest: Request raw tile data from controller storage
|
||||
*
|
||||
* Returns base64-encoded tile data that can be processed in timelapse mode
|
||||
* or for initial tile loading when canvas minimap starts up.
|
||||
*/
|
||||
export class GetRawTileRequest {
|
||||
declare ["constructor"]: typeof GetRawTileRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = "minimap.view";
|
||||
|
||||
constructor(
|
||||
public instance_id: number,
|
||||
public surface: string,
|
||||
public force: string,
|
||||
public tile_x: number,
|
||||
public tile_y: number,
|
||||
public tick?: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
"surface": Type.String(),
|
||||
"force": Type.String(),
|
||||
"tile_x": Type.Number(),
|
||||
"tile_y": Type.Number(),
|
||||
"tick": Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof GetRawTileRequest.jsonSchema>) {
|
||||
return new this(
|
||||
json.instance_id,
|
||||
json.surface,
|
||||
json.force,
|
||||
json.tile_x,
|
||||
json.tile_y,
|
||||
json.tick
|
||||
);
|
||||
}
|
||||
|
||||
static Response = lib.plainJson(Type.Object({
|
||||
"tile_data": Type.Union([Type.String(), Type.Null()]), // Base64 encoded tile file or null if not found
|
||||
}));
|
||||
}
|
||||
|
||||
export class ClearMinimapSurfaceDataRequest {
|
||||
declare ["constructor"]: typeof ClearMinimapSurfaceDataRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = "minimap.manage";
|
||||
|
||||
constructor(
|
||||
public instance_id: number,
|
||||
public surface: string,
|
||||
public force?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
"surface": Type.String(),
|
||||
"force": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof ClearMinimapSurfaceDataRequest.jsonSchema>) {
|
||||
return new this(
|
||||
json.instance_id,
|
||||
json.surface,
|
||||
json.force
|
||||
);
|
||||
}
|
||||
|
||||
static Response = lib.plainJson(Type.Object({
|
||||
"success": Type.Boolean(),
|
||||
}));
|
||||
}
|
||||
|
||||
export class ClearMinimapDataRequest {
|
||||
declare ["constructor"]: typeof ClearMinimapDataRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = "minimap.manage";
|
||||
|
||||
static jsonSchema = Type.Object({});
|
||||
|
||||
static fromJSON(_: Static<typeof ClearMinimapDataRequest.jsonSchema>) {
|
||||
return new this();
|
||||
}
|
||||
|
||||
static Response = lib.plainJson(Type.Object({
|
||||
"success": Type.Boolean(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* GetChartTagsRequest: Request existing chart tags from controller storage
|
||||
*
|
||||
* Returns all saved chart tags for a specific instance/surface/force combination.
|
||||
* Used to load existing chart tags when the web UI starts up or selection changes.
|
||||
*/
|
||||
export class GetChartTagsRequest {
|
||||
declare ["constructor"]: typeof GetChartTagsRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = "minimap.view";
|
||||
|
||||
constructor(
|
||||
public instance_id: number,
|
||||
public surface: string,
|
||||
public force: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
"surface": Type.String(),
|
||||
"force": Type.String(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof GetChartTagsRequest.jsonSchema>) {
|
||||
return new this(
|
||||
json.instance_id,
|
||||
json.surface,
|
||||
json.force
|
||||
);
|
||||
}
|
||||
|
||||
static Response = lib.plainJson(Type.Object({
|
||||
"chart_tags": Type.Array(ChartTagDataSchema),
|
||||
}));
|
||||
}
|
||||
|
||||
export class GetRawRecipeTileRequest {
|
||||
declare ["constructor"]: typeof GetRawRecipeTileRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = "minimap.view";
|
||||
|
||||
constructor(
|
||||
public instance_id: number,
|
||||
public surface: string,
|
||||
public force: string,
|
||||
public tile_x: number,
|
||||
public tile_y: number,
|
||||
public tick?: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
"surface": Type.String(),
|
||||
"force": Type.String(),
|
||||
"tile_x": Type.Number(),
|
||||
"tile_y": Type.Number(),
|
||||
"tick": Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof GetRawRecipeTileRequest.jsonSchema>) {
|
||||
return new this(
|
||||
json.instance_id,
|
||||
json.surface,
|
||||
json.force,
|
||||
json.tile_x,
|
||||
json.tile_y,
|
||||
json.tick
|
||||
);
|
||||
}
|
||||
|
||||
static Response = lib.plainJson(Type.Object({
|
||||
"recipe_tile": Type.Union([Type.String(), Type.Null()]),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* GetPlayerPathRequest: Request compressed historical player path data from controller storage
|
||||
*
|
||||
* Returns binary compressed player position data for timeline playback.
|
||||
* Used to load player movement history when opening timeline or specific player tracking.
|
||||
*/
|
||||
export class GetPlayerPathRequest {
|
||||
declare ["constructor"]: typeof GetPlayerPathRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = "minimap.view";
|
||||
|
||||
constructor(
|
||||
public instance_id: number,
|
||||
public surface: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
"surface": Type.String(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof GetPlayerPathRequest.jsonSchema>) {
|
||||
return new this(
|
||||
json.instance_id,
|
||||
json.surface,
|
||||
);
|
||||
}
|
||||
|
||||
static Response = lib.plainJson(Type.Object({
|
||||
"positions": Type.Union([Type.String(), Type.Null()]),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
local clusterio_api = require("modules/clusterio/api")
|
||||
local compat = require("modules/clusterio/compat")
|
||||
|
||||
-- Hot-loop local references for performance
|
||||
local math_floor = math.floor
|
||||
local bit32_rshift = bit32.rshift
|
||||
local table_insert = table.insert
|
||||
|
||||
local minimap = {}
|
||||
|
||||
-- Convert RGB565 to RGB888 values
|
||||
local function rgb565_to_rgb888(rgb565_value)
|
||||
local r = math_floor(bit32_rshift(rgb565_value, 11) * 255 / 31)
|
||||
local g = math_floor(bit32_rshift(bit32.band(rgb565_value, 0x07E0), 5) * 255 / 63)
|
||||
local b = math_floor(bit32.band(rgb565_value, 0x001F) * 255 / 31)
|
||||
return r, g, b
|
||||
end
|
||||
|
||||
-- Queue a chunk for update
|
||||
local function queue_chunk_for_update(chunk_position)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- If the chunk isn't already in the queue, add it
|
||||
for _, queued_chunk in pairs(storage.minimap.chunk_update_queue) do
|
||||
if queued_chunk.x == chunk_position.x and queued_chunk.y == chunk_position.y then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(storage.minimap.chunk_update_queue, chunk_position)
|
||||
end
|
||||
|
||||
-- Send chart tag data to plugin
|
||||
local function send_chart_tag_data(tag_data)
|
||||
clusterio_api.send_json("minimap:chart_tag_data", tag_data)
|
||||
end
|
||||
|
||||
-- Send recipe data to plugin
|
||||
local function send_recipe_data(recipe_data)
|
||||
clusterio_api.send_json("minimap:recipe_data", recipe_data)
|
||||
end
|
||||
|
||||
-- Send player position data to plugin
|
||||
local function send_player_position_data(player_data)
|
||||
clusterio_api.send_json("minimap:player_position", player_data)
|
||||
end
|
||||
|
||||
-- Send player session end data to plugin
|
||||
local function send_player_session_end(session_data)
|
||||
clusterio_api.send_json("minimap:player_session_end", session_data)
|
||||
end
|
||||
|
||||
-- Check if a position has moved significantly (≥1 tile)
|
||||
local function has_moved_significantly(old_pos, new_pos)
|
||||
if not old_pos then
|
||||
return true
|
||||
end
|
||||
local dx = math.abs(new_pos.x - old_pos.x)
|
||||
local dy = math.abs(new_pos.y - old_pos.y)
|
||||
return dx >= 1 or dy >= 1
|
||||
end
|
||||
|
||||
-- Process and send player position updates
|
||||
local function process_player_positions()
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
for _, player in pairs(game.connected_players) do
|
||||
if player.valid and player.character and player.character.valid then
|
||||
-- Use physical controller position for accurate tracking when in vehicles/remotes
|
||||
local surface = player.physical_surface
|
||||
local position = player.physical_position
|
||||
local player_key = player.index
|
||||
|
||||
-- Initialize player tracking data if needed
|
||||
if not storage.minimap.player_positions then
|
||||
storage.minimap.player_positions = {}
|
||||
end
|
||||
|
||||
local last_position = storage.minimap.player_positions[player_key]
|
||||
local current_sec = math_floor(game.tick / 60)
|
||||
|
||||
-- Check if position changed significantly or timeout elapsed
|
||||
local should_update = false
|
||||
if not last_position then
|
||||
-- First position for this player
|
||||
should_update = true
|
||||
elseif has_moved_significantly(last_position.position, position) then
|
||||
should_update = true
|
||||
elseif (current_sec - last_position.last_update_sec) >= 5 then
|
||||
-- Send update every 5 seconds even if not moving (for timeout)
|
||||
should_update = true
|
||||
end
|
||||
|
||||
if should_update then
|
||||
local player_data = {
|
||||
player_name = player.name,
|
||||
surface = surface.name,
|
||||
x = position.x,
|
||||
y = position.y,
|
||||
sec = current_sec,
|
||||
}
|
||||
|
||||
send_player_position_data(player_data)
|
||||
|
||||
-- Update tracking data
|
||||
storage.minimap.player_positions[player_key] = {
|
||||
position = { x = position.x, y = position.y },
|
||||
last_update_sec = current_sec,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle player session start
|
||||
local function on_player_joined_game(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local player = game.get_player(event.player_index)
|
||||
if not player or not player.valid then
|
||||
return
|
||||
end
|
||||
|
||||
-- Emit session start immediately when player joins using existing player_position channel
|
||||
local surface = player.physical_surface or player.surface
|
||||
local position = player.physical_position or player.position
|
||||
local player_data = {
|
||||
player_name = player.name,
|
||||
surface = surface.name,
|
||||
x = position.x,
|
||||
y = position.y,
|
||||
sec = math_floor(game.tick / 60),
|
||||
}
|
||||
send_player_position_data(player_data)
|
||||
|
||||
-- Clear any existing position data for this player to ensure fresh tracking
|
||||
if storage.minimap.player_positions then
|
||||
storage.minimap.player_positions[player.index] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle player session end
|
||||
local function on_player_left_game(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local player = game.get_player(event.player_index)
|
||||
if not player or not player.valid then
|
||||
return
|
||||
end
|
||||
|
||||
send_player_session_end({
|
||||
player_name = player.name,
|
||||
surface = player.surface.name,
|
||||
})
|
||||
|
||||
-- Clean up position tracking data
|
||||
if storage.minimap.player_positions then
|
||||
storage.minimap.player_positions[event.player_index] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Helper for creating and sending recipe start/stop events
|
||||
local function update_entity_recipe(entity)
|
||||
if not entity or not entity.valid then
|
||||
return
|
||||
end
|
||||
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- Only consider entities that can have recipes
|
||||
local recipe = nil
|
||||
if entity.type == "assembling-machine" then
|
||||
local rec_obj = entity.get_recipe()
|
||||
if rec_obj then
|
||||
recipe = rec_obj.name
|
||||
end
|
||||
elseif entity.type == "furnace" then
|
||||
local prev = entity.previous_recipe
|
||||
if prev and prev.name then
|
||||
-- Factorio furnaces expose previous_recipe with the id nested at previous_recipe.name.name.
|
||||
recipe = prev.name.name
|
||||
end
|
||||
end
|
||||
|
||||
local cache = storage.minimap.recipe_cache
|
||||
local unit_number = entity.unit_number
|
||||
if not unit_number then
|
||||
return -- Cannot track entities without persistent ID
|
||||
end
|
||||
|
||||
local previous = cache[unit_number]
|
||||
if previous and previous.recipe_name == recipe then
|
||||
return -- No change
|
||||
end
|
||||
|
||||
local position = entity.position
|
||||
local data_common = {
|
||||
position = { position.x, position.y },
|
||||
surface = entity.surface.name,
|
||||
force = entity.force.name,
|
||||
}
|
||||
|
||||
-- Close previous recipe if it existed
|
||||
if previous and previous.recipe_name then
|
||||
local end_data = {
|
||||
position = data_common.position,
|
||||
surface = data_common.surface,
|
||||
force = data_common.force,
|
||||
tick = game.tick,
|
||||
recipe = nil,
|
||||
}
|
||||
send_recipe_data(end_data)
|
||||
end
|
||||
|
||||
-- Open new recipe interval if recipe now set
|
||||
if recipe then
|
||||
local icon_signal = nil
|
||||
local recipe_proto = prototypes.recipe[recipe]
|
||||
if recipe_proto and recipe_proto.main_product then
|
||||
icon_signal = { type = recipe_proto.main_product.type, name = recipe_proto.main_product.name }
|
||||
end
|
||||
local start_data = {
|
||||
position = data_common.position,
|
||||
surface = data_common.surface,
|
||||
force = data_common.force,
|
||||
tick = game.tick,
|
||||
recipe = recipe,
|
||||
icon = icon_signal,
|
||||
}
|
||||
send_recipe_data(start_data)
|
||||
cache[unit_number] = { recipe_name = recipe }
|
||||
else
|
||||
-- Recipe cleared
|
||||
cache[unit_number] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Create tag data structure
|
||||
local function create_tag_data(tag, start_tick, end_tick)
|
||||
return {
|
||||
tag_number = tag.tag_number,
|
||||
start_tick = start_tick,
|
||||
end_tick = end_tick,
|
||||
force = tag.force.name,
|
||||
surface = tag.surface.name,
|
||||
position = {tag.position.x, tag.position.y},
|
||||
text = tag.text,
|
||||
icon = tag.icon or nil,
|
||||
last_user = tag.last_user and tag.last_user.name or nil
|
||||
}
|
||||
end
|
||||
|
||||
local function dump_chunk_chart(chunk_position)
|
||||
local data = {}
|
||||
local surfaces = game.surfaces
|
||||
local forces = game.forces
|
||||
for _, force in pairs(forces) do
|
||||
for _, surface in pairs(surfaces) do
|
||||
local chart_data = force.get_chunk_chart(surface, chunk_position)
|
||||
if chart_data then
|
||||
-- Use Factorio's built-in deflate compression and base64 encoding
|
||||
local encoded_data = helpers.encode_string(chart_data)
|
||||
table_insert(data, {surface = surface.name, force = force.name, chart_data = encoded_data})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Calculate world position from chunk position
|
||||
local world_x = chunk_position.x * 32
|
||||
local world_y = chunk_position.y * 32
|
||||
|
||||
-- Send tile data to the plugin
|
||||
clusterio_api.send_json("minimap:tile_data", {
|
||||
type = "chart",
|
||||
position = {world_x, world_y},
|
||||
tick = game.tick,
|
||||
data = data,
|
||||
})
|
||||
end
|
||||
|
||||
-- Chart tag event handlers
|
||||
local function on_chart_tag_added(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local tag = event.tag
|
||||
if not tag or not tag.valid then
|
||||
return
|
||||
end
|
||||
|
||||
local start_tick = game.tick
|
||||
local tag_data = create_tag_data(tag, start_tick, nil)
|
||||
|
||||
-- Send to plugin
|
||||
send_chart_tag_data(tag_data)
|
||||
end
|
||||
|
||||
local function on_chart_tag_modified(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local tag = event.tag
|
||||
if not tag or not tag.valid then
|
||||
return
|
||||
end
|
||||
|
||||
-- End the old version at current tick
|
||||
local end_tick = game.tick
|
||||
local old_tag_data = create_tag_data(tag, nil, end_tick)
|
||||
send_chart_tag_data(old_tag_data)
|
||||
|
||||
-- Create new tag entry starting at next tick
|
||||
local start_tick = game.tick + 1
|
||||
local tag_data = create_tag_data(tag, start_tick, nil)
|
||||
|
||||
-- Send to plugin
|
||||
send_chart_tag_data(tag_data)
|
||||
end
|
||||
|
||||
local function on_chart_tag_removed(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local tag = event.tag
|
||||
if not tag or not tag.valid then
|
||||
return
|
||||
end
|
||||
|
||||
-- End the tag
|
||||
local end_tick = game.tick
|
||||
local tag_data = create_tag_data(tag, nil, end_tick)
|
||||
send_chart_tag_data(tag_data)
|
||||
end
|
||||
|
||||
-- Event handlers
|
||||
local function on_chunk_charted(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- Convert area to chunk position
|
||||
local chunk_x = math_floor(event.area.left_top.x / 32)
|
||||
local chunk_y = math_floor(event.area.left_top.y / 32)
|
||||
|
||||
queue_chunk_for_update({x = chunk_x, y = chunk_y})
|
||||
end
|
||||
|
||||
local function on_entity_built(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
if event.created_entity and event.created_entity.valid then
|
||||
local entity = event.created_entity
|
||||
local position = entity.position
|
||||
local chunk_x = math_floor(position.x / 32)
|
||||
local chunk_y = math_floor(position.y / 32)
|
||||
|
||||
queue_chunk_for_update({x = chunk_x, y = chunk_y})
|
||||
-- Track recipe for newly built crafting entities
|
||||
update_entity_recipe(entity)
|
||||
end
|
||||
end
|
||||
|
||||
local function on_entity_removed(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
if event.entity and event.entity.position then
|
||||
local entity = event.entity
|
||||
local position = entity.position
|
||||
local chunk_x = math_floor(position.x / 32)
|
||||
local chunk_y = math_floor(position.y / 32)
|
||||
|
||||
queue_chunk_for_update({x = chunk_x, y = chunk_y})
|
||||
-- End recipe interval for removed entity
|
||||
update_entity_recipe(entity)
|
||||
end
|
||||
end
|
||||
|
||||
local function on_tile_changed(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- Get unique chunks that were affected
|
||||
local affected_chunks = {}
|
||||
for _, tile in pairs(event.tiles) do
|
||||
local chunk_x = math_floor(tile.position.x / 32)
|
||||
local chunk_y = math_floor(tile.position.y / 32)
|
||||
local chunk_key = chunk_x .. "," .. chunk_y
|
||||
|
||||
if not affected_chunks[chunk_key] then
|
||||
affected_chunks[chunk_key] = {x = chunk_x, y = chunk_y}
|
||||
end
|
||||
end
|
||||
|
||||
-- Update each affected chunk
|
||||
for _, chunk_pos in pairs(affected_chunks) do
|
||||
queue_chunk_for_update(chunk_pos)
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle recipe change via settings paste
|
||||
local function on_entity_settings_pasted(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
if event.destination and event.destination.valid then
|
||||
update_entity_recipe(event.destination)
|
||||
end
|
||||
end
|
||||
|
||||
local function on_gui_closed(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- We're only interested in entity GUIs
|
||||
if event.gui_type ~= defines.gui_type.entity then
|
||||
return
|
||||
end
|
||||
|
||||
local entity = event.entity
|
||||
if entity and entity.valid and (entity.type == "assembling-machine" or entity.type == "furnace") then
|
||||
update_entity_recipe(entity)
|
||||
end
|
||||
end
|
||||
|
||||
-- Initialize the module
|
||||
local function init()
|
||||
if not storage.minimap then
|
||||
storage.minimap = {
|
||||
enabled = true,
|
||||
chunk_update_queue = {},
|
||||
recipe_cache = {},
|
||||
player_positions = {}
|
||||
}
|
||||
end
|
||||
|
||||
if not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- Ensure all required fields exist
|
||||
if not storage.minimap.chunk_update_queue then
|
||||
storage.minimap.chunk_update_queue = {}
|
||||
end
|
||||
if not storage.minimap.recipe_cache then
|
||||
storage.minimap.recipe_cache = {}
|
||||
end
|
||||
if not storage.minimap.player_positions then
|
||||
storage.minimap.player_positions = {}
|
||||
end
|
||||
|
||||
-- Clear chunk queue before rebuilding
|
||||
storage.minimap.chunk_update_queue = {}
|
||||
|
||||
-- Queue all existing charted chunks for update
|
||||
local chunks_queued = 0
|
||||
|
||||
for _, force in pairs(game.forces) do
|
||||
for _, surface in pairs(game.surfaces) do
|
||||
-- Iterate through all generated chunks on this surface
|
||||
for chunk in surface.get_chunks() do
|
||||
local chunk_position = {x = chunk.x, y = chunk.y}
|
||||
|
||||
-- Check if this chunk is charted for this force
|
||||
if force.is_chunk_charted(surface, chunk_position) then
|
||||
table_insert(storage.minimap.chunk_update_queue, chunk_position)
|
||||
chunks_queued = chunks_queued + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Initialize existing chart tags
|
||||
local existing_tags = force.find_chart_tags(surface)
|
||||
for _, tag in pairs(existing_tags) do
|
||||
local start_tick = game.tick
|
||||
local tag_data = create_tag_data(tag, start_tick, nil)
|
||||
|
||||
-- Send to plugin
|
||||
send_chart_tag_data(tag_data)
|
||||
end
|
||||
|
||||
-- Initialize existing crafting recipes on this surface and force
|
||||
local crafting_entities = surface.find_entities_filtered{
|
||||
type = {
|
||||
"assembling-machine",
|
||||
"furnace",
|
||||
}
|
||||
}
|
||||
for _, entity in pairs(crafting_entities) do
|
||||
if entity.valid and entity.force == force then
|
||||
local recipe
|
||||
if entity.type == "assembling-machine" then
|
||||
recipe = entity.get_recipe()
|
||||
elseif entity.type == "furnace" then
|
||||
local prev = entity.previous_recipe
|
||||
if prev and prev.name then
|
||||
recipe = { name = prev.name.name }
|
||||
end
|
||||
end
|
||||
if recipe then
|
||||
local recipe_proto = prototypes.recipe[recipe.name]
|
||||
local icon_signal = nil
|
||||
if recipe_proto and recipe_proto.main_product then
|
||||
icon_signal = { type = recipe_proto.main_product.type, name = recipe_proto.main_product.name }
|
||||
end
|
||||
|
||||
local data = {
|
||||
position = { entity.position.x, entity.position.y },
|
||||
surface = surface.name,
|
||||
force = force.name,
|
||||
tick = game.tick,
|
||||
recipe = recipe.name,
|
||||
icon = icon_signal,
|
||||
}
|
||||
send_recipe_data(data)
|
||||
|
||||
-- Cache for tracking
|
||||
storage.minimap.recipe_cache[entity.unit_number] = { recipe_name = recipe.name }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function on_tick(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- Process the chunk update queue
|
||||
if #storage.minimap.chunk_update_queue > 0 then
|
||||
local chunk_position = table.remove(storage.minimap.chunk_update_queue, 1)
|
||||
dump_chunk_chart(chunk_position)
|
||||
end
|
||||
|
||||
-- Process player position updates every 250ms (15 ticks at 60 UPS)
|
||||
if game.tick % 15 == 0 then
|
||||
process_player_positions()
|
||||
end
|
||||
end
|
||||
|
||||
-- Export functions
|
||||
minimap.dump_chunk_chart = dump_chunk_chart
|
||||
|
||||
minimap.events = {}
|
||||
|
||||
-- Ensure this module only loads on Factorio 2.0 or later
|
||||
if not compat.version_ge("2.0") then
|
||||
log("[minimap] Minimap plugin disabled - requires Factorio 2.0 or later, current version: " .. (script.active_mods and script.active_mods.base or "unknown"))
|
||||
else
|
||||
minimap.events[clusterio_api.events.on_server_startup] = init
|
||||
minimap.events[defines.events.on_tick] = on_tick
|
||||
minimap.events[defines.events.on_chunk_charted] = on_chunk_charted
|
||||
minimap.events[defines.events.on_built_entity] = on_entity_built
|
||||
minimap.events[defines.events.on_robot_built_entity] = on_entity_built
|
||||
minimap.events[defines.events.on_entity_died] = on_entity_removed
|
||||
minimap.events[defines.events.on_player_mined_entity] = on_entity_removed
|
||||
minimap.events[defines.events.on_robot_mined_entity] = on_entity_removed
|
||||
minimap.events[defines.events.on_player_built_tile] = on_tile_changed
|
||||
minimap.events[defines.events.on_robot_built_tile] = on_tile_changed
|
||||
minimap.events[defines.events.on_player_mined_tile] = on_tile_changed
|
||||
minimap.events[defines.events.on_robot_mined_tile] = on_tile_changed
|
||||
minimap.events[defines.events.script_raised_set_tiles] = on_tile_changed
|
||||
minimap.events[defines.events.on_chart_tag_added] = on_chart_tag_added
|
||||
minimap.events[defines.events.on_chart_tag_modified] = on_chart_tag_modified
|
||||
minimap.events[defines.events.on_chart_tag_removed] = on_chart_tag_removed
|
||||
minimap.events[defines.events.on_entity_settings_pasted] = on_entity_settings_pasted
|
||||
minimap.events[defines.events.on_gui_closed] = on_gui_closed
|
||||
minimap.events[defines.events.on_player_joined_game] = on_player_joined_game
|
||||
minimap.events[defines.events.on_player_left_game] = on_player_left_game
|
||||
end
|
||||
|
||||
return minimap
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "minimap",
|
||||
"load": ["minimap.lua"]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-minimap",
|
||||
"version": "2.0.0-alpha.22b",
|
||||
"description": "Clusterio plugin for displaying interactive minimaps of Factorio instances",
|
||||
"author": "Clusterio Team",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
"license": "MIT",
|
||||
"repository": "clusterio/clusterio",
|
||||
"main": "dist/node/index.js",
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"clusterio-plugin",
|
||||
"factorio",
|
||||
"minimap"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"watch": "tsc --build --watch",
|
||||
"watch-web": "webpack-cli --env development --watch",
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/controller": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"antd": "catalog:",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"typescript": "catalog:",
|
||||
"webpack": "catalog:",
|
||||
"webpack-cli": "catalog:",
|
||||
"webpack-merge": "catalog:"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "catalog:",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.browser.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/browser"
|
||||
},
|
||||
"include": [
|
||||
"web/**/*",
|
||||
"messages.ts",
|
||||
"utils/tile-utils.ts",
|
||||
"utils/player-utils.ts",
|
||||
"utils/parsing-utils.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist/**/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.browser.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node"
|
||||
},
|
||||
"exclude": [
|
||||
"web/**/*",
|
||||
"dist/**/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Environment detection
|
||||
export const isNode = typeof Buffer !== "undefined" && typeof process !== "undefined" && process.versions?.node;
|
||||
|
||||
// Cross-platform type for binary data
|
||||
export type BinaryData = Buffer | Uint8Array;
|
||||
|
||||
// Cross-platform helper functions for reading integers
|
||||
export function readUInt8(data: BinaryData, offset: number): number {
|
||||
if (isNode && data instanceof Buffer) {
|
||||
return data.readUInt8(offset);
|
||||
}
|
||||
return (data as Uint8Array)[offset];
|
||||
}
|
||||
|
||||
export function readUInt16LE(data: BinaryData, offset: number): number {
|
||||
if (isNode && data instanceof Buffer) {
|
||||
return data.readUInt16LE(offset);
|
||||
}
|
||||
const bytes = data as Uint8Array;
|
||||
return bytes[offset] | (bytes[offset + 1] << 8);
|
||||
}
|
||||
|
||||
export function readUInt16BE(data: BinaryData, offset: number): number {
|
||||
if (isNode && data instanceof Buffer) {
|
||||
return data.readUInt16BE(offset);
|
||||
}
|
||||
const bytes = data as Uint8Array;
|
||||
return (bytes[offset] << 8) | bytes[offset + 1];
|
||||
}
|
||||
|
||||
export function readUInt32BE(data: BinaryData, offset: number): number {
|
||||
if (isNode && data instanceof Buffer) {
|
||||
return data.readUInt32BE(offset);
|
||||
}
|
||||
const bytes = data as Uint8Array;
|
||||
return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
|
||||
}
|
||||
|
||||
export function sliceData(data: BinaryData, start: number, end: number): BinaryData {
|
||||
return data.slice(start, end);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { BinaryData, sliceData, readUInt8, readUInt16BE, readUInt32BE, isNode } from "./parsing-utils";
|
||||
|
||||
/**
|
||||
* Parsed player position file data
|
||||
*/
|
||||
export interface ParsedPlayerPositions {
|
||||
/** Player name -> player ID mappings from SessionStart records */
|
||||
playerSessions: Map<string, number>;
|
||||
/** Maximum player ID found in the file */
|
||||
maxPlayerId: number;
|
||||
/** All position records in the file */
|
||||
positions: PlayerPositionRecord[];
|
||||
}
|
||||
|
||||
export type PlayerPositionRecord =
|
||||
| PlayerPositionRecordType0
|
||||
| PlayerPositionRecordType1
|
||||
| PlayerPositionRecordType2;
|
||||
|
||||
export interface PlayerPositionRecordType0 {
|
||||
type: 0;
|
||||
playerId: number;
|
||||
tSec: number;
|
||||
sec: number;
|
||||
xTiles: number;
|
||||
yTiles: number;
|
||||
}
|
||||
|
||||
export interface PlayerPositionRecordType1 {
|
||||
type: 1;
|
||||
playerId: number;
|
||||
tMs: number;
|
||||
playerName: string;
|
||||
}
|
||||
|
||||
export interface PlayerPositionRecordType2 {
|
||||
type: 2;
|
||||
playerId: number;
|
||||
tMs: number;
|
||||
}
|
||||
|
||||
export interface ParsedPlayerPos {
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
sec: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a signed 24-bit big-endian integer from binary data
|
||||
* @param buf The binary data buffer
|
||||
* @param offset Offset in the buffer to read from
|
||||
* @returns Signed 24-bit integer value
|
||||
*/
|
||||
function readSignedInt24(buf: BinaryData, offset: number): number {
|
||||
const unsigned = (readUInt8(buf, offset) << 16) | (readUInt8(buf, offset + 1) << 8) | readUInt8(buf, offset + 2);
|
||||
return unsigned > 0x7FFFFF ? unsigned - 0x1000000 : unsigned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse binary player position file data
|
||||
* @param buf Binary player position data
|
||||
* @returns Parsed player position data with sessions and records
|
||||
*/
|
||||
export function parsePlayerPositionsBinary(buf: BinaryData): ParsedPlayerPositions {
|
||||
const playerSessions = new Map<string, number>();
|
||||
const positions: PlayerPositionRecord[] = [];
|
||||
let maxPlayerId = 0;
|
||||
let offset = 0;
|
||||
|
||||
while (offset < buf.length) {
|
||||
if (offset + 1 > buf.length) { break; }
|
||||
|
||||
const type = readUInt8(buf, offset);
|
||||
offset += 1;
|
||||
|
||||
if (type === 0) {
|
||||
// Position record
|
||||
const result = parsePositionRecord(buf, offset);
|
||||
if (!result.success) { break; }
|
||||
|
||||
const record: PlayerPositionRecordType0 = {
|
||||
type: 0,
|
||||
...result.record,
|
||||
};
|
||||
|
||||
positions.push(record);
|
||||
if (result.record.playerId > maxPlayerId) {
|
||||
maxPlayerId = result.record.playerId;
|
||||
}
|
||||
offset = result.newOffset;
|
||||
|
||||
} else if (type === 1) {
|
||||
// SessionStart record
|
||||
const result = parseSessionStartRecord(buf, offset);
|
||||
if (!result.success) { break; }
|
||||
|
||||
const record: PlayerPositionRecordType1 = {
|
||||
type: 1,
|
||||
...result.record,
|
||||
};
|
||||
|
||||
positions.push(record);
|
||||
playerSessions.set(result.record.playerName, result.record.playerId);
|
||||
if (result.record.playerId > maxPlayerId) {
|
||||
maxPlayerId = result.record.playerId;
|
||||
}
|
||||
offset = result.newOffset;
|
||||
|
||||
} else if (type === 2) {
|
||||
// SessionEnd record
|
||||
const result = parseSessionEndRecord(buf, offset);
|
||||
if (!result.success) { break; }
|
||||
|
||||
const record: PlayerPositionRecordType2 = {
|
||||
type: 2,
|
||||
...result.record,
|
||||
};
|
||||
|
||||
positions.push(record);
|
||||
if (result.record.playerId > maxPlayerId) {
|
||||
maxPlayerId = result.record.playerId;
|
||||
}
|
||||
offset = result.newOffset;
|
||||
|
||||
} else {
|
||||
// Unknown record type, can't continue parsing safely
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { playerSessions, maxPlayerId, positions };
|
||||
}
|
||||
|
||||
type PositionRecordParseResult =
|
||||
| { success: false; newOffset: number }
|
||||
| { success: true; newOffset: number; record: Omit<PlayerPositionRecordType0, "type"> };
|
||||
|
||||
/**
|
||||
* Parse position record from player position file
|
||||
* @param buf The binary player position data
|
||||
* @param offset Current offset in the data
|
||||
* @returns Processing result with position data and new offset
|
||||
*/
|
||||
function parsePositionRecord(buf: BinaryData, offset: number): PositionRecordParseResult {
|
||||
// Position record: 4 + 4 + 3 + 3 + 2 = 16 bytes
|
||||
if (offset + 16 > buf.length) {
|
||||
return { success: false, newOffset: offset };
|
||||
}
|
||||
|
||||
const tSec = readUInt32BE(buf, offset);
|
||||
offset += 4;
|
||||
const sec = readUInt32BE(buf, offset);
|
||||
offset += 4;
|
||||
|
||||
const xTiles = readSignedInt24(buf, offset);
|
||||
offset += 3;
|
||||
|
||||
const yTiles = readSignedInt24(buf, offset);
|
||||
offset += 3;
|
||||
|
||||
const playerId = readUInt16BE(buf, offset);
|
||||
offset += 2;
|
||||
|
||||
return { success: true, newOffset: offset, record: { playerId, tSec, sec, xTiles, yTiles } };
|
||||
}
|
||||
|
||||
type SessionStartRecordParseResult =
|
||||
| { success: false; newOffset: number }
|
||||
| { success: true; newOffset: number; record: Omit<PlayerPositionRecordType1, "type"> };
|
||||
|
||||
/**
|
||||
* Parse SessionStart record from player position file
|
||||
* @param buf The binary player position data
|
||||
* @param offset Current offset in the data
|
||||
* @returns Processing result with session data and new offset
|
||||
*/
|
||||
function parseSessionStartRecord(
|
||||
buf: BinaryData,
|
||||
offset: number
|
||||
): SessionStartRecordParseResult {
|
||||
// SessionStart record: 4 + 2 + 1 + n = 7 + n bytes minimum
|
||||
if (offset + 7 > buf.length) {
|
||||
return { success: false, newOffset: offset };
|
||||
}
|
||||
|
||||
const tMs = readUInt32BE(buf, offset);
|
||||
offset += 4;
|
||||
const playerId = readUInt16BE(buf, offset);
|
||||
offset += 2;
|
||||
const nameLen = readUInt8(buf, offset);
|
||||
offset += 1;
|
||||
|
||||
if (offset + nameLen > buf.length) {
|
||||
return { success: false, newOffset: offset };
|
||||
}
|
||||
|
||||
const nameBytes = sliceData(buf, offset, offset + nameLen);
|
||||
offset += nameLen;
|
||||
|
||||
// Convert to string using cross-platform method
|
||||
let playerName: string;
|
||||
if (isNode && nameBytes instanceof Buffer) {
|
||||
playerName = nameBytes.toString("utf-8");
|
||||
} else {
|
||||
playerName = new TextDecoder().decode(nameBytes as Uint8Array);
|
||||
}
|
||||
|
||||
return { success: true, newOffset: offset, record: { playerId, tMs, playerName } };
|
||||
}
|
||||
|
||||
type SessionEndRecordParseResult =
|
||||
| { success: false; newOffset: number }
|
||||
| { success: true; newOffset: number; record: Omit<PlayerPositionRecordType2, "type"> };
|
||||
|
||||
/**
|
||||
* Parse SessionEnd record from player position file
|
||||
* @param buf The binary player position data
|
||||
* @param offset Current offset in the data
|
||||
* @returns Processing result with session data and new offset
|
||||
*/
|
||||
function parseSessionEndRecord(
|
||||
buf: BinaryData,
|
||||
offset: number
|
||||
): SessionEndRecordParseResult {
|
||||
// SessionEnd record: 4 + 2 = 6 bytes
|
||||
if (offset + 6 > buf.length) {
|
||||
return { success: false, newOffset: offset };
|
||||
}
|
||||
|
||||
const tMs = readUInt32BE(buf, offset);
|
||||
offset += 4;
|
||||
const playerId = readUInt16BE(buf, offset);
|
||||
offset += 2;
|
||||
|
||||
return { success: true, newOffset: offset, record: { playerId, tMs } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and deduplicate player positions from binary data
|
||||
* @param buf Binary player position data
|
||||
* @returns Map of player ID to deduplicated position timeline
|
||||
*/
|
||||
export function parseAndDeduplicatePlayerPositions(buf: BinaryData): Map<number, ParsedPlayerPos[]> {
|
||||
// Use the utility function to parse the binary data
|
||||
const parsed: ParsedPlayerPositions = parsePlayerPositionsBinary(buf);
|
||||
|
||||
// Build player timelines from the parsed data
|
||||
const playerTimelines = new Map<number, ParsedPlayerPos[]>();
|
||||
const playerNameById = new Map<number, string>();
|
||||
for (const [name, id] of parsed.playerSessions) {
|
||||
playerNameById.set(id, name);
|
||||
}
|
||||
|
||||
// Process all records in order
|
||||
for (const record of parsed.positions) {
|
||||
if (record.type === 0) {
|
||||
// Position record - convert tile coordinates to world coordinates
|
||||
const name = playerNameById.get(record.playerId);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!playerTimelines.has(record.playerId)) {
|
||||
playerTimelines.set(record.playerId, []);
|
||||
}
|
||||
|
||||
// Convert from tiles to world coordinates (tiles are 32x32 world coordinates)
|
||||
const x = record.xTiles * 32;
|
||||
const y = record.yTiles * 32;
|
||||
|
||||
playerTimelines.get(record.playerId)!.push({
|
||||
name,
|
||||
x,
|
||||
y,
|
||||
sec: record.sec,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate consecutive identical positions for each player
|
||||
const deduplicatedTimelines = new Map<number, ParsedPlayerPos[]>();
|
||||
|
||||
for (const [playerId, timeline] of playerTimelines) {
|
||||
if (timeline.length === 0) { continue; }
|
||||
|
||||
const deduplicated: ParsedPlayerPos[] = [];
|
||||
let lastPos = timeline[0];
|
||||
deduplicated.push(lastPos);
|
||||
|
||||
for (let i = 1; i < timeline.length; i++) {
|
||||
const currentPos = timeline[i];
|
||||
|
||||
// Check if position changed (with small tolerance for floating point)
|
||||
const deltaX = Math.abs(currentPos.x - lastPos.x);
|
||||
const deltaY = Math.abs(currentPos.y - lastPos.y);
|
||||
|
||||
if (deltaX > 4 || deltaY > 4) {
|
||||
// Position changed significantly, keep this point
|
||||
deduplicated.push(currentPos);
|
||||
lastPos = currentPos;
|
||||
}
|
||||
// If position is the same, skip this point (deduplication)
|
||||
}
|
||||
|
||||
// Always keep the last position to ensure timeline ends correctly
|
||||
if (timeline.length > 1 && deduplicated[deduplicated.length - 1] !== timeline[timeline.length - 1]) {
|
||||
deduplicated.push(timeline[timeline.length - 1]);
|
||||
}
|
||||
|
||||
deduplicatedTimelines.set(playerId, deduplicated);
|
||||
}
|
||||
|
||||
return deduplicatedTimelines;
|
||||
}
|
||||
@@ -0,0 +1,871 @@
|
||||
import { BinaryData, sliceData, readUInt8, readUInt16LE, readUInt16BE, readUInt32BE, isNode } from "./parsing-utils";
|
||||
import * as zlib from "zlib";
|
||||
|
||||
// Cross-platform decompression
|
||||
async function inflateData(data: BinaryData): Promise<Buffer | Uint8Array> {
|
||||
if (isNode) {
|
||||
// Node.js environment - use promisified inflate
|
||||
const { promisify } = await import("util");
|
||||
const inflateAsync = promisify(zlib.inflate);
|
||||
return inflateAsync(data as Buffer);
|
||||
}
|
||||
// Browser environment - use promisified inflate
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const buffer = data instanceof Buffer ? data : Buffer.from(data);
|
||||
zlib.inflate(buffer, (err, result) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB565 to RGB888 values using Factorio's exact method
|
||||
* @param rgb565Value The RGB565 color value to convert
|
||||
* @returns Tuple of RGB values [r, g, b]
|
||||
*/
|
||||
export function rgb565ToRgb888(rgb565Value: number): [number, number, number] {
|
||||
// This matches ByteColor(uint16_t rgb5) constructor in Factorio source
|
||||
const r = ((rgb565Value >> 11) & 0x1F) << 3; // 5 bits -> 8 bits
|
||||
const g = ((rgb565Value >> 5) & 0x3F) << 2; // 6 bits -> 8 bits
|
||||
const b = (rgb565Value & 0x1F) << 3; // 5 bits -> 8 bits
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render chunk data to get RGB565 pixel array
|
||||
* @param chunkData The compressed chunk data to render
|
||||
* @returns RGB565 pixel array for 32x32 chunk
|
||||
*/
|
||||
export async function renderChunkToPixels(chunkData: BinaryData): Promise<Uint16Array> {
|
||||
try {
|
||||
const decompressed = await inflateData(chunkData);
|
||||
const pixels = new Uint16Array(32 * 32);
|
||||
|
||||
// Ensure we don't read beyond available data
|
||||
const maxPixels = Math.min(decompressed.length / 2, 32 * 32);
|
||||
|
||||
for (let i = 0; i < maxPixels; i++) {
|
||||
pixels[i] = readUInt16LE(decompressed, i * 2);
|
||||
}
|
||||
|
||||
// Fill remaining pixels with black (0) if we have less data than expected
|
||||
for (let i = maxPixels; i < 32 * 32; i++) {
|
||||
pixels[i] = 0;
|
||||
}
|
||||
|
||||
return pixels;
|
||||
} catch (decompressError) {
|
||||
// Log warning before falling back to black pixels
|
||||
console.warn(`Failed to decompress chunk data: ${decompressError}`);
|
||||
// Return black pixels on error
|
||||
return new Uint16Array(32 * 32);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a 32x32 chunk area from a 256x256 tile
|
||||
* @param tilePixels The full tile pixel data
|
||||
* @param chunkX Chunk X coordinate within tile
|
||||
* @param chunkY Chunk Y coordinate within tile
|
||||
* @returns RGB565 pixel array for 32x32 chunk
|
||||
*/
|
||||
export function extractChunkFromTile(tilePixels: Uint16Array, chunkX: number, chunkY: number): Uint16Array {
|
||||
const chunkPixels = new Uint16Array(32 * 32);
|
||||
|
||||
for (let y = 0; y < 32; y++) {
|
||||
for (let x = 0; x < 32; x++) {
|
||||
const tileX = chunkX * 32 + x;
|
||||
const tileY = chunkY * 32 + y;
|
||||
const tileIndex = tileY * 256 + tileX;
|
||||
const chunkIndex = y * 32 + x;
|
||||
chunkPixels[chunkIndex] = tilePixels[tileIndex];
|
||||
}
|
||||
}
|
||||
|
||||
return chunkPixels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render tile data to RGB565 pixel array (256x256)
|
||||
* Supports rendering up to a specific tick for timelapse functionality
|
||||
* @param tileData The binary tile data to render
|
||||
* @param maxTick Optional maximum tick to render up to
|
||||
* @returns RGB565 pixel array for 256x256 tile
|
||||
*/
|
||||
export async function renderTileToPixels(tileData: BinaryData, maxTick?: number): Promise<Uint16Array> {
|
||||
const currentPixels = new Uint16Array(256 * 256); // 256x256 tile
|
||||
|
||||
let offset = 0;
|
||||
while (offset < tileData.length) {
|
||||
if (offset + 1 > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const type = readUInt8(tileData, offset);
|
||||
offset += 1;
|
||||
|
||||
if (type === 1) { // Chunk
|
||||
const chunkResult = await processChunkData(
|
||||
tileData, offset, currentPixels, maxTick
|
||||
);
|
||||
if (!chunkResult.success) {
|
||||
break;
|
||||
}
|
||||
offset = chunkResult.newOffset;
|
||||
} else if (type === 2) { // Pixels
|
||||
const pixelResult = processPixelData(tileData, offset, currentPixels, maxTick);
|
||||
if (!pixelResult.success) {
|
||||
break;
|
||||
}
|
||||
offset = pixelResult.newOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return currentPixels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process chunk data during tile rendering
|
||||
* @param tileData The binary tile data being processed
|
||||
* @param offset Current offset in the tile data
|
||||
* @param currentPixels The pixel array to update
|
||||
* @param maxTick Optional maximum tick to render up to
|
||||
* @returns Processing result with success status and new offset
|
||||
*/
|
||||
async function processChunkData(
|
||||
tileData: BinaryData,
|
||||
offset: number,
|
||||
currentPixels: Uint16Array,
|
||||
maxTick?: number
|
||||
): Promise<{ success: boolean; newOffset: number }> {
|
||||
if (offset + 7 > tileData.length) {
|
||||
return { success: false, newOffset: offset };
|
||||
}
|
||||
|
||||
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
|
||||
offset += 4;
|
||||
|
||||
// Break early if this record is beyond our target tick (append-only optimization)
|
||||
if (maxTick !== undefined && tick > maxTick) {
|
||||
return { success: false, newOffset: offset - 4 };
|
||||
}
|
||||
|
||||
const chunkCoordsByte = readUInt8(tileData, offset);
|
||||
offset += 1;
|
||||
const length = readUInt16BE(tileData, offset);
|
||||
offset += 2;
|
||||
|
||||
if (offset + length > tileData.length) {
|
||||
return { success: false, newOffset: offset };
|
||||
}
|
||||
|
||||
const chunkX = chunkCoordsByte >> 4;
|
||||
const chunkY = chunkCoordsByte & 0x0F;
|
||||
const chunkData = sliceData(tileData, offset, offset + length);
|
||||
|
||||
try {
|
||||
const chunkPixels = await renderChunkToPixels(chunkData);
|
||||
|
||||
// Update current pixel state
|
||||
updateTilePixelsWithChunk(currentPixels, chunkPixels, chunkX, chunkY);
|
||||
} catch (decompressError) {
|
||||
// Continue processing other chunks on error
|
||||
}
|
||||
|
||||
return { success: true, newOffset: offset + length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pixel data during tile rendering
|
||||
* @param tileData The binary tile data being processed
|
||||
* @param offset Current offset in the tile data
|
||||
* @param currentPixels The pixel array to update
|
||||
* @param maxTick Optional maximum tick to render up to
|
||||
* @returns Processing result with success status and new offset
|
||||
*/
|
||||
function processPixelData(
|
||||
tileData: BinaryData,
|
||||
offset: number,
|
||||
currentPixels: Uint16Array,
|
||||
maxTick?: number
|
||||
): { success: boolean; newOffset: number } {
|
||||
if (offset + 10 > tileData.length) {
|
||||
return { success: false, newOffset: offset };
|
||||
}
|
||||
|
||||
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
|
||||
offset += 4;
|
||||
const pixelCount = readUInt16BE(tileData, offset);
|
||||
offset += 2;
|
||||
const newColor = readUInt16BE(tileData, offset);
|
||||
offset += 2;
|
||||
const oldColor = readUInt16BE(tileData, offset);
|
||||
offset += 2;
|
||||
|
||||
const expectedDataLength = pixelCount * 2; // x,y per pixel
|
||||
if (offset + expectedDataLength > tileData.length) {
|
||||
return { success: false, newOffset: offset };
|
||||
}
|
||||
|
||||
// Break early if this record is beyond our target tick (append-only optimization)
|
||||
if (maxTick !== undefined && tick > maxTick) {
|
||||
return { success: false, newOffset: offset - 4 };
|
||||
}
|
||||
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
const pixelOffset = offset + i * 2;
|
||||
const x = readUInt8(tileData, pixelOffset);
|
||||
const y = readUInt8(tileData, pixelOffset + 1);
|
||||
if (x < 256 && y < 256) {
|
||||
currentPixels[y * 256 + x] = newColor;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, newOffset: offset + expectedDataLength };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tile pixels with chunk data
|
||||
* @param currentPixels The tile pixel array to update
|
||||
* @param chunkPixels The chunk pixel data to apply
|
||||
* @param chunkX Chunk X coordinate within tile
|
||||
* @param chunkY Chunk Y coordinate within tile
|
||||
*/
|
||||
function updateTilePixelsWithChunk(
|
||||
currentPixels: Uint16Array,
|
||||
chunkPixels: Uint16Array,
|
||||
chunkX: number,
|
||||
chunkY: number
|
||||
): void {
|
||||
for (let y = 0; y < 32; y++) {
|
||||
for (let x = 0; x < 32; x++) {
|
||||
const chunkIndex = y * 32 + x;
|
||||
const tileX = chunkX * 32 + x;
|
||||
const tileY = chunkY * 32 + y;
|
||||
const tileIndex = tileY * 256 + tileX;
|
||||
currentPixels[tileIndex] = chunkPixels[chunkIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB565 pixel array to RGBA pixel data for canvas rendering
|
||||
* Returns Uint8ClampedArray that can be used to create ImageData in browser
|
||||
* @param pixels The RGB565 pixel array to convert
|
||||
* @returns RGBA pixel data as Uint8ClampedArray
|
||||
*/
|
||||
export function pixelsToRGBA(pixels: Uint16Array): Uint8ClampedArray {
|
||||
const imageData = new Uint8ClampedArray(256 * 256 * 4);
|
||||
|
||||
for (let i = 0; i < 256 * 256; i++) {
|
||||
const rgb565Value = pixels[i];
|
||||
const [r, g, b] = rgb565ToRgb888(rgb565Value);
|
||||
const bufferIndex = i * 4;
|
||||
|
||||
imageData[bufferIndex] = r;
|
||||
imageData[bufferIndex + 1] = g;
|
||||
imageData[bufferIndex + 2] = b;
|
||||
imageData[bufferIndex + 3] = 255; // Alpha
|
||||
}
|
||||
|
||||
return imageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB565 pixel array to ImageData for canvas rendering (browser only)
|
||||
* @param pixels The RGB565 pixel array to convert
|
||||
* @returns ImageData object for canvas rendering
|
||||
*/
|
||||
export function pixelsToImageData(pixels: Uint16Array): any {
|
||||
if (typeof (globalThis as any).ImageData === "undefined") {
|
||||
throw new Error("ImageData is not available in this environment. Use pixelsToRGBA() instead.");
|
||||
}
|
||||
const rgbaData = pixelsToRGBA(pixels);
|
||||
return new (globalThis as any).ImageData(rgbaData, 256, 256);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tile data and extract all available ticks for timelapse functionality
|
||||
* @param tileData The binary tile data to parse
|
||||
* @returns Array of available ticks sorted in ascending order
|
||||
*/
|
||||
export function extractAvailableTicks(tileData: BinaryData): number[] {
|
||||
const ticks = new Set<number>();
|
||||
|
||||
let offset = 0;
|
||||
while (offset < tileData.length) {
|
||||
if (offset + 1 > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const type = readUInt8(tileData, offset);
|
||||
offset += 1;
|
||||
|
||||
if (type === 1) { // Chunk
|
||||
if (offset + 7 > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
|
||||
ticks.add(tick);
|
||||
offset += 4;
|
||||
|
||||
const chunkCoordsByte = readUInt8(tileData, offset);
|
||||
offset += 1;
|
||||
const length = readUInt16BE(tileData, offset);
|
||||
offset += 2;
|
||||
if (offset + length > tileData.length) {
|
||||
break;
|
||||
}
|
||||
offset += length; // Skip chunk data
|
||||
} else if (type === 2) { // Pixels
|
||||
if (offset + 10 > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
|
||||
ticks.add(tick);
|
||||
offset += 4;
|
||||
|
||||
const pixelCount = readUInt16BE(tileData, offset);
|
||||
offset += 2 + 2 + 2; // skip newColor oldColor too
|
||||
const expectedDataLength = pixelCount * 2;
|
||||
if (offset + expectedDataLength > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
offset += expectedDataLength;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ticks).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for tracking pixel changes in timelapse data
|
||||
*/
|
||||
export interface PixelChange {
|
||||
x: number;
|
||||
y: number;
|
||||
newColor: number;
|
||||
oldColor: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for chunk records in timelapse data
|
||||
*/
|
||||
export interface ChunkRecord {
|
||||
tick: number;
|
||||
chunkX: number;
|
||||
chunkY: number;
|
||||
data: BinaryData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for pixel change records in timelapse data
|
||||
*/
|
||||
export interface PixelChangeRecord {
|
||||
tick: number;
|
||||
changes: PixelChange[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for parsed tile data with all change records
|
||||
*/
|
||||
export interface ParsedTileData {
|
||||
chunks: ChunkRecord[];
|
||||
pixelChanges: PixelChangeRecord[];
|
||||
allTicks: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tile data into structured change records for efficient timelapse navigation
|
||||
* @param tileData The binary tile data to parse
|
||||
* @returns Parsed tile data with chunks, pixel changes, and all ticks
|
||||
*/
|
||||
export function parseTileData(tileData: BinaryData): ParsedTileData {
|
||||
const chunks: ChunkRecord[] = [];
|
||||
const pixelChanges: PixelChangeRecord[] = [];
|
||||
const ticks = new Set<number>();
|
||||
|
||||
let offset = 0;
|
||||
while (offset < tileData.length) {
|
||||
if (offset + 1 > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const type = readUInt8(tileData, offset);
|
||||
offset += 1;
|
||||
|
||||
if (type === 1) { // Chunk
|
||||
const result = parseChunkRecord(tileData, offset, chunks, ticks);
|
||||
if (!result.success) {
|
||||
break;
|
||||
}
|
||||
offset = result.newOffset;
|
||||
} else if (type === 2) { // Pixels
|
||||
const result = parsePixelChangeRecord(tileData, offset, pixelChanges, ticks);
|
||||
if (!result.success) {
|
||||
break;
|
||||
}
|
||||
offset = result.newOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chunks,
|
||||
pixelChanges,
|
||||
allTicks: Array.from(ticks).sort((a, b) => a - b),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ParsedChunkDataRecord {
|
||||
tick: number;
|
||||
chunkX: number;
|
||||
chunkY: number;
|
||||
data: BinaryData;
|
||||
}
|
||||
|
||||
export interface ParsedPixelDataRecord {
|
||||
tick: number;
|
||||
pixelCount: number;
|
||||
newColor: number;
|
||||
oldColor: number;
|
||||
pixelDataOffset: number;
|
||||
}
|
||||
|
||||
export type ParseRecordResult<T> =
|
||||
| { success: false; newOffset: number }
|
||||
| { success: true; newOffset: number; record: T };
|
||||
|
||||
export function parseChunkDataRecord(
|
||||
tileData: BinaryData,
|
||||
offset: number
|
||||
): ParseRecordResult<ParsedChunkDataRecord> {
|
||||
if (offset + 7 > tileData.length) {
|
||||
return { success: false, newOffset: offset };
|
||||
}
|
||||
|
||||
const tick = readUInt32BE(tileData, offset);
|
||||
offset += 4;
|
||||
const chunkCoordsByte = readUInt8(tileData, offset);
|
||||
offset += 1;
|
||||
const length = readUInt16BE(tileData, offset);
|
||||
offset += 2;
|
||||
|
||||
if (offset + length > tileData.length) {
|
||||
return { success: false, newOffset: offset };
|
||||
}
|
||||
|
||||
const chunkX = chunkCoordsByte >> 4;
|
||||
const chunkY = chunkCoordsByte & 0x0F;
|
||||
const data = sliceData(tileData, offset, offset + length);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newOffset: offset + length,
|
||||
record: { tick, chunkX, chunkY, data },
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePixelDataRecord(
|
||||
tileData: BinaryData,
|
||||
offset: number
|
||||
): ParseRecordResult<ParsedPixelDataRecord> {
|
||||
if (offset + 10 > tileData.length) {
|
||||
return { success: false, newOffset: offset };
|
||||
}
|
||||
|
||||
const tick = readUInt32BE(tileData, offset);
|
||||
offset += 4;
|
||||
const pixelCount = readUInt16BE(tileData, offset);
|
||||
offset += 2;
|
||||
const newColor = readUInt16BE(tileData, offset);
|
||||
offset += 2;
|
||||
const oldColor = readUInt16BE(tileData, offset);
|
||||
offset += 2;
|
||||
|
||||
const pixelDataLength = pixelCount * 2;
|
||||
if (offset + pixelDataLength > tileData.length) {
|
||||
return { success: false, newOffset: offset };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newOffset: offset + pixelDataLength,
|
||||
record: {
|
||||
tick,
|
||||
pixelCount,
|
||||
newColor,
|
||||
oldColor,
|
||||
pixelDataOffset: offset,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse chunk record from tile data
|
||||
* @param tileData The binary tile data being processed
|
||||
* @param offset Current offset in the tile data
|
||||
* @param chunks Array to add the parsed chunk to
|
||||
* @param ticks Set to add the chunk tick to
|
||||
* @returns Processing result with success status and new offset
|
||||
*/
|
||||
function parseChunkRecord(
|
||||
tileData: BinaryData,
|
||||
offset: number,
|
||||
chunks: ChunkRecord[],
|
||||
ticks: Set<number>
|
||||
): { success: boolean; newOffset: number } {
|
||||
const parsed = parseChunkDataRecord(tileData, offset);
|
||||
if (!parsed.success) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const tick = parsed.record.tick * 60; // Convert back to actual tick
|
||||
ticks.add(tick);
|
||||
|
||||
chunks.push({
|
||||
tick,
|
||||
chunkX: parsed.record.chunkX,
|
||||
chunkY: parsed.record.chunkY,
|
||||
data: parsed.record.data,
|
||||
});
|
||||
|
||||
return { success: true, newOffset: parsed.newOffset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse pixel change record from tile data
|
||||
* @param tileData The binary tile data being processed
|
||||
* @param offset Current offset in the tile data
|
||||
* @param pixelChanges Array to add the parsed pixel changes to
|
||||
* @param ticks Set to add the pixel change tick to
|
||||
* @returns Processing result with success status and new offset
|
||||
*/
|
||||
function parsePixelChangeRecord(
|
||||
tileData: BinaryData,
|
||||
offset: number,
|
||||
pixelChanges: PixelChangeRecord[],
|
||||
ticks: Set<number>
|
||||
): { success: boolean; newOffset: number } {
|
||||
const parsed = parsePixelDataRecord(tileData, offset);
|
||||
if (!parsed.success) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const tick = parsed.record.tick * 60; // Convert back to actual tick
|
||||
ticks.add(tick);
|
||||
|
||||
const changes: PixelChange[] = [];
|
||||
for (let i = 0; i < parsed.record.pixelCount; i++) {
|
||||
const pixelOffset = parsed.record.pixelDataOffset + i * 2;
|
||||
const x = readUInt8(tileData, pixelOffset);
|
||||
const y = readUInt8(tileData, pixelOffset + 1);
|
||||
changes.push({ x, y, newColor: parsed.record.newColor, oldColor: parsed.record.oldColor });
|
||||
}
|
||||
|
||||
pixelChanges.push({ tick, changes });
|
||||
return { success: true, newOffset: parsed.newOffset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply chunk data to RGB565 pixel array
|
||||
* @param pixels The tile pixel array to update
|
||||
* @param chunkRecord The chunk record to apply
|
||||
*/
|
||||
export async function applyChunkToPixels(pixels: Uint16Array, chunkRecord: ChunkRecord): Promise<void> {
|
||||
try {
|
||||
const chunkPixels = await renderChunkToPixels(chunkRecord.data);
|
||||
updateTilePixelsWithChunk(pixels, chunkPixels, chunkRecord.chunkX, chunkRecord.chunkY);
|
||||
} catch (err) {
|
||||
// Error applying chunk - continue without this chunk
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear chunk area to black (for rewinding past chunk creation)
|
||||
* @param pixels The tile pixel array to update
|
||||
* @param chunkRecord The chunk record to clear
|
||||
*/
|
||||
export function clearChunkInPixels(pixels: Uint16Array, chunkRecord: ChunkRecord): void {
|
||||
for (let y = 0; y < 32; y++) {
|
||||
for (let x = 0; x < 32; x++) {
|
||||
const tileX = chunkRecord.chunkX * 32 + x;
|
||||
const tileY = chunkRecord.chunkY * 32 + y;
|
||||
const tileIndex = tileY * 256 + tileX;
|
||||
pixels[tileIndex] = 0; // Black
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply pixel changes to RGB565 pixel array (forward direction)
|
||||
* @param pixels The tile pixel array to update
|
||||
* @param changes Array of pixel changes to apply
|
||||
*/
|
||||
export function applyPixelChanges(pixels: Uint16Array, changes: PixelChange[]): void {
|
||||
for (const change of changes) {
|
||||
const tileIndex = change.y * 256 + change.x;
|
||||
pixels[tileIndex] = change.newColor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert pixel changes from RGB565 pixel array (backward direction)
|
||||
* @param pixels The tile pixel array to update
|
||||
* @param changes Array of pixel changes to revert
|
||||
*/
|
||||
export function revertPixelChanges(pixels: Uint16Array, changes: PixelChange[]): void {
|
||||
for (const change of changes) {
|
||||
const tileIndex = change.y * 256 + change.x;
|
||||
pixels[tileIndex] = change.oldColor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render tile data incrementally from one tick to another
|
||||
* Much more efficient than re-rendering from scratch
|
||||
* @param parsedData The parsed tile data
|
||||
* @param currentPixels The current pixel state
|
||||
* @param fromTick The starting tick
|
||||
* @param toTick The target tick
|
||||
*/
|
||||
export async function renderTileIncremental(
|
||||
parsedData: ParsedTileData,
|
||||
currentPixels: Uint16Array,
|
||||
fromTick: number,
|
||||
toTick: number
|
||||
): Promise<void> {
|
||||
const { chunks, pixelChanges } = parsedData;
|
||||
|
||||
if (fromTick === toTick) {
|
||||
return; // No changes needed
|
||||
}
|
||||
|
||||
const forward = toTick > fromTick;
|
||||
|
||||
if (forward) {
|
||||
await applyChangesForward(chunks, pixelChanges, currentPixels, fromTick, toTick);
|
||||
} else {
|
||||
await applyChangesBackward(chunks, pixelChanges, currentPixels, fromTick, toTick);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply changes in forward direction
|
||||
* @param chunks Array of chunk records to process
|
||||
* @param pixelChanges Array of pixel change records to process
|
||||
* @param currentPixels The pixel array to update
|
||||
* @param fromTick The starting tick
|
||||
* @param toTick The target tick
|
||||
*/
|
||||
async function applyChangesForward(
|
||||
chunks: ChunkRecord[],
|
||||
pixelChanges: PixelChangeRecord[],
|
||||
currentPixels: Uint16Array,
|
||||
fromTick: number,
|
||||
toTick: number
|
||||
): Promise<void> {
|
||||
// Apply chunks that occur in this range
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.tick > fromTick && chunk.tick <= toTick) {
|
||||
await applyChunkToPixels(currentPixels, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply pixel changes that occur in this range
|
||||
for (const record of pixelChanges) {
|
||||
if (record.tick > fromTick && record.tick <= toTick) {
|
||||
applyPixelChanges(currentPixels, record.changes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply changes in backward direction
|
||||
* @param chunks Array of chunk records to process
|
||||
* @param pixelChanges Array of pixel change records to process
|
||||
* @param currentPixels The pixel array to update
|
||||
* @param fromTick The starting tick
|
||||
* @param toTick The target tick
|
||||
*/
|
||||
async function applyChangesBackward(
|
||||
chunks: ChunkRecord[],
|
||||
pixelChanges: PixelChangeRecord[],
|
||||
currentPixels: Uint16Array,
|
||||
fromTick: number,
|
||||
toTick: number
|
||||
): Promise<void> {
|
||||
// Revert pixel changes in reverse order
|
||||
for (let i = pixelChanges.length - 1; i >= 0; i--) {
|
||||
const record = pixelChanges[i];
|
||||
if (record.tick <= fromTick && record.tick > toTick) {
|
||||
revertPixelChanges(currentPixels, record.changes);
|
||||
}
|
||||
}
|
||||
|
||||
// Revert chunks (clear to black if we're going before their creation time)
|
||||
for (let i = chunks.length - 1; i >= 0; i--) {
|
||||
const chunk = chunks[i];
|
||||
if (chunk.tick <= fromTick && chunk.tick > toTick) {
|
||||
clearChunkInPixels(currentPixels, chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for parsed recipe tile data
|
||||
*/
|
||||
export interface ParsedRecipeTile {
|
||||
ticks: number[]; // ticks present in this tile
|
||||
activeRecipes: Map<string, string>; // posKey -> recipeName
|
||||
dictionary: Map<number, string>; // id -> recipeName
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse binary recipe tile data and return timeline ticks and active recipes at target tick
|
||||
* @param tileX Tile X coordinate for world position calculation
|
||||
* @param tileY Tile Y coordinate for world position calculation
|
||||
* @param buf Binary recipe tile data
|
||||
* @param targetTick Target tick for filtering (null = latest state)
|
||||
* @returns Parsed recipe data with ticks and active recipes
|
||||
*/
|
||||
export function parseRecipeTileBinary(
|
||||
tileX: number,
|
||||
tileY: number,
|
||||
buf: BinaryData,
|
||||
targetTick: number | null
|
||||
): ParsedRecipeTile {
|
||||
const ticks: number[] = [];
|
||||
const dict = new Map<number, string>();
|
||||
const active = new Map<string, string>();
|
||||
const targetSec = targetTick !== null ? Math.floor(targetTick / 60) : Infinity;
|
||||
|
||||
let offset = 0;
|
||||
while (offset < buf.length) {
|
||||
const type = readUInt8(buf, offset);
|
||||
offset += 1;
|
||||
|
||||
if (type === 0) { // Dictionary entry
|
||||
const result = parseDictionaryEntry(buf, offset, dict);
|
||||
offset = result.newOffset;
|
||||
} else if (type === 1 || type === 2) { // Set recipe (1) or Clear recipe (2)
|
||||
const result = parseRecipeEntry(
|
||||
buf, offset, type, tileX, tileY, ticks, active, targetSec, dict
|
||||
);
|
||||
offset = result.newOffset;
|
||||
} else {
|
||||
// Unknown recipe record type - stop parsing
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { ticks, activeRecipes: active, dictionary: dict };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse dictionary entry from recipe tile
|
||||
* @param buf The binary recipe tile data
|
||||
* @param offset Current offset in the data
|
||||
* @param dict Dictionary map to add the entry to
|
||||
* @returns Processing result with new offset
|
||||
*/
|
||||
function parseDictionaryEntry(
|
||||
buf: BinaryData,
|
||||
offset: number,
|
||||
dict: Map<number, string>
|
||||
): { newOffset: number } {
|
||||
const idx = readUInt16BE(buf, offset);
|
||||
offset += 2;
|
||||
const len = readUInt8(buf, offset);
|
||||
offset += 1;
|
||||
const nameBytes = sliceData(buf, offset, offset + len);
|
||||
offset += len;
|
||||
|
||||
// Convert to string using cross-platform method
|
||||
let recipeName: string;
|
||||
if (isNode && nameBytes instanceof Buffer) {
|
||||
recipeName = nameBytes.toString("utf-8");
|
||||
} else {
|
||||
recipeName = new TextDecoder().decode(nameBytes as Uint8Array);
|
||||
}
|
||||
|
||||
dict.set(idx, recipeName);
|
||||
return { newOffset: offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse recipe entry from recipe tile
|
||||
* @param buf The binary recipe tile data
|
||||
* @param offset Current offset in the data
|
||||
* @param type Entry type (1 = set recipe, 2 = clear recipe)
|
||||
* @param tileX Tile X coordinate for world position calculation
|
||||
* @param tileY Tile Y coordinate for world position calculation
|
||||
* @param ticks Array to add the entry tick to
|
||||
* @param active Map of active recipes to update
|
||||
* @param targetSec Target second for filtering
|
||||
* @param dict Dictionary map for recipe name lookup
|
||||
* @returns Processing result with new offset
|
||||
*/
|
||||
function parseRecipeEntry(
|
||||
buf: BinaryData,
|
||||
offset: number,
|
||||
type: number,
|
||||
tileX: number,
|
||||
tileY: number,
|
||||
ticks: number[],
|
||||
active: Map<string, string>,
|
||||
targetSec: number,
|
||||
dict: Map<number, string>
|
||||
): { newOffset: number } {
|
||||
const sec = readUInt32BE(buf, offset);
|
||||
offset += 4;
|
||||
const px = readUInt8(buf, offset);
|
||||
offset += 1;
|
||||
const py = readUInt8(buf, offset);
|
||||
offset += 1;
|
||||
|
||||
let idx: number | undefined;
|
||||
if (type === 1) {
|
||||
idx = readUInt16BE(buf, offset);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
const tickVal = sec * 60;
|
||||
ticks.push(tickVal);
|
||||
|
||||
if (sec > targetSec) {
|
||||
// Future to target time – skip without applying
|
||||
return { newOffset: offset };
|
||||
}
|
||||
|
||||
const worldX = tileX * 256 + px;
|
||||
const worldY = tileY * 256 + py;
|
||||
const posKey = `${worldX},${worldY}`;
|
||||
|
||||
if (type === 1 && idx !== undefined) {
|
||||
const name = dict.get(idx);
|
||||
if (name) {
|
||||
active.set(posKey, name);
|
||||
}
|
||||
} else if (type === 2) {
|
||||
active.delete(posKey);
|
||||
}
|
||||
|
||||
return { newOffset: offset };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,199 @@
|
||||
import type { Control } from "@clusterio/web_ui";
|
||||
import {
|
||||
GetChartTagsRequest,
|
||||
GetPlayerPathRequest,
|
||||
GetRawRecipeTileRequest,
|
||||
GetRawTileRequest,
|
||||
type TileDataEvent,
|
||||
type ChartTagDataEvent,
|
||||
type RecipeDataEvent,
|
||||
type PlayerPositionEvent,
|
||||
} from "../../messages";
|
||||
import type {
|
||||
ChartTagDataWithInstance,
|
||||
MinimapActiveView,
|
||||
MinimapDataSource,
|
||||
MinimapViewBounds,
|
||||
} from "../minimap-data-source";
|
||||
|
||||
interface MinimapWebPlugin {
|
||||
// Dual filter API: plural version accepts array (flexible), singular version is a convenience wrapper.
|
||||
// Both exist for backward compatibility with different plugin implementations (check plural first, then singular).
|
||||
setInstanceSurfaceFilters?(filters: Array<{ instanceId: number; surface: string }> | null): Promise<void> | void;
|
||||
setInstanceSurfaceFilter?(instanceId: number | null, surface: string | null): Promise<void> | void;
|
||||
onTileUpdate(callback: (event: TileDataEvent) => void): void;
|
||||
offTileUpdate(callback: (event: TileDataEvent) => void): void;
|
||||
onChartTagUpdate(callback: (event: ChartTagDataEvent) => void): void;
|
||||
offChartTagUpdate(callback: (event: ChartTagDataEvent) => void): void;
|
||||
onRecipeUpdate(callback: (event: RecipeDataEvent) => void): void;
|
||||
offRecipeUpdate(callback: (event: RecipeDataEvent) => void): void;
|
||||
onPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void): void;
|
||||
offPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void): void;
|
||||
}
|
||||
|
||||
const decodeBase64ToBytes = (base64: string): Uint8Array => {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export class SingleInstanceDataSource implements MinimapDataSource {
|
||||
private control: Control;
|
||||
private plugin: MinimapWebPlugin | null = null;
|
||||
private instanceId: number | null = null;
|
||||
private surface = "nauvis";
|
||||
private force = "player";
|
||||
private activeInstances: number[] = [];
|
||||
|
||||
/**
|
||||
* Data source for the canvas renderer that reads minimap data for a single selected instance.
|
||||
*
|
||||
* This bridges selection state to the minimap web plugin and keeps subscription filters in sync.
|
||||
*/
|
||||
constructor(control: Control) {
|
||||
this.control = control;
|
||||
this.plugin = control.plugins.get("minimap") as MinimapWebPlugin | undefined ?? null;
|
||||
}
|
||||
|
||||
setInstance(instanceId: number | null): void {
|
||||
this.instanceId = instanceId;
|
||||
this.updateFilters();
|
||||
}
|
||||
|
||||
setSurfaceForce(surface: string, force: string): void {
|
||||
this.surface = surface;
|
||||
this.force = force;
|
||||
this.updateFilters();
|
||||
}
|
||||
|
||||
setActiveView(_bounds: MinimapViewBounds): MinimapActiveView {
|
||||
const nextActive = this.instanceId !== null ? [this.instanceId] : [];
|
||||
const changed = nextActive.length !== this.activeInstances.length
|
||||
|| nextActive.some((id, index) => id !== this.activeInstances[index]);
|
||||
if (changed) {
|
||||
this.activeInstances = nextActive;
|
||||
}
|
||||
return { changed, activeInstanceIds: [...this.activeInstances] };
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.instanceId !== null;
|
||||
}
|
||||
|
||||
async getTileData(tileX: number, tileY: number, tick?: number | null): Promise<Uint8Array | null> {
|
||||
if (this.instanceId === null) {
|
||||
return null;
|
||||
}
|
||||
const response = await this.control.send(new GetRawTileRequest(
|
||||
this.instanceId,
|
||||
this.surface,
|
||||
this.force,
|
||||
tileX,
|
||||
tileY,
|
||||
tick ?? undefined,
|
||||
));
|
||||
if (!response.tile_data) {
|
||||
return null;
|
||||
}
|
||||
return decodeBase64ToBytes(response.tile_data);
|
||||
}
|
||||
|
||||
async getRecipeTileData(tileX: number, tileY: number, tick?: number | null): Promise<Uint8Array | null> {
|
||||
if (this.instanceId === null) {
|
||||
return null;
|
||||
}
|
||||
const response = await this.control.send(new GetRawRecipeTileRequest(
|
||||
this.instanceId,
|
||||
this.surface,
|
||||
this.force,
|
||||
tileX,
|
||||
tileY,
|
||||
tick ?? undefined,
|
||||
));
|
||||
if (!response.recipe_tile) {
|
||||
return null;
|
||||
}
|
||||
return decodeBase64ToBytes(response.recipe_tile);
|
||||
}
|
||||
|
||||
async getChartTags(): Promise<ChartTagDataWithInstance[]> {
|
||||
if (this.instanceId === null) {
|
||||
return [];
|
||||
}
|
||||
const response = await this.control.send(new GetChartTagsRequest(
|
||||
this.instanceId,
|
||||
this.surface,
|
||||
this.force,
|
||||
));
|
||||
return response.chart_tags.map(tag => ({
|
||||
...tag,
|
||||
instance_id: this.instanceId as number,
|
||||
}));
|
||||
}
|
||||
|
||||
async getPlayerPaths(): Promise<Array<{ instanceId: number; data: Uint8Array }>> {
|
||||
if (this.instanceId === null) {
|
||||
return [];
|
||||
}
|
||||
const response = await this.control.send(new GetPlayerPathRequest(
|
||||
this.instanceId,
|
||||
this.surface,
|
||||
));
|
||||
if (!response.positions) {
|
||||
return [];
|
||||
}
|
||||
return [{ instanceId: this.instanceId, data: decodeBase64ToBytes(response.positions) }];
|
||||
}
|
||||
|
||||
onTileUpdate(callback: (event: TileDataEvent) => void): () => void {
|
||||
this.plugin?.onTileUpdate(callback);
|
||||
return () => {
|
||||
this.plugin?.offTileUpdate(callback);
|
||||
};
|
||||
}
|
||||
|
||||
onChartTagUpdate(callback: (event: ChartTagDataEvent) => void): () => void {
|
||||
this.plugin?.onChartTagUpdate(callback);
|
||||
return () => {
|
||||
this.plugin?.offChartTagUpdate(callback);
|
||||
};
|
||||
}
|
||||
|
||||
onRecipeUpdate(callback: (event: RecipeDataEvent) => void): () => void {
|
||||
this.plugin?.onRecipeUpdate(callback);
|
||||
return () => {
|
||||
this.plugin?.offRecipeUpdate(callback);
|
||||
};
|
||||
}
|
||||
|
||||
onPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void): () => void {
|
||||
this.plugin?.onPlayerPositionUpdate(callback);
|
||||
return () => {
|
||||
this.plugin?.offPlayerPositionUpdate(callback);
|
||||
};
|
||||
}
|
||||
|
||||
private updateFilters() {
|
||||
if (!this.plugin) {
|
||||
return;
|
||||
}
|
||||
if (this.instanceId === null) {
|
||||
if (this.plugin.setInstanceSurfaceFilters) {
|
||||
this.plugin.setInstanceSurfaceFilters(null);
|
||||
} else if (this.plugin.setInstanceSurfaceFilter) {
|
||||
this.plugin.setInstanceSurfaceFilter(null, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const filters = [{ instanceId: this.instanceId, surface: this.surface }];
|
||||
// Support both the newer multi-filter API and the legacy single-filter API.
|
||||
if (this.plugin.setInstanceSurfaceFilters) {
|
||||
this.plugin.setInstanceSurfaceFilters(filters);
|
||||
} else if (this.plugin.setInstanceSurfaceFilter) {
|
||||
this.plugin.setInstanceSurfaceFilter(this.instanceId, this.surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import React from "react";
|
||||
import { BaseWebPlugin } from "@clusterio/web_ui";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import CanvasMinimapPage from "./CanvasMinimapPage";
|
||||
export { default as CanvasMinimapPage } from "./CanvasMinimapPage";
|
||||
export { SingleInstanceDataSource } from "./dataSources/SingleInstanceDataSource";
|
||||
export type {
|
||||
ChartTagDataWithInstance,
|
||||
MinimapActiveView,
|
||||
MinimapDataSource,
|
||||
MinimapViewBounds,
|
||||
} from "./minimap-data-source";
|
||||
export {
|
||||
GetRawTileRequest,
|
||||
GetRawRecipeTileRequest,
|
||||
GetChartTagsRequest,
|
||||
GetPlayerPathRequest,
|
||||
} from "../messages";
|
||||
import { TileDataEvent, ChartTagDataEvent, RecipeDataEvent, PlayerPositionEvent } from "../messages";
|
||||
|
||||
export class WebPlugin extends BaseWebPlugin {
|
||||
// Callback arrays for minimap updates
|
||||
tileUpdateCallbacks: Array<(event: TileDataEvent) => void> = [];
|
||||
chartTagUpdateCallbacks: Array<(event: ChartTagDataEvent) => void> = [];
|
||||
recipeUpdateCallbacks: Array<(event: RecipeDataEvent) => void> = [];
|
||||
playerPositionUpdateCallbacks: Array<(event: PlayerPositionEvent) => void> = [];
|
||||
|
||||
// Track subscription state
|
||||
private subscribed = false;
|
||||
private currentFilters: string[] | undefined = undefined;
|
||||
|
||||
async init() {
|
||||
this.pages = [
|
||||
{
|
||||
path: "/minimap",
|
||||
sidebarName: "Minimap",
|
||||
permission: "minimap.view",
|
||||
content: <CanvasMinimapPage />,
|
||||
},
|
||||
];
|
||||
|
||||
// Handle minimap events from the controller
|
||||
this.control.handle(TileDataEvent, this.handleTileDataEvent.bind(this));
|
||||
this.control.handle(ChartTagDataEvent, this.handleChartTagDataEvent.bind(this));
|
||||
this.control.handle(RecipeDataEvent, this.handleRecipeDataEvent.bind(this));
|
||||
this.control.handle(PlayerPositionEvent, this.handlePlayerPositionEvent.bind(this));
|
||||
}
|
||||
|
||||
onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
|
||||
if (event === "connect" || event === "resume") {
|
||||
this.updateSubscriptions();
|
||||
}
|
||||
}
|
||||
|
||||
// Update instance/surface filters used for subscriptions.
|
||||
async setInstanceSurfaceFilters(filters: Array<{ instanceId: number; surface: string }> | null) {
|
||||
const newFilters = filters && filters.length > 0
|
||||
? filters.map(filter => `${filter.instanceId}:${filter.surface}`)
|
||||
: undefined;
|
||||
|
||||
// No change
|
||||
const prev = this.currentFilters;
|
||||
const same = (
|
||||
(prev === undefined && newFilters === undefined)
|
||||
|| (Array.isArray(prev) && Array.isArray(newFilters)
|
||||
&& prev.length === newFilters.length
|
||||
&& prev.every((v, i) => v === newFilters[i]))
|
||||
);
|
||||
if (same) { return; }
|
||||
|
||||
// If we had previous filters and callbacks active, remove those filters first
|
||||
if (this.hasActiveCallbacks() && prev && prev.length > 0) {
|
||||
try {
|
||||
await this.control.send(new lib.SubscriptionRequest("minimap:TileDataEvent", false, 0, prev));
|
||||
await this.control.send(new lib.SubscriptionRequest("minimap:ChartTagDataEvent", false, 0, prev));
|
||||
await this.control.send(new lib.SubscriptionRequest("minimap:RecipeDataEvent", false, 0, prev));
|
||||
await this.control.send(new lib.SubscriptionRequest("minimap:PlayerPositionEvent", false, 0, prev));
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to remove previous minimap filters: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.currentFilters = newFilters;
|
||||
|
||||
// Apply new filters if we have active callbacks
|
||||
if (this.hasActiveCallbacks()) {
|
||||
await this.updateSubscriptions();
|
||||
}
|
||||
}
|
||||
|
||||
// Update instance/surface filter used for subscriptions.
|
||||
async setInstanceSurfaceFilter(instanceId: number | null, surface: string | null) {
|
||||
await this.setInstanceSurfaceFilters(
|
||||
instanceId !== null && surface
|
||||
? [{ instanceId, surface }]
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
private async updateSubscriptions() {
|
||||
if (!this.control.connector.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSubscribe = this.hasActiveCallbacks();
|
||||
|
||||
// Only subscribe if we both have active callbacks and a specific filter set
|
||||
if (shouldSubscribe && this.currentFilters && this.currentFilters.length > 0) {
|
||||
// Always (re)send with current filters when we have active callbacks
|
||||
const filters = this.currentFilters;
|
||||
try {
|
||||
await this.control.send(new lib.SubscriptionRequest("minimap:TileDataEvent", true, 0, filters));
|
||||
await this.control.send(new lib.SubscriptionRequest("minimap:ChartTagDataEvent", true, 0, filters));
|
||||
await this.control.send(new lib.SubscriptionRequest("minimap:RecipeDataEvent", true, 0, filters));
|
||||
await this.control.send(new lib.SubscriptionRequest("minimap:PlayerPositionEvent", true, 0, filters));
|
||||
this.subscribed = true;
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to subscribe to minimap events: ${err}`);
|
||||
}
|
||||
} else if ((!shouldSubscribe || !this.currentFilters || this.currentFilters.length === 0) && this.subscribed) {
|
||||
// Unsubscribe from all minimap events
|
||||
try {
|
||||
await this.control.send(new lib.SubscriptionRequest("minimap:TileDataEvent", false));
|
||||
await this.control.send(new lib.SubscriptionRequest("minimap:ChartTagDataEvent", false));
|
||||
await this.control.send(new lib.SubscriptionRequest("minimap:RecipeDataEvent", false));
|
||||
await this.control.send(new lib.SubscriptionRequest("minimap:PlayerPositionEvent", false));
|
||||
this.subscribed = false;
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to unsubscribe from minimap events: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private hasActiveCallbacks(): boolean {
|
||||
return this.tileUpdateCallbacks.length > 0
|
||||
|| this.chartTagUpdateCallbacks.length > 0
|
||||
|| this.recipeUpdateCallbacks.length > 0
|
||||
|| this.playerPositionUpdateCallbacks.length > 0;
|
||||
}
|
||||
|
||||
async handleTileDataEvent(event: TileDataEvent) {
|
||||
// Notify all registered callbacks
|
||||
for (let callback of this.tileUpdateCallbacks) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
|
||||
async handleChartTagDataEvent(event: ChartTagDataEvent) {
|
||||
for (let callback of this.chartTagUpdateCallbacks) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
|
||||
async handleRecipeDataEvent(event: RecipeDataEvent) {
|
||||
for (let callback of this.recipeUpdateCallbacks) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
|
||||
async handlePlayerPositionEvent(event: PlayerPositionEvent) {
|
||||
for (let callback of this.playerPositionUpdateCallbacks) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
|
||||
onTileUpdate(callback: (event: TileDataEvent) => void) {
|
||||
this.tileUpdateCallbacks.push(callback);
|
||||
this.updateSubscriptions();
|
||||
}
|
||||
|
||||
offTileUpdate(callback: (event: TileDataEvent) => void) {
|
||||
let index = this.tileUpdateCallbacks.lastIndexOf(callback);
|
||||
if (index === -1) {
|
||||
throw new Error("callback is not registered");
|
||||
}
|
||||
this.tileUpdateCallbacks.splice(index, 1);
|
||||
this.updateSubscriptions();
|
||||
}
|
||||
|
||||
onChartTagUpdate(callback: (event: ChartTagDataEvent) => void) {
|
||||
this.chartTagUpdateCallbacks.push(callback);
|
||||
this.updateSubscriptions();
|
||||
}
|
||||
|
||||
offChartTagUpdate(callback: (event: ChartTagDataEvent) => void) {
|
||||
let index = this.chartTagUpdateCallbacks.lastIndexOf(callback);
|
||||
if (index === -1) {
|
||||
throw new Error("callback is not registered");
|
||||
}
|
||||
this.chartTagUpdateCallbacks.splice(index, 1);
|
||||
this.updateSubscriptions();
|
||||
}
|
||||
|
||||
onRecipeUpdate(callback: (event: RecipeDataEvent) => void) {
|
||||
this.recipeUpdateCallbacks.push(callback);
|
||||
this.updateSubscriptions();
|
||||
}
|
||||
|
||||
offRecipeUpdate(callback: (event: RecipeDataEvent) => void) {
|
||||
const index = this.recipeUpdateCallbacks.lastIndexOf(callback);
|
||||
if (index === -1) {
|
||||
throw new Error("callback is not registered");
|
||||
}
|
||||
this.recipeUpdateCallbacks.splice(index, 1);
|
||||
this.updateSubscriptions();
|
||||
}
|
||||
|
||||
onPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void) {
|
||||
this.playerPositionUpdateCallbacks.push(callback);
|
||||
this.updateSubscriptions();
|
||||
}
|
||||
|
||||
offPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void) {
|
||||
const index = this.playerPositionUpdateCallbacks.lastIndexOf(callback);
|
||||
if (index === -1) {
|
||||
throw new Error("callback is not registered");
|
||||
}
|
||||
this.playerPositionUpdateCallbacks.splice(index, 1);
|
||||
this.updateSubscriptions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
ChartTagData,
|
||||
ChartTagDataEvent,
|
||||
PlayerPositionEvent,
|
||||
RecipeDataEvent,
|
||||
TileDataEvent,
|
||||
} from "../messages";
|
||||
|
||||
export interface ChartTagDataWithInstance extends ChartTagData {
|
||||
instance_id: number;
|
||||
}
|
||||
|
||||
export interface MinimapViewBounds {
|
||||
worldLeft: number;
|
||||
worldTop: number;
|
||||
worldRight: number;
|
||||
worldBottom: number;
|
||||
}
|
||||
|
||||
export interface MinimapActiveView {
|
||||
changed: boolean;
|
||||
activeInstanceIds: number[];
|
||||
}
|
||||
|
||||
export interface MinimapDataSource {
|
||||
setInstance?(instanceId: number | null): void;
|
||||
setSurfaceForce(surface: string, force: string): void;
|
||||
setActiveView(bounds: MinimapViewBounds): MinimapActiveView;
|
||||
isReady(): boolean;
|
||||
getTileData(tileX: number, tileY: number, tick?: number | null): Promise<Uint8Array | null>;
|
||||
getRecipeTileData(tileX: number, tileY: number, tick?: number | null): Promise<Uint8Array | null>;
|
||||
getChartTags(): Promise<ChartTagDataWithInstance[]>;
|
||||
getPlayerPaths(): Promise<Array<{ instanceId: number; data: Uint8Array }>>;
|
||||
onTileUpdate(callback: (event: TileDataEvent) => void): () => void;
|
||||
onChartTagUpdate(callback: (event: ChartTagDataEvent) => void): () => void;
|
||||
onRecipeUpdate(callback: (event: RecipeDataEvent) => void): () => void;
|
||||
onPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void): () => void;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist", "web"),
|
||||
},
|
||||
plugins: [
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
name: "minimap",
|
||||
library: { type: "var", name: "plugin_minimap" },
|
||||
exposes: {
|
||||
"./": "./index.ts",
|
||||
"./package.json": "./package.json",
|
||||
"./web": "./web/index.tsx",
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
"@clusterio/web_ui": { import: false },
|
||||
"antd": { import: false },
|
||||
"react": { import: false },
|
||||
"react-dom": { import: false },
|
||||
},
|
||||
}),
|
||||
],
|
||||
optimization: {
|
||||
chunkIds: "named",
|
||||
},
|
||||
});
|
||||
@@ -20,30 +20,35 @@
|
||||
"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"
|
||||
|
||||
@@ -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/*"],
|
||||
|
||||
@@ -20,25 +20,30 @@
|
||||
"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"
|
||||
|
||||
@@ -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/*"],
|
||||
|
||||
@@ -20,20 +20,23 @@
|
||||
"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"
|
||||
|
||||
@@ -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/*"],
|
||||
|
||||
@@ -20,29 +20,34 @@
|
||||
"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"
|
||||
|
||||
@@ -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/*"],
|
||||
|
||||
+39
-5
@@ -1,8 +1,42 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
- "plugins/*"
|
||||
- "external_plugins/*"
|
||||
- "external_plugins/*/*"
|
||||
- "test/"
|
||||
- packages/*
|
||||
- plugins/*
|
||||
- external_plugins/*
|
||||
- external_plugins/*/*
|
||||
- test/
|
||||
|
||||
catalog:
|
||||
'@ant-design/icons': ^5.6.1
|
||||
'@sinclair/typebox': ^0.30.4
|
||||
'@swc/core': ^1.13.3
|
||||
'@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/react': ^18.3.18
|
||||
'@types/set-blocking': ^2.0.0
|
||||
'@types/ws': ^8.5.14
|
||||
antd: ^5.24.2
|
||||
chalk: ^4.1.2
|
||||
express: ^5.1.0
|
||||
fs-extra: ^8.1.0
|
||||
jsonwebtoken: ^9.0.2
|
||||
jszip: ^3.10.1
|
||||
phin: ^3.7.0
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
react-router-dom: ^6.29.0
|
||||
set-blocking: ^2.0.0
|
||||
typescript: ^5.7.3
|
||||
webpack: ^5.98.0
|
||||
webpack-cli: ^5.1.4
|
||||
webpack-merge: ^5.9.0
|
||||
winston: ^3.17.0
|
||||
winston-daily-rotate-file: ^4.7.1
|
||||
winston-transport: ^4.9.0
|
||||
ws: ^8.18.1
|
||||
|
||||
catalogMode: strict
|
||||
|
||||
linkWorkspacePackages: true
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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() {
|
||||
|
||||
+17
-12
@@ -15,10 +15,12 @@ const { selectTargetCommand, initialize: initializeCtl } = require("@clusterio/c
|
||||
|
||||
// Make sure permissions from plugins are loaded
|
||||
require("../../plugins/global_chat/dist/node/index");
|
||||
require("../../plugins/minimap/dist/node/index");
|
||||
require("../../plugins/player_auth/dist/node/index");
|
||||
require("../../plugins/research_sync/dist/node/index");
|
||||
require("../../plugins/statistics_exporter/dist/node/index");
|
||||
require("../../plugins/subspace_storage/dist/node/index");
|
||||
require("../../plugins/inventory_sync/dist/node/index");
|
||||
|
||||
class TestControl extends lib.Link {
|
||||
constructor(connector, subscribe = true) {
|
||||
@@ -173,26 +175,26 @@ 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;
|
||||
@@ -229,7 +231,8 @@ function getControl() {
|
||||
return control;
|
||||
}
|
||||
|
||||
function spawn(name, cmd, waitFor) {
|
||||
/** @returns {Promise<child_process.ChildProcess>} */
|
||||
function spawn(name, cmd, waitFor, options = {}) {
|
||||
|
||||
const silent = process.env.SILENT_TEST;
|
||||
const bootstrap = !controllerProcess || !hostProcess;
|
||||
@@ -242,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 => {
|
||||
@@ -266,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() {
|
||||
@@ -328,6 +331,7 @@ before(async function() {
|
||||
await execCtlProcess("plugin add ../../plugins/subspace_storage");
|
||||
await execCtlProcess("plugin add ../../plugins/inventory_sync");
|
||||
await execCtlProcess("plugin add ../../plugins/player_auth");
|
||||
await execCtlProcess("plugin add ../../plugins/minimap");
|
||||
|
||||
console.log("Bootstrapping");
|
||||
await execController("bootstrap create-admin test");
|
||||
@@ -394,6 +398,7 @@ module.exports = {
|
||||
TestControl,
|
||||
TestControlConnector,
|
||||
TestHostConnector,
|
||||
execController,
|
||||
execCtlProcess,
|
||||
slowTest,
|
||||
get,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -39,6 +41,7 @@ describe("Clusterio Instance", function() {
|
||||
|
||||
for (const plugin of [
|
||||
"global_chat", "research_sync", "statistics_exporter", "subspace_storage", "player_auth", "inventory_sync",
|
||||
"minimap",
|
||||
]) {
|
||||
await execCtl(`instance config set ${instName} ${plugin}.load_plugin false`);
|
||||
}
|
||||
@@ -52,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);
|
||||
@@ -61,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' })"
|
||||
@@ -87,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' })"
|
||||
@@ -102,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");
|
||||
@@ -119,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");
|
||||
@@ -141,9 +161,9 @@ 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(15); // Propagation time, 10ms worked for me, so i made it 15ms
|
||||
await lib.wait(30); // Propagation time, had issues with 15ms in ci so i made it 30ms
|
||||
const userAdded = await getUser("WhitelistPlayer");
|
||||
assert(userAdded.isWhitelisted, "Player is not whitelisted");
|
||||
const isAltAdded = await sendRcon(instAltId, "/whitelist get WhitelistPlayer");
|
||||
@@ -153,7 +173,7 @@ describe("Clusterio Instance", function() {
|
||||
);
|
||||
|
||||
await sendRcon(instId, "/whitelist remove WhitelistPlayer");
|
||||
await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms
|
||||
await lib.wait(30); // Propagation time, had issues with 15ms in ci so i made it 30ms
|
||||
const userRemoved = await getUser("WhitelistPlayer");
|
||||
assert(!userRemoved.isWhitelisted, "Player is not unwhitelisted");
|
||||
const isAltRemoved = await sendRcon(instAltId, "/whitelist get WhitelistPlayer");
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+134
@@ -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(`
|
||||
<html>
|
||||
<body>
|
||||
<a class="
|
||||
slot-button-inline
|
||||
version-button-experimental
|
||||
" href="/download/archive/2.0.73">
|
||||
2.0.73
|
||||
</a>
|
||||
</body>
|
||||
<body>
|
||||
<a class="
|
||||
slot-button-inline
|
||||
version-button-stable
|
||||
" href="/download/archive/2.0.72">
|
||||
2.0.72
|
||||
</a>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
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(`
|
||||
<html>
|
||||
<body>
|
||||
<a class="
|
||||
slot-button-inline
|
||||
version-button-experimental
|
||||
" href="/download/archive/2.0.73">
|
||||
2.0.73
|
||||
</a>
|
||||
</body>
|
||||
<body>
|
||||
<a class="
|
||||
slot-button-inline
|
||||
version-button-stable
|
||||
" href="/download/archive/2.0.73">
|
||||
2.0.73
|
||||
</a>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user