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(); + } + }); }