From dab4dd4a4e44d9858c837f73aad1014a67fcbb25 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:11:02 +0000 Subject: [PATCH 1/6] Add test helpers for plugin entrypoint functions Co-Authored-By: Claude Opus 5.5 --- test/mock.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/mock.js b/test/mock.js index de016ea0..265e869a 100644 --- a/test/mock.js +++ b/test/mock.js @@ -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); +} From bd609c3c544732470f96a568d05b69baa4359f68 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:15:00 +0000 Subject: [PATCH 2/6] Allow metrics hook handlers to return nothing A handler that only updates gauges has nothing to yield. collect() already dropped undefined results at runtime, now its type says so too. Co-Authored-By: Claude Opus 5.5 --- packages/controller/src/BaseControllerPlugin.ts | 2 +- packages/host/src/BaseHostPlugin.ts | 2 +- packages/host/src/BaseInstancePlugin.ts | 2 +- packages/lib/src/AsyncHook.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/controller/src/BaseControllerPlugin.ts b/packages/controller/src/BaseControllerPlugin.ts index e515e82d..8a74db3a 100644 --- a/packages/controller/src/BaseControllerPlugin.ts +++ b/packages/controller/src/BaseControllerPlugin.ts @@ -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>; + readonly metrics: lib.AsyncHook<[], AsyncIterable | void>; /** * Called when the controller is shutting down diff --git a/packages/host/src/BaseHostPlugin.ts b/packages/host/src/BaseHostPlugin.ts index 06afe419..32114453 100644 --- a/packages/host/src/BaseHostPlugin.ts +++ b/packages/host/src/BaseHostPlugin.ts @@ -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>; + readonly metrics: lib.AsyncHook<[], AsyncIterable | void>; /** * Called when the host is shutting down diff --git a/packages/host/src/BaseInstancePlugin.ts b/packages/host/src/BaseInstancePlugin.ts index ac88662e..457c4bbe 100644 --- a/packages/host/src/BaseInstancePlugin.ts +++ b/packages/host/src/BaseInstancePlugin.ts @@ -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>; + readonly metrics: lib.AsyncHook<[], AsyncIterable | void>; /** * Called after the Factorio server is started diff --git a/packages/lib/src/AsyncHook.ts b/packages/lib/src/AsyncHook.ts index a0b8274c..04038da0 100644 --- a/packages/lib/src/AsyncHook.ts +++ b/packages/lib/src/AsyncHook.ts @@ -111,7 +111,7 @@ export class AsyncHook< * * Returns all successful results. */ - async collect(...args: Args): Promise { + async collect(...args: Args): Promise, 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 !== undefined + (result): result is Exclude, void> => result !== undefined ); } From cfeb4b6a4afc63482b0117f02e781357096e281d Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:15:00 +0000 Subject: [PATCH 3/6] Convert global_chat, research_sync and statistics_exporter to entrypoint functions Co-Authored-By: Claude Opus 5.5 --- plugins/global_chat/ctl.ts | 8 +- plugins/global_chat/instance.ts | 47 ++++--- plugins/global_chat/test/plugin.js | 45 ++++--- plugins/research_sync/controller.ts | 135 ++++++++++----------- plugins/research_sync/instance.ts | 75 ++++++------ plugins/statistics_exporter/instance.ts | 128 +++++++++---------- plugins/statistics_exporter/test/plugin.js | 30 +++-- 7 files changed, 231 insertions(+), 237 deletions(-) diff --git a/plugins/global_chat/ctl.ts b/plugins/global_chat/ctl.ts index cbb0bbca..6244b676 100644 --- a/plugins/global_chat/ctl.ts +++ b/plugins/global_chat/ctl.ts @@ -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); - } + }); } diff --git a/plugins/global_chat/instance.ts b/plugins/global_chat/instance.ts index 22cf1dda..c7ba9615 100644 --- a/plugins/global_chat/instance.ts +++ b/plugins/global_chat/instance.ts @@ -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? } diff --git a/plugins/global_chat/test/plugin.js b/plugins/global_chat/test/plugin.js index 0b3b1a1e..7b41041e 100644 --- a/plugins/global_chat/test/plugin.js +++ b/plugins/global_chat/test/plugin.js @@ -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; } } diff --git a/plugins/research_sync/controller.ts b/plugins/research_sync/controller.ts index 954e5f70..ba198dc0 100644 --- a/plugins/research_sync/controller.ts +++ b/plugins/research_sync/controller.ts @@ -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; - technologiesDirty = true; - progressRateLimiter!: lib.RateLimiter; - progressBroadcastId!: ReturnType | null; - progressToBroadcast!: Set; +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(); - 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 { + controller.handle(SyncTechnologiesRequest, async (request: SyncTechnologiesRequest): Promise => { 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(); + }); } diff --git a/plugins/research_sync/instance.ts b/plugins/research_sync/instance.ts index 5e254d86..77200f7d 100644 --- a/plugins/research_sync/instance.ts +++ b/plugins/research_sync/instance.ts @@ -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 = 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); } - } + }); } diff --git a/plugins/statistics_exporter/instance.ts b/plugins/statistics_exporter/instance.ts index 82382ada..74ac0003 100644 --- a/plugins/statistics_exporter/instance.ts +++ b/plugins/statistics_exporter/instance.ts @@ -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; diff --git a/plugins/statistics_exporter/test/plugin.js b/plugins/statistics_exporter/test/plugin.js index 49f1439e..170ace2b 100644 --- a/plugins/statistics_exporter/test/plugin.js +++ b/plugins/statistics_exporter/test/plugin.js @@ -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}"`) ); }); From b2705b87881b9d87a45c8224e666207abfb02c2d Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:15:00 +0000 Subject: [PATCH 4/6] Convert player_auth to entrypoint functions Co-Authored-By: Claude Opus 5.5 --- plugins/player_auth/controller.ts | 165 ++++++++++----------- plugins/player_auth/instance.ts | 40 ++--- plugins/player_auth/test/plugin.js | 227 +++++++++++++++-------------- plugins/player_auth/web/index.tsx | 19 ++- 4 files changed, 225 insertions(+), 226 deletions(-) diff --git a/plugins/player_auth/controller.ts b/plugins/player_auth/controller.ts index 19dcce81..38b6d947 100644 --- a/plugins/player_auth/controller.ts +++ b/plugins/player_auth/controller.ts @@ -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 { type PlayerCode = { playerCode: string, verifyCode: string | null, expiresMs: number }; -export class ControllerPlugin extends BaseControllerPlugin { - players!: Map; +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(); - // 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[] = []; + controller.app.get("/api/player_auth/servers", (req: Request, res: Response) => { + const servers: Static[] = []; - 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 diff --git a/plugins/player_auth/instance.ts b/plugins/player_auth/instance.ts index 58be8518..d38b4b45 100644 --- a/plugins/player_auth/instance.ts +++ b/plugins/player_auth/instance.ts @@ -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}`) + ) + ); } diff --git a/plugins/player_auth/test/plugin.js b/plugins/player_auth/test/plugin.js index 590af809..1eef21fc 100644 --- a/plugins/player_auth/test/plugin.js +++ b/plugins/player_auth/test/plugin.js @@ -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"); }); }); diff --git a/plugins/player_auth/web/index.tsx b/plugins/player_auth/web/index.tsx index 17cb7531..81c09f67 100644 --- a/plugins/player_auth/web/index.tsx +++ b/plugins/player_auth/web/index.tsx @@ -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, + }]); } From 1ca0c658b95427c2abebcf1f3c5a950e2cd05ef4 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:15:00 +0000 Subject: [PATCH 5/6] Convert inventory_sync to entrypoint functions Co-Authored-By: Claude Opus 5.5 --- plugins/inventory_sync/controller.ts | 148 ++++++++-------- plugins/inventory_sync/instance.ts | 219 ++++++++++++------------ plugins/inventory_sync/test/instance.js | 49 +++--- plugins/inventory_sync/web/index.tsx | 24 +-- 4 files changed, 215 insertions(+), 225 deletions(-) diff --git a/plugins/inventory_sync/controller.ts b/plugins/inventory_sync/controller.ts index 6bd7fa10..5f0f0208 100644 --- a/plugins/inventory_sync/controller.ts +++ b/plugins/inventory_sync/controller.ts @@ -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; - playerDatastore!: Map; - playerDatastoreDirty = false; +export default async function(context: ControllerPluginContext) { + const { controller, logger, plugin } = context; + const acquiredPlayers = new Map(); + const playerDatastore: Map = 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, }, ); - } + }); } diff --git a/plugins/inventory_sync/instance.ts b/plugins/inventory_sync/instance.ts index 831c7524..57645f03 100644 --- a/plugins/inventory_sync/instance.ts +++ b/plugins/inventory_sync/instance.ts @@ -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; - 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(); + 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}`) + ); + } + }); } diff --git a/plugins/inventory_sync/test/instance.js b/plugins/inventory_sync/test/instance.js index 020f5979..4702f838 100644 --- a/plugins/inventory_sync/test/instance.js +++ b/plugins/inventory_sync/test/instance.js @@ -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)]); }); }); }); diff --git a/plugins/inventory_sync/web/index.tsx b/plugins/inventory_sync/web/index.tsx index 21aef86b..09d47f46 100644 --- a/plugins/inventory_sync/web/index.tsx +++ b/plugins/inventory_sync/web/index.tsx @@ -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() { ; } -export class WebPlugin extends BaseWebPlugin { - async init() { - this.pages = [ - { - path: "/inventory", - sidebarName: "Inventory sync", - permission: "inventory_sync.inventory.view", - content: , - }, - ]; - } +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: , + }, + ]); } From 60dbb461e72cc460ab0a8d114896aedb9442d036 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:15:47 +0000 Subject: [PATCH 6/6] Convert subspace_storage to entrypoint functions Co-Authored-By: Claude Opus 5.5 --- plugins/subspace_storage/controller.ts | 189 +++++++++++------------ plugins/subspace_storage/instance.ts | 187 +++++++++++----------- plugins/subspace_storage/test/missing.js | 2 - plugins/subspace_storage/test/plugin.js | 95 ++++++++++++ plugins/subspace_storage/web/index.tsx | 167 ++++++++++---------- 5 files changed, 363 insertions(+), 277 deletions(-) create mode 100644 plugins/subspace_storage/test/plugin.js diff --git a/plugins/subspace_storage/controller.ts b/plugins/subspace_storage/controller.ts index 32f990cc..cad2ec52 100644 --- a/plugins/subspace_storage/controller.ts +++ b/plugins/subspace_storage/controller.ts @@ -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 = new Map(); - subscribedControlLinks!: Set; - doleMagicId!: ReturnType; - 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(); + for (const [name, qualities] of items.getEntries()) { + itemsLastUpdate.set(name, { ...qualities }); } + const subscribedControlLinks = new Set(); - 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); } - } + }); } diff --git a/plugins/subspace_storage/instance.ts b/plugins/subspace_storage/instance.ts index 87ab957f..5b1e6368 100644 --- a/plugins/subspace_storage/instance.ts +++ b/plugins/subspace_storage/instance.ts @@ -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; - pingId?: ReturnType; - timeUpdateId?: ReturnType; - cachedInventoryItems: Item[] = []; +export default async function(context: InstancePluginContext) { + const { instance, host, logger, plugin } = context; + const pendingTasks = new Set>(); + let pingId: ReturnType | undefined; + let timeUpdateId: ReturnType | 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); + }); } diff --git a/plugins/subspace_storage/test/missing.js b/plugins/subspace_storage/test/missing.js index e01532ac..af119a94 100644 --- a/plugins/subspace_storage/test/missing.js +++ b/plugins/subspace_storage/test/missing.js @@ -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"; diff --git a/plugins/subspace_storage/test/plugin.js b/plugins/subspace_storage/test/plugin.js new file mode 100644 index 00000000..285d2b93 --- /dev/null +++ b/plugins/subspace_storage/test/plugin.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); + }); + }); +}); diff --git a/plugins/subspace_storage/web/index.tsx b/plugins/subspace_storage/web/index.tsx index 2b91ca61..48901b31 100644 --- a/plugins/subspace_storage/web/index.tsx +++ b/plugins/subspace_storage/web/index.tsx @@ -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(); + 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() { ; } -export class WebPlugin extends BaseWebPlugin { - storage = new Map(); - 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: , - }, - ]; - this.control.handle(UpdateStorageEvent, this.handleUpdateStorageEvent.bind(this)); - } + control.hooks.pages.attach(plugin.name, () => [ + { + path: "/storage", + sidebarName: "Storage", + permission: "subspace_storage.storage.view", + content: , + }, + ]); - 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(); - } - } + }); }