mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 07:24:52 +09:00
Merge pull request #1052 from bbassie/refactor/migrate-plugins-2.0
Migrate bundled plugins to entrypoint functions
This commit is contained in:
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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)]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 />,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}]);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"`)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user