Port remaining plugins to Typescript (#499)

* .js to .ts

* Allow access to _items entries; getItemCount return number

* add permission field to PluginPage interface

* port plugins to typescript

* update Request classes

* fix IpcFlowType
This commit is contained in:
Luciole authored and GitHub committed 2023-08-22 16:17:16 +02:00
1 parent 50f12c719b
commit 0b3550b472
51 files changed
+1033 -1026

No files matched your search

+9 -2
View File
@@ -224,14 +224,14 @@ export class ItemDatabase {
* @param name - The name of the item to get the count of.
* @returns The count of the item stored.
*/
getItemCount(name: string) {
getItemCount(name: string): number {
checkName(name);
if (!this._items.has(name)) {
return 0;
}
return this._items.get(name);
return this._items.get(name)!;
}
/**
@@ -274,4 +274,11 @@ export class ItemDatabase {
this._items.set(name, count);
};
/**
* Allow to iterate through the items.
*/
getEntries(): IterableIterator<[string, number]> {
return this._items.entries()
}
}
+7
View File
@@ -10,6 +10,9 @@ import type { ParsedFactorioOutput } from "./logging_utils";
import type { ModPack, ModInfo } from "./data";
import type { Link } from "./link";
import type { CommandTree } from "./command";
import type { User } from "./users";
import type { MessageRequest, MessageEvent } from "./data";
/**
* Conceptual base for controller and instance plugins.
@@ -671,6 +674,10 @@ export interface PluginPage {
* Should render a PageLayout.
*/
content?: React["ReactNode"];
/**
* Permission to access page. function are expected to throw an error if access is deny.
*/
permission?: string | ((user: User, message: MessageRequest|MessageEvent) => void);
};
/**
+5 -4
View File
@@ -1,9 +1,10 @@
import { Type, Static } from "@sinclair/typebox";
export class ChatEvent {
static type = "event";
static src = ["control", "instance"];
static dst = "instance";
static plugin = "global_chat";
declare ["constructor"]: typeof ChatEvent;
static type = "event" as const;
static src = ["control", "instance"] as const;
static dst = "instance" as const;
static plugin = "global_chat" as const;
static permission = null;
constructor(
@@ -1,25 +1,20 @@
"use strict";
const fs = require("fs-extra");
const path = require("path");
import type InstanceInfo from "@clusterio/controller/src/InstanceInfo";
import type Controller from "@clusterio/controller/src/Controller";
import type { IpcPlayerData } from "./instance";
const lib = require("@clusterio/lib");
import fs from "fs-extra";
import path from "path";
import * as lib from "@clusterio/lib";
import * as msg from "./messages";
const {
AcquireRequest,
ReleaseRequest,
UploadRequest,
DownloadRequest,
DatabaseStatsRequest,
} = require("./messages");
async function loadDatabase(config, logger) {
async function loadDatabase(config: lib.ControllerConfig, logger: lib.Logger) {
let itemsPath = path.resolve(config.get("controller.database_directory"), "inventories.json");
logger.verbose(`Loading ${itemsPath}`);
try {
let content = await fs.readFile(itemsPath);
let content = await fs.readFile(itemsPath, { encoding: "utf8" });
return new Map(JSON.parse(content));
} catch (err) {
} catch (err: any) {
if (err.code === "ENOENT") {
logger.verbose("Creating new player data database");
return new Map();
@@ -28,7 +23,11 @@ async function loadDatabase(config, logger) {
}
}
async function saveDatabase(controllerConfig, playerDatastore, logger) {
async function saveDatabase(
controllerConfig: lib.ControllerConfig,
playerDatastore: Map<string, IpcPlayerData>,
logger: lib.Logger,
) {
if (playerDatastore) {
let file = path.resolve(controllerConfig.get("controller.database_directory"), "inventories.json");
logger.verbose(`writing ${file}`);
@@ -37,7 +36,11 @@ async function saveDatabase(controllerConfig, playerDatastore, logger) {
}
}
class ControllerPlugin extends lib.BaseControllerPlugin {
export class ControllerPlugin extends lib.BaseControllerPlugin {
acquiredPlayers!: Map<string, { instanceId: number, expires?: number }>;
playerDatastore!: Map<string, IpcPlayerData>;
autosaveId!: ReturnType<typeof setInterval>;
async init() {
this.acquiredPlayers = new Map();
this.playerDatastore = await loadDatabase(this.controller.config, this.logger);
@@ -47,14 +50,14 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
});
}, this.controller.config.get("inventory_sync.autosave_interval") * 1000);
this.controller.handle(AcquireRequest, this.handleAcquireRequest.bind(this));
this.controller.handle(ReleaseRequest, this.handleReleaseRequest.bind(this));
this.controller.handle(UploadRequest, this.handleUploadRequest.bind(this));
this.controller.handle(DownloadRequest, this.handleDownloadRequest.bind(this));
this.controller.handle(DatabaseStatsRequest, this.handleDatabaseStatsRequest.bind(this));
this.controller.handle(msg.AcquireRequest, this.handleAcquireRequest.bind(this));
this.controller.handle(msg.ReleaseRequest, this.handleReleaseRequest.bind(this));
this.controller.handle(msg.UploadRequest, this.handleUploadRequest.bind(this));
this.controller.handle(msg.DownloadRequest, this.handleDownloadRequest.bind(this));
this.controller.handle(msg.DatabaseStatsRequest, this.handleDatabaseStatsRequest.bind(this));
}
async onInstanceStatusChanged(instance) {
async onInstanceStatusChanged(instance: InstanceInfo) {
let instanceId = instance.id;
if (["unassigned", "deleted"].includes(instance.status)) {
for (let [playerName, acquisitionRecord] of this.acquiredPlayers) {
@@ -82,7 +85,7 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
}
}
acquire(instanceId, playerName) {
acquire(instanceId: number, playerName: string): boolean {
let acquisitionRecord = this.acquiredPlayers.get(playerName);
if (
!acquisitionRecord
@@ -97,11 +100,11 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
return false;
}
async handleAcquireRequest(request) {
async handleAcquireRequest(request: msg.AcquireRequest) {
let { instanceId, playerName } = request;
if (!this.acquire(instanceId, playerName)) {
let acquisitionRecord = this.acquiredPlayers.get(playerName);
let instance = this.controller.instances.get(acquisitionRecord.instanceId);
let instance = this.controller.instances.get(acquisitionRecord!.instanceId);
return {
status: "busy",
message: instance.config.get("instance.name"),
@@ -109,14 +112,14 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
}
let playerData = this.playerDatastore.get(playerName);
return new AcquireRequest.Response(
return new msg.AcquireRequest.Response(
"acquired",
playerData ? playerData.generation : 0,
Boolean(playerData),
);
}
async handleReleaseRequest(request) {
async handleReleaseRequest(request: msg.ReleaseRequest) {
let { instanceId, playerName } = request;
let acquisitionRecord = this.acquiredPlayers.get(playerName);
if (!acquisitionRecord) {
@@ -128,7 +131,7 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
}
}
async handleUploadRequest(request) {
async handleUploadRequest(request: msg.UploadRequest) {
let { instanceId, playerName, playerData } = request;
let instanceName = this.controller.instances.get(instanceId).config.get("instance.name");
let store = true;
@@ -162,7 +165,7 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
}
}
async handleDownloadRequest(request) {
async handleDownloadRequest(request: msg.DownloadRequest) {
let { instanceId, playerName } = request;
let instanceName = this.controller.instances.get(instanceId).config.get("instance.name");
@@ -174,7 +177,7 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
}
this.logger.verbose(`Sending player data for ${playerName} to ${instanceName}`);
return new DownloadRequest.Response(this.playerDatastore.get(playerName) || null);
return new msg.DownloadRequest.Response(this.playerDatastore.get(playerName) || null);
}
async onShutdown() {
@@ -189,7 +192,7 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
length: JSON.stringify(this.playerDatastore.get(name)).length,
}))
.sort((a, b) => b.length - a.length);
return new DatabaseStatsRequest.Response(
return new msg.DatabaseStatsRequest.Response(
playerDatastore.map(x => x.length).reduce((a, b) => b - a, 0),
playerDatastore.length,
{
@@ -199,7 +202,3 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
);
}
}
module.exports = {
ControllerPlugin,
};
@@ -1,6 +1,5 @@
"use strict";
const lib = require("@clusterio/lib");
let messages = require("./messages");
import * as lib from "@clusterio/lib";
import * as messages from "./messages";
class ControllerConfigGroup extends lib.PluginConfigGroup { }
ControllerConfigGroup.defaultAccess = ["controller", "host", "control"];
@@ -41,15 +40,15 @@ lib.definePermission({
grantByDefault: true,
});
module.exports.default = {
const info: lib.PluginInfo = {
name: "inventory_sync",
title: "Inventory sync",
description: "Synchronizes players inventories between instances",
instanceEntrypoint: "instance",
instanceEntrypoint: "dist/plugin/instance",
InstanceConfigGroup,
controllerEntrypoint: "controller",
controllerEntrypoint: "dist/plugin/controller",
ControllerConfigGroup,
messages: [
@@ -61,4 +60,6 @@ module.exports.default = {
],
webEntrypoint: "./web",
routes: ["/inventory"],
};
}
export default info;
@@ -1,47 +1,61 @@
/**
* @module
*/
"use strict";
const lib = require("@clusterio/lib");
import * as lib from "@clusterio/lib";
import { AcquireRequest, ReleaseRequest, UploadRequest, DownloadRequest } from "./messages";
const {
AcquireRequest,
ReleaseRequest,
UploadRequest,
DownloadRequest,
} = require("./messages");
type IpcPlayerName = {
player_name: string
}
export type IpcPlayerData = { //.\module\serialize.lua:serialize.serialize_player()
generation: number,
controller: string,
name: string,
color: number[],
chat_color: number[],
tag: string,
force: string,
cheat_mode: boolean,
flashlight: boolean,
ticks_to_respawn?: number,
character?: any,
inventories?: any,
hotbar?: string[],
personal_logistic_slots?: {name:string, min:number, max:number}[],
crafting_queue?: any,
}
/**
* Splits string into array of strings with max of a certain length
* @param {Number} chunkSize - Max length of each chunk
* @param {String} string - String to split into chunks
* @returns {String[]} Chunks
* @param chunkSize - Max length of each chunk
* @param string - String to split into chunks
*/
function chunkify(chunkSize, string) {
return string.match(new RegExp(`.{1,${chunkSize}}`, "g"));
function chunkify(chunkSize: number, string: string): string[] {
return string.match(new RegExp(`.{1,${chunkSize}}`, "g")) || [];
}
class InstancePlugin extends lib.BaseInstancePlugin {
export class InstancePlugin extends lib.BaseInstancePlugin {
playersToRelease!: Set<string>;
disconnecting!: boolean;
async init() {
this.playersToRelease = new Set();
this.disconnecting = false;
// Handle IPC from scenario script
this.instance.server.on("ipc-inventory_sync_acquire", content => this.handleAcquire(content)
this.instance.server.on("ipc-inventory_sync_acquire", (request: IpcPlayerName) => this.handleAcquire(request)
.catch(err => this.logger.error(`Error handling ipc-inventory_sync_acquire:\n${err.stack}`)));
this.instance.server.on("ipc-inventory_sync_release", content => this.handleRelease(content)
this.instance.server.on("ipc-inventory_sync_release", (request: IpcPlayerName) => this.handleRelease(request)
.catch(err => this.logger.error(`Error handling ipc-inventory_sync_release:\n${err.stack}`)));
this.instance.server.on("ipc-inventory_sync_upload", content => this.handleUpload(content)
this.instance.server.on("ipc-inventory_sync_upload", (player_data: IpcPlayerData) => this.handleUpload(player_data)
.catch(err => this.logger.error(`Error handling ipc-inventory_sync_upload:\n${err.stack}`)));
this.instance.server.on("ipc-inventory_sync_download", content => this.handleDownload(content)
this.instance.server.on("ipc-inventory_sync_download", (request: IpcPlayerName) => this.handleDownload(request)
.catch(err => this.logger.error(`Error handling ipc-inventory_sync_download:\n${err.stack}`)));
}
onPrepareControllerDisconnect() {
async onPrepareControllerDisconnect() {
this.disconnecting = true;
}
onControllerConnectionEvent(event) {
onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
if (event === "connect") {
this.disconnecting = false;
(async () => {
@@ -49,7 +63,7 @@ class InstancePlugin extends lib.BaseInstancePlugin {
if (!this.host.connector.connected || this.disconnecting) {
return;
}
this.playersToRelease.delete(player);
this.playersToRelease.delete(player_name);
await this.instance.sendTo("controller", new ReleaseRequest(this.instance.id, player_name));
}
})().catch(
@@ -58,11 +72,13 @@ class InstancePlugin extends lib.BaseInstancePlugin {
}
}
async handleAcquire(request) {
async handleAcquire(request: IpcPlayerName) {
let response = {
player_name: request.player_name,
status: "error",
message: "Controller is temporarily unavailable",
generation: undefined,
has_data: undefined,
};
if (this.host.connector.connected && !this.disconnecting) {
@@ -78,7 +94,7 @@ class InstancePlugin extends lib.BaseInstancePlugin {
has_data: acquireResponse.hasData,
message: acquireResponse.message,
};
} catch (err) {
} catch (err: any) {
if (!(err instanceof lib.SessionLost)) {
this.logger.error(`Unexpected error sending aquire request:\n${err.stack}`);
response.message = err.message;
@@ -90,23 +106,23 @@ class InstancePlugin extends lib.BaseInstancePlugin {
await this.sendRcon(`/sc inventory_sync.acquire_response("${json}")`, true);
}
async handleRelease(request) {
async handleRelease(request: IpcPlayerName) {
if (!this.host.connector.connected) {
this.playersToRelease.set(request.player_name);
this.playersToRelease.add(request.player_name);
}
try {
await this.instance.sendTo("controller", new ReleaseRequest(this.instance.id, request.player_name));
} catch (err) {
} catch (err: any) {
if (err instanceof lib.SessionLost) {
this.playersToRelease.set(request.player_name);
this.playersToRelease.add(request.player_name);
} else {
this.logger.error(`Unexpected error releasing player ${request.player_name}:\n${err.stack}`);
}
}
}
async handleUpload(player_data) {
async handleUpload(player_data: IpcPlayerData) {
if (!this.host.connector.connected || this.disconnecting) {
return;
}
@@ -118,7 +134,7 @@ class InstancePlugin extends lib.BaseInstancePlugin {
new UploadRequest(this.instance.id, player_data.name, player_data),
);
} catch (err) {
} catch (err: any) {
if (!(err instanceof lib.SessionLost)) {
this.logger.error(`Unexpected error uploading inventory for ${player_data.name}:\n${err.stack}`);
}
@@ -130,7 +146,7 @@ class InstancePlugin extends lib.BaseInstancePlugin {
);
}
async handleDownload(request) {
async handleDownload(request: IpcPlayerName) {
const playerName = request.player_name;
this.logger.verbose(`Downloading ${playerName}`);
@@ -154,7 +170,3 @@ class InstancePlugin extends lib.BaseInstancePlugin {
}
}
}
module.exports = {
InstancePlugin,
};
-229
View File
@@ -1,229 +0,0 @@
"use strict";
class AcquireRequest {
static type = "request";
static src = "instance";
static dst = "controller";
static plugin = "inventory_sync";
/** @type {number} */
instanceId;
/** @type {string} */
playerName;
constructor(instanceId, playerName) {
this.instanceId = instanceId;
this.playerName = playerName;
}
static jsonSchema = {
type: "object",
properties: {
"instanceId": { type: "integer" },
"playerName": { type: "string" },
},
};
static fromJSON(json) {
return new this(json.instanceId, json.playerName);
}
}
AcquireRequest.Response = class Response {
/** @type {string} */
status;
/** @type {number} */
generation;
/** @type {boolean} */
hasData;
/** @type {string} */
message;
constructor(status, generation, hasData, message) {
this.status = status;
if (generation !== undefined) { this.generation = generation; }
if (hasData !== undefined) { this.hasData = hasData; }
if (message !== undefined) { this.message = message; }
}
static jsonSchema = {
type: "object",
required: ["status"],
properties: {
"status": {
type: "string",
enum: ["acquired", "error", "busy"],
},
"generation": { type: "integer" },
"hasData": { type: "boolean" },
"message": { type: "string" },
},
};
static fromJSON(json) {
return new this(json.status, json.generation, json.hasData, json.message);
}
};
class ReleaseRequest {
static type = "request";
static src = "instance";
static dst = "controller";
static plugin = "inventory_sync";
/** @type {boolean} */
instanceId;
/** @type {boolean} */
playerName;
constructor(instanceId, playerName) {
this.instanceId = instanceId;
this.playerName = playerName;
}
static jsonSchema = {
type: "object",
properties: {
"instanceId": { type: "integer" },
"playerName": { type: "string" },
},
};
static fromJSON(json) {
return new this(json.instanceId, json.playerName);
}
}
class UploadRequest {
static type = "request";
static src = "instance";
static dst = "controller";
static plugin = "inventory_sync";
/** @type {boolean} */
instanceId;
/** @type {boolean} */
playerName;
/** @type {object} */
playerData;
constructor(instanceId, playerName, playerData) {
this.instanceId = instanceId;
this.playerName = playerName;
this.playerData = playerData;
}
static jsonSchema = {
type: "object",
required: ["instanceId", "playerName", "playerData"],
properties: {
"instanceId": { type: "integer" },
"playerName": { type: "string" },
"playerData": { type: "object" },
},
};
static fromJSON(json) {
return new this(json.instanceId, json.playerName, json.playerData);
}
}
class DownloadRequest {
static type = "request";
static src = "instance";
static dst = "controller";
static plugin = "inventory_sync";
/** @type {number} */
instanceId;
/** @type {string} */
playerName;
constructor(instanceId, playerName) {
this.instanceId = instanceId;
this.playerName = playerName;
}
static jsonSchema = {
type: "object",
required: ["instanceId", "playerName"],
properties: {
"instanceId": { type: "integer" },
"playerName": { type: "string" },
},
};
static fromJSON(json) {
return new this(json.instanceId, json.playerName);
}
}
DownloadRequest.Response = class Response {
/** @type {?object} */
playerData;
constructor(playerData) {
this.playerData = playerData;
}
static jsonSchema = {
type: "object",
required: ["playerData"],
properties: {
"playerData": { type: ["object", "null"] },
},
};
static fromJSON(json) {
return new this(json.playerData);
}
};
class DatabaseStatsRequest {
static type = "request";
static src = "control";
static dst = "controller";
static plugin = "inventory_sync";
static permission = "inventory_sync.inventory.view";
}
DatabaseStatsRequest.Response = class Response {
/** @type {boolean} */
databaseSize;
/** @type {boolean} */
databaseEntries;
/** @type {object} */
largestEntry;
constructor(databaseSize, databaseEntries, largestEntry) {
this.databaseSize = databaseSize;
this.databaseEntries = databaseEntries;
this.largestEntry = largestEntry;
}
static jsonSchema = {
type: "object",
required: ["databaseSize", "databaseEntries", "largestEntry"],
properties: {
"databaseSize": { type: "integer" },
"databaseEntries": { type: "integer" },
"largestEntry": {
type: "object",
properties: {
name: { type: "string" },
size: { type: "number" },
},
},
},
};
static fromJSON(json) {
return new this(json.databaseSize, json.databaseEntries, json.largestEntry);
}
};
module.exports = {
AcquireRequest,
ReleaseRequest,
UploadRequest,
DownloadRequest,
DatabaseStatsRequest,
};
+172
View File
@@ -0,0 +1,172 @@
import type { IpcPlayerData } from "./instance";
import { Type, Static } from "@sinclair/typebox";
import { StringEnum } from "@clusterio/lib";
const jsonPlayerData = Type.Object({
generation: Type.Number(),
name: Type.String(),
})
class AcquireResponse {
constructor(
public status: string,
public generation?: number,
public hasData?: boolean,
public message?: string,
) {
}
static jsonSchema = Type.Object({
"status": StringEnum(["acquired", "error", "busy"]),
"generation": Type.Optional(Type.Integer()),
"hasData": Type.Optional(Type.Boolean()),
"message": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof AcquireResponse.jsonSchema>): AcquireResponse {
return new this(json.status, json.generation, json.hasData, json.message);
}
};
export class AcquireRequest {
declare ["constructor"]: typeof AcquireRequest;
static type = "request" as const;
static src = "instance" as const;
static dst = "controller" as const;
static plugin = "inventory_sync" as const;
static Response = AcquireResponse;
constructor(
public instanceId: number,
public playerName: string,
) {
}
static jsonSchema = Type.Object({
"instanceId": Type.Number(),
"playerName": Type.String(),
})
static fromJSON(json: Static<typeof AcquireRequest.jsonSchema>): AcquireRequest {
return new this(json.instanceId, json.playerName);
}
}
export class ReleaseRequest {
declare ["constructor"]: typeof ReleaseRequest;
static type = "request" as const;
static src = "instance" as const;
static dst = "controller" as const;
static plugin = "inventory_sync" as const;
constructor(
public instanceId: number,
public playerName: string
) {
}
static jsonSchema = Type.Object({
"instanceId": Type.Integer(),
"playerName": Type.String(),
})
static fromJSON(json: Static<typeof ReleaseRequest.jsonSchema>) {
return new this(json.instanceId, json.playerName);
}
}
export class UploadRequest {
declare ["constructor"]: typeof UploadRequest;
static type = "request" as const;
static src = "instance" as const;
static dst = "controller" as const;
static plugin = "inventory_sync" as const;
constructor(
public instanceId: number,
public playerName: string,
public playerData: IpcPlayerData,
) {
}
static jsonSchema = Type.Object({
"instanceId": Type.Integer(),
"playerName": Type.String(),
"playerData": jsonPlayerData,
});
static fromJSON(json: Static<typeof UploadRequest.jsonSchema>) {
return new this(json.instanceId, json.playerName, json.playerData as IpcPlayerData);
}
}
class DownloadResponse {
constructor(
public playerData: IpcPlayerData | null
) {
}
static jsonSchema = Type.Object({
"playerData": Type.Optional(jsonPlayerData),
})
static fromJSON(json: Static<typeof DownloadResponse.jsonSchema>): DownloadResponse {
return new this(json.playerData as IpcPlayerData);
}
}
export class DownloadRequest {
declare ["constructor"]: typeof DownloadRequest;
static type = "request" as const;
static src = "instance" as const;
static dst = "controller" as const;
static plugin = "inventory_sync" as const;
static Response = DownloadResponse;
constructor(
public instanceId: number,
public playerName: string,
) {
}
static jsonSchema = Type.Object({
"instanceId": Type.Integer(),
"playerName": Type.String(),
})
static fromJSON(json: Static<typeof DownloadRequest.jsonSchema>) {
return new this(json.instanceId, json.playerName);
}
}
class DatabaseStatsResponse {
constructor(
public databaseSize: number,
public databaseEntries: number,
public largestEntry: { name: string, size: number },
) {
}
static jsonSchema = Type.Object({
"databaseSize": Type.Integer(),
"databaseEntries": Type.Integer(),
"largestEntry": Type.Object({
"name": Type.String(),
"size": Type.Number(),
})
})
static fromJSON(json: Static<typeof DatabaseStatsResponse.jsonSchema>): DatabaseStatsResponse {
return new this(json.databaseSize, json.databaseEntries, json.largestEntry);
}
}
export class DatabaseStatsRequest {
declare ["constructor"]: typeof DatabaseStatsRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static plugin = "inventory_sync" as const;
static permission = "inventory_sync.inventory.view" as const;
static Response = DatabaseStatsResponse;
}
+6 -2
View File
@@ -6,9 +6,9 @@
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
"license": "MIT",
"repository": "clusterio/factorioClusterio",
"main": "./info.js",
"main": "dist/plugin/info.js",
"scripts": {
"prepare": "webpack-cli --env production",
"prepare": "tsc --build && webpack-cli --env production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
@@ -21,14 +21,18 @@
"@clusterio/lib": "^2.0.0-alpha.0"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.13",
"@clusterio/web_ui": "^2.0.0-alpha.13",
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.4.5",
"antd": "^5.7.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.1.6",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
+16
View File
@@ -0,0 +1,16 @@
{
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"lib": ["es2023"],
"module": "commonjs",
"target": "es2022",
"rootDir": ".",
"outDir": "dist/plugin",
"sourceMap": true,
"strict": true,
"alwaysStrict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import React, { useContext, useEffect, useState } from "react";
import * as lib from "@clusterio/lib";
import { PageLayout, ControlContext } from "@clusterio/web_ui";
import { DatabaseStatsRequest } from "../messages";
import { DatabaseStatsRequest } from "../dist/plugin/messages";
import "./index.css";
+1 -1
View File
@@ -16,7 +16,7 @@ module.exports = (env = {}) => merge(common(env), {
name: "inventory_sync",
library: { type: "var", name: "plugin_inventory_sync" },
exposes: {
"./info": "./info.js",
"./info": "./dist/plugin/info.js",
"./package.json": "./package.json",
"./web": "./web/index.jsx",
},
@@ -1,19 +1,17 @@
/**
* @module
*/
"use strict";
const crypto = require("crypto");
const express = require("express");
const util = require("util");
const jwt = require("jsonwebtoken");
import type { Request, Response } from "express";
const lib = require("@clusterio/lib");
import crypto from "crypto";
import express from "express";
import util from "util";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
const { basicType } = lib;
const { FetchPlayerCodeRequest, SetVerifyCodeRequest } = require("./messages");
import { FetchPlayerCodeRequest, SetVerifyCodeRequest } from "./messages";
async function generateCode(length) {
async function generateCode(length: number): Promise<string> {
// ji1lI, 0oOQ, and 2Z are not present to ease reading.
let letters = "abcdefghkmnpqrstuvwxyzABCDEFGHJKLMNPRSTUVWXY3456789";
let asyncRandomBytes = util.promisify(crypto.randomBytes);
@@ -29,7 +27,11 @@ async function generateCode(length) {
}
class ControllerPlugin extends lib.BaseControllerPlugin {
type PlayerCode = { playerCode: string, verifyCode: string | null, expires: number };
export class ControllerPlugin extends lib.BaseControllerPlugin {
players!: Map<string, PlayerCode>;
async init() {
// Store of validation attempts by players
this.players = new Map();
@@ -44,7 +46,7 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
}
}, 60e3).unref();
this.controller.app.get("/api/player_auth/servers", (req, res) => {
this.controller.app.get("/api/player_auth/servers", (req: Request, res: Response) => {
let servers = [];
for (let instance of this.controller.instances.values()) {
if (instance.status === "running" && instance.config.get("player_auth.load_plugin")) {
@@ -54,11 +56,11 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
res.send(servers);
});
this.controller.app.post("/api/player_auth/player_code", express.json(), (req, res, next) => {
this.controller.app.post("/api/player_auth/player_code", express.json(), (req: Request, res: Response, next: any) => {
this.handlePlayerCode(req, res).catch(next);
});
this.controller.app.post("/api/player_auth/verify", express.json(), (req, res, next) => {
this.controller.app.post("/api/player_auth/verify", express.json(), (req: Request, res: Response, next: any) => {
this.handleVerify(req, res).catch(next);
});
@@ -66,7 +68,7 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
this.controller.handle(SetVerifyCodeRequest, this.handleSetVerifyCodeRequest.bind(this));
}
async handlePlayerCode(req, res) {
async handlePlayerCode(req: Request, res: Response) {
if (basicType(req.body) !== "object") {
res.sendStatus(400);
return;
@@ -100,7 +102,7 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
res.send({ error: true, message: "invalid player_code" });
}
async handleVerify(req, res) {
async handleVerify(req: Request, res: Response) {
if (basicType(req.body) !== "object") {
res.sendStatus(400);
return;
@@ -126,7 +128,7 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
let secret = Buffer.from(this.controller.config.get("controller.auth_secret"), "base64");
try {
let payload = jwt.verify(verifyToken, secret, { audience: "player_auth.verify_code" });
let payload = jwt.verify(verifyToken, secret, { audience: "player_auth.verify_code" }) as jwt.JwtPayload;
if (payload.verify_code !== verifyCode) {
throw new Error("invalid verify_code");
}
@@ -135,7 +137,7 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
throw new Error("invalid player_code");
}
} catch (err) {
} catch (err: any) {
res.send({ error: true, message: err.message });
return;
}
@@ -163,21 +165,18 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
res.send({ error: true, message: "invalid player_code" });
}
async handleFetchPlayerCodeRequest(request) {
async handleFetchPlayerCodeRequest(request: FetchPlayerCodeRequest) {
let playerCode = await generateCode(this.controller.config.get("player_auth.code_length"));
let expires = Date.now() + this.controller.config.get("player_auth.code_timeout") * 1000;
this.players.set(request.player, { playerCode, verifyCode: null, expires });
return { player_code: playerCode, controller_url: this.controller.getControllerUrl() };
}
async handleSetVerifyCodeRequest(request) {
async handleSetVerifyCodeRequest(request: SetVerifyCodeRequest) {
let { player, verifyCode } = request;
if (!this.players.has(player)) {
throw new lib.RequestError("invalid player");
}
let entry = this.players.get(player);
if (entry.expires < Date.now()) {
if (!entry || entry.expires < Date.now()) {
throw new lib.RequestError("invalid player");
}
@@ -185,9 +184,5 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
}
}
module.exports = {
ControllerPlugin,
// For testing only
_generateCode: generateCode,
};
// For testing only
export const _generateCode = generateCode;
@@ -1,7 +1,5 @@
"use strict";
const lib = require("@clusterio/lib");
let messages = require("./messages");
import * as lib from "@clusterio/lib";
import * as messages from "./messages";
class ControllerConfigGroup extends lib.PluginConfigGroup {}
ControllerConfigGroup.defaultAccess = ["controller", "host", "control"];
@@ -22,12 +20,12 @@ ControllerConfigGroup.define({
});
ControllerConfigGroup.finalize();
module.exports.default = {
const info: lib.PluginInfo = {
name: "player_auth",
title: "Player Auth",
description: "Provides authentication to the cluster via logging into a Factorio server.",
controllerEntrypoint: "controller",
instanceEntrypoint: "instance",
controllerEntrypoint: "dist/plugin/controller",
instanceEntrypoint: "dist/plugin/instance",
webEntrypoint: "./web",
ControllerConfigGroup,
@@ -36,3 +34,5 @@ module.exports.default = {
messages.SetVerifyCodeRequest,
],
};
export default info;
@@ -1,24 +1,28 @@
/**
* @module
*/
"use strict";
const lib = require("@clusterio/lib");
import * as lib from "@clusterio/lib";
const { FetchPlayerCodeRequest, SetVerifyCodeRequest } = require("./messages");
import { FetchPlayerCodeRequest, SetVerifyCodeRequest } from "./messages";
type ipcPlayerAuth = {
type: "open_dialog",
player: string,
} | {
type: "set_verify_code",
player: string,
verify_code: string,
}
class InstancePlugin extends lib.BaseInstancePlugin {
export class InstancePlugin extends lib.BaseInstancePlugin {
async init() {
if (!this.instance.config.get("factorio.enable_save_patching")) {
throw new Error("player_auth plugin requires save patching.");
}
this.instance.server.on("ipc-player_auth", event => this.handleEvent(event).catch(
this.instance.server.on("ipc-player_auth", (ipcPlayerAuth: ipcPlayerAuth) => this.handleEvent(ipcPlayerAuth).catch(
err => this.logger.error(`Error handling event:\n${err.stack}`)
));
}
async handleEvent(event) {
async handleEvent(event: ipcPlayerAuth) {
if (event.type === "open_dialog") {
if (!this.host.connector.connected) {
await this.sendRcon(`/web-login error ${event.player} login is temporarily unavailable`);
@@ -28,7 +32,7 @@ class InstancePlugin extends lib.BaseInstancePlugin {
let response;
try {
response = await this.instance.sendTo("controller", new FetchPlayerCodeRequest(event.player));
} catch (err) {
} catch (err: any) {
await this.sendRcon(`/web-login error ${event.player} ${err.message}`);
return;
}
@@ -38,7 +42,7 @@ class InstancePlugin extends lib.BaseInstancePlugin {
try {
await this.instance.sendTo("controller", new SetVerifyCodeRequest(event.player, event.verify_code));
} catch (err) {
} catch (err: any) {
await this.sendRcon(`/web-login error ${event.player} ${err.message}`);
return;
}
@@ -47,7 +51,3 @@ class InstancePlugin extends lib.BaseInstancePlugin {
}
}
}
module.exports = {
InstancePlugin,
};
-87
View File
@@ -1,87 +0,0 @@
"use strict";
class FetchPlayerCodeRequest {
static type = "request";
static src = "instance";
static dst = "controller";
static plugin = "player_auth";
/** @type {string} */
player;
constructor(player) {
this.player = player;
}
static jsonSchema = {
type: "object",
required: ["player"],
properties: {
"player": { type: "string" },
},
};
static fromJSON(json) {
return new this(json.player);
}
}
FetchPlayerCodeRequest.Response = class Response {
/** @type {string} */
playerCode;
/** @type {string} */
controllerUrl;
constructor(playerCode, controllerUrl) {
this.playerCode = playerCode;
this.controllerUrl = controllerUrl;
}
static jsonSchema = {
type: "object",
required: ["playerCode", "controllerUrl"],
properties: {
"playerCode": { type: "string" },
"controllerUrl": { type: "string" },
},
};
static fromJSON(json) {
return new this(json.playerCode, json.controllerUrl);
}
};
class SetVerifyCodeRequest {
static type = "request";
static src = "instance";
static dst = "controller";
static plugin = "player_auth";
/** @type {string} */
player;
/** @type {string} */
verifyCode;
constructor(player, verifyCode) {
this.player = player;
this.verifyCode = verifyCode;
}
static jsonSchema = {
type: "object",
required: ["player", "verifyCode"],
properties: {
"player": { type: "string" },
"verifyCode": { type: "string" },
},
};
static fromJSON(json) {
return new this(json.playerCode, json.verifyCode);
}
}
module.exports = {
FetchPlayerCodeRequest,
SetVerifyCodeRequest,
};
+63
View File
@@ -0,0 +1,63 @@
import { Type, Static } from "@sinclair/typebox";
class FetchPlayerCodeResponse {
constructor(
public playerCode: string,
public controllerUrl: string
) {
}
static jsonSchema = Type.Object({
"playerCode": Type.String(),
"controllerUrl": Type.String(),
})
static fromJSON(json: Static<typeof FetchPlayerCodeResponse.jsonSchema>): FetchPlayerCodeResponse {
return new this(json.playerCode, json.controllerUrl);
}
};
export class FetchPlayerCodeRequest {
declare ["constructor"]: typeof FetchPlayerCodeRequest;
static type = "request" as const;
static src = "instance" as const;
static dst = "controller" as const;
static plugin = "player_auth" as const;
static Response = FetchPlayerCodeResponse;
constructor(
public player: string
) {
}
static jsonSchema = Type.Object({
"player": Type.String(),
})
static fromJSON(json: Static<typeof FetchPlayerCodeRequest.jsonSchema>): FetchPlayerCodeRequest {
return new this(json.player);
}
}
export class SetVerifyCodeRequest {
declare ["constructor"]: typeof SetVerifyCodeRequest;
static type = "request" as const;
static src = "instance" as const;
static dst = "controller" as const;
static plugin = "player_auth" as const;
constructor(
public player: string,
public verifyCode: string
) {
}
static jsonSchema = Type.Object({
"player": Type.String(),
"verifyCode": Type.String(),
})
static fromJSON(json: Static<typeof SetVerifyCodeRequest.jsonSchema>): SetVerifyCodeRequest {
return new this(json.player, json.verifyCode);
}
}
+7 -2
View File
@@ -6,9 +6,9 @@
"homepage": "https://github.com/clusterio/clusterio#readme",
"license": "MIT",
"repository": "clusterio/clusterio",
"main": "./info.js",
"main": "dist/plugin/info.js",
"scripts": {
"prepare": "webpack-cli --env production",
"prepare": "tsc --build && webpack-cli --env production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
@@ -21,16 +21,21 @@
"@clusterio/lib": "^2.0.0-alpha.0"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4",
"express": "^4.16.2",
"jsonwebtoken": "^8.2.1"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.13",
"@clusterio/web_ui": "^2.0.0-alpha.13",
"@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.2",
"@types/node": "^20.4.5",
"antd": "^5.7.3",
"phin": "^3.7.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.1.6",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
+4 -4
View File
@@ -5,10 +5,10 @@ const jwt = require("jsonwebtoken");
const mock = require("../../../test/mock");
const controller = require("../controller");
const instance = require("../instance");
const info = require("../info").default;
const { FetchPlayerCodeRequest, SetVerifyCodeRequest } = require("../messages");
const controller = require("../dist/plugin/controller");
const instance = require("../dist/plugin/instance");
const info = require("../dist/plugin/info").default;
const { FetchPlayerCodeRequest, SetVerifyCodeRequest } = require("../dist/plugin/messages");
const lib = require("@clusterio/lib");
+16
View File
@@ -0,0 +1,16 @@
{
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"lib": ["es2023"],
"module": "commonjs",
"target": "es2022",
"rootDir": ".",
"outDir": "dist/plugin",
"sourceMap": true,
"strict": true,
"alwaysStrict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ module.exports = (env = {}) => merge(common(env), {
name: "player_auth",
library: { type: "var", name: "plugin_player_auth" },
exposes: {
"./info": "./info.js",
"./info": "./dist/plugin/info.js",
"./package.json": "./package.json",
"./web": "./web/index.jsx",
},
@@ -1,42 +1,52 @@
"use strict";
const fs = require("fs-extra");
const path = require("path");
import fs from "fs-extra";
import path from "path";
const lib = require("@clusterio/lib");
import * as lib from "@clusterio/lib";
const { RateLimiter } = lib;
const {
import {
ContributionEvent,
ProgressEvent,
FinishedEvent,
Technology,
SyncTechnologiesRequest,
} = require("./messages");
} from "./messages";
async function loadTechnologies(controllerConfig, logger) {
async function loadTechnologies(
controllerConfig: lib.ControllerConfig,
logger: lib.Logger
): Promise<Map<any, any>> {
let filePath = path.join(controllerConfig.get("controller.database_directory"), "technologies.json");
logger.verbose(`Loading ${filePath}`);
try {
return new Map(JSON.parse(await fs.readFile(filePath)));
return new Map(JSON.parse(await fs.readFile(filePath, { encoding: "utf8" })));
} catch (err) {
} catch (err: any) {
if (err.code === "ENOENT") {
logger.verbose("Creating new technologies database");
return new Map();
}
throw err;
}
}
async function saveTechnologies(controllerConfig, technologies, logger) {
async function saveTechnologies(
controllerConfig: lib.ControllerConfig,
technologies: Map<any, any>,
logger: lib.Logger
) {
let filePath = path.join(controllerConfig.get("controller.database_directory"), "technologies.json");
logger.verbose(`writing ${filePath}`);
await lib.safeOutputFile(filePath, JSON.stringify([...technologies.entries()], null, 4));
}
class ControllerPlugin extends lib.BaseControllerPlugin {
export class ControllerPlugin extends lib.BaseControllerPlugin {
technologies!: Map<string, Technology>;
progressRateLimiter!: lib.RateLimiter;
progressBroadcastId!: ReturnType<typeof setInterval> | null;
progressToBroadcast!: Set<string>;
async init() {
this.technologies = await loadTechnologies(this.controller.config, this.logger);
this.progressRateLimiter = new RateLimiter({
@@ -61,8 +71,8 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
let techs = [];
for (let name of this.progressToBroadcast) {
let tech = this.technologies.get(name);
if (tech.progress) {
techs.push({ name, level: tech.level, progress: tech.progress });
if (tech && tech.progress) {
techs.push(new Technology(name, tech.level, tech.progress, false));
}
}
this.progressToBroadcast.clear();
@@ -72,11 +82,11 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
}
}
async handleContributionEvent(event) {
async handleContributionEvent(event: ContributionEvent) {
let { name, level, contribution } = event;
let tech = this.technologies.get(name);
if (!tech) {
tech = { level, progress: 0, researched: false };
tech = new Technology(name, level, 0, false);
this.technologies.set(name, tech);
// Ignore contribution to already researched technologies
@@ -95,11 +105,11 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
return;
}
let newProgress = tech.progress + contribution;
let newProgress = tech.progress! + contribution;
if (newProgress < 1) {
tech.progress = newProgress;
this.progressToBroadcast.add(name);
this.progressRateLimiter.activate();
this.progressRateLimiter!.activate();
} else {
tech.researched = true;
@@ -110,17 +120,17 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
}
}
async handleFinishedEvent(event) {
async handleFinishedEvent(event: FinishedEvent) {
let { name, level } = event;
let tech = this.technologies.get(name);
if (!tech || tech.level <= level) {
this.progressToBroadcast.delete(name);
this.technologies.set(name, { level, progress: null, researched: true });
this.technologies.set(name, new Technology(name, level, null, true));
}
}
async handleSyncTechnologiesRequest(request) {
function baseLevel(name) {
async handleSyncTechnologiesRequest(request: SyncTechnologiesRequest): Promise<Technology[]> {
function baseLevel(name: string): number {
let match = /-(\d+)$/.exec(name);
if (!match) {
return 1;
@@ -132,11 +142,11 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
let { name, level, progress, researched } = instanceTech;
let tech = this.technologies.get(name);
if (!tech) {
this.technologies.set(name, { level, progress, researched });
this.technologies.set(name, new Technology(name, level, progress, researched));
if (progress) {
this.progressToBroadcast.add(name);
} else if (researched || baseLevel(name) !== level) {
this.controller.sendTo("allInstances", new FinishedEvent(name, tech));
this.controller.sendTo("allInstances", new FinishedEvent(name, level));
}
} else {
@@ -146,8 +156,8 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
if (tech.level < level || researched) {
// Send update if the unlocked level is greater
if (level - !researched > tech.level - !tech.researched) {
this.controller.sendTo("allInstances", new FinishedEvent(name, level - !researched));
if (level - Number(!researched) > tech.level - Number(!tech.researched)) {
this.controller.sendTo("allInstances", new FinishedEvent(name, level - Number(!researched)));
}
tech.level = level;
tech.progress = progress;
@@ -159,7 +169,7 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
this.progressToBroadcast.delete(name);
}
} else if (tech.progress < progress) {
} else if (tech.progress && progress && tech.progress < progress) {
tech.progress = progress;
this.progressToBroadcast.add(name);
}
@@ -175,8 +185,3 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
return technologies;
}
}
module.exports = {
ControllerPlugin,
};
@@ -1,12 +1,13 @@
"use strict";
const messages = require("./messages");
import type { PluginInfo } from "@clusterio/lib";
module.exports.default = {
import * as messages from "./messages";
const info: PluginInfo = {
name: "research_sync",
title: "Research Sync",
description: "Synchronises technology research progress between instances.",
instanceEntrypoint: "instance",
controllerEntrypoint: "controller",
instanceEntrypoint: "dist/plugin/instance",
controllerEntrypoint: "dist/plugin/controller",
messages: [
messages.ContributionEvent,
@@ -15,3 +16,5 @@ module.exports.default = {
messages.SyncTechnologiesRequest,
],
};
export default info;
@@ -1,16 +1,27 @@
"use strict";
const lib = require("@clusterio/lib");
const {
import * as lib from "@clusterio/lib";
import {
ContributionEvent,
ProgressEvent,
FinishedEvent,
Technology,
SyncTechnologiesRequest,
} = require("./messages");
} from "./messages";
// ./module/sync.lua
type IpcContribution = {
name: string,
level: number,
contribution: number,
};
type IpcFinished = {
name: string,
level: number,
};
class InstancePlugin extends lib.BaseInstancePlugin {
unexpectedError(err) {
export class InstancePlugin extends lib.BaseInstancePlugin {
syncStarted!: boolean;
unexpectedError(err: Error) {
this.logger.error(`Unexpected error:\n${err.stack}`);
}
@@ -19,10 +30,10 @@ class InstancePlugin extends lib.BaseInstancePlugin {
throw new Error("research_sync plugin requires save patching.");
}
this.instance.server.on("ipc-research_sync:contribution", (tech) => {
this.instance.server.on("ipc-research_sync:contribution", (tech: IpcContribution) => {
this.researchContribution(tech).catch(err => this.unexpectedError(err));
});
this.instance.server.on("ipc-research_sync:finished", (tech) => {
this.instance.server.on("ipc-research_sync:finished", (tech: IpcFinished) => {
this.researchFinished(tech).catch(err => this.unexpectedError(err));
});
@@ -31,11 +42,11 @@ class InstancePlugin extends lib.BaseInstancePlugin {
this.instance.handle(FinishedEvent, this.handleFinishedEvent.bind(this));
}
async researchContribution(tech) {
async researchContribution(tech: IpcContribution) {
this.instance.sendTo("controller", new ContributionEvent(tech.name, tech.level, tech.contribution));
}
async handleProgressEvent(event) {
async handleProgressEvent(event: ProgressEvent) {
if (!this.syncStarted || !["starting", "running"].includes(this.instance.status)) {
return;
}
@@ -43,11 +54,11 @@ class InstancePlugin extends lib.BaseInstancePlugin {
await this.sendOrderedRcon(`/sc research_sync.update_progress("${techsJson}")`, true);
}
async researchFinished(tech) {
async researchFinished(tech: IpcFinished) {
this.instance.sendTo("allInstances", new FinishedEvent(tech.name, tech.level));
}
async handleFinishedEvent(event) {
async handleFinishedEvent(event: FinishedEvent) {
if (!this.syncStarted || !["starting", "running"].includes(this.instance.status)) {
return;
}
@@ -93,7 +104,3 @@ class InstancePlugin extends lib.BaseInstancePlugin {
}
}
}
module.exports = {
InstancePlugin,
};
-179
View File
@@ -1,179 +0,0 @@
"use strict";
const lib = require("@clusterio/lib");
class ContributionEvent {
static type = "event";
static src = "instance";
static dst = "controller";
static plugin = "research_sync";
/** @type {string} */
name;
/** @type {number} */
level;
/** @type {number} */
contribution;
constructor(name, level, contribution) {
this.name = name;
this.level = level;
this.contribution = contribution;
}
static jsonSchema = {
type: "object",
required: ["name", "level", "contribution"],
properties: {
name: { type: "string" },
level: { type: "integer" },
contribution: { type: "number" },
},
};
static fromJSON(json) {
return new this(json.name, json.level, json.contribution);
}
}
class ProgressEvent {
static type = "event";
static src = "controller";
static dst = "instance";
static plugin = "research_sync";
/** @type {Array<Technology>} */
technologies;
constructor(technologies) {
this.technologies = technologies;
}
static jsonSchema = {
type: "object",
required: ["technologies"],
properties: {
technologies: {
type: "array",
items: {
type: "object",
required: ["name", "level", "progress"],
properties: {
name: { type: "string" },
level: { type: "integer" },
progress: { type: "number" },
},
additionalProperties: false,
},
},
},
};
static fromJSON(json) {
return new this(json.technologies);
}
}
class FinishedEvent {
static type = "event";
static src = ["instance", "controller"];
static dst = "instance";
static plugin = "research_sync";
/** @type {string} */
name;
/** @type {number} */
level;
constructor(name, level) {
this.name = name;
this.level = level;
}
static jsonSchema = {
type: "object",
required: ["name", "level"],
properties: {
name: { type: "string" },
level: { type: "integer" },
},
};
static fromJSON(json) {
return new this(json.name, json.level);
}
}
class Technology {
/** @type {string} */
name;
/** @type {number} */
level;
/** @type {?number} */
progress;
/** @type {boolean} */
researched;
constructor(name, level, progress, researched) {
this.name = name;
this.level = level;
this.progress = progress;
this.researched = researched;
}
static jsonSchema = {
type: "array",
minItems: 4,
maxItems: 4,
items: [
{ type: "string" },
{ type: "integer" },
{ type: ["null", "number"] },
{ type: "boolean" },
],
};
toJSON() {
return [this.name, this.level, this.progress, this.researched];
}
static fromJSON(json) {
return new this(json[0], json[1], json[2], json[3]);
}
}
class SyncTechnologiesRequest {
static type = "request";
static src = "instance";
static dst = "controller";
static plugin = "research_sync";
/** @type {Array<Technology>} */
technologies;
constructor(technologies) {
this.technologies = technologies;
}
static jsonSchema = {
type: "array",
items: Technology.jsonSchema,
};
toJSON() {
return this.technologies;
}
static fromJSON(json) {
return new this(json.map(e => Technology.fromJSON(e)));
}
static Response = lib.jsonArray(Technology);
}
module.exports = {
ContributionEvent,
ProgressEvent,
FinishedEvent,
Technology,
SyncTechnologiesRequest,
};
+137
View File
@@ -0,0 +1,137 @@
import { Type, Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
export class ContributionEvent {
declare ["constructor"]: typeof ContributionEvent;
static type = "event" as const;
static src = "instance" as const;
static dst = "controller" as const;
static plugin = "research_sync" as const;
constructor(
public name: string,
public level: number,
public contribution: number
) {
}
static jsonSchema = Type.Object({
"name": Type.String(),
"level": Type.Integer(),
"contribution": Type.Number(),
})
static fromJSON(json: Static<typeof ContributionEvent.jsonSchema>): ContributionEvent {
return new this(json.name, json.level, json.contribution);
}
}
export class TechnologyProgress {
constructor(
public name: string,
public level: number,
public progress: number | null,
) {
}
static jsonSchema = Type.Object({
"name": Type.String(),
"level": Type.Integer(),
"progress": Type.Number(),
})
}
export class ProgressEvent {
declare ["constructor"]: typeof ProgressEvent;
static type = "event" as const;
static src = "controller" as const;
static dst = "instance" as const;
static plugin = "research_sync" as const;
constructor(
public technologies: TechnologyProgress[],
) {
}
static jsonSchema = Type.Object({
"technologies": Type.Array(
TechnologyProgress.jsonSchema
)
})
static fromJSON(json: Static<typeof ProgressEvent.jsonSchema>): ProgressEvent {
return new this(json.technologies);
}
}
export class FinishedEvent {
declare ["constructor"]: typeof FinishedEvent;
static type = "event" as const;
static src = ["instance", "controller"] as const;
static dst = "instance" as const;
static plugin = "research_sync" as const;
constructor(
public name: string,
public level: number,
) {
}
static jsonSchema = Type.Object({
"name": Type.String(),
"level": Type.Integer(),
})
static fromJSON(json: Static<typeof FinishedEvent.jsonSchema>): FinishedEvent {
return new this(json.name, json.level);
}
}
export class Technology {
constructor(
public name: string,
public level: number,
public progress: number | null,
public researched: boolean,
) {
}
static jsonSchema = Type.Tuple([
Type.String(),
Type.Integer(),
Type.Union([ Type.Number(), Type.Null() ]),
Type.Boolean(),
])
toJSON() {
return [this.name, this.level, this.progress, this.researched];
}
static fromJSON(json: Static<typeof Technology.jsonSchema>): Technology {
return new this(json[0], json[1], json[2], json[3]);
}
}
export class SyncTechnologiesRequest {
declare ["constructor"]: typeof SyncTechnologiesRequest;
static type = "request" as const;
static src = "instance" as const;
static dst = "controller" as const;
static plugin = "research_sync" as const;
constructor(
public technologies: Technology[]
) {
}
static jsonSchema = Type.Array(Technology.jsonSchema);
toJSON() {
return this.technologies;
}
static fromJSON(json: Static<typeof SyncTechnologiesRequest.jsonSchema>): SyncTechnologiesRequest {
return new this(json.map(e => Technology.fromJSON(e)));
}
static Response = lib.jsonArray(Technology);
}
+6 -2
View File
@@ -6,9 +6,9 @@
"homepage": "https://github.com/clusterio/clusterio#readme",
"license": "MIT",
"repository": "clusterio/clusterio",
"main": "./info.js",
"main": "dist/plugin/info.js",
"scripts": {
"prepare": "webpack-cli --env production",
"prepare": "tsc --build && webpack-cli --env production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
@@ -21,11 +21,15 @@
"@clusterio/lib": "^2.0.0-alpha.0"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.13",
"@clusterio/web_ui": "^2.0.0-alpha.13",
"@types/node": "^20.4.5",
"@types/fs-extra": "^11.0.1",
"typescript": "^5.1.6",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
+2 -2
View File
@@ -2,5 +2,5 @@
// required here in order to track coverage for them.
"use strict";
require("../instance");
require("../controller");
require("../dist/plugin/instance");
require("../dist/plugin/controller");
+16
View File
@@ -0,0 +1,16 @@
{
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"lib": ["es2023"],
"module": "commonjs",
"target": "es2022",
"rootDir": ".",
"outDir": "dist/plugin",
"sourceMap": true,
"strict": true,
"alwaysStrict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ module.exports = (env = {}) => merge(common(env), {
name: "research_sync",
library: { type: "var", name: "plugin_research_sync" },
exposes: {
"./info": "./info.js",
"./info": "./dist/plugin/info.js",
"./package.json": "./package.json",
},
shared: {
@@ -1,5 +1,4 @@
"use strict";
const lib = require("@clusterio/lib");
import * as lib from "@clusterio/lib";
class InstanceConfigGroup extends lib.PluginConfigGroup {}
InstanceConfigGroup.defaultAccess = ["controller", "host", "control"];
@@ -13,12 +12,14 @@ InstanceConfigGroup.define({
});
InstanceConfigGroup.finalize();
module.exports.default = {
const info: lib.PluginInfo = {
name: "statistics_exporter",
title: "Statistics Exporter",
description:
"Provides in-game item/fluid production, builds, kills, and pollution "+
"statistics to the cluster's Prometheus endpoint.",
instanceEntrypoint: "instance",
instanceEntrypoint: "dist/plugin/instance",
InstanceConfigGroup,
};
export default info;
@@ -1,7 +1,23 @@
"use strict";
const lib = require("@clusterio/lib");
import * as lib from "@clusterio/lib";
const { Gauge } = lib;
type IpcStats = {
game_tick: number,
player_count: number,
game_flow_statistics: {
pollution_statistics: IpcFlowType,
},
force_flow_statistics: {
[key:string]: {
[key:string]: IpcFlowType,
}
},
}
type IpcFlowType = {
"input": Record<string, number>,
"output": Record<string, number>,
}
const instancePlayerCount = new Gauge(
"clusterio_statistics_exporter_instance_player_count",
@@ -24,7 +40,14 @@ const instanceGameFlowStatistics = new Gauge(
{ labels: ["instance_id", "statistic", "direction", "name"] },
);
function setForceFlowStatistic(instanceId, forceName, statisticName, direction, item, value) {
function setForceFlowStatistic(
instanceId: number,
forceName: string,
statisticName: string,
direction: string,
item: string,
value: number,
) {
instanceForceFlowStatistics.labels(String(instanceId), forceName, statisticName, direction, item).set(value);
// For item and fluid statistics it's useful to compare the input flow with the
@@ -36,7 +59,7 @@ function setForceFlowStatistic(instanceId, forceName, statisticName, direction,
}
class InstancePlugin extends lib.BaseInstancePlugin {
export class InstancePlugin extends lib.BaseInstancePlugin {
async init() {
if (!this.instance.config.get("factorio.enable_save_patching")) {
throw new Error("statistics_exporter plugin requires save patching.");
@@ -45,10 +68,10 @@ class InstancePlugin extends lib.BaseInstancePlugin {
async gatherMetrics() {
let string = await this.sendRcon("/sc statistics_exporter.export()");
let stats;
let stats: IpcStats;
try {
stats = JSON.parse(string);
} catch (err) {
} catch (err: any) {
throw new Error(`Error parsing statistics JSON: ${err.message}, content "${string}"`);
}
@@ -85,16 +108,11 @@ class InstancePlugin extends lib.BaseInstancePlugin {
// stream is overloaded. Should the timeout be exceeded the
// previous values for the metrics will end up being sent to controller.
let timeout = this.instance.config.get("statistics_exporter.command_timeout") * 1000;
await lib.timeout(this.gatherMetrics(), timeout);
await lib.timeout(this.gatherMetrics(), timeout, undefined);
}
}
module.exports = {
InstancePlugin,
// For testing only
_instancePlayerCount: instancePlayerCount,
_instanceGameTicksTotal: instanceGameTicksTotal,
_instanceForceFlowStatistics: instanceForceFlowStatistics,
_instanceGameFlowStatistics: instanceGameFlowStatistics,
};
export const _instancePlayerCount = instancePlayerCount;
export const _instanceGameTicksTotal = instanceGameTicksTotal;
export const _instanceForceFlowStatistics = instanceForceFlowStatistics;
export const _instanceGameFlowStatistics = instanceGameFlowStatistics;
+2 -2
View File
@@ -6,9 +6,9 @@
"homepage": "https://github.com/clusterio/clusterio#readme",
"license": "MIT",
"repository": "clusterio/clusterio",
"main": "./info.js",
"main": "dist/plugin/info.js",
"scripts": {
"prepare": "webpack-cli --env production",
"prepare": "tsc --build && webpack-cli --env production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
+2 -2
View File
@@ -2,8 +2,8 @@
const assert = require("assert").strict;
const mock = require("../../../test/mock");
const instance = require("../instance");
const info = require("../info").default;
const instance = require("../dist/plugin/instance");
const info = require("../dist/plugin/info").default;
describe("statistics_exporter plugin", function() {
+16
View File
@@ -0,0 +1,16 @@
{
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"lib": ["es2023"],
"module": "commonjs",
"target": "es2022",
"rootDir": ".",
"outDir": "dist/plugin",
"sourceMap": true,
"strict": true,
"alwaysStrict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
@@ -16,7 +16,7 @@ module.exports = (env = {}) => merge(common(env), {
name: "statistics_exporter",
library: { type: "var", name: "plugin_statistics_exporter" },
exposes: {
"./info": "./info.js",
"./info": "./dist/plugin/info.js",
"./package.json": "./package.json",
},
shared: {
@@ -1,21 +1,22 @@
"use strict";
const fs = require("fs-extra");
const path = require("path");
import type ControlConnection from "@clusterio/controller/src/ControlConnection";
const lib = require("@clusterio/lib");
const { Counter, Gauge, RateLimiter } = lib;
import fs from "fs-extra";
import path from "path";
const routes = require("./routes");
const dole = require("./dole");
import * as lib from "@clusterio/lib";
const { Counter, Gauge } = lib;
const {
import * as routes from "./routes";
import * as dole from "./dole";
import {
Item,
PlaceEvent,
RemoveRequest,
GetStorageRequest,
UpdateStorageEvent,
SetStorageSubscriptionRequest,
} = require("./messages");
} from "./messages";
const exportCounter = new Counter(
@@ -35,14 +36,17 @@ const controllerInventoryGauge = new Gauge(
);
async function loadDatabase(config, logger) {
async function loadDatabase(
config: lib.ControllerConfig,
logger: lib.Logger
): Promise<lib.ItemDatabase> {
let itemsPath = path.resolve(config.get("controller.database_directory"), "items.json");
logger.verbose(`Loading ${itemsPath}`);
try {
let content = await fs.readFile(itemsPath);
let content = await fs.readFile(itemsPath, { encoding: "utf8" });
return new lib.ItemDatabase(JSON.parse(content));
} catch (err) {
} catch (err: any) {
if (err.code === "ENOENT") {
logger.verbose("Creating new item database");
return new lib.ItemDatabase();
@@ -51,7 +55,11 @@ async function loadDatabase(config, logger) {
}
}
async function saveDatabase(controllerConfig, items, logger) {
async function saveDatabase(
controllerConfig: lib.ControllerConfig,
items: lib.ItemDatabase,
logger: lib.Logger,
) {
if (items && items.size < 50000) {
let file = path.resolve(controllerConfig.get("controller.database_directory"), "items.json");
logger.verbose(`writing ${file}`);
@@ -62,21 +70,28 @@ async function saveDatabase(controllerConfig, items, logger) {
}
}
class ControllerPlugin extends lib.BaseControllerPlugin {
async init() {
export class ControllerPlugin extends lib.BaseControllerPlugin {
items!: lib.ItemDatabase;
itemUpdateRateLimiter!: lib.RateLimiter;
itemsLastUpdate!: Map<string, number>;
subscribedControlLinks!: Set<ControlConnection>;
autosaveId!: ReturnType<typeof setInterval>;
doleMagicId!: ReturnType<typeof setInterval>;
neuralDole!: dole.NeuralDole;
async init() {
this.items = await loadDatabase(this.controller.config, this.logger);
this.itemUpdateRateLimiter = new RateLimiter({
this.itemUpdateRateLimiter = new lib.RateLimiter({
maxRate: 1,
action: () => {
try {
this.broadcastStorage();
} catch (err) {
} catch (err: any) {
this.logger.error(`Unexpected error sending storage update:\n${err.stack}`);
}
},
});
this.itemsLastUpdate = new Map(this.items._items.entries());
this.itemsLastUpdate = new Map(this.items.getEntries());
this.autosaveId = setInterval(() => {
saveDatabase(this.controller.config, this.items, this.logger).catch(err => {
this.logger.error(`Unexpected error autosaving items:\n${err.stack}`);
@@ -105,32 +120,32 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
}
broadcastStorage() {
let itemsToUpdate = new Map();
for (let [name, count] of this.items._items) {
let itemsToUpdateMap:Map<string, number> = new Map();
for (let [name, count] of this.items.getEntries()) {
if (this.itemsLastUpdate.get(name) === count) {
continue;
}
itemsToUpdate.set(name, count);
itemsToUpdateMap.set(name, count);
}
if (!itemsToUpdate.size) {
if (!itemsToUpdateMap.size) {
return;
}
itemsToUpdate = [...itemsToUpdate.entries()];
let update = new UpdateStorageEvent(itemsToUpdate);
let itemsToUpdate = [...itemsToUpdateMap.entries()];
let update = UpdateStorageEvent.fromJSON({ items: itemsToUpdate });
this.controller.sendTo("allInstances", update);
for (let link of this.subscribedControlLinks) {
link.send(update);
}
this.itemsLastUpdate = new Map(this.items._items.entries());
this.itemsLastUpdate = new Map(this.items.getEntries());
}
async handleGetStorageRequest() {
return [...this.items._items.entries()];
return [...this.items.getEntries()];
}
async handlePlaceEvent(request, src) {
async handlePlaceEvent(request: PlaceEvent, src: lib.Address) {
let instanceId = src.id;
for (let item of request.items) {
@@ -147,7 +162,7 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
}
}
async handleRemoveRequest(request, src) {
async handleRemoveRequest(request: RemoveRequest, src: lib.Address) {
let method = this.controller.config.get("subspace_storage.division_method");
let instanceId = src.id;
@@ -171,7 +186,7 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
// use fancy neural net to calculate a "fair" dole division rate.
if (method === "neural_dole") {
for (let item of request.items) {
let count = neuralDole.divider({ name: item.name, count: item.count, instanceId, instanceName });
let count = this.neuralDole.divider({ name: item.name, count: item.count, instanceId, instanceName });
if (count > 0) {
itemsRemoved.push(new Item(item.name, count));
}
@@ -212,7 +227,7 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
return itemsRemoved;
}
async handleSetStorageSubscriptionRequest(request, src) {
async handleSetStorageSubscriptionRequest(request: SetStorageSubscriptionRequest, src: lib.Address) {
let link = this.controller.wsServer.controlConnections.get(src.id);
if (request.storage) {
this.subscribedControlLinks.add(link);
@@ -221,15 +236,15 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
}
}
onControlConnectionEvent(connection, event) {
onControlConnectionEvent(connection: ControlConnection, event: string) {
if (event === "close") {
this.subscribedControlLinks.delete(connection);
}
}
onMetrics() {
async onMetrics() {
if (this.items) {
for (let [key, count] of this.items._items) {
for (let [key, count] of this.items.getEntries()) {
controllerInventoryGauge.labels(key).set(Number(count) || 0);
}
}
@@ -242,7 +257,3 @@ class ControllerPlugin extends lib.BaseControllerPlugin {
await saveDatabase(this.controller.config, this.items, this.logger);
}
}
module.exports = {
ControllerPlugin,
};
@@ -1,7 +1,6 @@
"use strict";
const doleNN = require("./dole_nn_base");
import * as doleNN from "./dole_nn_base";
const lib = require("@clusterio/lib");
import * as lib from "@clusterio/lib";
const { Gauge } = lib;
@@ -17,8 +16,8 @@ const prometheusDoleFactorGauge = new Gauge(
{ labels: ["resource"] }
);
class NeuralDole {
getRequestStats(itemname, samples) {
export class NeuralDole {
getRequestStats(itemname: string, samples: number): number {
let sum = 0;
samples = Math.min(samples, (this.stats[itemname] || []).length - 1);
if (samples < 1) {
@@ -33,25 +32,25 @@ class NeuralDole {
return sum / samples;
}
constructor({
items,
}) {
items: lib.ItemDatabase;
itemsLastTick: Map<string, number>;
dole: any = {};
carry: any = {};
lastRequest: any = {};
stats: any = {};
debt: any = {};
constructor({ items }: { items: lib.ItemDatabase }) {
// Set some storage variables for the dole divider
this.items = items;
this.itemsLastTick = new Map(items._items);
this.dole = {};
this.carry = {};
this.lastRequest = {};
this.stats=[];
this.debt= {};
this.itemsLastTick = new Map(items.getEntries());
}
doMagic() {
for (let [name, count] of this.items._items) {
for (let [name, count] of this.items.getEntries()) {
let magicData = doleNN.tick(
count,
this.dole[name],
this.itemsLastTick.get(name),
this.itemsLastTick.get(name) || 0,
this.getRequestStats(name, 5)
);
this.stats[name] = this.stats[name] || [];
@@ -64,31 +63,31 @@ class NeuralDole {
// DONE handle magicData[1] for graphing for our users
prometheusNNDoleGauge.labels(name).set(magicData[1] || 0);
}
this.itemsLastTick = new Map(this.items._items);
this.itemsLastTick = new Map(this.items.getEntries());
}
divider(object) {
divider(object: { name:string, count:number, instanceId:number, instanceName:string }): number {
let magicData = doleNN.dose(
object.count, // numReq
this.items.getItemCount(object.name),
this.items.getItemCount(object.name) || 0,
this.itemsLastTick.get(object.name) || 0,
this.dole[object.name],
this.carry[`${object.name} ${object.instanceID}`] || 0,
this.lastRequest[`${object.name}_${object.instanceID}_${object.instanceName}`] || 0,
this.carry[`${object.name} ${object.instanceId}`] || 0,
this.lastRequest[`${object.name}_${object.instanceId}_${object.instanceName}`] || 0,
this.getRequestStats(object.name, 5),
this.debt[`${object.name} ${object.instanceID}`] || 0
this.debt[`${object.name} ${object.instanceId}`] || 0
);
if ((this.stats[object.name] || []).length > 0) {
this.stats[object.name][0].req += Number(object.count);
this.stats[object.name][0].given += Number(magicData[0]);
}
this.lastRequest[`${object.name}_${object.instanceID}_${object.instanceName}`] = object.count;
this.lastRequest[`${object.name}_${object.instanceId}_${object.instanceName}`] = object.count;
// 0. Number of items to give in that dose
// 1. New dole for item X
// 2. New carry for item X instance Y
this.dole[object.name] = magicData[1];
this.carry[`${object.name} ${object.instanceID}`] = magicData[2];
this.debt[`${object.name} ${object.instanceID}`] = magicData[3];
this.carry[`${object.name} ${object.instanceId}`] = magicData[2];
this.debt[`${object.name} ${object.instanceId}`] = magicData[3];
// Remove item from DB and send it
this.items.removeItem(object.name, magicData[0]);
@@ -99,13 +98,19 @@ class NeuralDole {
// If the server regularly can't fulfill requests, this number grows until
// it can. Then it slowly shrinks back down.
let _doleDivisionFactor = {};
function doleDivider({
object,
items,
logItemTransfers,
logger,
}) {
let _doleDivisionFactor: { [key:string]: number } = {};
export function doleDivider({
object,
items,
logItemTransfers,
logger,
} :{
object: { name:string, count:number, instanceId:number, instanceName:string },
items: lib.ItemDatabase,
logItemTransfers: boolean,
logger: lib.Logger,
},
) {
let itemCount = items.getItemCount(object.name);
// lower rates will equal more dramatic swings
const doleDivisionRetardation = 10;
@@ -143,8 +148,3 @@ function doleDivider({
return 0;
// console.log(`failure out of ${object.name}/${object.count} from ${object.instanceID} (${object.instanceName})`);
}
module.exports = {
doleDivider,
NeuralDole,
};
@@ -1,25 +1,24 @@
// Sigmoids...
"use strict";
function sigmoid0(x) {
function sigmoid0(x: number): number {
return x / (1 + Math.abs(x));
}
function sigmoid1(x) {
function sigmoid1(x: number): number {
if (x >= 0.0) {
return 1.0;
}
return 0.0;
}
function sigmoid2(x) {
function sigmoid2(x: number): number {
if (x >= 0.0) {
return x;
}
return 0.0;
}
function sigmoid3(x) {
function sigmoid3(x: number) {
return x;
}
@@ -36,12 +35,21 @@ function sigmoid3(x) {
// requests for item, numinstance - number of instances requesting that item
// debt - debt for item X instance Y
// Returns: Array of 3 elements
// Returns: Array of 4 elements
// 0.Number of items to give in that dose
// 1.New dole for item X
// 2.New carry for item X instance Y
// 3.New debt
function dose(numreq, instore, store_last_tick, dole, carry, prev_req, numreq_total_adj, debt) {
export function dose(
numreq: number,
instore: number,
store_last_tick: number,
dole: number,
carry: number,
prev_req: number,
numreq_total_adj: number,
debt: number,
): [number, number, number, number] {
numreq = Number(numreq);
instore = Number(instore);
let instore_adj = instore;
@@ -115,7 +123,12 @@ function dose(numreq, instore, store_last_tick, dole, carry, prev_req, numreq_to
// Returns: Array of 2 elements
// 0.New dole for item X
// 1.Guessed percentage average of items supplied in last 10 ticks
function tick(instore, dole, store_last_tick, numreq_total_adj) {
export function tick(
instore: number,
dole: number,
store_last_tick: number,
numreq_total_adj: number,
): [number, number] {
let instore_adj = instore;
if (instore_adj === 0) {
instore_adj = 0.1;
@@ -148,5 +161,3 @@ function tick(instore, dole, store_last_tick, numreq_total_adj) {
return [sigmoid0(outputs[0]), avg];
}
module.exports = { dose, tick };
@@ -1,6 +1,5 @@
"use strict";
const lib = require("@clusterio/lib");
const messages = require("./messages");
import * as lib from "@clusterio/lib";
import * as messages from "./messages";
class ControllerConfigGroup extends lib.PluginConfigGroup {}
ControllerConfigGroup.defaultAccess = ["controller", "host", "control"];
@@ -52,10 +51,10 @@ module.exports.default = {
name: "subspace_storage",
title: "Subspace Storage",
description: "Provides shared storage across instances for the Subspace Storage mod",
instanceEntrypoint: "instance",
instanceEntrypoint: "dist/plugin/instance",
InstanceConfigGroup,
controllerEntrypoint: "controller",
controllerEntrypoint: "dist/plugin/controller",
ControllerConfigGroup,
messages: [
@@ -1,24 +1,29 @@
"use strict";
const lib = require("@clusterio/lib");
import * as lib from "@clusterio/lib";
const {
import {
PlaceEvent,
RemoveRequest,
GetStorageRequest,
UpdateStorageEvent,
} = require("./messages");
Item,
} from "./messages";
class InstancePlugin extends lib.BaseInstancePlugin {
unexpectedError(err) {
type IpcItems = { name: string, count: number }[];
export class InstancePlugin extends lib.BaseInstancePlugin {
pendingTasks!: Set<any>;
pingId?: ReturnType<typeof setTimeout>;
unexpectedError(err: Error) {
this.logger.error(`Unexpected error:\n${err.stack}`);
}
async init() {
this.pendingTasks = new Set();
this.instance.server.on("ipc-subspace_storage:output", (output) => {
this.instance.server.on("ipc-subspace_storage:output", (output: IpcItems) => {
this.provideItems(output).catch(err => this.unexpectedError(err));
});
this.instance.server.on("ipc-subspace_storage:orders", (orders) => {
this.instance.server.on("ipc-subspace_storage:orders", (orders: IpcItems) => {
if (this.instance.status !== "running" || !this.host.connected) {
return;
}
@@ -57,7 +62,7 @@ class InstancePlugin extends lib.BaseInstancePlugin {
}
// provide items --------------------------------------------------------------
async provideItems(items) {
async provideItems(items: IpcItems) {
if (!this.host.connector.hasSession) {
// For now the items are voided if the controller connection is
// down, which is no different from the previous behaviour.
@@ -68,7 +73,8 @@ class InstancePlugin extends lib.BaseInstancePlugin {
return;
}
this.instance.sendTo("controller", new PlaceEvent(items));
const fromIpcItems = items.map(item => new Item(item.name, item.count))
this.instance.sendTo("controller", new PlaceEvent(fromIpcItems));
if (this.instance.config.get("subspace_storage.log_item_transfers")) {
this.logger.verbose("Exported the following to controller:");
@@ -77,9 +83,10 @@ class InstancePlugin extends lib.BaseInstancePlugin {
}
// request items --------------------------------------------------------------
async requestItems(requestItems) {
async requestItems(requestItems: IpcItems) {
// Request the items all at once
let items = await this.instance.sendTo("controller", new RemoveRequest(requestItems));
let fromIpcItems = requestItems.map(item => new Item(item.name, item.count))
let items = await this.instance.sendTo("controller", new RemoveRequest(fromIpcItems));
if (!items.length) {
return;
@@ -95,14 +102,14 @@ class InstancePlugin extends lib.BaseInstancePlugin {
}
// combinator signals ---------------------------------------------------------
async handleUpdateStorageEvent(request) {
async handleUpdateStorageEvent(event: UpdateStorageEvent) {
if (this.instance.status !== "running") {
return;
}
let items = request.items;
let items = event.items;
// XXX this should be moved to instance/clusterio api
items.push(["signal-unixtime", Math.floor(Date.now()/1000)]);
items.push(new Item("signal-unixtime", Math.floor(Date.now()/1000)));
let itemsJson = lib.escapeString(JSON.stringify(items));
let task = this.sendRcon(`/sc __subspace_storage__ UpdateInvData("${itemsJson}")`, true);
@@ -111,7 +118,3 @@ class InstancePlugin extends lib.BaseInstancePlugin {
await task;
}
}
module.exports = {
InstancePlugin,
};
-167
View File
@@ -1,167 +0,0 @@
"use strict";
const lib = require("@clusterio/lib");
class Item {
/** @type {string} */
name;
/** @type {number} */
count;
constructor(name, count) {
this.name = name;
this.count = count;
}
static jsonSchema = {
type: "array",
minItems: 2,
maxItems: 2,
items: [
{ type: "string" },
{ type: "integer" },
],
};
toJSON() {
return [this.name, this.count];
}
static fromJSON(json) {
return new this(json[0], json[1]);
}
}
// XXX this should be a request to be reliable
class PlaceEvent {
static type = "event";
static src = "instance";
static dst = "controller";
static plugin = "subspace_storage";
/** @type {Array<Item>} */
items;
constructor(items) {
this.items = items;
}
static jsonSchema = {
type: "object",
required: ["items"],
properties: {
"items": {
type: "array",
items: Item.jsonSchema,
},
},
};
static fromJSON(json) {
return new this(json.items);
}
}
class RemoveRequest {
static type = "request";
static src = "instance";
static dst = "controller";
static plugin = "subspace_storage";
/** @type {Array<Item>} */
items;
constructor(items) {
this.items = items;
}
static jsonSchema = {
type: "object",
required: ["items"],
properties: {
"items": {
type: "array",
items: Item.jsonSchema,
},
},
};
static fromJSON(json) {
return new this(json.items);
}
static Response = lib.jsonArray(Item);
}
class GetStorageRequest {
static type = "request";
static src = ["instance", "control"];
static dst = "controller";
static plugin = "subspace_storage";
static permission = "subspace_storage.storage.view";
static Response = lib.jsonArray(Item);
}
class UpdateStorageEvent {
static type = "request";
static src = "controller";
static dst = ["instance", "control"];
static plugin = "subspace_storage";
/** @type {Array<Item>} */
items;
constructor(items) {
this.items = items;
}
static jsonSchema = {
type: "object",
required: ["items"],
properties: {
"items": {
type: "array",
items: Item.jsonSchema,
},
},
};
static fromJSON(json) {
return new this(json.items);
}
}
class SetStorageSubscriptionRequest {
static type = "request";
static src = "control";
static dst = "controller";
static plugin = "subspace_storage";
static permission = "subspace_storage.storage.view";
/** @type {boolean} */
storage;
constructor(storage) {
this.storage = storage;
}
static jsonSchema = {
type: "object",
required: ["storage"],
properties: {
"storage": { type: "boolean" },
},
};
static fromJSON(json) {
return new this(json.storage);
}
}
module.exports = {
Item,
PlaceEvent,
RemoveRequest,
GetStorageRequest,
UpdateStorageEvent,
SetStorageSubscriptionRequest,
};
+122
View File
@@ -0,0 +1,122 @@
import { Type, Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
import { type } from "os";
export class Item {
constructor(
public name: string,
public count: number
) {
}
static jsonSchema = Type.Tuple([
Type.String(),
Type.Number(),
]);
toJSON() {
return [this.name, this.count];
}
static fromJSON(json: Static<typeof Item.jsonSchema>): Item {
return new this(json[0], json[1]);
}
}
// XXX this should be a request to be reliable
export class PlaceEvent {
declare ["constructor"]: typeof PlaceEvent;
static type = "event" as const;
static src = "instance" as const;
static dst = "controller" as const;
static plugin = "subspace_storage" as const;
constructor(
public items: Item[]
) {
}
static jsonSchema = Type.Object({
"items": Type.Array(Item.jsonSchema)
})
static fromJSON(json: Static<typeof PlaceEvent.jsonSchema>): PlaceEvent {
return new this(json.items.map(item => Item.fromJSON(item)));
}
}
export class RemoveRequest {
declare ["constructor"]: typeof RemoveRequest;
static type = "request" as const;
static src = "instance" as const;
static dst = "controller" as const;
static plugin = "subspace_storage" as const;
constructor(
public items: Item[]
) {
}
static jsonSchema = Type.Object({
"items": Type.Array(Item.jsonSchema)
})
static fromJSON(json: Static<typeof RemoveRequest.jsonSchema>): RemoveRequest {
return new this(json.items.map(item => Item.fromJSON(item)));
}
static Response = lib.jsonArray(Item);
}
export class GetStorageRequest {
declare ["constructor"]: typeof GetStorageRequest;
static type = "request" as const;
static src = ["instance", "control"] as const;
static dst = "controller" as const;
static plugin = "subspace_storage" as const;
static permission = "subspace_storage.storage.view" as const;
static Response = lib.jsonArray(Item);
}
export class UpdateStorageEvent {
declare ["constructor"]: typeof UpdateStorageEvent;
static type = "request" as const;
static src = "controller" as const;
static dst = ["instance", "control"] as const;
static plugin = "subspace_storage" as const;
constructor(
public items: Item[]
) {
}
static jsonSchema = Type.Object({
"items": Type.Array(Item.jsonSchema)
})
static fromJSON(json: Static<typeof UpdateStorageEvent.jsonSchema>): UpdateStorageEvent {
return new this(json.items.map(item => Item.fromJSON(item)));
}
}
export class SetStorageSubscriptionRequest {
declare ["constructor"]: typeof SetStorageSubscriptionRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static plugin = "subspace_storage" as const;
static permission = "subspace_storage.storage.view" as const;
constructor(
public storage: boolean
) {
}
static jsonSchema = Type.Object({
"storage": Type.Boolean()
})
static fromJSON(json: Static<typeof SetStorageSubscriptionRequest.jsonSchema>): SetStorageSubscriptionRequest {
return new this(json.storage);
}
}
+6 -2
View File
@@ -6,9 +6,9 @@
"homepage": "https://github.com/clusterio/clusterio#readme",
"license": "MIT",
"repository": "clusterio/clusterio",
"main": "./info.js",
"main": "dist/plugin/info.js",
"scripts": {
"prepare": "webpack-cli --env production",
"prepare": "tsc --build && webpack-cli --env production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
@@ -21,11 +21,15 @@
"@clusterio/lib": "^2.0.0-alpha.0"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.13",
"@clusterio/web_ui": "^2.0.0-alpha.13",
"@types/express": "^4.17.17",
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.4.5",
"antd": "^5.7.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -1,5 +1,7 @@
"use strict";
function addApiRoutes(app, items) {
import type { Application, Request, Response } from "express";
import * as lib from "@clusterio/lib";
export function addApiRoutes(app: Application, items: lib.ItemDatabase) {
/**
* GET endpoint to read the controllers current inventory of items.
@@ -9,12 +11,12 @@ function addApiRoutes(app, items) {
* @alias api/inventory
* @returns {object[]} JSON [{name:"iron-plate", count:100},{name:"copper-plate",count:5}]
*/
app.get("/api/inventory", (req, res) => {
app.get("/api/inventory", (req: Request, res: Response) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
// Check it and send it
let inventory = [];
for (let [name, count] of items._items) {
for (let [name, count] of items.getEntries()) {
inventory.push({ name, count });
}
res.type("json");
@@ -29,15 +31,11 @@ function addApiRoutes(app, items) {
* @alias api/inventoryAsObject
* @returns {object} JSON {"iron-plate":100, "copper-plate":5}
*/
app.get("/api/inventoryAsObject", (req, res) => {
app.get("/api/inventoryAsObject", (req: Request, res: Response) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
// Check it and send it
res.type("json");
res.send(JSON.stringify(items.serialise()));
res.send(JSON.stringify(items.serialize()));
});
}
module.exports = {
addApiRoutes,
};
+4 -4
View File
@@ -2,7 +2,7 @@
// required here in order to track coverage for them.
"use strict";
require("../instance");
require("../controller");
require("../dole");
require("../dole_nn_base");
require("../dist/plugin/instance");
require("../dist/plugin/controller");
require("../dist/plugin/dole");
require("../dist/plugin/dole_nn_base");
+1 -1
View File
@@ -1,7 +1,7 @@
"use strict";
const assert = require("assert").strict;
const routes = require("../routes");
const routes = require("../dist/plugin/routes");
const { get } = require("../../../test/integration");
+16
View File
@@ -0,0 +1,16 @@
{
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"lib": ["es2023"],
"module": "commonjs",
"target": "es2022",
"rootDir": ".",
"outDir": "dist/plugin",
"sourceMap": true,
"strict": true,
"alwaysStrict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { Input, Table, Typography } from "antd";
import * as lib from "@clusterio/lib";
import { notifyErrorHandler, useItemMetadata, useLocale, PageLayout, ControlContext } from "@clusterio/web_ui";
import { GetStorageRequest, SetStorageSubscriptionRequest } from "../messages";
import { GetStorageRequest, SetStorageSubscriptionRequest } from "../dist/plugin/messages";
import "./index.css";
+1 -1
View File
@@ -16,7 +16,7 @@ module.exports = (env = {}) => merge(common(env), {
name: "subspace_storage",
library: { type: "var", name: "plugin_subspace_storage" },
exposes: {
"./info": "./info.js",
"./info": "./dist/plugin/info.js",
"./package.json": "./package.json",
"./web": "./web/index.jsx",
},
+4 -4
View File
@@ -13,10 +13,10 @@ const { LineSplitter, ConsoleTransport, logger } = lib;
// Make sure permissions from plugins are loaded
require("../../plugins/global_chat/dist/plugin/info");
require("../../plugins/player_auth/info");
require("../../plugins/research_sync/info");
require("../../plugins/statistics_exporter/info");
require("../../plugins/subspace_storage/info");
require("../../plugins/player_auth/dist/plugin/info");
require("../../plugins/research_sync/dist/plugin/info");
require("../../plugins/statistics_exporter/dist/plugin/info");
require("../../plugins/subspace_storage/dist/plugin/info");
class TestControl extends lib.Link {