From 68a964cf577f752ac78f18ea4853c1d537b120e1 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:50:15 +0200 Subject: [PATCH] Write plugins without state shared elsewhere as functions (#492) The instance plugins of exp_groups, exp_reports, exp_roles, exp_scenario and exp_server_ups, and the controller plugins of exp_reports and exp_scenario, were classes only to hold their handlers. They are now init functions with closures, like clusterio's own plugins. The exp_roles and exp_groups controller plugins stay classes because exp_scenario reaches their datastores through get(controller). AutoRestart stays a class because its tests step check() with a fake clock. Tests spy on handle() and the hooks to call the handlers. The reports webhook tests stub fetch instead of replacing postWebhook, and the feature reconcile test writes the stored records to disk before start. Co-authored-by: Claude Opus 5.5 --- exp_groups/instance.ts | 200 +++++++++--------------- exp_reports/controller.ts | 206 +++++++++++-------------- exp_reports/instance.ts | 75 ++++----- exp_reports/test/controller.test.js | 153 +++++++++++-------- exp_reports/test/instance.test.js | 53 ++++--- exp_roles/instance.ts | 144 +++++++----------- exp_roles/test/instance.test.js | 85 ++++++----- exp_scenario/controller.ts | 220 ++++++++++++--------------- exp_scenario/instance.ts | 82 ++++------ exp_scenario/test/controller.test.js | 134 ++++++++++------ exp_scenario/test/instance.test.js | 61 +++++--- exp_server_ups/instance.ts | 116 ++++++-------- 12 files changed, 709 insertions(+), 820 deletions(-) diff --git a/exp_groups/instance.ts b/exp_groups/instance.ts index 88e97769..afc8e591 100644 --- a/exp_groups/instance.ts +++ b/exp_groups/instance.ts @@ -1,4 +1,4 @@ -import type { Instance, InstancePluginContext } from "@clusterio/host"; +import type { InstancePluginContext } from "@clusterio/host"; import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; @@ -17,174 +17,126 @@ export type IpcPlayerAssignments = { assignments: Record, }; -export class InstancePlugin { +export default async function (context: InstancePluginContext) { + const { instance, plugin } = context; + const syncMode = () => instance.config.get("exp_groups.sync_mode"); // Once only, don't send permissions for these groups // This is used for groups created on this instance that only need the controller generated id - skipSendingPermissions = new Set(); + const skipSendingPermissions = new Set(); // This is used for groups updated / deleted on this instance to stop cycles - skipSendingUpdate = new Set(); + const skipSendingUpdate = new Set(); // Track known online players so that we only apply assignment updates for them - onlinePlayers = new Set(); + const onlinePlayers = new Set(); - private constructor( - public instance: Instance, - public logger: lib.Logger, - public name: string, - ) {} - - static async fromContext(context: InstancePluginContext) { - const instance = context.instance; - const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); - - instance.handle(messages.GroupUpdatedEvent, plugin.handleGroupUpdatedEvent.bind(plugin)); - instance.handle(messages.ResolvedAssignmentUpdatedEvent, plugin.handleResolvedAssignmentUpdatedEvent.bind(plugin)); - instance.server.handle(`exp_group:group_updated`, plugin.handleGroupUpdatedIPC.bind(plugin)) - instance.server.handle(`exp_group:group_deleted`, plugin.handleGroupDeletedIPC.bind(plugin)) - instance.server.handle(`exp_group:player_assignments`, plugin.handlePlayerAssignmentsIPC.bind(plugin)) - - const hooks = instance.hooks; - hooks.instanceConfigFieldChanged.attach(plugin.name, plugin.onInstanceConfigFieldChanged.bind(plugin)); - hooks.start.attach(plugin.name, plugin.onStart.bind(plugin)); - hooks.playerEvent.attach(plugin.name, plugin.onPlayerEvent.bind(plugin)); - return plugin; + async function luaSend(receiver: string, json: any) { + await instance.sendRcon(`/sc exp_groups.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true) } - async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { - switch(field) { - case "exp_groups.sync_mode": - await this.luaSetEmitEvents(curr == "bidirectional") - break; - } - } - - async onStart() { - // We use Date.now() because we need to manually initialise the groups on the lua side - await this.instance.sendTo("controller", new lib.SubscriptionRequest( - `exp_groups:${messages.GroupUpdatedEvent.name}`, "subscribe", Date.now() + async function updatePlayerAssignmentSubscription(playerName: string, action: "subscribe" | "unsubscribe") { + await instance.sendTo("controller", new lib.SubscriptionRequest( + `exp_groups:${messages.ResolvedAssignmentUpdatedEvent.name}`, action, 0, lib.SubscriptionFilters.fromShorthand(playerName) )); - const groups = await this.instance.sendTo("controller", new messages.GroupListRequest()) - await this.luaSendInitialGroups(groups); - await this.luaSetEmitEvents(this.instance.config.get("exp_groups.sync_mode") == "bidirectional") } - async onPlayerEvent(event: lib.PlayerEvent) { - switch(event.type) { - case "join": - this.onlinePlayers.add(event.name); - await this.subscribePlayerAssignment(event.name); - break; - case "leave": - this.onlinePlayers.delete(event.name); - await this.unsubscribePlayerAssignment(event.name); - break; + instance.handle(messages.GroupUpdatedEvent, async event => { + if (syncMode() === "disabled") { + return; } - } - async handleGroupUpdatedEvent(event: messages.GroupUpdatedEvent) { for (const group of event.updates) { - await this.luaSendGroupUpdate(group); + if (skipSendingUpdate.has(group.id)) { + skipSendingUpdate.delete(group.id); + continue; + } + + const json = group.toJSON(); + if (skipSendingPermissions.has(group.name)) { + skipSendingPermissions.delete(group.name); + delete (json as any).permissions; + } + + await luaSend("receive_group_update", json); } - } + }); + + instance.handle(messages.ResolvedAssignmentUpdatedEvent, async event => { + if (syncMode() === "disabled") { + return; + } - async handleResolvedAssignmentUpdatedEvent(event: messages.ManualAssignmentUpdatedEvent) { for (const assignment of event.updates) { - if (this.onlinePlayers.has(assignment.name)) { - await this.luaSendAssignmentUpdate(assignment); + if (onlinePlayers.has(assignment.name)) { + await luaSend("receive_assignment_update", assignment); } } - } + }); - async handleGroupUpdatedIPC(event: IpcGroupUpdated) { + instance.server.handle("exp_group:group_updated", async (event: IpcGroupUpdated) => { const permissions = new messages.GroupPermissions( event.permissions.is_blacklist, event.permissions.permissions ?? [], ) if (event.group_id === undefined) { - this.skipSendingPermissions.add(event.group_name); - await this.instance.sendTo("controller", + skipSendingPermissions.add(event.group_name); + await instance.sendTo("controller", new messages.GroupCreateRequest(event.group_name, permissions), ); } else { - this.skipSendingUpdate.add(event.group_id); - await this.instance.sendTo("controller", new messages.GroupUpdateRequest( + skipSendingUpdate.add(event.group_id); + await instance.sendTo("controller", new messages.GroupUpdateRequest( new messages.GroupRecord(event.group_id, event.group_name, permissions), )); } - } + }); - async handleGroupDeletedIPC(event: IpcGroupDeleted) { + instance.server.handle("exp_group:group_deleted", async (event: IpcGroupDeleted) => { if (event.group_id === undefined) { return; } - this.skipSendingUpdate.add(event.group_id); - await this.instance.sendTo("controller", new messages.GroupDeleteRequest(event.group_id)); - } + skipSendingUpdate.add(event.group_id); + await instance.sendTo("controller", new messages.GroupDeleteRequest(event.group_id)); + }); - async handlePlayerAssignmentsIPC(event: IpcPlayerAssignments) { + instance.server.handle("exp_group:player_assignments", async (event: IpcPlayerAssignments) => { await Promise.all( Object.entries(event.assignments).map(([playerName, groupId]) => - this.instance.sendTo("controller", + instance.sendTo("controller", new messages.AssignmentUpdateRequest(new messages.AssignmentRecord(playerName, groupId)), ) ) ); - } + }); - async subscribePlayerAssignment(playerName: string) { - await this.instance.sendTo("controller", new lib.SubscriptionRequest( - `exp_groups:${messages.ResolvedAssignmentUpdatedEvent.name}`, "subscribe", 0, lib.SubscriptionFilters.fromShorthand(playerName) + const hooks = instance.hooks; + hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr) => { + if (field === "exp_groups.sync_mode") { + await luaSend("set_emit_events", curr == "bidirectional") + } + }); + + hooks.start.attach(plugin.name, async () => { + // We use Date.now() because we need to manually initialise the groups on the lua side + await instance.sendTo("controller", new lib.SubscriptionRequest( + `exp_groups:${messages.GroupUpdatedEvent.name}`, "subscribe", Date.now() )); - } - - async unsubscribePlayerAssignment(playerName: string) { - await this.instance.sendTo("controller", new lib.SubscriptionRequest( - `exp_groups:${messages.ResolvedAssignmentUpdatedEvent.name}`, "unsubscribe", 0, lib.SubscriptionFilters.fromShorthand(playerName) - )); - } - - async luaSendInitialGroups(groups: messages.GroupRecord[]) { - if (this.instance.config.get("exp_groups.sync_mode") === "disabled") { - return; + const groups = await instance.sendTo("controller", new messages.GroupListRequest()) + if (syncMode() !== "disabled") { + await luaSend("initialise_groups", groups); } - await this.luaSend("initialise_groups", groups); - } + await luaSend("set_emit_events", syncMode() == "bidirectional") + }); - async luaSendGroupUpdate(group: messages.GroupRecord) { - if (this.instance.config.get("exp_groups.sync_mode") === "disabled") { - return; + hooks.playerEvent.attach(plugin.name, async (event: lib.PlayerEvent) => { + switch(event.type) { + case "join": + onlinePlayers.add(event.name); + await updatePlayerAssignmentSubscription(event.name, "subscribe"); + break; + case "leave": + onlinePlayers.delete(event.name); + await updatePlayerAssignmentSubscription(event.name, "unsubscribe"); + break; } - - if (this.skipSendingUpdate.has(group.id)) { - this.skipSendingUpdate.delete(group.id); - return; - } - - const json = group.toJSON(); - if (this.skipSendingPermissions.has(group.name)) { - this.skipSendingPermissions.delete(group.name); - delete (json as any).permissions; - } - - await this.luaSend("receive_group_update", json); - } - - async luaSendAssignmentUpdate(assignment: messages.AssignmentRecord) { - if (this.instance.config.get("exp_groups.sync_mode") === "disabled") { - return; - } - await this.luaSend("receive_assignment_update", assignment); - } - - async luaSetEmitEvents(emitEvents: boolean) { - await this.luaSend("set_emit_events", emitEvents); - } - - async luaSend(receiver: string, json: any) { - await this.instance.sendRcon(`/sc exp_groups.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true) - } -} - -export default async function (context: InstancePluginContext) { - await InstancePlugin.fromContext(context); + }); } diff --git a/exp_reports/controller.ts b/exp_reports/controller.ts index f8b6ca59..128a0514 100644 --- a/exp_reports/controller.ts +++ b/exp_reports/controller.ts @@ -1,122 +1,29 @@ -import type { Controller, ControllerPluginContext } from "@clusterio/controller"; +import type { ControllerPluginContext } from "@clusterio/controller"; import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; import * as path from "node:path"; -export class ControllerPlugin { - private constructor( - public controller: Controller, - public logger: lib.Logger, - public name: string, - public reports: lib.SubscribableDatastore, - ) {} - - static async fromContext(context: ControllerPluginContext) { - const controller = context.controller; - const databaseDirectory = controller.config.get("controller.database_directory"); - - const reports = new lib.SubscribableDatastore( - ...await new lib.JsonIdDatastoreProvider( - path.join(databaseDirectory, "exp_reports", "reports.json"), - messages.ReportRecord.fromJSON.bind(messages.ReportRecord), - ).bootstrap() - ); - - const plugin = new ControllerPlugin(controller, context.logger, context.plugin.name, reports); - - controller.subscriptions.handle(messages.ReportUpdatedEvent, plugin.handleReportSubscription.bind(plugin)); - reports.on("update", plugin.reportsUpdated.bind(plugin)); - - controller.handle(messages.ReportListRequest, plugin.handleReportListRequest.bind(plugin)); - controller.handle(messages.ReportGetRequest, plugin.handleReportGetRequest.bind(plugin)); - controller.handle(messages.ReportCreateRequest, plugin.handleReportCreateRequest.bind(plugin)); - controller.handle(messages.ReportDeleteRequest, plugin.handleReportDeleteRequest.bind(plugin)); - - controller.hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin)); - return plugin; - } - - async onShutdown() { - await this.reports.save(); - } - - /** Reports are immutable, so any update which is not a deletion is a new report. */ - reportsUpdated(reports: messages.ReportRecord[]) { - this.controller.subscriptions.broadcast(new messages.ReportUpdatedEvent(reports)); - - for (const report of reports) { - if (!report.isDeleted) { - this.sendWebhooks(report); - } - } - } - - async handleReportSubscription(request: lib.SubscriptionRequest) { - const reports = [...this.reports.values()].filter(report => report.updatedAtMs > request.lastRequestTimeMs); - return reports.length ? new messages.ReportUpdatedEvent(reports) : null; - } +export default async function (context: ControllerPluginContext) { + const { controller, logger, plugin } = context; + const databaseDirectory = controller.config.get("controller.database_directory"); + const reports = new lib.SubscribableDatastore( + ...await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "exp_reports", "reports.json"), + messages.ReportRecord.fromJSON.bind(messages.ReportRecord), + ).bootstrap() + ); /** Every report, or only those against one player. */ - listReports(playerName?: string) { - const reports = [...this.reports.values()]; - return playerName === undefined ? reports : reports.filter(report => report.playerName === playerName); - } - - async handleReportListRequest(request: messages.ReportListRequest) { - return this.listReports(request.playerName); - } - - async handleReportGetRequest(request: messages.ReportGetRequest) { - const report = this.reports.get(request.id); - if (!report) { - throw new lib.RequestError(`Report with ID ${request.id} does not exist`); - } - return report; - } - - async handleReportCreateRequest(request: messages.ReportCreateRequest, src: lib.Address) { - let byPlayerName = request.byPlayerName; - let instanceName = ""; - if (src.type === lib.Address.control) { - byPlayerName = this.controller.wsServer.controlConnections.get(src.id)!.user.name; - } else { - const instance = this.controller.instances.get(src.id); - instanceName = instance ? instance.config.get("instance.name") : String(src.id); - } - - if (!byPlayerName) { - throw new lib.RequestError("A report needs the name of the player making it"); - } - if (!request.reason.trim()) { - throw new lib.RequestError("A report needs a reason"); - } - if (this.listReports(request.playerName).some(report => report.byPlayerName === byPlayerName)) { - throw new lib.RequestError(`${byPlayerName} has already reported ${request.playerName}`); - } - - let id = Math.random() * 2 ** 31 | 0; - while (this.reports.has(id)) { - id = Math.random() * 2 ** 31 | 0; - } - - const report = new messages.ReportRecord(id, request.playerName, byPlayerName, request.reason.trim(), instanceName); - this.reports.set(report); - return report; - } - - async handleReportDeleteRequest(request: messages.ReportDeleteRequest) { - const report = this.reports.getMutable(request.id); - if (!report) { - throw new lib.RequestError(`Report with ID ${request.id} does not exist`); - } - this.reports.delete(report); + function listReports(playerName?: string) { + const all = [...reports.values()]; + return playerName === undefined ? all : all.filter(report => report.playerName === playerName); } /** Post a new report to the configured webhooks, failures are logged rather than failing the report. */ - sendWebhooks(report: messages.ReportRecord) { - const discordUrl = this.controller.config.get("exp_reports.discord_webhook_url"); + function sendWebhooks(report: messages.ReportRecord) { + const discordUrl = controller.config.get("exp_reports.discord_webhook_url"); if (discordUrl) { - this.postWebhook(discordUrl, { + postWebhook(discordUrl, { embeds: [{ title: "Player reported", color: 0xffcc00, @@ -131,13 +38,13 @@ export class ControllerPlugin { }); } - const jsonUrl = this.controller.config.get("exp_reports.json_webhook_url"); + const jsonUrl = controller.config.get("exp_reports.json_webhook_url"); if (jsonUrl) { - this.postWebhook(jsonUrl, { type: "report_created", report: report.toJSON() }); + postWebhook(jsonUrl, { type: "report_created", report: report.toJSON() }); } } - async postWebhook(url: string, body: unknown) { + async function postWebhook(url: string, body: unknown) { try { const response = await fetch(url, { method: "POST", @@ -145,14 +52,79 @@ export class ControllerPlugin { body: JSON.stringify(body), }); if (!response.ok) { - this.logger.warn(`Webhook ${url} responded with ${response.status}`); + logger.warn(`Webhook ${url} responded with ${response.status}`); } } catch (err: any) { - this.logger.warn(`Webhook ${url} failed: ${err.message}`); + logger.warn(`Webhook ${url} failed: ${err.message}`); } } -} -export default async function (context: ControllerPluginContext) { - await ControllerPlugin.fromContext(context); + controller.subscriptions.handle(messages.ReportUpdatedEvent, async request => { + const updates = [...reports.values()].filter(report => report.updatedAtMs > request.lastRequestTimeMs); + return updates.length ? new messages.ReportUpdatedEvent(updates) : null; + }); + + // Reports are immutable, so any update which is not a deletion is a new report + reports.on("update", updates => { + controller.subscriptions.broadcast(new messages.ReportUpdatedEvent(updates)); + for (const report of updates) { + if (!report.isDeleted) { + sendWebhooks(report); + } + } + }); + + controller.handle(messages.ReportListRequest, async (request: messages.ReportListRequest) => ( + listReports(request.playerName) + )); + + controller.handle(messages.ReportGetRequest, async (request: messages.ReportGetRequest) => { + const report = reports.get(request.id); + if (!report) { + throw new lib.RequestError(`Report with ID ${request.id} does not exist`); + } + return report; + }); + + controller.handle(messages.ReportCreateRequest, async (request: messages.ReportCreateRequest, src: lib.Address) => { + let byPlayerName = request.byPlayerName; + let instanceName = ""; + if (src.type === lib.Address.control) { + byPlayerName = controller.wsServer.controlConnections.get(src.id)!.user.name; + } else { + const instance = controller.instances.get(src.id); + instanceName = instance ? instance.config.get("instance.name") : String(src.id); + } + + if (!byPlayerName) { + throw new lib.RequestError("A report needs the name of the player making it"); + } + if (!request.reason.trim()) { + throw new lib.RequestError("A report needs a reason"); + } + if (listReports(request.playerName).some(report => report.byPlayerName === byPlayerName)) { + throw new lib.RequestError(`${byPlayerName} has already reported ${request.playerName}`); + } + + let id = Math.random() * 2 ** 31 | 0; + while (reports.has(id)) { + id = Math.random() * 2 ** 31 | 0; + } + + const report = new messages.ReportRecord(id, request.playerName, byPlayerName, request.reason.trim(), instanceName); + reports.set(report); + return report; + }); + + controller.handle(messages.ReportDeleteRequest, async (request: messages.ReportDeleteRequest) => { + const report = reports.getMutable(request.id); + if (!report) { + throw new lib.RequestError(`Report with ID ${request.id} does not exist`); + } + reports.delete(report); + }); + + controller.hooks.shutdown.attach(plugin.name, async () => { + await reports.save(); + }); } diff --git a/exp_reports/instance.ts b/exp_reports/instance.ts index 71c90c2b..1ca29d95 100644 --- a/exp_reports/instance.ts +++ b/exp_reports/instance.ts @@ -1,5 +1,4 @@ -import type { Instance, InstancePluginContext } from "@clusterio/host"; -import type * as lib from "@clusterio/lib"; +import type { InstancePluginContext } from "@clusterio/host"; import * as messages from "./messages.js"; /** Sent by the lua side when a player reports another. */ @@ -29,82 +28,64 @@ export type IpcReportDelete = { * the answer is handed back to lua once it arrives. The instance may have * stopped by then, in which case the answer is dropped. */ -export class InstancePlugin { - private constructor( - public instance: Instance, - public logger: lib.Logger, - public name: string, - ) {} +export default async function (context: InstancePluginContext) { + const { instance } = context; - static async fromContext(context: InstancePluginContext) { - const instance = context.instance; - const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); - - instance.server.handle("exp_reports:create", plugin.handleCreateIPC.bind(plugin)); - instance.server.handle("exp_reports:list", plugin.handleListIPC.bind(plugin)); - instance.server.handle("exp_reports:delete", plugin.handleDeleteIPC.bind(plugin)); - return plugin; + /** Hand an answer to lua, unless the instance stopped while it was being fetched. */ + async function luaSend(receiver: string, json: any) { + if (instance.status !== "running") { + return; + } + await instance.sendRcon( + `/sc exp_reports.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true + ); } - async handleCreateIPC(event: IpcReportCreate) { + instance.server.handle("exp_reports:create", async (event: IpcReportCreate) => { try { - const report = await this.instance.sendTo("controller", new messages.ReportCreateRequest( + const report = await instance.sendTo("controller", new messages.ReportCreateRequest( event.player_name, event.reason, event.by_player_name, )); - const reports = await this.instance.sendTo("controller", new messages.ReportListRequest(event.player_name)); - await this.luaSend("receive_created", { + const reports = await instance.sendTo("controller", new messages.ReportListRequest(event.player_name)); + await luaSend("receive_created", { report: report.toJSON(), reports: reports.map(other => other.toJSON()), }); } catch (err: any) { - await this.luaSend("receive_error", { caller: event.by_player_name, message: err.message }); + await luaSend("receive_error", { caller: event.by_player_name, message: err.message }); } - } + }); - async handleListIPC(event: IpcReportList) { + instance.server.handle("exp_reports:list", async (event: IpcReportList) => { try { - const reports = await this.instance.sendTo("controller", new messages.ReportListRequest(event.player_name)); - await this.luaSend("receive_list", { + const reports = await instance.sendTo("controller", new messages.ReportListRequest(event.player_name)); + await luaSend("receive_list", { caller: event.caller, player_name: event.player_name, reports: reports.map(report => report.toJSON()), }); } catch (err: any) { - await this.luaSend("receive_error", { caller: event.caller, message: err.message }); + await luaSend("receive_error", { caller: event.caller, message: err.message }); } - } + }); - async handleDeleteIPC(event: IpcReportDelete) { + instance.server.handle("exp_reports:delete", async (event: IpcReportDelete) => { try { - let reports = await this.instance.sendTo("controller", new messages.ReportListRequest(event.player_name)); + let reports = await instance.sendTo("controller", new messages.ReportListRequest(event.player_name)); if (event.by_player_name !== undefined) { reports = reports.filter(report => report.byPlayerName === event.by_player_name); } for (const report of reports) { - await this.instance.sendTo("controller", new messages.ReportDeleteRequest(report.id)); + await instance.sendTo("controller", new messages.ReportDeleteRequest(report.id)); } - await this.luaSend("receive_deleted", { + await luaSend("receive_deleted", { caller: event.caller, player_name: event.player_name, by_player_name: event.by_player_name, count: reports.length, }); } catch (err: any) { - await this.luaSend("receive_error", { caller: event.caller, message: err.message }); + await luaSend("receive_error", { caller: event.caller, message: err.message }); } - } - - /** Hand an answer to lua, unless the instance stopped while it was being fetched. */ - async luaSend(receiver: string, json: any) { - if (this.instance.status !== "running") { - return; - } - await this.instance.sendRcon( - `/sc exp_reports.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true - ); - } -} - -export default async function (context: InstancePluginContext) { - await InstancePlugin.fromContext(context); + }); } diff --git a/exp_reports/test/controller.test.js b/exp_reports/test/controller.test.js index 7581cb42..2a654e26 100644 --- a/exp_reports/test/controller.test.js +++ b/exp_reports/test/controller.test.js @@ -1,7 +1,7 @@ import t from "tap"; import * as lib from "@clusterio/lib"; import { Controller, InstanceRecord } from "@clusterio/controller"; -import entrypoint, { ControllerPlugin } from "../dist/node/controller.js"; +import entrypoint from "../dist/node/controller.js"; import * as messages from "../dist/node/messages.js"; import { plugin as pluginDeclaration } from "../dist/node/index.js"; @@ -24,7 +24,7 @@ const fromInstance = lib.Address.fromShorthand({ instanceId: 1 }); const fromUnknownInstance = lib.Address.fromShorthand({ instanceId: 9 }); const fromControl = lib.Address.fromShorthand({ controlId: 7 }); -/** Build the plugin around a real controller, which is side effect free while not started. */ +/** Run the plugin on a real controller, which is side effect free while not started, returns the handlers it registered. */ async function startPlugin(t2, { config = {} } = {}) { const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir(), @@ -46,12 +46,45 @@ async function startPlugin(t2, { config = {} } = {}) { broadcast(event); }; + const realFetch = globalThis.fetch; + globalThis.fetch = async (url, init) => { + state.posts.push({ url, body: JSON.parse(init.body) }); + return state.fetchResult(); + }; + state.fetchResult = () => ({ ok: true, status: 200 }); + t2.teardown(() => { globalThis.fetch = realFetch; }); + + const handlers = new Map(); + const handle = controller.handle.bind(controller); + controller.handle = (Class, handler) => { + handlers.set(Class, handler); + handle(Class, handler); + }; + const subscriptionHandle = controller.subscriptions.handle.bind(controller.subscriptions); + controller.subscriptions.handle = (Class, handler) => { + handlers.set(Class, handler); + subscriptionHandle(Class, handler); + }; + const pluginLogger = { ...logger, warn: message => state.warnings.push(message) }; - const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_reports" }, controller, logger: pluginLogger }); - plugin.postWebhook = async (url, body) => { state.posts.push({ url, body }); }; - return { plugin, controller, state }; + await entrypoint({ plugin: { name: "exp_reports" }, controller, logger: pluginLogger }); + const request = (Request, ...args) => handlers.get(Request)(new Request(...args), fromInstance); + return { + controller, + state, + create: (playerName, reason, byPlayerName, src = fromInstance) => handlers.get(messages.ReportCreateRequest)( + new messages.ReportCreateRequest(playerName, reason, byPlayerName), src, + ), + list: playerName => request(messages.ReportListRequest, playerName), + get: id => request(messages.ReportGetRequest, id), + remove: id => request(messages.ReportDeleteRequest, id), + subscription: lastRequestTimeMs => handlers.get(messages.ReportUpdatedEvent)({ lastRequestTimeMs }), + }; } +/** Let the webhook posts which are not awaited finish. */ +const settle = () => new Promise(resolve => setImmediate(resolve)); + t.test("entrypoint", async t2 => { const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() }); const controller = new Controller(logger, [], controllerConfig); @@ -62,108 +95,102 @@ t.test("entrypoint", async t2 => { await controller.hooks.shutdown.invoke(); }); -t.test("class ControllerPlugin", t2 => { - t2.test(".handleReportCreateRequest() from an instance records the instance and the reporter", async t3 => { - const { plugin } = await startPlugin(t3); - const report = await plugin.handleReportCreateRequest( - new messages.ReportCreateRequest("bob", " griefing ", "alice"), fromInstance, - ); +t.test("controller plugin", t2 => { + t2.test("ReportCreateRequest from an instance records the instance and the reporter", async t3 => { + const { create, list } = await startPlugin(t3); + const report = await create("bob", " griefing ", "alice", fromInstance); t3.strictSame(report.playerName, "bob"); t3.strictSame(report.byPlayerName, "alice", "the reporter comes from the request"); t3.strictSame(report.reason, "griefing", "the reason is trimmed"); t3.strictSame(report.instanceName, "EXP", "the instance is named"); t3.ok(report.updatedAtMs > 0, "the report is timestamped"); - t3.strictSame(plugin.listReports(), [report], "the report is stored"); + t3.strictSame(await list(), [report], "the report is stored"); - const unknown = await plugin.handleReportCreateRequest( - new messages.ReportCreateRequest("carol", "spam", "alice"), fromUnknownInstance, - ); + const unknown = await create("carol", "spam", "alice", fromUnknownInstance); t3.strictSame(unknown.instanceName, "9", "an unknown instance is named by its id"); }); - t2.test(".handleReportCreateRequest() from the web ui uses the user of the connection", async t3 => { - const { plugin } = await startPlugin(t3); - const report = await plugin.handleReportCreateRequest( - new messages.ReportCreateRequest("bob", "griefing", "someone-else"), fromControl, - ); + t2.test("ReportCreateRequest from the web ui uses the user of the connection", async t3 => { + const { create } = await startPlugin(t3); + const report = await create("bob", "griefing", "someone-else", fromControl); t3.strictSame(report.byPlayerName, "admin", "the reporter is the connected user"); t3.strictSame(report.instanceName, "", "there is no instance"); }); - t2.test(".handleReportCreateRequest() rejects reports which can not be stored", async t3 => { - const { plugin } = await startPlugin(t3); - await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance); + t2.test("ReportCreateRequest rejects reports which can not be stored", async t3 => { + const { create, list } = await startPlugin(t3); + await create("bob", "griefing", "alice", fromInstance); await t3.rejects( - plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "again", "alice"), fromInstance), + create("bob", "again", "alice", fromInstance), { message: "alice has already reported bob" }, "a player can only report another once", ); await t3.rejects( - plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", " ", "carol"), fromInstance), + create("bob", " ", "carol", fromInstance), { message: "A report needs a reason" }, "a reason is required", ); await t3.rejects( - plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "spam", ""), fromInstance), + create("bob", "spam", "", fromInstance), { message: "A report needs the name of the player making it" }, "an instance must name the reporter", ); - t3.strictSame(plugin.listReports().length, 1, "nothing else was stored"); + t3.strictSame((await list()).length, 1, "nothing else was stored"); }); - t2.test(".handleReportListRequest() and .handleReportGetRequest() find reports", async t3 => { - const { plugin } = await startPlugin(t3); - const first = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance); - await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("carol", "spam", "alice"), fromInstance); + t2.test("ReportListRequest and ReportGetRequest find reports", async t3 => { + const { create, list, get } = await startPlugin(t3); + const first = await create("bob", "griefing", "alice", fromInstance); + await create("carol", "spam", "alice", fromInstance); - t3.strictSame((await plugin.handleReportListRequest(new messages.ReportListRequest())).length, 2, "everything is listed"); + t3.strictSame((await list()).length, 2, "everything is listed"); t3.strictSame( - await plugin.handleReportListRequest(new messages.ReportListRequest("bob")), [first], + await list("bob"), [first], "a player's reports are listed", ); - t3.strictSame(await plugin.handleReportGetRequest(new messages.ReportGetRequest(first.id)), first); + t3.strictSame(await get(first.id), first); await t3.rejects( - plugin.handleReportGetRequest(new messages.ReportGetRequest(123)), + get(123), { message: "Report with ID 123 does not exist" }, ); }); - t2.test(".handleReportDeleteRequest() removes the report and broadcasts the deletion", async t3 => { - const { plugin, state } = await startPlugin(t3); - const report = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance); + t2.test("ReportDeleteRequest removes the report and broadcasts the deletion", async t3 => { + const { create, list, remove, state } = await startPlugin(t3); + const report = await create("bob", "griefing", "alice", fromInstance); state.broadcasts.length = 0; - await plugin.handleReportDeleteRequest(new messages.ReportDeleteRequest(report.id)); - t3.strictSame(plugin.listReports(), [], "the report is gone"); + await remove(report.id); + t3.strictSame(await list(), [], "the report is gone"); t3.ok( state.broadcasts.some(event => event instanceof messages.ReportUpdatedEvent && event.updates[0].isDeleted), "subscribers are told it was deleted", ); await t3.rejects( - plugin.handleReportDeleteRequest(new messages.ReportDeleteRequest(report.id)), + remove(report.id), { message: `Report with ID ${report.id} does not exist` }, ); }); - t2.test(".handleReportSubscription() replays only newer records", async t3 => { - const { plugin } = await startPlugin(t3); - const report = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance); + t2.test("subscribing replays only newer records", async t3 => { + const { create, subscription } = await startPlugin(t3); + const report = await create("bob", "griefing", "alice", fromInstance); - const all = await plugin.handleReportSubscription({ lastRequestTimeMs: 0 }); + const all = await subscription(0); t3.strictSame(all.updates, [report], "everything is replayed from the start"); - const none = await plugin.handleReportSubscription({ lastRequestTimeMs: report.updatedAtMs }); + const none = await subscription(report.updatedAtMs); t3.strictSame(none, null, "nothing is replayed when up to date"); }); - t2.test(".sendWebhooks() posts new reports to the configured hooks", async t3 => { - const { plugin, state } = await startPlugin(t3, { config: { + t2.test("new reports are posted to the configured hooks", async t3 => { + const { create, remove, state } = await startPlugin(t3, { config: { "exp_reports.discord_webhook_url": "https://discord.example/hook", "exp_reports.json_webhook_url": "https://json.example/hook", } }); - const report = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance); + const report = await create("bob", "griefing", "alice", fromInstance); t3.strictSame(state.posts.map(post => post.url), ["https://discord.example/hook", "https://json.example/hook"]); const embed = state.posts[0].body.embeds[0]; @@ -171,29 +198,29 @@ t.test("class ControllerPlugin", t2 => { t3.strictSame(state.posts[1].body, { type: "report_created", report: report.toJSON() }, "the json hook gets the record"); state.posts.length = 0; - await plugin.handleReportDeleteRequest(new messages.ReportDeleteRequest(report.id)); + await remove(report.id); t3.strictSame(state.posts, [], "deletions are not posted"); }); - t2.test(".sendWebhooks() does nothing without hooks", async t3 => { - const { plugin, state } = await startPlugin(t3); - await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance); + t2.test("nothing is posted without hooks", async t3 => { + const { create, state } = await startPlugin(t3); + await create("bob", "griefing", "alice", fromInstance); t3.strictSame(state.posts, []); }); - t2.test(".postWebhook() logs rather than throws when the hook fails", async t3 => { - const { plugin, state } = await startPlugin(t3); - plugin.postWebhook = ControllerPlugin.prototype.postWebhook; + t2.test("webhooks log rather than throw when the hook fails", async t3 => { + const { create, state } = await startPlugin(t3, { config: { + "exp_reports.json_webhook_url": "https://down.example/hook", + } }); - const realFetch = globalThis.fetch; - globalThis.fetch = async () => { throw new Error("connection refused"); }; - t3.teardown(() => { globalThis.fetch = realFetch; }); - - await plugin.postWebhook("https://down.example/hook", {}); + state.fetchResult = () => { throw new Error("connection refused"); }; + await create("bob", "griefing", "alice"); + await settle(); t3.strictSame(state.warnings, ["Webhook https://down.example/hook failed: connection refused"]); - globalThis.fetch = async () => ({ ok: false, status: 404 }); - await plugin.postWebhook("https://down.example/hook", {}); + state.fetchResult = () => ({ ok: false, status: 404 }); + await create("bob", "griefing", "carol"); + await settle(); t3.strictSame(state.warnings[1], "Webhook https://down.example/hook responded with 404"); }); diff --git a/exp_reports/test/instance.test.js b/exp_reports/test/instance.test.js index 4cfcafa5..9353f34d 100644 --- a/exp_reports/test/instance.test.js +++ b/exp_reports/test/instance.test.js @@ -1,7 +1,7 @@ import t from "tap"; import * as lib from "@clusterio/lib"; import { Instance } from "@clusterio/host"; -import { InstancePlugin } from "../dist/node/instance.js"; +import entrypoint from "../dist/node/instance.js"; import { plugin as pluginDeclaration } from "../dist/node/index.js"; import * as messages from "../dist/node/messages.js"; @@ -25,7 +25,7 @@ class TestConnector extends lib.BaseConnector { const report = (id, playerName, byPlayerName) => new messages.ReportRecord(id, playerName, byPlayerName, "griefing", "EXP", 1000); -/** Build a plugin around a real running instance with spies on what leaves it. */ +/** Run the plugin on a real running instance with spies on what leaves it, returns the IPC handlers it registered. */ async function startPlugin(t2, { reports = [report(1, "bob", "alice"), report(2, "bob", "carol")] } = {}) { const instanceConfig = new lib.InstanceConfig("host"); instanceConfig.set("instance.id", 1); @@ -37,8 +37,9 @@ async function startPlugin(t2, { reports = [report(1, "bob", "alice"), report(2, // Spies which record the messages and commands leaving the instance const state = { sent: [], rcons: [], failNext: null }; + const ipc = new Map(); instance.server = { - handle: () => {}, + handle: (name, handler) => ipc.set(name, handler), sendRcon: async command => { state.rcons.push(command); return ""; @@ -63,8 +64,14 @@ async function startPlugin(t2, { reports = [report(1, "bob", "alice"), report(2, instance.notifyStatus("running"); state.sent.length = 0; - const plugin = await InstancePlugin.fromContext({ plugin: { name: "exp_reports" }, instance, host: {}, logger }); - return { plugin, instance, state }; + await entrypoint({ plugin: { name: "exp_reports" }, instance, host: {}, logger }); + return { + instance, + state, + createIPC: event => ipc.get("exp_reports:create")(event), + listIPC: event => ipc.get("exp_reports:list")(event), + deleteIPC: event => ipc.get("exp_reports:delete")(event), + }; } /** The lua receiver and payload of a recorded rcon command. */ @@ -73,10 +80,10 @@ function decodeRcon(command) { return { receiver: match[1], payload: JSON.parse(match[2]) }; } -t.test("class InstancePlugin", t2 => { - t2.test(".handleCreateIPC() creates the report and hands it to lua with the others against the player", async t3 => { - const { plugin, state } = await startPlugin(t3); - await plugin.handleCreateIPC({ player_name: "bob", by_player_name: "dave", reason: "griefing" }); +t.test("instance plugin", t2 => { + t2.test("exp_reports:create creates the report and hands it to lua with the others against the player", async t3 => { + const { state, createIPC } = await startPlugin(t3); + await createIPC({ player_name: "bob", by_player_name: "dave", reason: "griefing" }); const create = state.sent.find(request => request instanceof messages.ReportCreateRequest); t3.strictSame([create.playerName, create.byPlayerName, create.reason], ["bob", "dave", "griefing"]); @@ -89,20 +96,20 @@ t.test("class InstancePlugin", t2 => { t3.strictSame(payload.reports.map(other => other.by_player_name), ["alice", "carol"], "along with the existing ones"); }); - t2.test(".handleCreateIPC() prints a refusal to the reporter", async t3 => { - const { plugin, state } = await startPlugin(t3); + t2.test("exp_reports:create prints a refusal to the reporter", async t3 => { + const { state, createIPC } = await startPlugin(t3); state.failNext = new lib.RequestError("dave has already reported bob"); - await plugin.handleCreateIPC({ player_name: "bob", by_player_name: "dave", reason: "griefing" }); + await createIPC({ player_name: "bob", by_player_name: "dave", reason: "griefing" }); const { receiver, payload } = decodeRcon(state.rcons[0]); t3.strictSame(receiver, "receive_error"); t3.strictSame(payload, { caller: "dave", message: "dave has already reported bob" }); }); - t2.test(".handleListIPC() lists the reports for the caller", async t3 => { - const { plugin, state } = await startPlugin(t3); - await plugin.handleListIPC({ caller: "admin", player_name: "bob" }); - await plugin.handleListIPC({ caller: "admin", player_name: undefined }); + t2.test("exp_reports:list lists the reports for the caller", async t3 => { + const { state, listIPC } = await startPlugin(t3); + await listIPC({ caller: "admin", player_name: "bob" }); + await listIPC({ caller: "admin", player_name: undefined }); const forPlayer = decodeRcon(state.rcons[0]); t3.strictSame(forPlayer.receiver, "receive_list"); @@ -113,9 +120,9 @@ t.test("class InstancePlugin", t2 => { t3.strictSame(forAll.payload.player_name, undefined, "no player when listing everyone"); }); - t2.test(".handleDeleteIPC() deletes only the matching reports", async t3 => { - const { plugin, state } = await startPlugin(t3); - await plugin.handleDeleteIPC({ caller: "admin", player_name: "bob", by_player_name: "carol" }); + t2.test("exp_reports:delete deletes only the matching reports", async t3 => { + const { state, deleteIPC } = await startPlugin(t3); + await deleteIPC({ caller: "admin", player_name: "bob", by_player_name: "carol" }); const deletes = state.sent.filter(request => request instanceof messages.ReportDeleteRequest); t3.strictSame(deletes.map(request => request.id), [2], "only carol's report is deleted"); @@ -124,17 +131,17 @@ t.test("class InstancePlugin", t2 => { t3.strictSame(payload, { caller: "admin", player_name: "bob", by_player_name: "carol", count: 1 }); state.sent.length = 0; - await plugin.handleDeleteIPC({ caller: "admin", player_name: "bob", by_player_name: undefined }); + await deleteIPC({ caller: "admin", player_name: "bob", by_player_name: undefined }); t3.strictSame( state.sent.filter(request => request instanceof messages.ReportDeleteRequest).length, 2, "every report is deleted without a reporter", ); }); - t2.test(".luaSend() drops answers once the instance has stopped", async t3 => { - const { plugin, instance, state } = await startPlugin(t3); + t2.test("answers are dropped once the instance has stopped", async t3 => { + const { instance, state, listIPC } = await startPlugin(t3); instance.notifyStatus("stopped"); - await plugin.handleListIPC({ caller: "admin", player_name: undefined }); + await listIPC({ caller: "admin", player_name: undefined }); t3.strictSame(state.rcons, [], "nothing is sent to a stopped instance"); }); diff --git a/exp_roles/instance.ts b/exp_roles/instance.ts index 8cb87e22..a855d83e 100644 --- a/exp_roles/instance.ts +++ b/exp_roles/instance.ts @@ -1,4 +1,4 @@ -import type { Instance, InstancePluginContext } from "@clusterio/host"; +import type { InstancePluginContext } from "@clusterio/host"; import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; @@ -9,118 +9,82 @@ export type IpcAssignmentUpdate = { unassign: number[] | undefined, }; -export class InstancePlugin { - private constructor( - public instance: Instance, - public logger: lib.Logger, - public name: string, - ) {} +export default async function (context: InstancePluginContext) { + const { instance, logger, plugin } = context; + const syncMode = () => instance.config.get("exp_roles.sync_mode"); - static async fromContext(context: InstancePluginContext) { - const instance = context.instance; - const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); - - instance.handle(messages.RoleUpdatedEvent, plugin.handleRoleUpdatedEvent.bind(plugin)); - instance.handle(messages.AssignmentUpdatedEvent, plugin.handleAssignmentUpdatedEvent.bind(plugin)); - instance.server.handle("exp_roles:assignment_update", plugin.handleAssignmentUpdateIPC.bind(plugin)); - - const hooks = instance.hooks; - hooks.instanceConfigFieldChanged.attach(plugin.name, plugin.onInstanceConfigFieldChanged.bind(plugin)); - hooks.start.attach(plugin.name, plugin.onStart.bind(plugin)); - return plugin; + async function luaSend(receiver: string, json: any) { + await instance.sendRcon( + `/sc exp_roles.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true + ); } - get syncMode() { - return this.instance.config.get("exp_roles.sync_mode"); - } - - async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { - switch (field) { - case "exp_roles.sync_mode": - await this.luaSetEmitEvents(curr === "bidirectional"); - break; + instance.handle(messages.RoleUpdatedEvent, async event => { + if (syncMode() !== "disabled") { + await luaSend("receive_role_updates", event.updates.map(role => role.toJSON())); } - } + }); - async onStart() { - if (this.syncMode === "disabled") { + instance.handle(messages.AssignmentUpdatedEvent, async event => { + if (syncMode() !== "disabled") { + await luaSend("receive_assignment_updates", event.updates.map(a => a.toJSON())); + } + }); + + instance.server.handle("exp_roles:assignment_update", async (event: IpcAssignmentUpdate) => { + if (syncMode() !== "bidirectional") { + return; + } + + const assign = event.assign ?? []; + try { + await instance.sendTo("controller", new messages.AssignmentUpdateRequest( + event.name, assign, event.unassign ?? [], + )); + } catch (err: any) { + // The roles were already applied in game, so they have to be taken + // back off again now that the controller has refused them + logger.warn(`Role change for ${event.name} was rejected: ${err.message}`); + if (assign.length) { + await luaSend("reject_assignment", { name: event.name, role_ids: assign }); + } + } + }); + + instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr) => { + if (field === "exp_roles.sync_mode") { + await luaSend("set_emit_events", curr === "bidirectional"); + } + }); + + instance.hooks.start.attach(plugin.name, async () => { + if (syncMode() === "disabled") { return; } // Date.now() is used because the lua state is initialised from the full // list below, so only updates made after this point are of interest const subscribedAtMs = Date.now(); - await this.instance.sendTo("controller", new lib.SubscriptionRequest( + await instance.sendTo("controller", new lib.SubscriptionRequest( `exp_roles:${messages.RoleUpdatedEvent.name}`, true, subscribedAtMs )); - await this.instance.sendTo("controller", new lib.SubscriptionRequest( + await instance.sendTo("controller", new lib.SubscriptionRequest( `exp_roles:${messages.AssignmentUpdatedEvent.name}`, true, subscribedAtMs )); const [roles, assignments] = await Promise.all([ - this.instance.sendTo("controller", new messages.RoleListRequest()), - this.instance.sendTo("controller", new messages.AssignmentListRequest()), + instance.sendTo("controller", new messages.RoleListRequest()), + instance.sendTo("controller", new messages.AssignmentListRequest()), ]); if (!roles.some(role => role.isDefault)) { - this.logger.warn("No default role is set on the controller, players will hold no roles in game"); + logger.warn("No default role is set on the controller, players will hold no roles in game"); } - await this.luaSend("initialise", { + await luaSend("initialise", { ...messages.encodeRolesForLua(roles), assignments: assignments.map(assignment => assignment.toJSON()), }); - await this.luaSetEmitEvents(this.syncMode === "bidirectional"); - } - - async handleRoleUpdatedEvent(event: messages.RoleUpdatedEvent) { - if (this.syncMode === "disabled") { - return; - } - - await this.luaSend("receive_role_updates", event.updates.map(role => role.toJSON())); - } - - async handleAssignmentUpdatedEvent(event: messages.AssignmentUpdatedEvent) { - if (this.syncMode === "disabled") { - return; - } - - await this.luaSend("receive_assignment_updates", event.updates.map(a => a.toJSON())); - } - - async handleAssignmentUpdateIPC(event: IpcAssignmentUpdate) { - if (this.syncMode !== "bidirectional") { - return; - } - - const assign = event.assign ?? []; - try { - await this.instance.sendTo("controller", new messages.AssignmentUpdateRequest( - event.name, assign, event.unassign ?? [], - )); - } catch (err: any) { - // The roles were already applied in game, so they have to be taken - // back off again now that the controller has refused them - this.logger.warn(`Role change for ${event.name} was rejected: ${err.message}`); - if (assign.length) { - await this.luaSend("reject_assignment", { name: event.name, role_ids: assign }); - } - return; - } - } - - async luaSetEmitEvents(emitEvents: boolean) { - await this.luaSend("set_emit_events", emitEvents); - } - - async luaSend(receiver: string, json: any) { - await this.instance.sendRcon( - `/sc exp_roles.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true - ); - } -} - -export default async function (context: InstancePluginContext) { - await InstancePlugin.fromContext(context); + await luaSend("set_emit_events", syncMode() === "bidirectional"); + }); } diff --git a/exp_roles/test/instance.test.js b/exp_roles/test/instance.test.js index 5921adc7..0876474a 100644 --- a/exp_roles/test/instance.test.js +++ b/exp_roles/test/instance.test.js @@ -1,7 +1,7 @@ import t from "tap"; import * as lib from "@clusterio/lib"; import { Instance } from "@clusterio/host"; -import entrypoint, { InstancePlugin } from "../dist/node/instance.js"; +import entrypoint from "../dist/node/instance.js"; import { plugin as pluginDeclaration } from "../dist/node/index.js"; import * as messages from "../dist/node/messages.js"; @@ -33,7 +33,7 @@ const sampleRoles = () => [ ]; const sampleAssignments = () => [new messages.AssignmentRecord("alice", new Set([5]))]; -/** Build a plugin around a real instance with spies on what leaves it, started on first use by each test. */ +/** Run the plugin on a real instance with spies on what leaves it, returns its handlers and hooks. */ async function startPlugin(t2, { syncMode = "bidirectional", roles = sampleRoles() } = {}) { const instanceConfig = new lib.InstanceConfig("host"); instanceConfig.set("instance.id", 1); @@ -46,8 +46,10 @@ async function startPlugin(t2, { syncMode = "bidirectional", roles = sampleRoles // Spies which record the messages and commands leaving the instance const state = { sent: [], rcons: [], warnings: [], failNext: null }; + const handlers = new Map(); + instance.handle = (Event, handler) => handlers.set(Event, handler); instance.server = { - handle: () => {}, + handle: (name, handler) => handlers.set(name, handler), sendRcon: async command => { state.rcons.push(command); return ""; @@ -70,8 +72,15 @@ async function startPlugin(t2, { syncMode = "bidirectional", roles = sampleRoles }; const pluginLogger = { ...logger, warn: message => state.warnings.push(message) }; - const plugin = await InstancePlugin.fromContext({ plugin: { name: "exp_roles" }, instance, host: {}, logger: pluginLogger }); - return { plugin, state }; + await entrypoint({ plugin: { name: "exp_roles" }, instance, host: {}, logger: pluginLogger }); + return { + state, + start: () => instance.hooks.start.invoke(), + configChanged: (...args) => instance.hooks.instanceConfigFieldChanged.invoke(...args), + roleUpdated: event => handlers.get(messages.RoleUpdatedEvent)(event), + assignmentUpdated: event => handlers.get(messages.AssignmentUpdatedEvent)(event), + assignmentIPC: event => handlers.get("exp_roles:assignment_update")(event), + }; } /** The lua receiver and payload of a recorded rcon command. */ @@ -94,10 +103,10 @@ t.test("entrypoint", async t2 => { } }); -t.test("class InstancePlugin", t2 => { - t2.test(".onStart() subscribes and initialises the lua module", async t3 => { - const { plugin, state } = await startPlugin(t3, { syncMode: "enabled" }); - await plugin.onStart(); +t.test("instance plugin", t2 => { + t2.test("start subscribes and initialises the lua module", async t3 => { + const { state, start } = await startPlugin(t3, { syncMode: "enabled" }); + await start(); const subscribed = state.sent.filter(request => request instanceof lib.SubscriptionRequest); t3.strictSame(subscribed.length, 2, "roles and assignments are subscribed to"); @@ -119,76 +128,76 @@ t.test("class InstancePlugin", t2 => { t3.strictSame(state.warnings.length, 0, "no warnings with a default role"); }); - t2.test(".onStart() with bidirectional emits changes back", async t3 => { - const { plugin, state } = await startPlugin(t3); - await plugin.onStart(); + t2.test("start with bidirectional emits changes back", async t3 => { + const { state, start } = await startPlugin(t3); + await start(); const emit = decodeRcon(state.rcons[state.rcons.length - 1]); t3.strictSame([emit.receiver, emit.payload], ["set_emit_events", true]); }); - t2.test(".onStart() with disabled does nothing", async t3 => { - const { plugin, state } = await startPlugin(t3, { syncMode: "disabled" }); - await plugin.onStart(); + t2.test("start with disabled does nothing", async t3 => { + const { state, start } = await startPlugin(t3, { syncMode: "disabled" }); + await start(); t3.strictSame(state.sent.length, 0, "nothing is sent"); t3.strictSame(state.rcons.length, 0, "no commands are run"); }); - t2.test(".onStart() warns when no default role is set", async t3 => { + t2.test("start warns when no default role is set", async t3 => { const roles = sampleRoles().map(record => { record.isDefault = false; return record; }); - const { plugin, state } = await startPlugin(t3, { roles }); - await plugin.onStart(); + const { state, start } = await startPlugin(t3, { roles }); + await start(); t3.ok(state.warnings.some(message => /default role/.test(message)), "the warning names the default role"); }); - t2.test(".handleRoleUpdatedEvent() and .handleAssignmentUpdatedEvent() forward to lua", async t3 => { - const { plugin, state } = await startPlugin(t3); - await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles())); + t2.test("role and assignment updates forward to lua", async t3 => { + const { state, roleUpdated, assignmentUpdated } = await startPlugin(t3); + await roleUpdated(new messages.RoleUpdatedEvent(sampleRoles())); const roles = decodeRcon(state.rcons[0]); t3.strictSame(roles.receiver, "receive_role_updates", "role updates are forwarded"); t3.strictSame(roles.payload[0].name, "Player", "the records are sent in plain form"); - await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments())); + await assignmentUpdated(new messages.AssignmentUpdatedEvent(sampleAssignments())); const assignments = decodeRcon(state.rcons[1]); t3.strictSame(assignments.receiver, "receive_assignment_updates", "assignment updates are forwarded"); }); - t2.test(".handleRoleUpdatedEvent() and .handleAssignmentUpdatedEvent() are gated while disabled", async t3 => { - const { plugin, state } = await startPlugin(t3, { syncMode: "disabled" }); - await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles())); - await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments())); + t2.test("role and assignment updates are gated while disabled", async t3 => { + const { state, roleUpdated, assignmentUpdated } = await startPlugin(t3, { syncMode: "disabled" }); + await roleUpdated(new messages.RoleUpdatedEvent(sampleRoles())); + await assignmentUpdated(new messages.AssignmentUpdatedEvent(sampleAssignments())); t3.strictSame(state.rcons.length, 0, "no commands are run"); }); - t2.test(".handleAssignmentUpdateIPC() sends the change and rolls back a refusal", async t3 => { - const { plugin, state } = await startPlugin(t3); - await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); + t2.test("exp_roles:assignment_update sends the change and rolls back a refusal", async t3 => { + const { state, assignmentIPC } = await startPlugin(t3); + await assignmentIPC({ name: "alice", assign: [5], unassign: undefined }); const request = state.sent[0]; t3.ok(request instanceof messages.AssignmentUpdateRequest, "the change is sent as a request"); t3.strictSame([request.name, request.assign, request.unassign], ["alice", [5], []], "the request names the change"); - await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: undefined, unassign: [5] }); + await assignmentIPC({ name: "alice", assign: undefined, unassign: [5] }); t3.strictSame(state.sent[1].unassign, [5], "removals are sent the same way"); state.failNext = new Error("User 'alice' does not exist"); - await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); + await assignmentIPC({ name: "alice", assign: [5], unassign: undefined }); const rollback = decodeRcon(state.rcons[0]); t3.strictSame(rollback.receiver, "reject_assignment", "the refusal is rolled back in lua"); t3.strictSame(rollback.payload, { name: "alice", role_ids: [5] }, "the rollback names the refused roles"); state.failNext = new Error("User 'alice' does not exist"); - await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: undefined, unassign: [5] }); + await assignmentIPC({ name: "alice", assign: undefined, unassign: [5] }); t3.strictSame(state.rcons.length, 1, "a refused removal has nothing to roll back"); }); - t2.test(".handleAssignmentUpdateIPC() is gated unless bidirectional", async t3 => { - const { plugin, state } = await startPlugin(t3, { syncMode: "enabled" }); - await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); + t2.test("exp_roles:assignment_update is gated unless bidirectional", async t3 => { + const { state, assignmentIPC } = await startPlugin(t3, { syncMode: "enabled" }); + await assignmentIPC({ name: "alice", assign: [5], unassign: undefined }); t3.strictSame(state.sent.length, 0, "nothing is sent"); }); - t2.test(".onInstanceConfigFieldChanged() toggles emit with the sync mode", async t3 => { - const { plugin, state } = await startPlugin(t3); - await plugin.onInstanceConfigFieldChanged("exp_roles.sync_mode", "enabled", "bidirectional"); + t2.test("changing the sync mode toggles emit with the sync mode", async t3 => { + const { state, configChanged } = await startPlugin(t3); + await configChanged("exp_roles.sync_mode", "enabled", "bidirectional"); const emit = decodeRcon(state.rcons[0]); t3.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "leaving bidirectional stops emitting"); }); diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index 59557a5b..a24d4a39 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -1,6 +1,6 @@ import * as lib from "@clusterio/lib"; import * as path from "node:path"; -import type { Controller, ControllerPluginContext } from "@clusterio/controller"; +import type { ControllerPluginContext } from "@clusterio/controller"; import { RoleMetaRecord } from "@expcluster/roles"; import { GroupRecord, GroupPermissions, RoleMappingRecord } from "@expcluster/permission-groups"; import { ControllerPlugin as RolesPlugin } from "@expcluster/roles/dist/node/controller.js"; @@ -9,149 +9,55 @@ import * as messages from "./messages.js"; import { features, pruneFeatureValues, validateFeatureValues } from "./features.js"; import { SeedRole, SeedGroup, seedRoles, seedGroups, flattenSeedPermissions } from "./seed.js"; -export class ControllerPlugin { - private constructor( - public controller: Controller, - public logger: lib.Logger, - public name: string, - public features: lib.SubscribableDatastore, - ) {} - - static async fromContext(context: ControllerPluginContext) { - const controller = context.controller; - const databaseDirectory = controller.config.get("controller.database_directory"); - const features = new lib.SubscribableDatastore( - ...await new lib.JsonIdDatastoreProvider( - path.join(databaseDirectory, "exp_scenario", "features.json"), - messages.FeatureRecord.fromJSON.bind(messages.FeatureRecord), - ).bootstrap() - ); - - const plugin = new ControllerPlugin(controller, context.logger, context.plugin.name, features); - plugin.reconcileFeatures(); - - controller.subscriptions.handle(messages.FeatureUpdatedEvent, plugin.handleFeatureSubscription.bind(plugin)); - features.on("update", plugin.featuresUpdated.bind(plugin)); - - controller.handle(messages.SeedRequest, plugin.handleSeedRequest.bind(plugin)); - controller.handle(messages.FeatureListRequest, plugin.handleFeatureListRequest.bind(plugin)); - controller.handle(messages.FeatureUpdateRequest, plugin.handleFeatureUpdateRequest.bind(plugin)); - - controller.hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin)); - return plugin; - } - - async onShutdown() { - await this.features.save(); - } +export default async function (context: ControllerPluginContext) { + const { controller, logger, plugin } = context; + const databaseDirectory = controller.config.get("controller.database_directory"); + const featureStore = new lib.SubscribableDatastore( + ...await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "exp_scenario", "features.json"), + messages.FeatureRecord.fromJSON.bind(messages.FeatureRecord), + ).bootstrap() + ); /** Add new features, delete removed ones, and drop stored values which no longer fit a field */ - reconcileFeatures() { + function reconcileFeatures() { const declared = new Map(features.map(feature => [feature.name, feature])); - for (const record of this.features.values()) { + for (const record of featureStore.values()) { const feature = declared.get(record.id); if (!feature) { - this.logger.warn(`Dropping stored config of removed feature ${record.id}`); - this.features.delete(record); + logger.warn(`Dropping stored config of removed feature ${record.id}`); + featureStore.delete(record); continue; } const { kept, dropped } = pruneFeatureValues(feature, record.values); if (dropped.length) { - this.logger.warn(`Dropping stored values of ${record.id} which no longer match a setting: ${dropped.join(", ")}`); - this.features.set(new messages.FeatureRecord(record.id, record.enabled, kept)); + logger.warn(`Dropping stored values of ${record.id} which no longer match a setting: ${dropped.join(", ")}`); + featureStore.set(new messages.FeatureRecord(record.id, record.enabled, kept)); } } - const missing = features.filter(feature => !this.features.has(feature.name)); + const missing = features.filter(feature => !featureStore.has(feature.name)); if (missing.length) { - this.features.setMany(missing.map(feature => new messages.FeatureRecord(feature.name, true))); + featureStore.setMany(missing.map(feature => new messages.FeatureRecord(feature.name, true))); } } - featuresUpdated(updates: messages.FeatureRecord[]) { - this.controller.subscriptions.broadcast(new messages.FeatureUpdatedEvent(updates)); - } - - async handleFeatureSubscription(request: lib.SubscriptionRequest) { - const updates = [...this.features.values()].filter(feature => feature.updatedAtMs > request.lastRequestTimeMs); - return updates.length ? new messages.FeatureUpdatedEvent(updates) : null; - } - - async handleFeatureListRequest() { - return [...this.features.values()]; - } - - async handleFeatureUpdateRequest(request: messages.FeatureUpdateRequest) { - let values; - try { - values = validateFeatureValues(request.id, request.values); - } catch (err: any) { - throw new lib.RequestError(err.message); - } - - const feature = new messages.FeatureRecord(request.id, request.enabled, values); - this.features.set(feature); - return feature; - } - - /** - * Create the roles and permission groups the scenario shipped with. - * - * Roles which already exist by name are reused and only gain the seed - * permissions, groups which already exist by name are reset to the seed. - */ - async handleSeedRequest() { - const rolesPlugin = RolesPlugin.get(this.controller); - const groupsPlugin = GroupsPlugin.get(this.controller); - if (!rolesPlugin || !groupsPlugin) { - throw new lib.RequestError("Seeding requires the exp_roles and exp_groups plugins"); - } - - const roleIds = new Map(); - for (const [index, seedRole] of seedRoles.entries()) { - const role = this.seedRole(seedRole); - if (!role) { - continue; - } - - roleIds.set(seedRole.name, role.id); - rolesPlugin.roleMeta.set(new RoleMetaRecord( - role.id, - index + 1, - seedRole.priority ?? 0, - seedRole.shortHand, - "", - seedRole.color, - seedRole.autoAssignHours === undefined ? null : seedRole.autoAssignHours * 3600000, - seedRole.blockAutoAssign ?? false, - )); - } - - const groupIds = new Map(); - for (const seedGroup of seedGroups) { - groupIds.set(seedGroup.name, this.seedGroup(groupsPlugin, seedGroup).id); - } - - this.seedRoleMappings(groupsPlugin, roleIds, groupIds); - this.logger.info(`Seeded ${roleIds.size} roles and ${groupIds.size} permission groups`); - } - /** Find or create the clusterio role for a seed role, returns undefined if it has no role to use. */ - seedRole(seedRole: SeedRole) { - const roles = this.controller.roles; + function ensureRole(seedRole: SeedRole) { + const roles = controller.roles; if (seedRole.isAdmin) { return roles.get(lib.Role.DefaultAdminRoleId); } if (seedRole.isDefault) { - const defaultRoleId = this.controller.config.get("controller.default_role_id"); + const defaultRoleId = controller.config.get("controller.default_role_id"); return defaultRoleId !== null ? roles.get(defaultRoleId) : undefined; } const permissions = flattenSeedPermissions(seedRole); for (const permission of permissions) { if (!lib.permissions.has(permission)) { - this.logger.warn(`Seed role ${seedRole.name} grants unknown permission ${permission}`); + logger.warn(`Seed role ${seedRole.name} grants unknown permission ${permission}`); } } @@ -163,19 +69,19 @@ export class ControllerPlugin { } else { const id = Math.max(5, ...[...roles.keys()].map(other => other + 1)); role = new lib.Role(id, seedRole.name, "", permissions); - this.logger.info(`Created role ${seedRole.name}`); + logger.info(`Created role ${seedRole.name}`); } roles.set(role); return role; } /** Find or create the permission group for a seed group. */ - seedGroup(groupsPlugin: GroupsPlugin, seedGroup: SeedGroup) { + function ensureGroup(groupsPlugin: GroupsPlugin, seedGroup: SeedGroup) { const permissions = new GroupPermissions(seedGroup.isBlacklist, [...seedGroup.inputActions]); const existing = [...groupsPlugin.groups.values()].find(other => other.name === seedGroup.name); const group = new GroupRecord(existing?.id ?? newId(groupsPlugin.groups), seedGroup.name, permissions); if (!existing) { - this.logger.info(`Created permission group ${seedGroup.name}`); + logger.info(`Created permission group ${seedGroup.name}`); } groupsPlugin.groups.set(group); return group; @@ -188,7 +94,7 @@ export class ControllerPlugin { * priorities follow how exp_roles picks a player's highest role: role * priority first, then the role order. */ - seedRoleMappings(groupsPlugin: GroupsPlugin, roleIds: Map, groupIds: Map) { + function seedRoleMappings(groupsPlugin: GroupsPlugin, roleIds: Map, groupIds: Map) { // Lowest role first, so the mapping priority rises with the role const ranked = seedRoles .filter(seedRole => seedRole.group !== undefined && roleIds.has(seedRole.name)) @@ -227,12 +133,78 @@ export class ControllerPlugin { } groupsPlugin.roleMappings.setMany(mappings); - return mappings; } -} -export default async function (context: ControllerPluginContext) { - await ControllerPlugin.fromContext(context); + reconcileFeatures(); + + controller.subscriptions.handle(messages.FeatureUpdatedEvent, async request => { + const updates = [...featureStore.values()].filter(feature => feature.updatedAtMs > request.lastRequestTimeMs); + return updates.length ? new messages.FeatureUpdatedEvent(updates) : null; + }); + featureStore.on("update", updates => { + controller.subscriptions.broadcast(new messages.FeatureUpdatedEvent(updates)); + }); + + /** + * Create the roles and permission groups the scenario shipped with. + * + * Roles which already exist by name are reused and only gain the seed + * permissions, groups which already exist by name are reset to the seed. + */ + controller.handle(messages.SeedRequest, async () => { + const rolesPlugin = RolesPlugin.get(controller); + const groupsPlugin = GroupsPlugin.get(controller); + if (!rolesPlugin || !groupsPlugin) { + throw new lib.RequestError("Seeding requires the exp_roles and exp_groups plugins"); + } + + const roleIds = new Map(); + for (const [index, seed] of seedRoles.entries()) { + const role = ensureRole(seed); + if (!role) { + continue; + } + + roleIds.set(seed.name, role.id); + rolesPlugin.roleMeta.set(new RoleMetaRecord( + role.id, + index + 1, + seed.priority ?? 0, + seed.shortHand, + "", + seed.color, + seed.autoAssignHours === undefined ? null : seed.autoAssignHours * 3600000, + seed.blockAutoAssign ?? false, + )); + } + + const groupIds = new Map(); + for (const seed of seedGroups) { + groupIds.set(seed.name, ensureGroup(groupsPlugin, seed).id); + } + + seedRoleMappings(groupsPlugin, roleIds, groupIds); + logger.info(`Seeded ${roleIds.size} roles and ${groupIds.size} permission groups`); + }); + + controller.handle(messages.FeatureListRequest, async () => [...featureStore.values()]); + + controller.handle(messages.FeatureUpdateRequest, async (request: messages.FeatureUpdateRequest) => { + let values; + try { + values = validateFeatureValues(request.id, request.values); + } catch (err: any) { + throw new lib.RequestError(err.message); + } + + const feature = new messages.FeatureRecord(request.id, request.enabled, values); + featureStore.set(feature); + return feature; + }); + + controller.hooks.shutdown.attach(plugin.name, async () => { + await featureStore.save(); + }); } function newId(datastore: { has(id: number): boolean }) { diff --git a/exp_scenario/instance.ts b/exp_scenario/instance.ts index 0e6a9da8..93f0ef73 100644 --- a/exp_scenario/instance.ts +++ b/exp_scenario/instance.ts @@ -1,63 +1,25 @@ -import type { Instance, InstancePluginContext } from "@clusterio/host"; +import type { InstancePluginContext } from "@clusterio/host"; import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; import { features, FeatureValue } from "./features.js"; /** Sends feature config to the lua side, see module/features.lua. */ -export class InstancePlugin { +export default async function (context: InstancePluginContext) { + const { instance, plugin } = context; /** Rcon is up and the lua side can take updates */ - started = false; - - private constructor( - public instance: Instance, - public logger: lib.Logger, - public name: string, - ) {} - - static async fromContext(context: InstancePluginContext) { - const instance = context.instance; - const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); - - instance.handle(messages.FeatureUpdatedEvent, plugin.handleFeatureUpdatedEvent.bind(plugin)); - instance.hooks.start.attach(plugin.name, plugin.onStart.bind(plugin)); - instance.hooks.exit.attach(plugin.name, plugin.onExit.bind(plugin)); - instance.hooks.controllerConnectionEvent.attach(plugin.name, plugin.onControllerConnectionEvent.bind(plugin)); - return plugin; - } - - async onStart() { - this.started = true; - await this.syncFeatures(); - } - - async onExit() { - this.started = false; - } - - /** A new session means the controller restarted and forgot the subscription. */ - async onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { - if (event === "connect" && this.started) { - await this.syncFeatures(); - } - } + let started = false; /** Subscribe, then send every feature */ - async syncFeatures() { - await this.instance.sendTo("controller", new lib.SubscriptionRequest( + async function syncFeatures() { + await instance.sendTo("controller", new lib.SubscriptionRequest( `exp_scenario:${messages.FeatureUpdatedEvent.name}`, "subscribe", Date.now(), )); - const features = await this.instance.sendTo("controller", new messages.FeatureListRequest()); - await this.luaSendFeatures(features); - } - - async handleFeatureUpdatedEvent(event: messages.FeatureUpdatedEvent) { - if (this.started) { - await this.luaSendFeatures(event.updates.filter(feature => !feature.isDeleted)); - } + const records = await instance.sendTo("controller", new messages.FeatureListRequest()); + await luaSendFeatures(records); } /** Every value of each feature is sent, so the lua side sets them all */ - async luaSendFeatures(records: messages.FeatureRecord[]) { + async function luaSendFeatures(records: messages.FeatureRecord[]) { if (!records.length) { return; } @@ -75,12 +37,30 @@ export class InstancePlugin { while (json.includes(`]${level}]`)) { level += "="; } - await this.instance.sendRcon( + await instance.sendRcon( `/sc exp_scenario.features.receive_update(helpers.json_to_table[${level}[${json}]${level}])`, true, ); } -} -export default async function (context: InstancePluginContext) { - await InstancePlugin.fromContext(context); + instance.handle(messages.FeatureUpdatedEvent, async event => { + if (started) { + await luaSendFeatures(event.updates.filter(feature => !feature.isDeleted)); + } + }); + + instance.hooks.start.attach(plugin.name, async () => { + started = true; + await syncFeatures(); + }); + + instance.hooks.exit.attach(plugin.name, async () => { + started = false; + }); + + // A new session means the controller restarted and forgot the subscription + instance.hooks.controllerConnectionEvent.attach(plugin.name, async event => { + if (event === "connect" && started) { + await syncFeatures(); + } + }); } diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index ba3d8c41..32806c21 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -1,7 +1,8 @@ import t from "tap"; +import path from "node:path"; import * as lib from "@clusterio/lib"; import { Controller } from "@clusterio/controller"; -import { ControllerPlugin } from "../dist/node/controller.js"; +import entrypoint from "../dist/node/controller.js"; import * as messages from "../dist/node/messages.js"; import { seedRoles, seedGroups } from "../dist/node/seed.js"; import { features } from "../dist/node/features.js"; @@ -29,7 +30,7 @@ t.after(() => { lib.logger.silent = false; }); -/** Build the plugin around a real controller, which is side effect free while not started. */ +/** Run the plugin on a real controller, which is side effect free while not started, returns the handlers it registered. */ async function startPlugin(t2, { withPlugins = true, databaseDirectory = t2.testdir() } = {}) { const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": databaseDirectory, @@ -45,42 +46,74 @@ async function startPlugin(t2, { withPlugins = true, databaseDirectory = t2.test } } - const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_scenario" }, controller, logger }); - return { plugin, controller }; + const handlers = new Map(); + const handle = controller.handle.bind(controller); + controller.handle = (Class, handler) => { + handlers.set(Class, handler); + handle(Class, handler); + }; + const subscriptionHandle = controller.subscriptions.handle.bind(controller.subscriptions); + controller.subscriptions.handle = (Class, handler) => { + handlers.set(Class, handler); + subscriptionHandle(Class, handler); + }; + + await entrypoint({ plugin: { name: "exp_scenario" }, controller, logger }); + return { + controller, + seed: () => handlers.get(messages.SeedRequest)(new messages.SeedRequest()), + listFeatures: () => handlers.get(messages.FeatureListRequest)(new messages.FeatureListRequest()), + updateFeature: request => handlers.get(messages.FeatureUpdateRequest)(request), + featureSubscription: request => handlers.get(messages.FeatureUpdatedEvent)(request), + shutdown: () => controller.hooks.shutdown.invoke(), + }; +} + +/** Write the stored features as a previous run would have left them. */ +async function storeFeatures(databaseDirectory, records) { + await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "exp_scenario", "features.json"), + messages.FeatureRecord.fromJSON.bind(messages.FeatureRecord), + ).save(new Map(records.map(record => [record.id, record]))); +} + +/** The feature record with the given id. */ +async function getFeature(listFeatures, id) { + return (await listFeatures()).find(record => record.id === id); } const byName = (datastore, name) => [...datastore.values()].find(other => other.name === name); -t.test("class ControllerPlugin", t2 => { - t2.test(".handleSeedRequest() throws when the seeded plugins are missing", async t3 => { - const { plugin } = await startPlugin(t3, { withPlugins: false }); +t.test("controller plugin", t2 => { + t2.test("SeedRequest throws when the seeded plugins are missing", async t3 => { + const { seed } = await startPlugin(t3, { withPlugins: false }); await t3.rejects( - plugin.handleSeedRequest(), + seed(), { message: "Seeding requires the exp_roles and exp_groups plugins" }, "the request is rejected", ); }); - t2.test(".handleSeedRequest() creates the roles and reuses them by name", async t3 => { - const { plugin, controller } = await startPlugin(t3); + t2.test("SeedRequest creates the roles and reuses them by name", async t3 => { + const { seed, controller } = await startPlugin(t3); const rolesPlugin = RolesPlugin.get(controller); - await plugin.handleSeedRequest(); + await seed(); t3.strictSame(controller.roles.size, seedRoles.length, "every seed role exists"); const moderator = byName(controller.roles, "Moderator"); t3.ok(moderator.permissions.has("exp_scenario.command.jail"), "parent permissions are flattened in"); t3.strictSame(rolesPlugin.roleMeta.get(moderator.id).shortHand, "Mod", "the role properties match the seed"); - await plugin.handleSeedRequest(); + await seed(); t3.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles"); }); - t2.test(".handleSeedRequest() creates the groups and resets them by name", async t3 => { - const { plugin, controller } = await startPlugin(t3); + t2.test("SeedRequest creates the groups and resets them by name", async t3 => { + const { seed, controller } = await startPlugin(t3); const groupsPlugin = GroupsPlugin.get(controller); - await plugin.handleSeedRequest(); + await seed(); t3.strictSame(groupsPlugin.groups.size, seedGroups.length, "every seed group exists"); const restricted = byName(groupsPlugin.groups, "Restricted"); @@ -89,7 +122,7 @@ t.test("class ControllerPlugin", t2 => { const admin = byName(groupsPlugin.groups, "Admin"); groupsPlugin.groups.set(new GroupRecord(admin.id, admin.name, new GroupPermissions(true, []))); - await plugin.handleSeedRequest(); + await seed(); t3.strictSame(groupsPlugin.groups.size, seedGroups.length, "seeding again reuses the groups"); t3.ok( byName(groupsPlugin.groups, "Admin").permissions.permissions.includes("toggle_map_editor"), @@ -97,11 +130,11 @@ t.test("class ControllerPlugin", t2 => { ); }); - t2.test(".handleSeedRequest() maps each role onto its group by rank", async t3 => { - const { plugin, controller } = await startPlugin(t3); + t2.test("SeedRequest maps each role onto its group by rank", async t3 => { + const { seed, controller } = await startPlugin(t3); const groupsPlugin = GroupsPlugin.get(controller); - await plugin.handleSeedRequest(); + await seed(); const mapped = seedRoles.filter(role => role.group !== undefined); t3.strictSame(groupsPlugin.roleMappings.size, mapped.length, "every role with a group is mapped"); @@ -119,74 +152,75 @@ t.test("class ControllerPlugin", t2 => { t3.strictSame(priorities.length, new Set(priorities).size, "priorities are unique"); }); - t2.test(".handleSeedRequest() reuses mappings and keeps clear of other mappings", async t3 => { - const { plugin, controller } = await startPlugin(t3); + t2.test("SeedRequest reuses mappings and keeps clear of other mappings", async t3 => { + const { seed, controller } = await startPlugin(t3); const groupsPlugin = GroupsPlugin.get(controller); groupsPlugin.roleMappings.set(new RoleMappingRecord(99, new Set([0, 1]), 5, 2, true)); - await plugin.handleSeedRequest(); + await seed(); const first = new Map([...groupsPlugin.roleMappings.values()].map(mapping => [mapping.id, mapping.priority])); - await plugin.handleSeedRequest(); + await seed(); const second = new Map([...groupsPlugin.roleMappings.values()].map(mapping => [mapping.id, mapping.priority])); t3.strictSame(second, first, "seeding again reuses the mappings"); t3.strictSame(groupsPlugin.roleMappings.get(99).priority, 2, "other mappings are left alone"); t3.notOk([...first.values()].filter(priority => priority === 2).length > 1, "seed priorities skip taken ones"); }); - t2.test(".fromContext() adds every feature with its defaults", async t3 => { - const { plugin } = await startPlugin(t3, { withPlugins: false }); - const records = await plugin.handleFeatureListRequest(); + t2.test("startup adds every feature with its defaults", async t3 => { + const { listFeatures } = await startPlugin(t3, { withPlugins: false }); + const records = await listFeatures(); t3.strictSame(records.map(record => record.id), features.map(feature => feature.name), "one record per feature"); t3.ok(records.every(record => record.enabled && !Object.keys(record.values).length), "enabled with no overrides"); }); - t2.test(".reconcileFeatures() drops stored values and features which no longer exist", async t3 => { + t2.test("startup drops stored values and features which no longer exist", async t3 => { const databaseDirectory = t3.testdir(); - const { plugin } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); - plugin.features.set(new messages.FeatureRecord("afk_kick", false, { afk_minutes: 5, trust_time: 600 })); - plugin.features.set(new messages.FeatureRecord("gone", true, {})); - await plugin.onShutdown(); + await storeFeatures(databaseDirectory, [ + new messages.FeatureRecord("afk_kick", false, { afk_minutes: 5, trust_time: 600 }), + new messages.FeatureRecord("gone", true, {}), + ]); - const { plugin: restarted } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); - const record = restarted.features.get("afk_kick"); + const { listFeatures } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + const record = await getFeature(listFeatures, "afk_kick"); t3.strictSame([record.enabled, record.values], [false, { afk_minutes: 5 }], "the stale value is dropped, the rest kept"); - t3.notOk(restarted.features.has("gone"), "the removed feature is deleted"); - t3.ok(features.every(feature => restarted.features.has(feature.name)), "every declared feature still has a record"); + const ids = new Set((await listFeatures()).map(other => other.id)); + t3.notOk(ids.has("gone"), "the removed feature is deleted"); + t3.ok(features.every(feature => ids.has(feature.name)), "every declared feature still has a record"); }); - t2.test(".handleFeatureUpdateRequest() stores the values which differ from the default", async t3 => { - const { plugin } = await startPlugin(t3, { withPlugins: false }); + t2.test("FeatureUpdateRequest stores the values which differ from the default", async t3 => { + const { controller, listFeatures, updateFeature, featureSubscription } = await startPlugin(t3, { withPlugins: false }); const broadcasts = []; - plugin.controller.subscriptions.broadcast = event => broadcasts.push(event); + controller.subscriptions.broadcast = event => broadcasts.push(event); - const record = await plugin.handleFeatureUpdateRequest(new messages.FeatureUpdateRequest( + const record = await updateFeature(new messages.FeatureUpdateRequest( "death_markers", false, { show_map_markers: false, collect_corpses: true }, )); t3.strictSame(record.values, { show_map_markers: false }, "the default value is dropped"); - t3.equal(plugin.features.get("death_markers").enabled, false, "the feature is disabled"); + t3.equal((await getFeature(listFeatures, "death_markers")).enabled, false, "the feature is disabled"); t3.strictSame(broadcasts.map(event => event.updates.map(update => update.id)), [["death_markers"]], "the update is broadcast"); - const subscription = await plugin.handleFeatureSubscription({ lastRequestTimeMs: record.updatedAtMs - 1 }); + const subscription = await featureSubscription({ lastRequestTimeMs: record.updatedAtMs - 1 }); t3.strictSame(subscription.updates.map(update => update.id), ["death_markers"], "subscribers catch up on the change"); }); - t2.test(".handleFeatureUpdateRequest() rejects invalid values", async t3 => { - const { plugin } = await startPlugin(t3, { withPlugins: false }); + t2.test("FeatureUpdateRequest rejects invalid values", async t3 => { + const { updateFeature } = await startPlugin(t3, { withPlugins: false }); await t3.rejects( - plugin.handleFeatureUpdateRequest(new messages.FeatureUpdateRequest("death_markers", true, { unknown: 1 })), + updateFeature(new messages.FeatureUpdateRequest("death_markers", true, { unknown: 1 })), { message: "Feature death_markers has no setting unknown" }, ); }); - t2.test(".fromContext() keeps stored features across restarts", async t3 => { + t2.test("stored features are kept across restarts", async t3 => { const databaseDirectory = t3.testdir(); - const { plugin } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); - await plugin.handleFeatureUpdateRequest(new messages.FeatureUpdateRequest("afk_kick", false, { afk_minutes: 5 })); - await plugin.onShutdown(); + const { updateFeature, shutdown } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + await updateFeature(new messages.FeatureUpdateRequest("afk_kick", false, { afk_minutes: 5 })); + await shutdown(); - const { plugin: restarted } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); - const record = restarted.features.get("afk_kick"); + const { listFeatures } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + const record = await getFeature(listFeatures, "afk_kick"); t3.strictSame([record.enabled, record.values], [false, { afk_minutes: 5 }], "the stored record is loaded"); }); diff --git a/exp_scenario/test/instance.test.js b/exp_scenario/test/instance.test.js index 22f59639..1a53adc8 100644 --- a/exp_scenario/test/instance.test.js +++ b/exp_scenario/test/instance.test.js @@ -1,7 +1,7 @@ import t from "tap"; import * as lib from "@clusterio/lib"; import { Instance } from "@clusterio/host"; -import { InstancePlugin } from "../dist/node/instance.js"; +import entrypoint from "../dist/node/instance.js"; import { plugin as pluginDeclaration } from "../dist/node/index.js"; import * as messages from "../dist/node/messages.js"; import { features } from "../dist/node/features.js"; @@ -24,7 +24,7 @@ class TestConnector extends lib.BaseConnector { send() {} } -/** Build a plugin around a real instance with spies on what leaves it. */ +/** Run the plugin on a real instance with spies on what leaves it, returns the event handler it registered. */ async function startPlugin(t2, features = [new messages.FeatureRecord("death_markers", false, { show_map_markers: false })]) { const instanceConfig = new lib.InstanceConfig("host"); instanceConfig.set("instance.id", 1); @@ -47,8 +47,18 @@ async function startPlugin(t2, features = [new messages.FeatureRecord("death_mar return request instanceof messages.FeatureListRequest ? features : undefined; }; - const plugin = await InstancePlugin.fromContext({ plugin: { name: "exp_scenario" }, instance, host: {}, logger }); - return { plugin, instance, state }; + const handlers = new Map(); + instance.handle = (Event, handler) => handlers.set(Event, handler); + + await entrypoint({ plugin: { name: "exp_scenario" }, instance, host: {}, logger }); + const hooks = instance.hooks; + return { + state, + start: () => hooks.start.invoke(), + exit: () => hooks.exit.invoke(), + connectionEvent: event => hooks.controllerConnectionEvent.invoke(event), + featureUpdated: handlers.get(messages.FeatureUpdatedEvent), + }; } /** The payload of a recorded rcon command. */ @@ -57,10 +67,10 @@ function decodeRcon(command) { return JSON.parse(match[2]); } -t.test("class InstancePlugin", t2 => { - t2.test(".onStart() subscribes and sends every feature to lua", async t3 => { - const { plugin, state } = await startPlugin(t3); - await plugin.onStart(); +t.test("instance plugin", t2 => { + t2.test("start subscribes and sends every feature to lua", async t3 => { + const { start, state } = await startPlugin(t3); + await start(); t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes to updates first"); t3.ok(state.sent[1] instanceof messages.FeatureListRequest, "then lists the features"); @@ -72,49 +82,52 @@ t.test("class InstancePlugin", t2 => { t3.equal(Object.keys(update.values).length, deathMarkers.fields.length + 1, "every field and enabled"); }); - t2.test(".handleFeatureUpdatedEvent() only sends while the game is up", async t3 => { - const { plugin, state } = await startPlugin(t3); + t2.test("FeatureUpdatedEvent only sends while the game is up", async t3 => { + const { start, exit, featureUpdated, state } = await startPlugin(t3); const event = new messages.FeatureUpdatedEvent([ new messages.FeatureRecord("afk_kick", true, { afk_minutes: 5 }), new messages.FeatureRecord("removed", true, {}, 0, true), ]); - await plugin.handleFeatureUpdatedEvent(event); + await featureUpdated(event); t3.strictSame(state.rcons, [], "nothing is sent before start"); - await plugin.onStart(); + await start(); state.rcons.length = 0; - await plugin.handleFeatureUpdatedEvent(event); + await featureUpdated(event); const updates = decodeRcon(state.rcons[0]); t3.strictSame(updates.map(update => update.name), ["afk_kick"], "deleted features are left out"); t3.strictSame([updates[0].values.enabled, updates[0].values.afk_minutes], [true, 5]); - await plugin.onExit(); + await exit(); state.rcons.length = 0; - await plugin.handleFeatureUpdatedEvent(event); + await featureUpdated(event); t3.strictSame(state.rcons, [], "nothing is sent after exit"); }); - t2.test(".onControllerConnectionEvent() syncs again after the controller restarts", async t3 => { - const { plugin, state } = await startPlugin(t3); - await plugin.onControllerConnectionEvent("connect"); + t2.test("a new controller connection syncs again after the controller restarts", async t3 => { + const { start, connectionEvent, state } = await startPlugin(t3); + await connectionEvent("connect"); t3.strictSame(state.sent, [], "nothing happens before start"); - await plugin.onStart(); + await start(); state.sent.length = 0; state.rcons.length = 0; - await plugin.onControllerConnectionEvent("resume"); + await connectionEvent("resume"); t3.strictSame(state.sent, [], "a resumed session keeps its subscription"); - await plugin.onControllerConnectionEvent("connect"); + await connectionEvent("connect"); t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes again"); t3.equal(state.rcons.length, 1, "and sends every feature"); }); - t2.test(".luaSendFeatures() picks a long bracket the values do not close", async t3 => { - const { plugin, state } = await startPlugin(t3); + t2.test("feature updates pick a long bracket the values do not close", async t3 => { + const { start, featureUpdated, state } = await startPlugin(t3, []); + await start(); const name = "a]=]b]==]c"; - await plugin.luaSendFeatures([new messages.FeatureRecord("station_auto_name", true, { station_name: name })]); + await featureUpdated(new messages.FeatureUpdatedEvent([ + new messages.FeatureRecord("station_auto_name", true, { station_name: name }), + ])); t3.match(state.rcons[0], "json_to_table[===[", "uses a level not found in the json"); t3.strictSame(decodeRcon(state.rcons[0])[0].values.station_name, name); }); diff --git a/exp_server_ups/instance.ts b/exp_server_ups/instance.ts index b9a04207..ba52ecce 100644 --- a/exp_server_ups/instance.ts +++ b/exp_server_ups/instance.ts @@ -1,91 +1,69 @@ import * as lib from "@clusterio/lib"; -import type { Instance, InstancePluginContext } from "@clusterio/host"; +import type { InstancePluginContext } from "@clusterio/host"; -export class InstancePlugin { - private updateInterval?: ReturnType; - private gameTimes: number[] = []; +export default async function (context: InstancePluginContext) { + const { instance, logger, plugin } = context; + let updateInterval: ReturnType | undefined; + const gameTimes: number[] = []; - private constructor( - public instance: Instance, - public logger: lib.Logger, - public name: string, - ) {} - - static async fromContext(context: InstancePluginContext) { - const instance = context.instance; - const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); - - const hooks = instance.hooks; - hooks.start.attach(plugin.name, plugin.onStart.bind(plugin)); - hooks.exit.attach(plugin.name, plugin.onExit.bind(plugin)); - hooks.instanceConfigFieldChanged.attach(plugin.name, plugin.onInstanceConfigFieldChanged.bind(plugin)); - hooks.playerEvent.attach(plugin.name, plugin.onPlayerEvent.bind(plugin)); - return plugin; - } - - async onStart() { - if (!this.instance.config.get("factorio.settings")["auto_pause"]) { - this.setInterval(); + function startUpdates() { + if (!updateInterval) { + updateInterval = setInterval(updateUps, instance.config.get("exp_server_ups.update_interval")); } } - onExit() { - this.clearInterval(); - } - - async onInstanceConfigFieldChanged(field: string, curr: unknown): Promise { - if (field === "exp_server_ups.update_interval") { - this.clearInterval(); - this.setInterval(); - } else if (field === "exp_server_ups.average_interval") { - this.gameTimes.splice(curr as number); + function stopUpdates() { + if (updateInterval) { + clearInterval(updateInterval); + updateInterval = undefined; } } - async onPlayerEvent(event: lib.PlayerEvent): Promise { - if (event.type === "join") { - this.setInterval(); - } else if (event.type === "leave" && this.instance.playersOnline.size == 0 && this.instance.config.get("factorio.settings")["auto_pause"]) { - this.clearInterval(); - } - } - - setInterval() { - if (!this.updateInterval) { - this.updateInterval = setInterval(this.updateUps.bind(this), this.instance.config.get("exp_server_ups.update_interval")); - } - } - - clearInterval() { - if (this.updateInterval) { - clearInterval(this.updateInterval); - this.updateInterval = undefined; - } - } - - async updateUps() { + async function updateUps() { let ups = 0; - const collected = this.gameTimes.length - 1; + const collected = gameTimes.length - 1; if (collected > 0) { - const minTick = this.gameTimes[0]; - const maxTick = this.gameTimes[collected]; - const interval = this.instance.config.get("exp_server_ups.update_interval") / 1000; + const minTick = gameTimes[0]; + const maxTick = gameTimes[collected]; + const interval = instance.config.get("exp_server_ups.update_interval") / 1000; ups = (maxTick - minTick) / (collected * interval); } try { - const newGameTime = await this.instance.sendRcon(`/_rcon return exp_server_ups.refresh(${ups})`, false, this.name); - this.gameTimes.push(Number(newGameTime)); + const newGameTime = await instance.sendRcon(`/_rcon return exp_server_ups.refresh(${ups})`, false, plugin.name); + gameTimes.push(Number(newGameTime)); } catch (error: any) { - this.logger.error(`Failed to receive new game time: ${error}`); + logger.error(`Failed to receive new game time: ${error}`); } - if (collected > this.instance.config.get("exp_server_ups.average_interval")) { - this.gameTimes.shift(); + if (collected > instance.config.get("exp_server_ups.average_interval")) { + gameTimes.shift(); } } -} -export default async function (context: InstancePluginContext) { - await InstancePlugin.fromContext(context); + const hooks = instance.hooks; + hooks.start.attach(plugin.name, async () => { + if (!instance.config.get("factorio.settings")["auto_pause"]) { + startUpdates(); + } + }); + + hooks.exit.attach(plugin.name, stopUpdates); + + hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr) => { + if (field === "exp_server_ups.update_interval") { + stopUpdates(); + startUpdates(); + } else if (field === "exp_server_ups.average_interval") { + gameTimes.splice(curr as number); + } + }); + + hooks.playerEvent.attach(plugin.name, async (event: lib.PlayerEvent) => { + if (event.type === "join") { + startUpdates(); + } else if (event.type === "leave" && instance.playersOnline.size == 0 && instance.config.get("factorio.settings")["auto_pause"]) { + stopUpdates(); + } + }); }