Merge pull request #1052 from bbassie/refactor/migrate-plugins-2.0

Migrate bundled plugins to entrypoint functions
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-10-08 11:12:21 +01:00
commit ce6bfe282f
25 files changed
+1059 -970

No files matched your search

@@ -125,7 +125,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
*
* @returns an async iterator of prometheus metric results or undefined.
*/
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult>>;
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
/**
* Called when the controller is shutting down
+1 -1
View File
@@ -46,7 +46,7 @@ export class HostHooks extends lib.AsyncHookCollection {
*
* @returns an async iterator of prometheus metric results or undefined.
*/
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult>>;
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
/**
* Called when the host is shutting down
+1 -1
View File
@@ -52,7 +52,7 @@ export class InstanceHooks extends lib.AsyncHookCollection {
*
* @returns an async iterator of prometheus metric results or undefined.
*/
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult>>;
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
/**
* Called after the Factorio server is started
+2 -2
View File
@@ -111,7 +111,7 @@ export class AsyncHook<
*
* Returns all successful results.
*/
async collect(...args: Args): Promise<Result[]> {
async collect(...args: Args): Promise<Exclude<Awaited<Result>, void>[]> {
const results = await Promise.all(
[...this._handlers.entries()].map(
([name, handler]) => this._invokeHandler(name, handler, args)
@@ -119,7 +119,7 @@ export class AsyncHook<
);
return results.filter(
(result): result is Awaited<Result> => result !== undefined
(result): result is Exclude<Awaited<Result>, void> => result !== undefined
);
}
+4 -4
View File
@@ -1,5 +1,5 @@
import { CommandTree, Command } from "@clusterio/lib";
import { BaseCtlPlugin } from "@clusterio/ctl";
import type { CtlPluginContext } from "@clusterio/ctl";
import { ChatEvent } from "./messages.js";
const globalChatCommands = new CommandTree({
@@ -14,8 +14,8 @@ globalChatCommands.add(new Command({
},
}));
export class CtlPlugin extends BaseCtlPlugin {
async addCommands(rootCommand: CommandTree) {
export default async function(context: CtlPluginContext) {
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
rootCommand.add(globalChatCommands);
}
});
}
+22 -25
View File
@@ -1,5 +1,5 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
import type { InstancePluginContext } from "@clusterio/host";
import { ChatEvent } from "./messages.js";
/**
@@ -12,41 +12,38 @@ function removeTags(content: string): string {
return content.replace(/\[(?:gps|special-item|train|train-stop)=\S*?\]/gm, "");
}
export class InstancePlugin extends BaseInstancePlugin {
messageQueue: string[] = [];
export default async function(context: InstancePluginContext) {
const { instance, host, plugin } = context;
let messageQueue: string[] = [];
async init() {
this.instance.handle(ChatEvent, this.handleChatEvent.bind(this));
function sendChat(message: string) {
instance.sendTo("allInstances", new ChatEvent(instance.name, message));
}
onControllerConnectionEvent(event: string) {
if (event === "connect") {
for (let message of this.messageQueue) {
this.sendChat(message);
}
this.messageQueue = [];
}
}
async handleChatEvent(event: ChatEvent) {
instance.handle(ChatEvent, async (event: ChatEvent) => {
// TODO check if cross server chat is enabled
let content = `[${event.instanceName}] ${removeTags(event.content)}`;
await this.sendRcon(`/sc game.print('${lib.escapeString(content)}')`, true);
}
await instance.sendRcon(`/sc game.print('${lib.escapeString(content)}')`, true, plugin.name);
});
sendChat(message: string) {
this.instance.sendTo("allInstances", new ChatEvent(this.instance.name, message));
}
instance.hooks.controllerConnectionEvent.attach(plugin.name, (event) => {
if (event === "connect") {
for (let message of messageQueue) {
sendChat(message);
}
messageQueue = [];
}
});
async onOutput(output: lib.ParsedFactorioOutput) {
instance.hooks.output.attach(plugin.name, async (output) => {
if (output.type === "action" && output.action === "CHAT") {
if (this.host.connector.connected) {
this.sendChat(output.message);
if (host.connector.connected) {
sendChat(output.message);
} else {
this.messageQueue.push(output.message);
messageQueue.push(output.message);
}
}
}
});
// TODO implement info command in lua?
}
+25 -20
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import * as mock from "../../../test/mock.js";
import * as lines from "../../../test/lib/factorio/lines.js";
@@ -31,40 +32,44 @@ describe("global_chat plugin", function() {
});
});
describe("class InstancePlugin", function() {
let instancePlugin;
describe("instance entrypoint", function() {
let mockInstance;
before(async function() {
instancePlugin = new instance.InstancePlugin(info, new mock.MockInstance(), new mock.MockHost());
await instancePlugin.init();
try {
lib.registerPluginMessages([info]);
} catch (err) {
// Already registered by the full test suite
}
({ instance: mockInstance } = await mock.loadInstancePlugin(instance.default, info));
});
describe(".handleChatEvent()", function() {
describe("ChatEvent handler", function() {
it("should send received chat as command", async function() {
instancePlugin.instance.server.rconCommands = [];
await instancePlugin.handleChatEvent(new ChatEvent("test", "User: message"));
mockInstance.server.rconCommands = [];
await mock.getHandler(mockInstance, ChatEvent)(new ChatEvent("test", "User: message"));
assert.deepEqual(
instancePlugin.instance.server.rconCommands,
mockInstance.server.rconCommands,
["/sc game.print('[test] User: message')"],
);
});
it("should filter server-specific tags from received chat", async function() {
instancePlugin.instance.server.rconCommands = [];
await instancePlugin.handleChatEvent(new ChatEvent("test", "Train [train=1235]"));
mockInstance.server.rconCommands = [];
await mock.getHandler(mockInstance, ChatEvent)(new ChatEvent("test", "Train [train=1235]"));
assert.deepEqual(
instancePlugin.instance.server.rconCommands,
mockInstance.server.rconCommands,
["/sc game.print('[test] Train ')"],
);
});
});
describe(".onOutput()", function() {
describe("output hook", function() {
it("should forward chat", async function() {
let count = 0;
for (let [_, output] of lines.testLines) {
for (let [line, output] of lines.testLines) {
if (output.type === "action" && output.action === "CHAT") {
instancePlugin.instance.connector.sentMessages = [];
await instancePlugin.onOutput(output);
assert(instancePlugin.instance.connector.sentMessages.length, "message was not sent");
mockInstance.connector.sentMessages = [];
await mockInstance.hooks.output.invoke(output, line);
assert(mockInstance.connector.sentMessages.length, "message was not sent");
count += 1;
}
}
@@ -72,11 +77,11 @@ describe("global_chat plugin", function() {
});
it("should ignore regular output", async function() {
let count = 0;
for (let [_, output] of lines.testLines) {
for (let [line, output] of lines.testLines) {
if (output.type !== "action" || output.action !== "CHAT") {
instancePlugin.instance.connector.sentMessages = [];
await instancePlugin.onOutput(output);
assert(!instancePlugin.instance.connector.sentMessages.length, "message was sent");
mockInstance.connector.sentMessages = [];
await mockInstance.hooks.output.invoke(output, line);
assert(!mockInstance.connector.sentMessages.length, "message was sent");
count += 1;
}
}
+69 -79
View File
@@ -1,4 +1,4 @@
import { BaseControllerPlugin, type InstanceRecord } from "@clusterio/controller";
import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller";
import type { IpcPlayerData } from "./messages.js";
import fs from "node:fs/promises";
@@ -35,35 +35,40 @@ async function saveDatabase(
}
}
export class ControllerPlugin extends BaseControllerPlugin {
acquiredPlayers!: Map<string, { instanceId: number, expiresMs?: number }>;
playerDatastore!: Map<string, IpcPlayerData>;
playerDatastoreDirty = false;
export default async function(context: ControllerPluginContext) {
const { controller, logger, plugin } = context;
const acquiredPlayers = new Map<string, { instanceId: number, expiresMs?: number }>();
const playerDatastore: Map<string, IpcPlayerData> = await loadDatabase(controller.config, logger);
let playerDatastoreDirty = false;
async init() {
this.acquiredPlayers = new Map();
this.playerDatastore = await loadDatabase(this.controller.config, this.logger);
function acquire(instanceId: number, playerName: string): boolean {
let acquisitionRecord = acquiredPlayers.get(playerName);
if (
!acquisitionRecord
|| acquisitionRecord.instanceId === instanceId
|| !controller.instances.has(acquisitionRecord.instanceId)
|| acquisitionRecord.expiresMs && acquisitionRecord.expiresMs < Date.now()
) {
acquiredPlayers.set(playerName, { instanceId });
return true;
}
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));
return false;
}
async onInstanceStatusChanged(instance: InstanceRecord) {
controller.hooks.instanceStatusChanged.attach(plugin.name, async (instance: InstanceRecord) => {
let instanceId = instance.id;
if (["unassigned", "deleted"].includes(instance.status)) {
for (let [playerName, acquisitionRecord] of this.acquiredPlayers) {
for (let [playerName, acquisitionRecord] of acquiredPlayers) {
if (acquisitionRecord.instanceId === instanceId) {
this.acquiredPlayers.delete(playerName);
acquiredPlayers.delete(playerName);
}
}
}
if (["unknown", "stopped"].includes(instance.status)) {
let timeoutMs = this.controller.config.get("inventory_sync.player_lock_timeout") * 1000;
for (let acquisitonRecord of this.acquiredPlayers.values()) {
let timeoutMs = controller.config.get("inventory_sync.player_lock_timeout") * 1000;
for (let acquisitonRecord of acquiredPlayers.values()) {
if (acquisitonRecord.instanceId === instanceId && !acquisitonRecord.expiresMs) {
acquisitonRecord.expiresMs = Date.now() + timeoutMs;
}
@@ -71,82 +76,74 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
if (instance.status === "running") {
for (let acquisitonRecord of this.acquiredPlayers.values()) {
for (let acquisitonRecord of acquiredPlayers.values()) {
if (acquisitonRecord.instanceId === instanceId && acquisitonRecord.expiresMs) {
delete acquisitonRecord.expiresMs;
}
}
}
}
});
acquire(instanceId: number, playerName: string): boolean {
let acquisitionRecord = this.acquiredPlayers.get(playerName);
if (
!acquisitionRecord
|| acquisitionRecord.instanceId === instanceId
|| !this.controller.instances.has(acquisitionRecord.instanceId)
|| acquisitionRecord.expiresMs && acquisitionRecord.expiresMs < Date.now()
) {
this.acquiredPlayers.set(playerName, { instanceId });
return true;
controller.hooks.save.attach(plugin.name, async () => {
if (playerDatastoreDirty) {
playerDatastoreDirty = false;
await saveDatabase(controller.config, playerDatastore, logger);
}
});
return false;
}
async handleAcquireRequest(request: msg.AcquireRequest) {
controller.handle(msg.AcquireRequest, async (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)!;
if (!acquire(instanceId, playerName)) {
let acquisitionRecord = acquiredPlayers.get(playerName);
let instance = controller.instances.get(acquisitionRecord!.instanceId)!;
return {
status: "busy",
message: instance.config.get("instance.name"),
};
}
let playerData = this.playerDatastore.get(playerName);
let playerData = playerDatastore.get(playerName);
return new msg.AcquireRequest.Response(
"acquired",
playerData ? playerData.generation : 0,
Boolean(playerData),
);
}
});
async handleReleaseRequest(request: msg.ReleaseRequest) {
controller.handle(msg.ReleaseRequest, async (request: msg.ReleaseRequest) => {
let { instanceId, playerName } = request;
let acquisitionRecord = this.acquiredPlayers.get(playerName);
let acquisitionRecord = acquiredPlayers.get(playerName);
if (!acquisitionRecord) {
return;
}
if (acquisitionRecord.instanceId === instanceId) {
this.acquiredPlayers.delete(playerName);
acquiredPlayers.delete(playerName);
}
}
});
async handleUploadRequest(request: msg.UploadRequest) {
controller.handle(msg.UploadRequest, async (request: msg.UploadRequest) => {
let { instanceId, playerName, playerData } = request;
let instanceName = this.controller.instances.get(instanceId)!.config.get("instance.name");
let instanceName = controller.instances.get(instanceId)!.config.get("instance.name");
let store = true;
let acquisitionRecord = this.acquiredPlayers.get(playerName);
let acquisitionRecord = acquiredPlayers.get(playerName);
if (!acquisitionRecord) {
this.logger.warn(`${instanceName} uploaded ${playerName} without an acquisition`);
logger.warn(`${instanceName} uploaded ${playerName} without an acquisition`);
// Allow upload in this case as it might come from a crashed instance that restarted and is now
// uploading the player data for all the players that were online during the last autosave.
} else if (acquisitionRecord.instanceId !== instanceId) {
this.logger.warn(`${instanceName} uploaded ${playerName} while another instance has acquired it`);
logger.warn(`${instanceName} uploaded ${playerName} while another instance has acquired it`);
store = false;
} else {
this.acquiredPlayers.delete(playerName);
acquiredPlayers.delete(playerName);
}
this.acquiredPlayers.delete(playerName);
let oldPlayerData = this.playerDatastore.get(playerName);
acquiredPlayers.delete(playerName);
let oldPlayerData = playerDatastore.get(playerName);
if (store && oldPlayerData && oldPlayerData.generation >= playerData.generation) {
this.logger.warn(
logger.warn(
`${instanceName} uploaded generation ${playerData.generation} while the stored` +
`generation is ${oldPlayerData.generation} for ${playerName}`
);
@@ -154,48 +151,41 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
if (store) {
this.logger.verbose(`Received player data for ${playerName} from ${instanceName}`);
this.playerDatastore.set(playerName, playerData);
this.playerDatastoreDirty = true;
logger.verbose(`Received player data for ${playerName} from ${instanceName}`);
playerDatastore.set(playerName, playerData);
playerDatastoreDirty = true;
}
}
});
async handleDownloadRequest(request: msg.DownloadRequest) {
controller.handle(msg.DownloadRequest, async (request: msg.DownloadRequest) => {
let { instanceId, playerName } = request;
let instanceName = this.controller.instances.get(instanceId)!.config.get("instance.name");
let instanceName = controller.instances.get(instanceId)!.config.get("instance.name");
let acquisitionRecord = this.acquiredPlayers.get(playerName);
let acquisitionRecord = acquiredPlayers.get(playerName);
if (!acquisitionRecord) {
this.logger.warn(`${instanceName} downloaded ${playerName} without an acquisition`);
logger.warn(`${instanceName} downloaded ${playerName} without an acquisition`);
} else if (acquisitionRecord.instanceId !== instanceId) {
this.logger.warn(`${instanceName} downloaded ${playerName} while another instance has acquired it`);
logger.warn(`${instanceName} downloaded ${playerName} while another instance has acquired it`);
}
this.logger.verbose(`Sending player data for ${playerName} to ${instanceName}`);
return new msg.DownloadRequest.Response(this.playerDatastore.get(playerName) || null);
}
logger.verbose(`Sending player data for ${playerName} to ${instanceName}`);
return new msg.DownloadRequest.Response(playerDatastore.get(playerName) || null);
});
async onSaveData() {
if (this.playerDatastoreDirty) {
this.playerDatastoreDirty = false;
await saveDatabase(this.controller.config, this.playerDatastore, this.logger);
}
}
async handleDatabaseStatsRequest() {
let playerDatastore = Array.from(this.playerDatastore.keys())
controller.handle(msg.DatabaseStatsRequest, async () => {
let entries = Array.from(playerDatastore.keys())
.map(name => ({
name,
length: JSON.stringify(this.playerDatastore.get(name)).length,
length: JSON.stringify(playerDatastore.get(name)).length,
}))
.sort((a, b) => b.length - a.length);
return new msg.DatabaseStatsRequest.Response(
playerDatastore.map(x => x.length).reduce((acc, val) => acc + val, 0),
playerDatastore.length,
entries.map(x => x.length).reduce((acc, val) => acc + val, 0),
entries.length,
{
name: playerDatastore[0] && playerDatastore[0].name || "-",
size: playerDatastore[0] && playerDatastore[0].length || 0,
name: entries[0] && entries[0].name || "-",
size: entries[0] && entries[0].length || 0,
},
);
}
});
}
+110 -109
View File
@@ -1,7 +1,7 @@
import util from "util";
import zlib from "zlib";
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
import type { InstancePluginContext } from "@clusterio/host";
import {
AcquireRequest, AcquireResponse, ReleaseRequest, UploadRequest, DownloadRequest, DownloadResponse, IpcPlayerData,
} from "./messages.js";
@@ -76,67 +76,36 @@ export function recipeNotificationDelta(stored: string[], current: string[]): Re
return delta;
}
export class InstancePlugin extends 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",
(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",
(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",
(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",
(request: IpcDownloadRequest) => this.handleDownload(request).catch(
err => this.logger.error(`Error handling ipc-inventory_sync_download:\n${err.stack}`)
),
);
/**
* Replace the stored recipe notifications with the difference from the
* current state on the instance to reduce the amount of data sent to Lua.
*/
export async function applyRecipeNotificationDelta(
playerData: IpcPlayerData,
current: string | undefined,
logger: lib.Logger,
) {
if (!playerData.recipe_notifications) {
return;
}
async onPrepareControllerDisconnect() {
this.disconnecting = true;
try {
const delta = recipeNotificationDelta(
JSON.parse(await decodeLuaString(playerData.recipe_notifications)),
current ? JSON.parse(await decodeLuaString(current)) : [],
);
playerData.recipe_notifications = await encodeLuaString(JSON.stringify(delta));
} catch (err: any) {
logger.warn(`Dropping invalid recipe notifications for ${playerData.name}:\n${err.stack}`);
delete playerData.recipe_notifications;
}
}
export default async function(context: InstancePluginContext) {
const { instance, host, logger, plugin } = context;
const playersToRelease = new Set<string>();
let disconnecting = false;
onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
if (event === "connect") {
this.disconnecting = false;
(async () => {
for (let player_name of this.playersToRelease) {
if (!this.host.connector.connected || this.disconnecting) {
return;
}
this.playersToRelease.delete(player_name);
await this.instance.sendTo(
"controller",
new ReleaseRequest(this.instance.id, player_name)
);
}
})().catch(
err => this.logger.error(`Unpexpected error releasing queued up players:\n${err.stack}`)
);
}
}
async handleAcquire(request: IpcPlayerName) {
async function handleAcquire(request: IpcPlayerName) {
let response: IpcAcquireResponse = {
player_name: request.player_name,
status: "error",
@@ -145,11 +114,11 @@ export class InstancePlugin extends BaseInstancePlugin {
generation: undefined,
};
if (this.host.connector.connected && !this.disconnecting) {
if (host.connector.connected && !disconnecting) {
try {
let acquireResponse: AcquireResponse = await this.instance.sendTo(
let acquireResponse: AcquireResponse = await instance.sendTo(
"controller",
new AcquireRequest(this.instance.id, request.player_name),
new AcquireRequest(instance.id, request.player_name),
);
response = {
player_name: request.player_name,
@@ -160,105 +129,137 @@ export class InstancePlugin extends BaseInstancePlugin {
};
} catch (err: any) {
if (!(err instanceof lib.SessionLost)) {
this.logger.error(`Unexpected error sending aquire request:\n${err.stack}`);
logger.error(`Unexpected error sending aquire request:\n${err.stack}`);
response.message = err.message;
}
}
}
let json = lib.escapeString(JSON.stringify(response));
await this.sendRcon(`/sc inventory_sync.acquire_response("${json}")`, true);
await instance.sendRcon(`/sc inventory_sync.acquire_response("${json}")`, true, plugin.name);
}
async handleRelease(request: IpcPlayerName) {
if (!this.host.connector.connected) {
this.playersToRelease.add(request.player_name);
async function handleRelease(request: IpcPlayerName) {
if (!host.connector.connected) {
playersToRelease.add(request.player_name);
}
try {
await this.instance.sendTo(
await instance.sendTo(
"controller",
new ReleaseRequest(this.instance.id, request.player_name)
new ReleaseRequest(instance.id, request.player_name)
);
} catch (err: any) {
if (err instanceof lib.SessionLost) {
this.playersToRelease.add(request.player_name);
playersToRelease.add(request.player_name);
} else {
this.logger.error(`Unexpected error releasing player ${request.player_name}:\n${err.stack}`);
logger.error(`Unexpected error releasing player ${request.player_name}:\n${err.stack}`);
}
}
}
async handleUpload(player_data: IpcPlayerData) {
if (!this.host.connector.connected || this.disconnecting) {
async function handleUpload(player_data: IpcPlayerData) {
if (!host.connector.connected || disconnecting) {
return;
}
this.logger.verbose(`Uploading ${player_data.name} (${JSON.stringify(player_data).length / 1000}kB)`);
logger.verbose(`Uploading ${player_data.name} (${JSON.stringify(player_data).length / 1000}kB)`);
try {
await this.instance.sendTo(
await instance.sendTo(
"controller",
new UploadRequest(this.instance.id, player_data.name, player_data),
new UploadRequest(instance.id, player_data.name, player_data),
);
} catch (err: any) {
if (!(err instanceof lib.SessionLost)) {
this.logger.error(`Unexpected error uploading inventory for ${player_data.name}:\n${err.stack}`);
logger.error(`Unexpected error uploading inventory for ${player_data.name}:\n${err.stack}`);
}
return;
}
await this.sendRcon(
`/sc inventory_sync.confirm_upload("${player_data.name}", ${player_data.generation})`, true
await instance.sendRcon(
`/sc inventory_sync.confirm_upload("${player_data.name}", ${player_data.generation})`, true, plugin.name
);
}
/**
* Replace the stored recipe notifications with the difference from the
* current state on the instance to reduce the amount of data sent to Lua.
*/
async applyRecipeNotificationDelta(playerData: IpcPlayerData, current?: string) {
if (!playerData.recipe_notifications) {
return;
}
try {
const delta = recipeNotificationDelta(
JSON.parse(await decodeLuaString(playerData.recipe_notifications)),
current ? JSON.parse(await decodeLuaString(current)) : [],
);
playerData.recipe_notifications = await encodeLuaString(JSON.stringify(delta));
} catch (err: any) {
this.logger.warn(`Dropping invalid recipe notifications for ${playerData.name}:\n${err.stack}`);
delete playerData.recipe_notifications;
}
}
async handleDownload(request: IpcDownloadRequest) {
async function handleDownload(request: IpcDownloadRequest) {
const playerName = request.player_name;
this.logger.verbose(`Downloading ${playerName}`);
logger.verbose(`Downloading ${playerName}`);
let response: DownloadResponse = await this.instance.sendTo(
let response: DownloadResponse = await instance.sendTo(
"controller",
new DownloadRequest(this.instance.id, playerName)
new DownloadRequest(instance.id, playerName)
);
if (!response.playerData) {
await this.sendRcon(`/sc inventory_sync.download_inventory('${playerName}',nil,0,0)`, true);
await instance.sendRcon(
`/sc inventory_sync.download_inventory('${playerName}',nil,0,0)`, true, plugin.name
);
return;
}
await this.applyRecipeNotificationDelta(response.playerData, request.recipe_notifications);
await applyRecipeNotificationDelta(response.playerData, request.recipe_notifications, logger);
const chunkSize = this.instance.config.get("inventory_sync.rcon_chunk_size");
const chunkSize = instance.config.get("inventory_sync.rcon_chunk_size");
const chunks = chunkify(chunkSize, JSON.stringify(response.playerData));
this.logger.verbose(`Sending inventory for ${playerName} in ${chunks.length} chunks`);
logger.verbose(`Sending inventory for ${playerName} in ${chunks.length} chunks`);
for (let i = 0; i < chunks.length; i++) {
// this.logger.verbose(`Sending chunk ${i+1} of ${chunks.length}`)
const chunk = lib.escapeString(chunks[i]);
await this.sendRcon(
await instance.sendRcon(
`/sc inventory_sync.download_inventory('${playerName}','${chunk}',${i + 1},${chunks.length})`,
true
true,
plugin.name,
);
}
}
// Handle IPC from scenario script
instance.server.on(
"ipc-inventory_sync_acquire",
(request: IpcPlayerName) => handleAcquire(request).catch(
err => logger.error(`Error handling ipc-inventory_sync_acquire:\n${err.stack}`)
),
);
instance.server.on(
"ipc-inventory_sync_release",
(request: IpcPlayerName) => handleRelease(request).catch(
err => logger.error(`Error handling ipc-inventory_sync_release:\n${err.stack}`)
),
);
instance.server.on(
"ipc-inventory_sync_upload",
(player_data: IpcPlayerData) => handleUpload(player_data).catch(
err => logger.error(`Error handling ipc-inventory_sync_upload:\n${err.stack}`)
),
);
instance.server.on(
"ipc-inventory_sync_download",
(request: IpcDownloadRequest) => handleDownload(request).catch(
err => logger.error(`Error handling ipc-inventory_sync_download:\n${err.stack}`)
),
);
instance.hooks.prepareControllerDisconnect.attach(plugin.name, async () => {
disconnecting = true;
});
instance.hooks.controllerConnectionEvent.attach(plugin.name, async (event) => {
if (event === "connect") {
disconnecting = false;
(async () => {
for (let player_name of playersToRelease) {
if (!host.connector.connected || disconnecting) {
return;
}
playersToRelease.delete(player_name);
await instance.sendTo(
"controller",
new ReleaseRequest(instance.id, player_name)
);
}
})().catch(
err => logger.error(`Unpexpected error releasing queued up players:\n${err.stack}`)
);
}
});
}
+24 -25
View File
@@ -4,7 +4,7 @@ import zlib from "node:zlib";
import * as lib from "@clusterio/lib";
import * as mock from "../../../test/mock.js";
import { recipeNotificationDelta, InstancePlugin } from "../dist/node/instance.js";
import loadInstancePlugin, { recipeNotificationDelta, applyRecipeNotificationDelta } from "../dist/node/instance.js";
import { DownloadResponse } from "../dist/node/messages.js";
import { plugin as info } from "../dist/node/index.js";
@@ -38,10 +38,10 @@ describe("inventory_sync", function() {
});
});
describe("InstancePlugin.applyRecipeNotificationDelta()", function() {
describe("applyRecipeNotificationDelta()", function() {
const warnings = [];
const plugin = { logger: { warn: msg => warnings.push(msg) } };
const apply = InstancePlugin.prototype.applyRecipeNotificationDelta.bind(plugin);
const logger = { warn: msg => warnings.push(msg) };
const apply = (playerData, current) => applyRecipeNotificationDelta(playerData, current, logger);
it("should replace the stored list with an encoded delta", async function() {
const playerData = { name: "test", recipe_notifications: await encode(["a", "b"]) };
@@ -71,28 +71,35 @@ describe("inventory_sync", function() {
});
});
describe("InstancePlugin.handleDownload()", function() {
let instancePlugin;
describe("ipc-inventory_sync_download", function() {
let instance;
before(async function() {
try {
lib.registerPluginMessages([info]);
} catch (err) {
// Already registered by the full test suite
}
instancePlugin = await mock.createInstancePlugin(InstancePlugin, info);
instancePlugin.instance.mockConfigEntries.set("inventory_sync.rcon_chunk_size", 100000);
({ instance } = await mock.loadInstancePlugin(loadInstancePlugin, info));
instance.mockConfigEntries.set("inventory_sync.rcon_chunk_size", 100000);
});
beforeEach(function() {
instancePlugin.instance.server.reset();
instance.server.reset();
});
function respondWith(playerData) {
instancePlugin.instance.connector.once("send", message => {
instancePlugin.instance.connector.emit("message", new lib.MessageResponse(
instance.connector.once("send", message => {
instance.connector.emit("message", new lib.MessageResponse(
1, message.dst, message.src, new DownloadResponse(playerData)
));
});
}
async function download(request) {
instance.server.emit("ipc-inventory_sync_download", request);
// zlib runs on the threadpool, so poll on timers rather than setImmediate
for (let i = 0; i < 1000 && !instance.server.rconCommands.length; i++) {
await lib.wait(1);
}
}
function downloadCommand(playerData) {
const json = lib.escapeString(JSON.stringify(playerData));
return `/sc inventory_sync.download_inventory('test','${json}',1,1)`;
@@ -100,31 +107,23 @@ describe("inventory_sync", function() {
it("should send an empty download when the controller has no data", async function() {
respondWith(undefined);
await instancePlugin.handleDownload({ player_name: "test" });
await download({ player_name: "test" });
assert.deepEqual(
instancePlugin.instance.server.rconCommands,
instance.server.rconCommands,
["/sc inventory_sync.download_inventory('test',nil,0,0)"]
);
});
it("should send everything as a delta when no snapshot was given", async function() {
respondWith({ generation: 1, name: "test", recipe_notifications: await encode(["a"]) });
await instancePlugin.handleDownload({ player_name: "test" });
await download({ player_name: "test" });
const expected = { generation: 1, name: "test", recipe_notifications: await encode({ add: ["a"] }) };
assert.deepEqual(instancePlugin.instance.server.rconCommands, [downloadCommand(expected)]);
assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]);
});
it("should send a delta when a snapshot was given", async function() {
respondWith({ generation: 1, name: "test", recipe_notifications: await encode(["a", "b"]) });
await instancePlugin.handleDownload({ player_name: "test", recipe_notifications: await encode(["b"]) });
await download({ player_name: "test", recipe_notifications: await encode(["b"]) });
const expected = { generation: 1, name: "test", recipe_notifications: await encode({ add: ["a"] }) };
assert.deepEqual(instancePlugin.instance.server.rconCommands, [downloadCommand(expected)]);
});
it("should be invoked by the download ipc", async function() {
respondWith(undefined);
instancePlugin.instance.server.emit("ipc-inventory_sync_download", { player_name: "test" });
for (let i = 0; i < 100 && !instancePlugin.instance.server.rconCommands.length; i++) {
await new Promise(resolve => setImmediate(resolve));
}
assert.equal(instancePlugin.instance.server.rconCommands.length, 1);
assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]);
});
});
});
+12 -12
View File
@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useState } from "react";
import { BaseWebPlugin, PageLayout, PageHeader, ControlContext } from "@clusterio/web_ui";
import { PageLayout, PageHeader, ControlContext, type WebPluginContext } from "@clusterio/web_ui";
import { DatabaseStatsRequest, DatabaseStatsResponse } from "../messages.js";
import "./style.css";
@@ -27,15 +27,15 @@ function InventoryPage() {
</PageLayout>;
}
export class WebPlugin extends BaseWebPlugin {
async init() {
this.pages = [
{
path: "/inventory",
sidebarName: "Inventory sync",
permission: "inventory_sync.inventory.view",
content: <InventoryPage />,
},
];
}
export default async function(context: WebPluginContext) {
const { control, plugin } = context;
control.hooks.pages.attach(plugin.name, () => [
{
path: "/inventory",
sidebarName: "Inventory sync",
permission: "inventory_sync.inventory.view",
content: <InventoryPage />,
},
]);
}
+80 -85
View File
@@ -4,7 +4,7 @@ import util from "util";
import jwt from "jsonwebtoken";
import { Static } from "@sinclair/typebox";
import { BaseControllerPlugin } from "@clusterio/controller";
import type { ControllerPluginContext } from "@clusterio/controller";
import { basicType, RequestError } from "@clusterio/lib";
import { FetchPlayerCodeRequest, PlayerAuthServer, SetVerifyCodeRequest } from "./messages.js";
@@ -28,75 +28,88 @@ async function generateCode(length: number): Promise<string> {
type PlayerCode = { playerCode: string, verifyCode: string | null, expiresMs: number };
export class ControllerPlugin extends BaseControllerPlugin {
players!: Map<string, PlayerCode>;
export default async function loadControllerPlugin(context: ControllerPluginContext) {
const { controller } = context;
async init() {
// Store of validation attempts by players
this.players = new Map();
// Store of validation attempts by players
const players = new Map<string, PlayerCode>();
// Periodically remove expired entries
setInterval(() => {
let now = Date.now();
for (let [player, entry] of this.players) {
if (entry.expiresMs < now) {
this.players.delete(player);
}
// Periodically remove expired entries
setInterval(() => {
let now = Date.now();
for (let [player, entry] of players) {
if (entry.expiresMs < now) {
players.delete(player);
}
}, 60e3).unref();
}
}, 60e3).unref();
this.controller.app.get("/api/player_auth/servers", (req: Request, res: Response) => {
const servers: Static<typeof PlayerAuthServer.jsonSchema>[] = [];
controller.app.get("/api/player_auth/servers", (req: Request, res: Response) => {
const servers: Static<typeof PlayerAuthServer.jsonSchema>[] = [];
for (const instance of this.controller.instances.values()) {
const pluginLoaded = instance.config.get("player_auth.load_plugin");
const assignedHost = instance.config.get("instance.assigned_host");
if (instance.status !== "running" || !pluginLoaded || assignedHost === null) {
continue;
}
const host = this.controller.hosts.get(assignedHost);
if (!host) {
continue;
}
const address = instance.gamePort !== undefined
? `${host.publicAddress}:${instance.gamePort}`
: host.publicAddress;
const settings = instance.config.get("factorio.settings");
const includeAddress = this.controller.config.get("player_auth.show_connect_address");
servers.push({
name: settings["name"] as string || "unnamed server",
factorioVersion: instance.factorioVersion,
address: (includeAddress && host.publicAddress !== "") ? address : undefined,
});
for (const instance of controller.instances.values()) {
const pluginLoaded = instance.config.get("player_auth.load_plugin");
const assignedHost = instance.config.get("instance.assigned_host");
if (instance.status !== "running" || !pluginLoaded || assignedHost === null) {
continue;
}
res.send(servers);
});
this.controller.app.post(
"/api/player_auth/player_code",
express.json(),
(req: Request, res: Response, next: any) => {
this.handlePlayerCode(req, res).catch(next);
const host = controller.hosts.get(assignedHost);
if (!host) {
continue;
}
);
this.controller.app.post(
"/api/player_auth/verify",
express.json(),
(req: Request, res: Response, next: any) => {
this.handleVerify(req, res).catch(next);
}
);
const address = instance.gamePort !== undefined
? `${host.publicAddress}:${instance.gamePort}`
: host.publicAddress;
this.controller.handle(FetchPlayerCodeRequest, this.handleFetchPlayerCodeRequest.bind(this));
this.controller.handle(SetVerifyCodeRequest, this.handleSetVerifyCodeRequest.bind(this));
}
const settings = instance.config.get("factorio.settings");
const includeAddress = controller.config.get("player_auth.show_connect_address");
servers.push({
name: settings["name"] as string || "unnamed server",
factorioVersion: instance.factorioVersion,
address: (includeAddress && host.publicAddress !== "") ? address : undefined,
});
}
async handlePlayerCode(req: Request, res: Response) {
res.send(servers);
});
controller.app.post(
"/api/player_auth/player_code",
express.json(),
(req: Request, res: Response, next: any) => {
handlePlayerCode(req, res).catch(next);
}
);
controller.app.post(
"/api/player_auth/verify",
express.json(),
(req: Request, res: Response, next: any) => {
handleVerify(req, res).catch(next);
}
);
controller.handle(FetchPlayerCodeRequest, async (request: FetchPlayerCodeRequest) => {
let playerCode = await generateCode(controller.config.get("player_auth.code_length"));
let expiresMs = Date.now() + controller.config.get("player_auth.code_timeout") * 1000;
players.set(request.player, { playerCode, verifyCode: null, expiresMs });
return { playerCode, controllerUrl: controller.getControllerUrl() };
});
controller.handle(SetVerifyCodeRequest, async (request: SetVerifyCodeRequest) => {
let { player, verifyCode } = request;
let entry = players.get(player);
if (!entry || entry.expiresMs < Date.now()) {
throw new RequestError("invalid player");
}
entry.verifyCode = verifyCode;
});
async function handlePlayerCode(req: Request, res: Response) {
if (basicType(req.body) !== "object") {
res.sendStatus(400);
return;
@@ -108,9 +121,9 @@ export class ControllerPlugin extends BaseControllerPlugin {
return;
}
for (let entry of this.players.values()) {
for (let entry of players.values()) {
if (entry.playerCode === playerCode && entry.expiresMs > Date.now()) {
let verifyCode = await generateCode(this.controller.config.get("player_auth.code_length"));
let verifyCode = await generateCode(controller.config.get("player_auth.code_length"));
let verifyToken = jwt.sign(
{
aud: "player_auth.verify_code",
@@ -118,7 +131,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
verify_code: verifyCode,
player_code: playerCode,
},
this.controller.authSecret
controller.authSecret
);
res.send({ verify_code: verifyCode, verify_token: verifyToken });
@@ -129,7 +142,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
res.send({ error: true, message: "invalid player_code" });
}
async handleVerify(req: Request, res: Response) {
async function handleVerify(req: Request, res: Response) {
if (basicType(req.body) !== "object") {
res.sendStatus(400);
return;
@@ -155,7 +168,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
try {
let payload = jwt.verify(
verifyToken, this.controller.authSecret, { audience: "player_auth.verify_code" }
verifyToken, controller.authSecret, { audience: "player_auth.verify_code" }
) as jwt.JwtPayload;
if (payload.verify_code !== verifyCode) {
@@ -171,16 +184,16 @@ export class ControllerPlugin extends BaseControllerPlugin {
return;
}
for (let [player, entry] of this.players) {
for (let [player, entry] of players) {
if (entry.playerCode === playerCode && entry.expiresMs > Date.now()) {
if (entry.verifyCode === verifyCode) {
let user = this.controller.users.getByName(player);
let user = controller.users.getByName(player);
if (!user) {
res.send({ error: true, message: "invalid user" });
return;
}
let token = this.controller.users.signUserToken(user);
let token = controller.users.signUserToken(user);
res.send({ verified: true, token });
return;
@@ -193,24 +206,6 @@ export class ControllerPlugin extends BaseControllerPlugin {
res.send({ error: true, message: "invalid player_code" });
}
async handleFetchPlayerCodeRequest(request: FetchPlayerCodeRequest) {
let playerCode = await generateCode(this.controller.config.get("player_auth.code_length"));
let expiresMs = Date.now() + this.controller.config.get("player_auth.code_timeout") * 1000;
this.players.set(request.player, { playerCode, verifyCode: null, expiresMs });
return { playerCode, controllerUrl: this.controller.getControllerUrl() };
}
async handleSetVerifyCodeRequest(request: SetVerifyCodeRequest) {
let { player, verifyCode } = request;
let entry = this.players.get(player);
if (!entry || entry.expiresMs < Date.now()) {
throw new RequestError("invalid player");
}
entry.verifyCode = verifyCode;
}
}
// For testing only
+22 -18
View File
@@ -1,4 +1,4 @@
import { BaseInstancePlugin } from "@clusterio/host";
import type { InstancePluginContext } from "@clusterio/host";
import { FetchPlayerCodeRequest, SetVerifyCodeRequest } from "./messages.js";
@@ -11,42 +11,46 @@ type IpcPlayerAuth = {
verify_code: string,
}
export class InstancePlugin extends BaseInstancePlugin {
async init() {
this.instance.server.on(
"ipc-player_auth",
(ipcPlayerAuth: IpcPlayerAuth) => this.handleEvent(ipcPlayerAuth).catch(
err => this.logger.error(`Error handling event:\n${err.stack}`)
)
);
export default async function loadInstancePlugin(context: InstancePluginContext) {
const { instance, host, logger, plugin } = context;
async function sendRcon(command: string) {
await instance.sendRcon(command, false, plugin.name);
}
async handleEvent(event: IpcPlayerAuth) {
async function 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`);
if (!host.connector.connected) {
await sendRcon(`/web-login error ${event.player} login is temporarily unavailable`);
return;
}
let response;
try {
response = await this.instance.sendTo("controller", new FetchPlayerCodeRequest(event.player));
response = await instance.sendTo("controller", new FetchPlayerCodeRequest(event.player));
} catch (err: any) {
await this.sendRcon(`/web-login error ${event.player} ${err.message}`);
await sendRcon(`/web-login error ${event.player} ${err.message}`);
return;
}
await this.sendRcon(`/web-login open ${event.player} ${response.controllerUrl} ${response.playerCode}`);
await sendRcon(`/web-login open ${event.player} ${response.controllerUrl} ${response.playerCode}`);
} else if (event.type === "set_verify_code") {
try {
await this.instance.sendTo("controller", new SetVerifyCodeRequest(event.player, event.verify_code));
await instance.sendTo("controller", new SetVerifyCodeRequest(event.player, event.verify_code));
} catch (err: any) {
await this.sendRcon(`/web-login error ${event.player} ${err.message}`);
await sendRcon(`/web-login error ${event.player} ${err.message}`);
return;
}
await this.sendRcon(`/web-login code_set ${event.player}`);
await sendRcon(`/web-login code_set ${event.player}`);
}
}
instance.server.on(
"ipc-player_auth",
(ipcPlayerAuth: IpcPlayerAuth) => handleEvent(ipcPlayerAuth).catch(
err => logger.error(`Error handling event:\n${err.stack}`)
)
);
}
+114 -113
View File
@@ -4,7 +4,7 @@ import jwt from "jsonwebtoken";
import * as mock from "../../../test/mock.js";
import * as controller from "../dist/node/controller.js";
import * as instance from "../dist/node/instance.js";
import * as instancePlugin from "../dist/node/instance.js";
import { plugin as info } from "../dist/node/index.js";
import { FetchPlayerCodeRequest, SetVerifyCodeRequest, PlayerAuthServer } from "../dist/node/messages.js";
import { testRoundTripJsonSerialisable, testMatrix } from "../../../test/common.js";
@@ -81,27 +81,54 @@ describe("player_auth", function() {
});
});
describe("ControllerPlugin", function() {
let controllerPlugin;
describe("entrypoint", function() {
let mockController;
let controllerUrl;
let endpoint;
let fetchPlayerCode;
let setVerifyCode;
// Adds a pending code for player through the request handlers
async function addPlayer(player, { timeout = 1, verifyCode } = {}) {
mockController.mockConfigEntries.set("player_auth.code_timeout", timeout);
try {
const { playerCode } = await fetchPlayerCode(new FetchPlayerCodeRequest(player));
if (verifyCode) {
await setVerifyCode(new SetVerifyCodeRequest(player, verifyCode));
}
return playerCode;
} finally {
mockController.mockConfigEntries.set("player_auth.code_timeout", 1);
}
}
function signVerifyToken(playerCode, verifyCode) {
return jwt.sign({
aud: "player_auth.verify_code",
player_code: playerCode,
verify_code: verifyCode,
}, Buffer.from("TestSecretDoNotUse", "base64"));
}
before(async function() {
controllerPlugin = await mock.createControllerPlugin(controller.ControllerPlugin, info);
controllerPlugin.controller.mockConfigEntries.set("player_auth.code_length", 10);
controllerPlugin.controller.mockConfigEntries.set("player_auth.code_timeout", 1);
controllerPlugin.controller.mockConfigEntries.set("player_auth.show_connect_address", true);
controllerUrl = await controllerPlugin.controller.startServer();
mockController = await mock.loadControllerPlugin(controller.default, info);
mockController.mockConfigEntries.set("player_auth.code_length", 10);
mockController.mockConfigEntries.set("player_auth.code_timeout", 1);
mockController.mockConfigEntries.set("player_auth.show_connect_address", true);
fetchPlayerCode = mock.getHandler(mockController, FetchPlayerCodeRequest);
setVerifyCode = mock.getHandler(mockController, SetVerifyCodeRequest);
controllerUrl = await mockController.startServer();
});
after(async function() {
if (controllerPlugin) {
await controllerPlugin.controller.stopServer();
if (mockController) {
await mockController.stopServer();
}
});
describe("/api/player_auth/servers", function() {
it("should return a list of running servers with player_auth loaded", async function() {
function addInstance(id, status, load, name, version, gamePort, host=1) {
controllerPlugin.controller.instances.records.set(id, {
mockController.instances.records.set(id, {
config: {
get(field) {
if (field === "player_auth.load_plugin") {
@@ -135,7 +162,7 @@ describe("player_auth", function() {
addInstance(8, "running", true, "invalid host", "1.1.8", 34197, 2);
addInstance(9, "running", true, "no version", undefined, 34197);
controllerPlugin.controller.hosts.set(1, {
mockController.hosts.set(1, {
publicAddress: "127.0.0.1",
});
@@ -170,15 +197,13 @@ describe("player_auth", function() {
}
});
it("should return invalid player_code if the code is expired", async function() {
let expiresMs = Date.now() - 1000;
controllerPlugin.players.set("expired", { playerCode: "expired", verifyCode: null, expiresMs });
const result = await postJSON(endpoint, { player_code: "expired" });
const playerCode = await addPlayer("expired", { timeout: -1 });
const result = await postJSON(endpoint, { player_code: playerCode });
assert.deepEqual(await result.json(), { error: true, message: "invalid player_code" });
});
it("should return a verify code and token if code is valid", async function() {
let expiresMs = Date.now() + 1000;
controllerPlugin.players.set("valid", { playerCode: "valid", verifyCode: null, expiresMs });
const result = await postJSON(endpoint, { player_code: "valid" });
const playerCode = await addPlayer("valid");
const result = await postJSON(endpoint, { player_code: playerCode });
const body = await result.json();
assert.equal(typeof body.verify_code, "string");
assert.equal(typeof body.verify_token, "string");
@@ -232,61 +257,38 @@ describe("player_auth", function() {
await verify({ aud: "not_player_auth" });
});
it("should return invalid player_code if the code is expired", async function() {
let expiresMs = Date.now() - 1000;
controllerPlugin.players.set("expired", { playerCode: "expired", verifyCode: "verify", expiresMs });
const playerCode = await addPlayer("expired", { timeout: -1 });
const result = await postJSON(endpoint, {
player_code: "expired",
player_code: playerCode,
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "expired",
verify_code: "verify",
}, Buffer.from("TestSecretDoNotUse", "base64")),
verify_token: signVerifyToken(playerCode, "verify"),
});
assert.deepEqual(await result.json(), { error: true, message: "invalid player_code" });
});
it("should return verified false if verify code has not yet been set", async function() {
let expiresMs = Date.now() + 1000;
controllerPlugin.players.set(
"unverified",
{ playerCode: "unverified", verifyCode: null, expiresMs },
);
const playerCode = await addPlayer("unverified");
const result = await postJSON(endpoint, {
player_code: "unverified",
player_code: playerCode,
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "unverified",
verify_code: "verify",
}, Buffer.from("TestSecretDoNotUse", "base64")),
verify_token: signVerifyToken(playerCode, "verify"),
});
assert.deepEqual(await result.json(), { verified: false });
});
it("should return error if user is missing", async function() {
let expiresMs = Date.now() + 1000;
controllerPlugin.players.set("missing", { playerCode: "missing", verifyCode: "verify", expiresMs });
const playerCode = await addPlayer("missing", { verifyCode: "verify" });
const result = await postJSON(endpoint, {
player_code: "missing",
player_code: playerCode,
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "missing",
verify_code: "verify",
}, Buffer.from("TestSecretDoNotUse", "base64")),
verify_token: signVerifyToken(playerCode, "verify"),
});
assert.deepEqual(await result.json(), { error: true, message: "invalid user" });
});
it("should return verified true with token if valid verification", async function() {
let expiresMs = Date.now() + 1000;
controllerPlugin.players.set("player", { playerCode: "player", verifyCode: "verify", expiresMs });
const playerCode = await addPlayer("player", { verifyCode: "verify" });
const result = await postJSON(endpoint, {
player_code: "player",
player_code: playerCode,
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "player",
verify_code: "verify",
}, Buffer.from("TestSecretDoNotUse", "base64")),
verify_token: signVerifyToken(playerCode, "verify"),
});
const body = await result.json();
assert.equal(body.verified, true);
@@ -294,35 +296,37 @@ describe("player_auth", function() {
});
});
describe(".handleFetchPlayerCodeRequest()", function() {
describe("FetchPlayerCodeRequest handler", function() {
it("should return a code", async function() {
let result = await controllerPlugin.handleFetchPlayerCodeRequest(
new FetchPlayerCodeRequest("test")
);
let result = await fetchPlayerCode(new FetchPlayerCodeRequest("test"));
assert(typeof result.playerCode === "string", "no code returned");
assert(result.playerCode.length === 10, "incorrect code length returned");
let expiresMs = controllerPlugin.players.get("test").expiresMs;
let msFromExpected = Math.abs(expiresMs - Date.now() - 1000);
assert(msFromExpected < 100, `expiry time expected outside window (${msFromExpected}ms)`);
assert.equal(result.controllerUrl, "http://controller.example/");
});
it("should expire the code after the timeout", async function() {
const start = Date.now();
const playerCode = await addPlayer("test", { timeout: 10 });
const end = Date.now();
const result = await postJSON(
`${controllerUrl}/api/player_auth/player_code`, { player_code: playerCode }
);
const { exp } = jwt.decode((await result.json()).verify_token);
assert(exp >= Math.floor((start + 10e3) / 1000), "expiry before expected window");
assert(exp <= Math.floor((end + 10e3) / 1000), "expiry after expected window");
});
});
describe(".handleSetVerifyCodeRequest()", function() {
describe("SetVerifyCodeRequest handler", function() {
it("should throw if player does not exist", async function() {
await assert.rejects(
controllerPlugin.handleSetVerifyCodeRequest(
new SetVerifyCodeRequest("invalid", "invalid")
),
setVerifyCode(new SetVerifyCodeRequest("invalid", "invalid")),
new lib.RequestError("invalid player")
);
});
it("should throw if player code has expired", async function() {
let expiresMs = Date.now() - 1000;
controllerPlugin.players.set("expired", { playerCode: "expired", verifyCode: null, expiresMs });
await addPlayer("expired", { timeout: -1 });
await assert.rejects(
controllerPlugin.handleSetVerifyCodeRequest(
new SetVerifyCodeRequest("expired", "expired")
),
setVerifyCode(new SetVerifyCodeRequest("expired", "expired")),
new lib.RequestError("invalid player")
);
});
@@ -330,10 +334,7 @@ describe("player_auth", function() {
describe("integration", function() {
it("should verify a full login flow", async function() {
let app = controllerPlugin.controller.app;
let { playerCode } = await controllerPlugin.handleFetchPlayerCodeRequest(
new FetchPlayerCodeRequest("test")
);
let { playerCode } = await fetchPlayerCode(new FetchPlayerCodeRequest("test"));
const playerCodeResult = await postJSON(
`${controllerUrl}/api/player_auth/player_code`,
@@ -341,12 +342,10 @@ describe("player_auth", function() {
);
let { verify_code, verify_token } = await playerCodeResult.json();
await controllerPlugin.handleSetVerifyCodeRequest(
new SetVerifyCodeRequest("test", verify_code)
);
await setVerifyCode(new SetVerifyCodeRequest("test", verify_code));
const user = controllerPlugin.controller.users.getByName("test");
const token = controllerPlugin.controller.users.signUserToken(user);
const user = mockController.users.getByName("test");
const token = mockController.users.signUserToken(user);
const verifyResult = await postJSON(
`${controllerUrl}/api/player_auth/verify`,
{ player_code: playerCode, verify_code, verify_token }
@@ -359,76 +358,78 @@ describe("player_auth", function() {
});
describe("instance.js", function() {
describe("class InstancePlugin", function() {
let instancePlugin;
describe("entrypoint", function() {
let instance;
let host;
before(async function() {
instancePlugin = await mock.createInstancePlugin(instance.InstancePlugin, info);
try {
lib.registerPluginMessages([info]);
} catch (err) {
// Already registered by the full test suite
}
({ instance, host } = await mock.loadInstancePlugin(instancePlugin.default, info));
});
describe(".handleEvent()", async function() {
describe("open_dialog", async function() {
// Emits an ipc-player_auth event and returns the first RCON command it sent
async function emitIpc(event) {
instance.server.reset();
instance.server.emit("ipc-player_auth", event);
while (!instance.server.rconCommands.length) {
await new Promise(resolve => setImmediate(resolve));
}
return instance.server.rconCommands[0];
}
describe("ipc-player_auth", function() {
describe("open_dialog", function() {
it("should call /web-login error if not connected to controller", async function() {
instancePlugin.instance.server.reset();
instancePlugin.host.connector.connected = false;
await instancePlugin.handleEvent({ type: "open_dialog", player: "test" });
let command = instancePlugin.instance.server.rconCommands[0];
instancePlugin.host.connector.connected = true;
host.connector.connected = false;
let command = await emitIpc({ type: "open_dialog", player: "test" });
host.connector.connected = true;
assert.equal(command, "/web-login error test login is temporarily unavailable");
});
it("should call /web-login error after error from the controller", async function() {
instancePlugin.instance.server.reset();
instancePlugin.instance.connector.once("send", message => {
instancePlugin.instance.connector.emit("message",
instance.connector.once("send", message => {
instance.connector.emit("message",
new lib.MessageResponseError(1, message.dst, message.src,
new lib.ResponseError("controller error")
)
);
});
await instancePlugin.handleEvent({ type: "open_dialog", player: "test" });
let command = instancePlugin.instance.server.rconCommands[0];
let command = await emitIpc({ type: "open_dialog", player: "test" });
assert.equal(command, "/web-login error test controller error");
});
it("should call /web-login open after a valid response from the controller", async function() {
instancePlugin.instance.server.reset();
instancePlugin.instance.connector.once("send", message => {
instancePlugin.instance.connector.emit("message",
instance.connector.once("send", message => {
instance.connector.emit("message",
new lib.MessageResponse(1, message.dst, message.src,
new FetchPlayerCodeRequest.Response("code", "controller-url")
)
);
});
await instancePlugin.handleEvent({ type: "open_dialog", player: "test" });
let command = instancePlugin.instance.server.rconCommands[0];
let command = await emitIpc({ type: "open_dialog", player: "test" });
assert.equal(command, "/web-login open test controller-url code");
});
});
describe("open_dialog", async function() {
describe("set_verify_code", function() {
it("should call /web-login code_set after a valid response from the controller", async function() {
instancePlugin.instance.server.reset();
instancePlugin.instance.connector.once("send", message => {
instancePlugin.instance.connector.emit("message",
instance.connector.once("send", message => {
instance.connector.emit("message",
new lib.MessageResponse(1, message.dst, message.src)
);
});
await instancePlugin.handleEvent(
{ type: "set_verify_code", player: "test", verify_code: "verify" }
);
let command = instancePlugin.instance.server.rconCommands[0];
let command = await emitIpc({ type: "set_verify_code", player: "test", verify_code: "verify" });
assert.equal(command, "/web-login code_set test");
});
it("should call /web-login error after error from the controller", async function() {
instancePlugin.instance.server.reset();
instancePlugin.instance.connector.once("send", message => {
instancePlugin.instance.connector.emit("message",
instance.connector.once("send", message => {
instance.connector.emit("message",
new lib.MessageResponseError(1, message.dst, message.src,
new lib.ResponseError("controller error")
)
);
});
await instancePlugin.handleEvent(
{ type: "set_verify_code", player: "test", verify_code: "verify" }
);
let command = instancePlugin.instance.server.rconCommands[0];
let command = await emitIpc({ type: "set_verify_code", player: "test", verify_code: "verify" });
assert.equal(command, "/web-login error test controller error");
});
});
+9 -10
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { BaseWebPlugin, notifyErrorHandler } from "@clusterio/web_ui";
import { notifyErrorHandler, type WebPluginContext } from "@clusterio/web_ui";
import { Alert, Button, Form, Input, Modal, Space, Spin, Typography } from "antd";
import { PlayerAuthServer } from "../messages.js";
@@ -251,13 +251,12 @@ function LoginForm(props: LoginFormProps) {
}
export class WebPlugin extends BaseWebPlugin {
async init() {
this.logger.info("Player Auth init");
this.loginForms = [{
name: "player_auth.factorio",
title: "Factorio",
Component: LoginForm,
}];
}
export default async function loadWebPlugin(context: WebPluginContext) {
const { control, logger, plugin } = context;
logger.info("Player Auth init");
control.hooks.loginForms.attach(plugin.name, () => [{
name: "player_auth.factorio",
title: "Factorio",
Component: LoginForm,
}]);
}
+62 -73
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "path";
import { BaseControllerPlugin } from "@clusterio/controller";
import type { ControllerPluginContext } from "@clusterio/controller";
import { Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
@@ -49,61 +49,39 @@ async function saveTechnologies(
await lib.safeOutputFile(filePath, JSON.stringify([...technologies.entries()], null, "\t"));
}
export class ControllerPlugin extends BaseControllerPlugin {
technologies!: Map<string, Technology>;
technologiesDirty = true;
progressRateLimiter!: lib.RateLimiter;
progressBroadcastId!: ReturnType<typeof setInterval> | null;
progressToBroadcast!: Set<string>;
export default async function(context: ControllerPluginContext) {
const { controller, logger, plugin } = context;
const technologies = await loadTechnologies(controller.config, logger);
let technologiesDirty = true;
const progressToBroadcast = new Set<string>();
async init() {
this.technologies = await loadTechnologies(this.controller.config, this.logger);
this.progressRateLimiter = new RateLimiter({
maxRate: 1,
action: () => this.broadcastProgress(),
});
this.progressBroadcastId = null;
this.progressToBroadcast = new Set();
this.controller.handle(ContributionEvent, this.handleContributionEvent.bind(this));
this.controller.handle(FinishedEvent, this.handleFinishedEvent.bind(this));
this.controller.handle(SyncTechnologiesRequest, this.handleSyncTechnologiesRequest.bind(this));
}
async onSaveData() {
if (this.technologiesDirty) {
this.technologiesDirty = false;
await saveTechnologies(this.controller.config, this.technologies, this.logger);
}
}
async onShutdown() {
this.progressRateLimiter.cancel();
}
broadcastProgress() {
function broadcastProgress() {
let techs = [];
for (let name of this.progressToBroadcast) {
let tech = this.technologies.get(name);
for (let name of progressToBroadcast) {
let tech = technologies.get(name);
if (tech && tech.progress) {
techs.push(new TechnologyProgress(name, tech.level, tech.progress));
}
}
this.progressToBroadcast.clear();
progressToBroadcast.clear();
if (techs.length) {
this.controller.sendTo("allInstances", new ProgressEvent(techs));
controller.sendTo("allInstances", new ProgressEvent(techs));
}
}
async handleContributionEvent(event: ContributionEvent) {
const progressRateLimiter = new RateLimiter({
maxRate: 1,
action: broadcastProgress,
});
controller.handle(ContributionEvent, async (event: ContributionEvent) => {
let { name, level, contribution } = event;
let tech = this.technologies.get(name);
let tech = technologies.get(name);
if (!tech) {
tech = { level, progress: 0, researched: false };
this.technologies.set(name, tech);
this.technologiesDirty = true;
technologies.set(name, tech);
technologiesDirty = true;
// Ignore contribution to already researched technologies
} else if (tech.level > level || tech.level === level && tech.researched) {
@@ -124,31 +102,31 @@ export class ControllerPlugin extends BaseControllerPlugin {
let newProgress = tech.progress! + contribution;
if (newProgress < 1) {
tech.progress = newProgress;
this.progressToBroadcast.add(name);
this.progressRateLimiter!.activate();
progressToBroadcast.add(name);
progressRateLimiter.activate();
} else {
tech.researched = true;
tech.progress = null;
this.progressToBroadcast.delete(name);
progressToBroadcast.delete(name);
this.controller.sendTo("allInstances", new FinishedEvent(name, tech.level));
controller.sendTo("allInstances", new FinishedEvent(name, tech.level));
}
this.technologiesDirty = true;
}
technologiesDirty = true;
});
async handleFinishedEvent(event: FinishedEvent) {
controller.handle(FinishedEvent, async (event: FinishedEvent) => {
let { name, level } = event;
let tech = this.technologies.get(name);
let tech = technologies.get(name);
if (!tech || tech.level <= level) {
this.controller.sendTo("allInstances", event);
this.progressToBroadcast.delete(name);
this.technologies.set(name, { level, progress: null, researched: true });
this.technologiesDirty = true;
controller.sendTo("allInstances", event);
progressToBroadcast.delete(name);
technologies.set(name, { level, progress: null, researched: true });
technologiesDirty = true;
}
}
});
async handleSyncTechnologiesRequest(request: SyncTechnologiesRequest): Promise<TechnologySync[]> {
controller.handle(SyncTechnologiesRequest, async (request: SyncTechnologiesRequest): Promise<TechnologySync[]> => {
function baseLevel(name: string): number {
let match = /-(\d+)$/.exec(name);
if (!match) {
@@ -159,14 +137,14 @@ export class ControllerPlugin extends BaseControllerPlugin {
for (let instanceTech of request.technologies) {
let { name, level, progress, researched } = instanceTech;
let tech = this.technologies.get(name);
let tech = technologies.get(name);
if (!tech) {
this.technologies.set(name, { level, progress, researched });
this.technologiesDirty = true;
technologies.set(name, { level, progress, researched });
technologiesDirty = true;
if (progress) {
this.progressToBroadcast.add(name);
progressToBroadcast.add(name);
} else if (researched || baseLevel(name) !== level) {
this.controller.sendTo("allInstances", new FinishedEvent(name, level));
controller.sendTo("allInstances", new FinishedEvent(name, level));
}
} else {
@@ -177,33 +155,44 @@ export class ControllerPlugin extends BaseControllerPlugin {
if (tech.level < level || researched) {
// Send update if the unlocked level is greater
if (level - Number(!researched) > tech.level - Number(!tech.researched)) {
this.controller.sendTo("allInstances", new FinishedEvent(name, level - Number(!researched)));
controller.sendTo("allInstances", new FinishedEvent(name, level - Number(!researched)));
}
tech.level = level;
tech.progress = progress;
tech.researched = researched;
if (progress) {
this.progressToBroadcast.add(name);
progressToBroadcast.add(name);
} else {
this.progressToBroadcast.delete(name);
progressToBroadcast.delete(name);
}
this.technologiesDirty = true;
technologiesDirty = true;
} else if (tech.progress && progress && tech.progress < progress) {
tech.progress = progress;
this.progressToBroadcast.add(name);
this.technologiesDirty = true;
progressToBroadcast.add(name);
technologiesDirty = true;
}
}
}
this.progressRateLimiter.activate();
progressRateLimiter.activate();
let technologies = [];
for (let [name, tech] of this.technologies) {
technologies.push(new TechnologySync(name, tech.level, tech.progress, tech.researched));
let result = [];
for (let [name, tech] of technologies) {
result.push(new TechnologySync(name, tech.level, tech.progress, tech.researched));
}
return technologies;
}
return result;
});
controller.hooks.save.attach(plugin.name, async () => {
if (technologiesDirty) {
technologiesDirty = false;
await saveTechnologies(controller.config, technologies, logger);
}
});
controller.hooks.shutdown.attach(plugin.name, async () => {
progressRateLimiter.cancel();
});
}
+39 -36
View File
@@ -1,5 +1,5 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
import type { InstancePluginContext } from "@clusterio/host";
import {
ContributionEvent,
ProgressEvent,
@@ -19,54 +19,57 @@ type IpcFinished = {
level: number,
};
export class InstancePlugin extends BaseInstancePlugin {
syncStarted!: boolean;
export default async function(context: InstancePluginContext) {
const { instance, logger, plugin } = context;
let syncStarted = false;
unexpectedError(err: Error) {
this.logger.error(`Unexpected error:\n${err.stack}`);
// Commands are sent one at a time so they execute in order
let rconQueue: Promise<unknown> = Promise.resolve();
function sendOrderedRcon(message: string, expectEmpty = false) {
const result = rconQueue.then(() => instance.sendRcon(message, expectEmpty, plugin.name));
rconQueue = result.catch(() => {});
return result;
}
async init() {
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: IpcFinished) => {
this.researchFinished(tech).catch(err => this.unexpectedError(err));
});
this.syncStarted = false;
this.instance.handle(ProgressEvent, this.handleProgressEvent.bind(this));
this.instance.handle(FinishedEvent, this.handleFinishedEvent.bind(this));
function unexpectedError(err: Error) {
logger.error(`Unexpected error:\n${err.stack}`);
}
async researchContribution(tech: IpcContribution) {
this.instance.sendTo("controller", new ContributionEvent(tech.name, tech.level, tech.contribution));
async function researchContribution(tech: IpcContribution) {
instance.sendTo("controller", new ContributionEvent(tech.name, tech.level, tech.contribution));
}
async handleProgressEvent(event: ProgressEvent) {
if (!this.syncStarted || !["starting", "running"].includes(this.instance.status)) {
async function researchFinished(tech: IpcFinished) {
instance.sendTo("controller", new FinishedEvent(tech.name, tech.level));
}
instance.server.on("ipc-research_sync:contribution", (tech: IpcContribution) => {
researchContribution(tech).catch(err => unexpectedError(err));
});
instance.server.on("ipc-research_sync:finished", (tech: IpcFinished) => {
researchFinished(tech).catch(err => unexpectedError(err));
});
instance.handle(ProgressEvent, async (event: ProgressEvent) => {
if (!syncStarted || !["starting", "running"].includes(instance.status)) {
return;
}
let techsJson = lib.escapeString(JSON.stringify(event.technologies));
await this.sendOrderedRcon(`/sc research_sync.update_progress("${techsJson}")`, true);
}
await sendOrderedRcon(`/sc research_sync.update_progress("${techsJson}")`, true);
});
async researchFinished(tech: IpcFinished) {
this.instance.sendTo("controller", new FinishedEvent(tech.name, tech.level));
}
async handleFinishedEvent(event: FinishedEvent) {
if (!this.syncStarted || !["starting", "running"].includes(this.instance.status)) {
instance.handle(FinishedEvent, async (event: FinishedEvent) => {
if (!syncStarted || !["starting", "running"].includes(instance.status)) {
return;
}
let { name, level } = event;
await this.sendOrderedRcon(
await sendOrderedRcon(
`/sc research_sync.research_technology("${lib.escapeString(name)}", ${level})`, true
);
}
});
async onStart() {
let dumpJson = await this.sendOrderedRcon("/sc research_sync.dump_technologies()");
instance.hooks.start.attach(plugin.name, async () => {
let dumpJson = await sendOrderedRcon("/sc research_sync.dump_technologies()");
let techsToSend = [];
let instanceTechs = new Map();
for (let tech of JSON.parse(dumpJson)) {
@@ -79,8 +82,8 @@ export class InstancePlugin extends BaseInstancePlugin {
instanceTechs.set(tech.name, tech);
}
let controllerTechs = await this.instance.sendTo("controller", new SyncTechnologiesRequest(techsToSend));
this.syncStarted = true;
let controllerTechs = await instance.sendTo("controller", new SyncTechnologiesRequest(techsToSend));
syncStarted = true;
let techsToSync = [];
for (let controllerTech of controllerTechs) {
let { name, level, progress, researched } = controllerTech;
@@ -97,7 +100,7 @@ export class InstancePlugin extends BaseInstancePlugin {
if (techsToSync.length) {
let syncJson = lib.escapeString(JSON.stringify(techsToSync));
await this.sendOrderedRcon(`/sc research_sync.sync_technologies("${syncJson}")`, true);
await sendOrderedRcon(`/sc research_sync.sync_technologies("${syncJson}")`, true);
}
}
});
}
+65 -63
View File
@@ -1,5 +1,5 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
import type { Instance, InstancePluginContext } from "@clusterio/host";
import { Gauge } from "@clusterio/lib";
@@ -103,79 +103,81 @@ function setForceFlowStatistic(
}
export class InstancePlugin extends BaseInstancePlugin {
async gatherMetrics() {
let string = await this.sendRcon("/sc statistics_exporter.export()");
let stats: IpcStats;
try {
stats = JSON.parse(string);
} catch (err: any) {
throw new Error(`Error parsing statistics JSON: ${err.message}, content "${string}"`);
}
export async function gatherMetrics(instance: Instance, pluginName: string) {
let string = await instance.sendRcon("/sc statistics_exporter.export()", false, pluginName);
let stats: IpcStats;
try {
stats = JSON.parse(string);
} catch (err: any) {
throw new Error(`Error parsing statistics JSON: ${err.message}, content "${string}"`);
}
let instanceId = this.instance.id;
instanceGameTicksTotal.labels(String(instanceId)).set(stats.game_tick);
instancePlayerCount.labels(String(instanceId)).set(stats.player_count);
let instanceId = instance.id;
instanceGameTicksTotal.labels(String(instanceId)).set(stats.game_tick);
instancePlayerCount.labels(String(instanceId)).set(stats.player_count);
// Remove existing platform mappings and stats for this instance
instancePlatformMapping.removeAll({ instance_id: String(instanceId) });
instancePlatformSpeed.removeAll({ instance_id: String(instanceId) });
instancePlatformWeight.removeAll({ instance_id: String(instanceId) });
// Remove existing platform mappings and stats for this instance
instancePlatformMapping.removeAll({ instance_id: String(instanceId) });
instancePlatformSpeed.removeAll({ instance_id: String(instanceId) });
instancePlatformWeight.removeAll({ instance_id: String(instanceId) });
// Set new platform mappings and stats
for (let [platformName, platform] of Object.entries(stats.platforms)) {
instancePlatformMapping.labels(
String(instanceId),
platformName,
platform.force,
platform.surface
).set(1);
// Set new platform mappings and stats
for (let [platformName, platform] of Object.entries(stats.platforms)) {
instancePlatformMapping.labels(
String(instanceId),
platformName,
platform.force,
platform.surface
).set(1);
instancePlatformSpeed.labels(
String(instanceId),
platform.force,
platform.surface
).set(platform.speed);
instancePlatformSpeed.labels(
String(instanceId),
platform.force,
platform.surface
).set(platform.speed);
instancePlatformWeight.labels(
String(instanceId),
platform.force,
platform.surface
).set(platform.weight);
}
instancePlatformWeight.labels(
String(instanceId),
platform.force,
platform.surface
).set(platform.weight);
}
for (let [surfaceName, surfaceStats] of Object.entries(stats.surface_statistics)) {
for (let [forceName, flowStatistics] of Object.entries(surfaceStats.force_flow_statistics)) {
for (let [statisticName, statistic] of Object.entries(flowStatistics)) {
for (let [direction, counts] of Object.entries(statistic)) {
// eslint-disable-next-line max-depth
for (let [item, value] of Object.entries(counts)) {
setForceFlowStatistic(
instanceId,
surfaceName,
forceName,
statisticName,
direction,
item,
value
);
}
for (let [surfaceName, surfaceStats] of Object.entries(stats.surface_statistics)) {
for (let [forceName, flowStatistics] of Object.entries(surfaceStats.force_flow_statistics)) {
for (let [statisticName, statistic] of Object.entries(flowStatistics)) {
for (let [direction, counts] of Object.entries(statistic)) {
// eslint-disable-next-line max-depth
for (let [item, value] of Object.entries(counts)) {
setForceFlowStatistic(
instanceId,
surfaceName,
forceName,
statisticName,
direction,
item,
value
);
}
}
}
}
for (let [direction, counts] of Object.entries(surfaceStats.game_flow_statistics.pollution_statistics)) {
for (let [item, value] of Object.entries(counts)) {
instanceGameFlowStatistics.labels(
String(instanceId), surfaceName, "pollution_statistics", direction, item
).set(value);
}
for (let [direction, counts] of Object.entries(surfaceStats.game_flow_statistics.pollution_statistics)) {
for (let [item, value] of Object.entries(counts)) {
instanceGameFlowStatistics.labels(
String(instanceId), surfaceName, "pollution_statistics", direction, item
).set(value);
}
}
}
}
async onMetrics() {
if (this.instance.status !== "running") {
export default async function(context: InstancePluginContext) {
const { instance, plugin } = context;
instance.hooks.metrics.attach(plugin.name, async () => {
if (instance.status !== "running") {
return;
}
@@ -183,9 +185,9 @@ export class InstancePlugin extends BaseInstancePlugin {
// take a long time for the command to go through if the command
// stream is overloaded. Should the timeout be exceeded the
// previous values for the metrics will end up being sent to controller.
let timeoutMs = this.instance.config.get("statistics_exporter.command_timeout") * 1000;
await lib.timeout(this.gatherMetrics(), timeoutMs, undefined);
}
let timeoutMs = instance.config.get("statistics_exporter.command_timeout") * 1000;
await lib.timeout(gatherMetrics(instance, plugin.name), timeoutMs, undefined);
});
}
export const _instancePlayerCount = instancePlayerCount;
+14 -16
View File
@@ -6,25 +6,23 @@ import { plugin as info } from "../dist/node/index.js";
describe("statistics_exporter plugin", function() {
describe("class InstancePlugin", function() {
let instancePlugin;
let mockInstance = new mock.MockInstance();
mockInstance.mockConfigEntries.set("statistics_exporter.command_timeout", 1);
describe("instance entrypoint", function() {
let mockInstance;
before(async function() {
instancePlugin = new instance.InstancePlugin(info, mockInstance);
await instancePlugin.init();
({ instance: mockInstance } = await mock.loadInstancePlugin(instance.default, info));
mockInstance.mockConfigEntries.set("statistics_exporter.command_timeout", 1);
});
describe(".onMetrics()", function() {
describe("metrics hook", function() {
it("should not send commands before running", async function() {
instancePlugin.instance.server.rconCommands.length = 0;
instancePlugin.instance.status = "starting";
await instancePlugin.onMetrics();
assert(instancePlugin.instance.server.rconCommands.length === 0, "commands were run");
mockInstance.server.rconCommands.length = 0;
mockInstance.status = "starting";
await mockInstance.hooks.metrics.invoke();
assert(mockInstance.server.rconCommands.length === 0, "commands were run");
});
it("should record statistics", async function() {
instancePlugin.instance.status = "running";
instancePlugin.instance.server.rconCommandResults.set(
mockInstance.status = "running";
mockInstance.server.rconCommandResults.set(
"/sc statistics_exporter.export()",
JSON.stringify({
game_tick: 100,
@@ -58,7 +56,7 @@ describe("statistics_exporter plugin", function() {
})
);
await instancePlugin.onMetrics();
await mockInstance.hooks.metrics.invoke();
assert.equal(instance._instancePlayerCount.labels("7357").get(), 3);
assert.equal(instance._instanceGameTicksTotal.labels("7357").get(), 100);
@@ -90,7 +88,7 @@ describe("statistics_exporter plugin", function() {
});
it("should pass on JSON parse errors", async function() {
let string = "An error occured\n";
instancePlugin.instance.server.rconCommandResults.set(
mockInstance.server.rconCommandResults.set(
"/sc statistics_exporter.export()", string
);
@@ -102,7 +100,7 @@ describe("statistics_exporter plugin", function() {
}
await assert.rejects(
instancePlugin.onMetrics(),
instance.gatherMetrics(mockInstance, info.name),
new Error(`Error parsing statistics JSON: ${errorMessage}, content "${string}"`)
);
});
+89 -100
View File
@@ -1,4 +1,4 @@
import { BaseControllerPlugin, type ControlConnection } from "@clusterio/controller";
import type { ControlConnection, ControllerPluginContext } from "@clusterio/controller";
import fs from "node:fs/promises";
import path from "path";
@@ -70,58 +70,21 @@ async function saveDatabase(
}
}
export class ControllerPlugin extends BaseControllerPlugin {
items!: lib.ItemDatabase;
itemUpdateRateLimiter!: lib.RateLimiter;
itemsLastUpdate: Map<string, lib.ItemCountWithQuality> = new Map();
subscribedControlLinks!: Set<ControlConnection>;
doleMagicId!: ReturnType<typeof setInterval>;
neuralDole!: dole.NeuralDole;
storageDirty = false;
export default async function(context: ControllerPluginContext) {
const { controller, logger, plugin } = context;
async init() {
this.items = await loadDatabase(this.controller.config, this.logger);
this.itemUpdateRateLimiter = new lib.RateLimiter({
maxRate: 1,
action: () => {
try {
this.broadcastStorage();
} catch (err: any) {
this.logger.error(`Unexpected error sending storage update:\n${err.stack}`);
}
},
});
this.itemsLastUpdate = new Map();
for (const [name, qualities] of this.items.getEntries()) {
this.itemsLastUpdate.set(name, { ...qualities });
}
this.neuralDole = new dole.NeuralDole({ items: this.items });
this.doleMagicId = setInterval(() => {
if (this.controller.config.get("subspace_storage.division_method") === "neural_dole") {
this.neuralDole.doMagic();
}
}, 1000);
this.subscribedControlLinks = new Set();
routes.addApiRoutes(this.controller.app, this.items);
this.controller.handle(GetStorageRequest, this.handleGetStorageRequest.bind(this));
this.controller.handle(PlaceEvent, this.handlePlaceEvent.bind(this));
this.controller.handle(RemoveRequest, this.handleRemoveRequest.bind(this));
this.controller.handle(SetStorageSubscriptionRequest, this.handleSetStorageSubscriptionRequest.bind(this));
const items = await loadDatabase(controller.config, logger);
let storageDirty = false;
let itemsLastUpdate = new Map<string, lib.ItemCountWithQuality>();
for (const [name, qualities] of items.getEntries()) {
itemsLastUpdate.set(name, { ...qualities });
}
const subscribedControlLinks = new Set<ControlConnection>();
updateStorage() {
this.itemUpdateRateLimiter.activate();
this.storageDirty = true;
}
broadcastStorage() {
function broadcastStorage() {
let itemsToUpdate: Item[] = [];
for (const [name, qualities] of this.items.getEntries()) {
const lastQualities = this.itemsLastUpdate.get(name);
for (const [name, qualities] of items.getEntries()) {
const lastQualities = itemsLastUpdate.get(name);
for (const [quality, count] of Object.entries(qualities)) {
if (!lastQualities || lastQualities[quality] !== count) {
itemsToUpdate.push(new Item(name, count, quality));
@@ -134,68 +97,93 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
let update = new UpdateStorageEvent(itemsToUpdate);
this.controller.sendTo("allInstances", update);
for (let link of this.subscribedControlLinks) {
controller.sendTo("allInstances", update);
for (let link of subscribedControlLinks) {
link.send(update);
}
this.itemsLastUpdate = new Map();
for (const [name, qualities] of this.items.getEntries()) {
this.itemsLastUpdate.set(name, { ...qualities });
itemsLastUpdate = new Map();
for (const [name, qualities] of items.getEntries()) {
itemsLastUpdate.set(name, { ...qualities });
}
}
async handleGetStorageRequest() {
const itemUpdateRateLimiter = new lib.RateLimiter({
maxRate: 1,
action: () => {
try {
broadcastStorage();
} catch (err: any) {
logger.error(`Unexpected error sending storage update:\n${err.stack}`);
}
},
});
function updateStorage() {
itemUpdateRateLimiter.activate();
storageDirty = true;
}
const neuralDole = new dole.NeuralDole({ items });
const doleMagicId = setInterval(() => {
if (controller.config.get("subspace_storage.division_method") === "neural_dole") {
neuralDole.doMagic();
}
}, 1000);
routes.addApiRoutes(controller.app, items);
controller.handle(GetStorageRequest, async () => {
const result: Item[] = [];
for (const [name, qualities] of this.items.getEntries()) {
for (const [name, qualities] of items.getEntries()) {
for (const [quality, count] of Object.entries(qualities)) {
result.push(new Item(name, count, quality));
}
}
return result;
}
});
async handlePlaceEvent(request: PlaceEvent, src: lib.Address) {
controller.handle(PlaceEvent, async (request: PlaceEvent, src: lib.Address) => {
let instanceId = src.id;
for (let item of request.items) {
this.items.addItem(item.name, item.count, item.quality || "normal");
items.addItem(item.name, item.count, item.quality || "normal");
exportCounter.labels(String(instanceId), item.name, item.quality || "normal").inc(item.count);
}
this.updateStorage();
updateStorage();
if (this.controller.config.get("subspace_storage.log_item_transfers")) {
this.logger.verbose(
if (controller.config.get("subspace_storage.log_item_transfers")) {
logger.verbose(
`Imported the following from ${instanceId}:\n${JSON.stringify(request.items)}`
);
}
}
});
async handleRemoveRequest(request: RemoveRequest, src: lib.Address) {
let method = this.controller.config.get("subspace_storage.division_method");
controller.handle(RemoveRequest, async (request: RemoveRequest, src: lib.Address) => {
let method = controller.config.get("subspace_storage.division_method");
let instanceId = src.id;
let itemsRemoved = [];
if (method === "simple") {
for (let item of request.items) {
const quality = item.quality || "normal";
let count = this.items.getItemCount(item.name, quality);
let count = items.getItemCount(item.name, quality);
let toRemove = Math.min(count, item.count);
if (toRemove > 0) {
this.items.removeItem(item.name, toRemove, quality);
items.removeItem(item.name, toRemove, quality);
itemsRemoved.push(new Item(item.name, toRemove, quality));
}
}
} else {
let instance = this.controller.instances.get(instanceId);
let instance = controller.instances.get(instanceId);
let instanceName = instance ? instance.config.get("instance.name") : "unknown";
// use fancy neural net to calculate a "fair" dole division rate.
if (method === "neural_dole") {
for (let item of request.items) {
const quality = item.quality || "normal";
let count = this.neuralDole.divider(
let count = neuralDole.divider(
{ name: item.name, quality, count: item.count, instanceId, instanceName }
);
if (count > 0) {
@@ -209,9 +197,9 @@ export class ControllerPlugin extends BaseControllerPlugin {
const quality = item.quality || "normal";
let count = dole.doleDivider({
object: { name: item.name, quality, count: item.count, instanceId, instanceName },
items: this.items,
logItemTransfers: this.controller.config.get("subspace_storage.log_item_transfers"),
logger: this.logger,
items,
logItemTransfers: controller.config.get("subspace_storage.log_item_transfers"),
logger,
});
if (count > 0) {
itemsRemoved.push(new Item(item.name, count, quality));
@@ -229,50 +217,51 @@ export class ControllerPlugin extends BaseControllerPlugin {
importCounter.labels(String(instanceId), item.name, item.quality || "normal").inc(item.count);
}
this.updateStorage();
updateStorage();
if (itemsRemoved.length && this.controller.config.get("subspace_storage.log_item_transfers")) {
this.logger.verbose(`Exported the following to ${instanceId}:\n${JSON.stringify(itemsRemoved)}`);
if (itemsRemoved.length && controller.config.get("subspace_storage.log_item_transfers")) {
logger.verbose(`Exported the following to ${instanceId}:\n${JSON.stringify(itemsRemoved)}`);
}
}
return itemsRemoved;
}
});
async handleSetStorageSubscriptionRequest(request: SetStorageSubscriptionRequest, src: lib.Address) {
let link = this.controller.wsServer.controlConnections.get(src.id)!;
controller.handle(SetStorageSubscriptionRequest, async (
request: SetStorageSubscriptionRequest,
src: lib.Address,
) => {
let link = controller.wsServer.controlConnections.get(src.id)!;
if (request.storage) {
this.subscribedControlLinks.add(link);
subscribedControlLinks.add(link);
} else {
this.subscribedControlLinks.delete(link);
subscribedControlLinks.delete(link);
}
}
});
onControlConnectionEvent(connection: ControlConnection, event: string) {
controller.hooks.controlConnectionEvent.attach(plugin.name, (connection, event) => {
if (event === "close") {
this.subscribedControlLinks.delete(connection);
subscribedControlLinks.delete(connection);
}
}
});
async onMetrics() {
if (this.items) {
for (const [name, qualities] of this.items.getEntries()) {
for (const [quality, count] of Object.entries(qualities)) {
controllerInventoryGauge.labels(name, quality).set(Number(count) || 0);
}
controller.hooks.metrics.attach(plugin.name, async () => {
for (const [name, qualities] of items.getEntries()) {
for (const [quality, count] of Object.entries(qualities)) {
controllerInventoryGauge.labels(name, quality).set(Number(count) || 0);
}
}
}
});
async onShutdown() {
this.itemUpdateRateLimiter.cancel();
clearInterval(this.doleMagicId);
}
controller.hooks.shutdown.attach(plugin.name, async () => {
itemUpdateRateLimiter.cancel();
clearInterval(doleMagicId);
});
async onSaveData() {
if (this.storageDirty) {
this.storageDirty = false;
await saveDatabase(this.controller.config, this.items, this.logger);
controller.hooks.save.attach(plugin.name, async () => {
if (storageDirty) {
storageDirty = false;
await saveDatabase(controller.config, items, logger);
}
}
});
}
+92 -95
View File
@@ -1,5 +1,5 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
import type { InstancePluginContext } from "@clusterio/host";
import {
PlaceEvent,
@@ -11,127 +11,124 @@ import {
type IpcItems = [name: string, count: number, quality: string][];
export class InstancePlugin extends BaseInstancePlugin {
pendingTasks!: Set<any>;
pingId?: ReturnType<typeof setTimeout>;
timeUpdateId?: ReturnType<typeof setInterval>;
cachedInventoryItems: Item[] = [];
export default async function(context: InstancePluginContext) {
const { instance, host, logger, plugin } = context;
const pendingTasks = new Set<Promise<unknown>>();
let pingId: ReturnType<typeof setInterval> | undefined;
let timeUpdateId: ReturnType<typeof setInterval> | undefined;
let cachedInventoryItems: Item[] = [];
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: IpcItems) => {
this.logger.info("Received output items:");
this.logger.info(JSON.stringify(output));
this.provideItems(output).catch(err => this.unexpectedError(err));
});
this.instance.server.on("ipc-subspace_storage:orders", (orders: IpcItems) => {
if (this.instance.status !== "running" || !this.host.connected) {
return;
}
let task = this.requestItems(orders).catch(err => this.unexpectedError(err));
this.pendingTasks.add(task);
task.finally(() => { this.pendingTasks.delete(task); });
});
this.instance.handle(UpdateStorageEvent, this.handleUpdateStorageEvent.bind(this));
}
async onStart() {
this.pingId = setInterval(() => {
if (!this.host.connected) {
return; // Only ping if we are actually connected to the controller.
}
this.sendRcon(
"/sc __subspace_storage__ global.ticksSinceMasterPinged = 0", true
).catch(err => this.unexpectedError(err));
}, 5000);
let items = await this.instance.sendTo("controller", new GetStorageRequest());
// Cache items for periodic time updates
this.cachedInventoryItems = items;
// Ensure a payload with updated time is sent every second
this.timeUpdateId = setInterval(() => {
if (this.instance.status !== "running") {
return;
}
const payloadItems = [
...this.cachedInventoryItems,
new Item("signal-unixtime", Math.floor(Date.now() / 1000), "normal"),
];
const payloadJson = lib.escapeString(JSON.stringify(payloadItems));
const task = this.sendRcon(`/sc __subspace_storage__ UpdateInvData("${payloadJson}")`, true)
.catch(err => this.unexpectedError(err));
this.pendingTasks.add(task);
task.finally(() => { this.pendingTasks.delete(task); });
}, 1000);
}
async onStop() {
clearInterval(this.pingId);
clearInterval(this.timeUpdateId);
await Promise.all(this.pendingTasks);
}
onExit() {
clearInterval(this.pingId);
clearInterval(this.timeUpdateId);
function unexpectedError(err: Error) {
logger.error(`Unexpected error:\n${err.stack}`);
}
// provide items --------------------------------------------------------------
async provideItems(items: IpcItems) {
if (!this.host.connector.hasSession) {
async function provideItems(items: IpcItems) {
if (!host.connector.hasSession) {
// For now the items are voided if the controller connection is
// down, which is no different from the previous behaviour.
if (this.instance.config.get("subspace_storage.log_item_transfers")) {
this.logger.verbose("Voided the following items:");
this.logger.verbose(JSON.stringify(items));
if (instance.config.get("subspace_storage.log_item_transfers")) {
logger.verbose("Voided the following items:");
logger.verbose(JSON.stringify(items));
}
return;
}
const fromIpcItems = items.map(item => new Item(item[0], item[1], item[2]));
this.instance.sendTo("controller", new PlaceEvent(fromIpcItems));
instance.sendTo("controller", new PlaceEvent(fromIpcItems));
if (this.instance.config.get("subspace_storage.log_item_transfers")) {
this.logger.verbose("Exported the following to controller:");
this.logger.verbose(JSON.stringify(items));
if (instance.config.get("subspace_storage.log_item_transfers")) {
logger.verbose("Exported the following to controller:");
logger.verbose(JSON.stringify(items));
}
}
// request items --------------------------------------------------------------
async requestItems(requestItems: IpcItems) {
this.logger.info(`Requesting items: ${JSON.stringify(requestItems)}`);
async function requestItems(orders: IpcItems) {
logger.info(`Requesting items: ${JSON.stringify(orders)}`);
// Request the items all at once
const fromIpcItems = requestItems.map(item => new Item(item[0], item[1], item[2]));
let items = await this.instance.sendTo("controller", new RemoveRequest(fromIpcItems));
const fromIpcItems = orders.map(item => new Item(item[0], item[1], item[2]));
let items = await instance.sendTo("controller", new RemoveRequest(fromIpcItems));
if (!items.length) {
return;
}
if (this.instance.config.get("subspace_storage.log_item_transfers")) {
this.logger.verbose("Imported following from controller:");
this.logger.verbose(JSON.stringify(items));
if (instance.config.get("subspace_storage.log_item_transfers")) {
logger.verbose("Imported following from controller:");
logger.verbose(JSON.stringify(items));
}
let itemsJson = lib.escapeString(JSON.stringify(items));
await this.sendRcon(`/sc __subspace_storage__ Import("${itemsJson}")`, true);
await instance.sendRcon(`/sc __subspace_storage__ Import("${itemsJson}")`, true, plugin.name);
}
// combinator signals ---------------------------------------------------------
async handleUpdateStorageEvent(event: UpdateStorageEvent) {
if (this.instance.status !== "running") {
instance.server.on("ipc-subspace_storage:output", (output: IpcItems) => {
logger.info("Received output items:");
logger.info(JSON.stringify(output));
provideItems(output).catch(err => unexpectedError(err));
});
instance.server.on("ipc-subspace_storage:orders", (orders: IpcItems) => {
if (instance.status !== "running" || !host.connected) {
return;
}
// Cache latest inventory from controller
this.cachedInventoryItems = event.items;
}
let task = requestItems(orders).catch(err => unexpectedError(err));
pendingTasks.add(task);
task.finally(() => { pendingTasks.delete(task); });
});
// combinator signals ---------------------------------------------------------
instance.handle(UpdateStorageEvent, async (event: UpdateStorageEvent) => {
if (instance.status !== "running") {
return;
}
// Cache latest inventory from controller
cachedInventoryItems = event.items;
});
instance.hooks.start.attach(plugin.name, async () => {
pingId = setInterval(() => {
if (!host.connected) {
return; // Only ping if we are actually connected to the controller.
}
instance.sendRcon(
"/sc __subspace_storage__ global.ticksSinceMasterPinged = 0", true, plugin.name
).catch(err => unexpectedError(err));
}, 5000);
let items = await instance.sendTo("controller", new GetStorageRequest());
// Cache items for periodic time updates
cachedInventoryItems = items;
// Ensure a payload with updated time is sent every second
timeUpdateId = setInterval(() => {
if (instance.status !== "running") {
return;
}
const payloadItems = [
...cachedInventoryItems,
new Item("signal-unixtime", Math.floor(Date.now() / 1000), "normal"),
];
const payloadJson = lib.escapeString(JSON.stringify(payloadItems));
const task = instance.sendRcon(
`/sc __subspace_storage__ UpdateInvData("${payloadJson}")`, true, plugin.name
).catch(err => unexpectedError(err));
pendingTasks.add(task);
task.finally(() => { pendingTasks.delete(task); });
}, 1000);
});
instance.hooks.stop.attach(plugin.name, async () => {
clearInterval(pingId);
clearInterval(timeUpdateId);
await Promise.all(pendingTasks);
});
instance.hooks.exit.attach(plugin.name, () => {
clearInterval(pingId);
clearInterval(timeUpdateId);
});
}
-2
View File
@@ -1,7 +1,5 @@
// These are libraries that does not have any units test, they are
// required here in order to track coverage for them.
import "../dist/node/instance.js";
import "../dist/node/controller.js";
import "../dist/node/dole.js";
import "../dist/node/dole_nn_base.js";
+95
View File
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import * as lib from "@clusterio/lib";
import * as mock from "../../../test/mock.js";
import controllerEntrypoint from "../dist/node/controller.js";
import instanceEntrypoint from "../dist/node/instance.js";
import { plugin as info } from "../dist/node/index.js";
import {
Item, PlaceEvent, RemoveRequest, GetStorageRequest, UpdateStorageEvent,
} from "../dist/node/messages.js";
describe("subspace_storage plugin", function() {
before(function() {
try {
lib.registerPluginMessages([info]);
} catch (err) {
// Already registered by the full test suite
}
});
describe("controller entrypoint", function() {
const databaseDir = path.join("temp", "test", "subspace_storage");
const src = lib.Address.fromShorthand({ instanceId: 7357 });
let controller;
let sent;
before(async function() {
await fs.rm(databaseDir, { recursive: true, force: true });
controller = await mock.loadControllerPlugin(async (context) => {
context.controller.mockConfigEntries.set("controller.database_directory", databaseDir);
context.controller.mockConfigEntries.set("subspace_storage.division_method", "simple");
context.controller.mockConfigEntries.set("subspace_storage.log_item_transfers", false);
sent = [];
context.controller.sendTo = (dst, event) => { sent.push(event); };
await controllerEntrypoint(context);
}, info);
});
after(async function() {
await controller.hooks.shutdown.invoke();
});
it("should store placed items and broadcast the update", async function() {
await mock.getHandler(controller, PlaceEvent)(
new PlaceEvent([new Item("iron-plate", 20, "normal")]), src
);
const storage = await mock.getHandler(controller, GetStorageRequest)();
assert.deepEqual(storage, [new Item("iron-plate", 20, "normal")]);
assert(sent[0] instanceof UpdateStorageEvent);
});
it("should remove requested items up to the stored amount", async function() {
const removed = await mock.getHandler(controller, RemoveRequest)(
new RemoveRequest([new Item("iron-plate", 15, "normal"), new Item("copper-plate", 5, "normal")]), src
);
assert.deepEqual(removed, [new Item("iron-plate", 15, "normal")]);
});
it("should update the inventory gauge on metrics", async function() {
for (const result of await controller.hooks.metrics.collect()) {
for await (const _ of result) { /* drain */ }
}
const gauge = lib.defaultRegistry.collectors.find(
collector => collector.metric?.name === "clusterio_subspace_storage_controller_inventory"
);
assert.equal(gauge.labels("iron-plate", "normal").get(), 5);
});
it("should save the item database", async function() {
await controller.hooks.save.invoke();
const content = JSON.parse(await fs.readFile(path.join(databaseDir, "items.json"), "utf8"));
assert.deepEqual(content, { "iron-plate": { normal: 5 } });
});
});
describe("instance entrypoint", function() {
let instance;
before(async function() {
({ instance } = await mock.loadInstancePlugin(instanceEntrypoint, info));
instance.mockConfigEntries.set("subspace_storage.log_item_transfers", false);
});
it("should send output items to the controller", async function() {
instance.server.emit("ipc-subspace_storage:output", [["iron-plate", 10, "normal"]]);
await new Promise(resolve => setImmediate(resolve));
const message = instance.connector.sentMessages.find(m => m.data instanceof PlaceEvent);
assert(message, "PlaceEvent was not sent");
});
it("should ignore orders while not running", async function() {
instance.status = "starting";
instance.server.emit("ipc-subspace_storage:orders", [["iron-plate", 10, "normal"]]);
await new Promise(resolve => setImmediate(resolve));
const message = instance.connector.sentMessages.find(m => m.data instanceof RemoveRequest);
assert.equal(message, undefined);
});
});
});
+87 -80
View File
@@ -1,41 +1,99 @@
import { useContext, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { Table } from "antd";
import {
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
PageLayout, PageHeader, Control, notifyErrorHandler,
useExportLocale, useExportPrototypeMetadata, useDefaultModPack, FactorioIcon,
useTableQueryState, useColumnSearch,
useTableQueryState, useColumnSearch, type WebPluginContext,
} from "@clusterio/web_ui";
import { GetStorageRequest, Item, SetStorageSubscriptionRequest, UpdateStorageEvent } from "../messages.js";
import "./style.css";
function useStorage(control: Control) {
let plugin = control.plugins.get("subspace_storage") as WebPlugin;
let [storage, setStorage] = useState([...plugin.storage]);
class StorageSubscriber {
storage = new Map<string, Item>();
callbacks: (() => void)[] = [];
constructor(
private control: Control,
) { }
onUpdate(callback: () => void) {
this.callbacks.push(callback);
if (this.callbacks.length) {
this.updateSubscription();
}
}
offUpdate(callback: () => void) {
let index = this.callbacks.lastIndexOf(callback);
if (index === -1) {
throw new Error("callback is not registered");
}
this.callbacks.splice(index, 1);
if (!this.callbacks.length) {
this.updateSubscription();
}
}
updateStorage(items: Item[]) {
for (let item of items) {
this.storage.set(`${item.name}:${item.quality}`, item);
}
for (let callback of this.callbacks) {
callback();
}
}
updateSubscription() {
if (!this.control.connector.connected) {
return;
}
this.control.send(
new SetStorageSubscriptionRequest(Boolean(this.callbacks.length))
).catch(notifyErrorHandler("Error subscribing to storage"));
if (this.callbacks.length) {
this.control.send(new GetStorageRequest()).then(
items => {
this.updateStorage(items);
}
).catch(notifyErrorHandler("Error updating storage"));
} else {
this.storage.clear();
}
}
}
// Created when the plugin is loaded, before any page is rendered
let storageSubscriber: StorageSubscriber;
function useStorage() {
let [storage, setStorage] = useState([...storageSubscriber.storage]);
useEffect(() => {
function update() {
setStorage([...plugin.storage]);
setStorage([...storageSubscriber.storage]);
}
plugin.onUpdate(update);
storageSubscriber.onUpdate(update);
return () => {
plugin.offUpdate(update);
storageSubscriber.offUpdate(update);
};
}, []);
return storage;
}
function StoragePage() {
let control = useContext(ControlContext);
const modPack = useDefaultModPack();
let locale = useExportLocale(modPack);
const prototypes = useExportPrototypeMetadata(modPack);
let itemMetadata = prototypes?.get("item");
let fluidMetadata = prototypes?.get("fluid");
let storage = useStorage(control);
let storage = useStorage();
const tableState = useTableQueryState<[string, Item]>({
namespace: "storage", defaultSortKey: "quantity", defaultSortOrder: "descend", pagination: false,
});
@@ -136,77 +194,26 @@ function StoragePage() {
</PageLayout>;
}
export class WebPlugin extends BaseWebPlugin {
storage = new Map<string, Item>();
callbacks: (() => void)[] = [];
export default async function(context: WebPluginContext) {
const { control, plugin } = context;
storageSubscriber = new StorageSubscriber(control);
async init() {
this.pages = [
{
path: "/storage",
sidebarName: "Storage",
permission: "subspace_storage.storage.view",
content: <StoragePage/>,
},
];
this.control.handle(UpdateStorageEvent, this.handleUpdateStorageEvent.bind(this));
}
control.hooks.pages.attach(plugin.name, () => [
{
path: "/storage",
sidebarName: "Storage",
permission: "subspace_storage.storage.view",
content: <StoragePage/>,
},
]);
onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
control.handle(UpdateStorageEvent, async (event: UpdateStorageEvent) => {
storageSubscriber.updateStorage(event.items);
});
control.hooks.controllerConnectionEvent.attach(plugin.name, (event) => {
if (event === "connect") {
this.updateSubscription();
storageSubscriber.updateSubscription();
}
}
async handleUpdateStorageEvent(event: UpdateStorageEvent) {
this.updateStorage(event.items);
}
onUpdate(callback: () => void) {
this.callbacks.push(callback);
if (this.callbacks.length) {
this.updateSubscription();
}
}
offUpdate(callback: () => void) {
let index = this.callbacks.lastIndexOf(callback);
if (index === -1) {
throw new Error("callback is not registered");
}
this.callbacks.splice(index, 1);
if (!this.callbacks.length) {
this.updateSubscription();
}
}
updateStorage(items: Item[]) {
for (let item of items) {
this.storage.set(`${item.name}:${item.quality}`, item);
}
for (let callback of this.callbacks) {
callback();
}
}
updateSubscription() {
if (!this.control.connector.connected) {
return;
}
this.control.send(
new SetStorageSubscriptionRequest(Boolean(this.callbacks.length))
).catch(notifyErrorHandler("Error subscribing to storage"));
if (this.callbacks.length) {
this.control!.send(new GetStorageRequest()).then(
items => {
this.updateStorage(items);
}
).catch(notifyErrorHandler("Error updating storage"));
} else {
this.storage.clear();
}
}
});
}
+20
View File
@@ -248,3 +248,23 @@ export async function createInstancePlugin(InstancePluginClass, info) {
await plugin.init();
return plugin;
}
export async function loadControllerPlugin(entrypoint, info) {
const controller = new MockController();
await entrypoint({ controller, metrics: {}, logger: new MockLogger(), plugin: info });
return controller;
}
export async function loadInstancePlugin(entrypoint, info) {
const instance = new MockInstance();
const host = new MockHost();
await entrypoint({ instance, host, logger: new MockLogger(), plugin: info });
return { instance, host };
}
export function getHandler(link, Class) {
if (link instanceof MockController) {
return link.handles.get(Class);
}
return link._requestHandlers.get(Class) ?? link._eventHandlers.get(Class);
}