From ea9bf1fb78d9a66ca9a43cd28b5444b4e9c23211 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:19:53 +0200 Subject: [PATCH] Construct plugins with a static fromContext (#487) Each plugin class now has a private constructor and a static async fromContext(context) that loads its datastores, constructs the instance and attaches its handlers and hooks. This replaces the public constructor followed by init(), and AutoRestart's start(). Datastores are loaded before construction and passed to the constructor, so the definite assignment assertions on them are gone. Tests build the plugins through fromContext and pass a wrapped logger in the context instead of overwriting plugin.logger before init(). Co-authored-by: Claude Opus 5.5 --- exp_auto_restart/controller.ts | 48 ++++++------- exp_auto_restart/test/controller.test.js | 5 +- exp_groups/controller.ts | 89 ++++++++++++------------ exp_groups/instance.ts | 39 ++++++----- exp_reports/controller.ts | 42 +++++------ exp_reports/instance.ts | 26 +++---- exp_reports/test/controller.test.js | 5 +- exp_reports/test/instance.test.js | 3 +- exp_roles/controller.ts | 64 ++++++++--------- exp_roles/instance.ts | 32 ++++----- exp_roles/test/controller.test.js | 5 +- exp_roles/test/instance.test.js | 5 +- exp_scenario/controller.ts | 41 ++++++----- exp_scenario/instance.ts | 29 ++++---- exp_scenario/test/controller.test.js | 10 ++- exp_scenario/test/instance.test.js | 3 +- exp_server_ups/instance.ts | 31 +++++---- 17 files changed, 236 insertions(+), 241 deletions(-) diff --git a/exp_auto_restart/controller.ts b/exp_auto_restart/controller.ts index 130dd2b0..5695c293 100644 --- a/exp_auto_restart/controller.ts +++ b/exp_auto_restart/controller.ts @@ -16,10 +16,6 @@ export interface PendingRestart { } export class AutoRestart { - controller: Controller; - logger: lib.Logger; - name: string; - /** When each host, and the cluster as a whole, last became empty of players. Absent while players are online. */ idleSince = new Map(); /** Hosts that were told to restart and are waiting for their instances to come back */ @@ -31,32 +27,36 @@ export class AutoRestart { controllerRestarting = false; private checkInterval?: ReturnType; - constructor(context: ControllerPluginContext) { - this.controller = context.controller; - this.logger = context.logger; - this.name = context.plugin.name; - } + private constructor( + public controller: Controller, + public logger: lib.Logger, + public name: string, + ) {} - /** Attach to the controller hooks and start checking for restarts. */ - start() { - const hooks = this.controller.hooks; - hooks.shutdown.attach(this.name, () => this.stop()); - hooks.playerEvent.attach(this.name, (instance, event) => this.onPlayerEvent(instance, event)); - hooks.instanceStatusChanged.attach(this.name, (instance, prev) => this.onInstanceStatusChanged(instance, prev)); - hooks.hostConnectionEvent.attach(this.name, (connection, event) => this.onHostConnectionEvent(connection, event)); + /** Create the plugin, attach to the controller hooks and start checking for restarts. */ + static async fromContext(context: ControllerPluginContext) { + const controller = context.controller; + const plugin = new AutoRestart(controller, context.logger, context.plugin.name); - if (this.controller.config.get("controller.system_metrics_interval") <= 0) { - this.logger.warn( + const hooks = controller.hooks; + hooks.shutdown.attach(plugin.name, () => plugin.stop()); + hooks.playerEvent.attach(plugin.name, (instance, event) => plugin.onPlayerEvent(instance, event)); + hooks.instanceStatusChanged.attach(plugin.name, (instance, prev) => plugin.onInstanceStatusChanged(instance, prev)); + hooks.hostConnectionEvent.attach(plugin.name, (connection, event) => plugin.onHostConnectionEvent(connection, event)); + + if (controller.config.get("controller.system_metrics_interval") <= 0) { + plugin.logger.warn( "controller.system_metrics_interval is 0, so the controller never learns which hosts need a restart" ); } - this.refreshIdle(); - this.checkInterval = setInterval(() => { - this.check().catch((err: any) => { - this.logger.error(`Unexpected error checking for restarts:\n${err.stack ?? err.message}`); + plugin.refreshIdle(); + plugin.checkInterval = setInterval(() => { + plugin.check().catch((err: any) => { + plugin.logger.error(`Unexpected error checking for restarts:\n${err.stack ?? err.message}`); }); }, CHECK_INTERVAL_MS); - this.checkInterval.unref(); + plugin.checkInterval.unref(); + return plugin; } stop() { @@ -291,5 +291,5 @@ export class AutoRestart { } export default async function (context: ControllerPluginContext) { - new AutoRestart(context).start(); + await AutoRestart.fromContext(context); } diff --git a/exp_auto_restart/test/controller.test.js b/exp_auto_restart/test/controller.test.js index e7461f53..05ec45e9 100644 --- a/exp_auto_restart/test/controller.test.js +++ b/exp_auto_restart/test/controller.test.js @@ -35,8 +35,7 @@ async function startPlugin(t2, { config = {}, canRestart = false } = {}) { sent.push({ address, message }); }; - const plugin = new AutoRestart({ controller, logger, plugin: pluginDeclaration, metrics: undefined }); - plugin.start(); + const plugin = await AutoRestart.fromContext({ controller, logger, plugin: pluginDeclaration, metrics: undefined }); t2.teardown(() => plugin.stop()); return { plugin, controller, sent, logs }; } @@ -344,7 +343,7 @@ t.test("class AutoRestart", t2 => { t3.ok(logs.some(([level, message]) => level === "warn" && message.includes("--can-restart"))); }); - t2.test(".start() warns when system metrics are disabled", async t3 => { + t2.test(".fromContext() warns when system metrics are disabled", async t3 => { const { logs } = await startPlugin(t3, { config: { "controller.system_metrics_interval": 0 } }); t3.ok(logs.some(([level, message]) => level === "warn" && message.includes("system_metrics_interval"))); }); diff --git a/exp_groups/controller.ts b/exp_groups/controller.ts index 89f6bd1e..63c17413 100644 --- a/exp_groups/controller.ts +++ b/exp_groups/controller.ts @@ -6,81 +6,82 @@ import * as path from "node:path"; const loaded = new WeakMap(); export class ControllerPlugin { - controller: Controller; - logger: lib.Logger; - name: string; - groups!: lib.SubscribableDatastore; - roleMappings!: lib.SubscribableDatastore; - manualAssignments!: lib.SubscribableDatastore; - resolvedAssignments!: lib.SubscribableDatastore; + resolvedAssignments = new lib.SubscribableDatastore(); /** The plugin loaded on a controller, for the exp_scenario seed. */ static get(controller: Controller) { return loaded.get(controller); } - constructor(context: ControllerPluginContext) { - this.controller = context.controller; - this.logger = context.logger; - this.name = context.plugin.name; - } + private constructor( + public controller: Controller, + public logger: lib.Logger, + public name: string, + public groups: lib.SubscribableDatastore, + public roleMappings: lib.SubscribableDatastore, + public manualAssignments: lib.SubscribableDatastore, + ) {} - async init() { - loaded.set(this.controller, this); - const databaseDirectory = this.controller.config.get("controller.database_directory"); + static async fromContext(context: ControllerPluginContext) { + const controller = context.controller; + const databaseDirectory = controller.config.get("controller.database_directory"); - this.groups = new lib.SubscribableDatastore( + const groups = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "exp_groups", "groups.json"), messages.GroupRecord.fromJSON.bind(messages.GroupRecord), ).bootstrap() ); - this.roleMappings = new lib.SubscribableDatastore( + const roleMappings = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "exp_groups", "role_mappings.json"), messages.RoleMappingRecord.fromJSON.bind(messages.RoleMappingRecord), ).bootstrap() ); - this.manualAssignments = new lib.SubscribableDatastore( + const manualAssignments = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "exp_groups", "assignments.json"), messages.AssignmentRecord.fromJSON.bind(messages.AssignmentRecord), ).bootstrap() ); - this.resolvedAssignments = new lib.SubscribableDatastore(); + const plugin = new ControllerPlugin( + controller, context.logger, context.plugin.name, groups, roleMappings, manualAssignments, + ); + loaded.set(controller, plugin); - this.controller.subscriptions.handle(messages.GroupUpdatedEvent, this.handleGroupSubscription.bind(this)); - this.controller.subscriptions.handle(messages.RoleMappingUpdatedEvent, this.handleRoleMappingSubscription.bind(this)); - this.controller.subscriptions.handle(messages.ManualAssignmentUpdatedEvent, this.handleManualAssignmentSubscription.bind(this)); - this.controller.subscriptions.handle(messages.ResolvedAssignmentUpdatedEvent, this.handleResolvedAssignmentSubscription.bind(this)); + controller.subscriptions.handle(messages.GroupUpdatedEvent, plugin.handleGroupSubscription.bind(plugin)); + controller.subscriptions.handle(messages.RoleMappingUpdatedEvent, plugin.handleRoleMappingSubscription.bind(plugin)); + controller.subscriptions.handle(messages.ManualAssignmentUpdatedEvent, plugin.handleManualAssignmentSubscription.bind(plugin)); + controller.subscriptions.handle(messages.ResolvedAssignmentUpdatedEvent, plugin.handleResolvedAssignmentSubscription.bind(plugin)); - this.groups.on("update", this.groupsUpdated.bind(this)); - this.roleMappings.on("update", this.roleMappingsUpdated.bind(this)); - this.manualAssignments.on("update", this.manualAssignmentsUpdated.bind(this)); - this.resolvedAssignments.on("update", this.resolvedAssignmentsUpdated.bind(this)); + groups.on("update", plugin.groupsUpdated.bind(plugin)); + roleMappings.on("update", plugin.roleMappingsUpdated.bind(plugin)); + manualAssignments.on("update", plugin.manualAssignmentsUpdated.bind(plugin)); + plugin.resolvedAssignments.on("update", plugin.resolvedAssignmentsUpdated.bind(plugin)); - this.controller.handle(messages.GroupCreateRequest, this.handleGroupCreateRequest.bind(this)); - this.controller.handle(messages.GroupUpdateRequest, this.handleGroupUpdateRequest.bind(this)); - this.controller.handle(messages.GroupDeleteRequest, this.handleGroupDeleteRequest.bind(this)); - this.controller.handle(messages.GroupGetRequest, this.handleGroupGetRequest.bind(this)); - this.controller.handle(messages.GroupListRequest, this.handleGroupListRequest.bind(this)); + controller.handle(messages.GroupCreateRequest, plugin.handleGroupCreateRequest.bind(plugin)); + controller.handle(messages.GroupUpdateRequest, plugin.handleGroupUpdateRequest.bind(plugin)); + controller.handle(messages.GroupDeleteRequest, plugin.handleGroupDeleteRequest.bind(plugin)); + controller.handle(messages.GroupGetRequest, plugin.handleGroupGetRequest.bind(plugin)); + controller.handle(messages.GroupListRequest, plugin.handleGroupListRequest.bind(plugin)); - this.controller.handle(messages.AssignmentCreateRequest, this.handleAssignmentCreateRequest.bind(this)); - this.controller.handle(messages.AssignmentUpdateRequest, this.handleAssignmentUpdateRequest.bind(this)); - this.controller.handle(messages.AssignmentDeleteRequest, this.handleAssignmentDeleteRequest.bind(this)); - this.controller.handle(messages.AssignmentGetRequest, this.handleAssignmentGetRequest.bind(this)); - this.controller.handle(messages.AssignmentListRequest, this.handleAssignmentListRequest.bind(this)); + controller.handle(messages.AssignmentCreateRequest, plugin.handleAssignmentCreateRequest.bind(plugin)); + controller.handle(messages.AssignmentUpdateRequest, plugin.handleAssignmentUpdateRequest.bind(plugin)); + controller.handle(messages.AssignmentDeleteRequest, plugin.handleAssignmentDeleteRequest.bind(plugin)); + controller.handle(messages.AssignmentGetRequest, plugin.handleAssignmentGetRequest.bind(plugin)); + controller.handle(messages.AssignmentListRequest, plugin.handleAssignmentListRequest.bind(plugin)); - this.controller.handle(messages.RoleMappingCreateRequest, this.handleRoleMappingCreateRequest.bind(this)); - this.controller.handle(messages.RoleMappingUpdateRequest, this.handleRoleMappingUpdateRequest.bind(this)); - this.controller.handle(messages.RoleMappingDeleteRequest, this.handleRoleMappingDeleteRequest.bind(this)); - this.controller.handle(messages.RoleMappingGetRequest, this.handleRoleMappingGetRequest.bind(this)); - this.controller.handle(messages.RoleMappingListRequest, this.handleRoleMappingListRequest.bind(this)); + controller.handle(messages.RoleMappingCreateRequest, plugin.handleRoleMappingCreateRequest.bind(plugin)); + controller.handle(messages.RoleMappingUpdateRequest, plugin.handleRoleMappingUpdateRequest.bind(plugin)); + controller.handle(messages.RoleMappingDeleteRequest, plugin.handleRoleMappingDeleteRequest.bind(plugin)); + controller.handle(messages.RoleMappingGetRequest, plugin.handleRoleMappingGetRequest.bind(plugin)); + controller.handle(messages.RoleMappingListRequest, plugin.handleRoleMappingListRequest.bind(plugin)); - this.controller.hooks.shutdown.attach(this.name, this.onShutdown.bind(this)); + controller.hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin)); + return plugin; } async onShutdown() { @@ -453,5 +454,5 @@ export class ControllerPlugin { } export default async function (context: ControllerPluginContext) { - await new ControllerPlugin(context).init(); + await ControllerPlugin.fromContext(context); } diff --git a/exp_groups/instance.ts b/exp_groups/instance.ts index 594f025d..88e97769 100644 --- a/exp_groups/instance.ts +++ b/exp_groups/instance.ts @@ -18,9 +18,6 @@ export type IpcPlayerAssignments = { }; export class InstancePlugin { - instance: Instance; - logger: lib.Logger; - name: string; // 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(); @@ -29,23 +26,27 @@ export class InstancePlugin { // Track known online players so that we only apply assignment updates for them onlinePlayers = new Set(); - constructor(context: InstancePluginContext) { - this.instance = context.instance; - this.logger = context.logger; - this.name = context.plugin.name; - } + private constructor( + public instance: Instance, + public logger: lib.Logger, + public name: string, + ) {} - async init() { - this.instance.handle(messages.GroupUpdatedEvent, this.handleGroupUpdatedEvent.bind(this)); - this.instance.handle(messages.ResolvedAssignmentUpdatedEvent, this.handleResolvedAssignmentUpdatedEvent.bind(this)); - this.instance.server.handle(`exp_group:group_updated`, this.handleGroupUpdatedIPC.bind(this)) - this.instance.server.handle(`exp_group:group_deleted`, this.handleGroupDeletedIPC.bind(this)) - this.instance.server.handle(`exp_group:player_assignments`, this.handlePlayerAssignmentsIPC.bind(this)) + static async fromContext(context: InstancePluginContext) { + const instance = context.instance; + const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); - const hooks = this.instance.hooks; - hooks.instanceConfigFieldChanged.attach(this.name, this.onInstanceConfigFieldChanged.bind(this)); - hooks.start.attach(this.name, this.onStart.bind(this)); - hooks.playerEvent.attach(this.name, this.onPlayerEvent.bind(this)); + 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 onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { @@ -185,5 +186,5 @@ export class InstancePlugin { } export default async function (context: InstancePluginContext) { - await new InstancePlugin(context).init(); + await InstancePlugin.fromContext(context); } diff --git a/exp_reports/controller.ts b/exp_reports/controller.ts index 53c5adb3..f8b6ca59 100644 --- a/exp_reports/controller.ts +++ b/exp_reports/controller.ts @@ -4,36 +4,36 @@ import * as messages from "./messages.js"; import * as path from "node:path"; export class ControllerPlugin { - controller: Controller; - logger: lib.Logger; - name: string; - reports!: lib.SubscribableDatastore; + private constructor( + public controller: Controller, + public logger: lib.Logger, + public name: string, + public reports: lib.SubscribableDatastore, + ) {} - constructor(context: ControllerPluginContext) { - this.controller = context.controller; - this.logger = context.logger; - this.name = context.plugin.name; - } + static async fromContext(context: ControllerPluginContext) { + const controller = context.controller; + const databaseDirectory = controller.config.get("controller.database_directory"); - async init() { - const databaseDirectory = this.controller.config.get("controller.database_directory"); - - this.reports = new lib.SubscribableDatastore( + const reports = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "exp_reports", "reports.json"), messages.ReportRecord.fromJSON.bind(messages.ReportRecord), ).bootstrap() ); - this.controller.subscriptions.handle(messages.ReportUpdatedEvent, this.handleReportSubscription.bind(this)); - this.reports.on("update", this.reportsUpdated.bind(this)); + const plugin = new ControllerPlugin(controller, context.logger, context.plugin.name, reports); - this.controller.handle(messages.ReportListRequest, this.handleReportListRequest.bind(this)); - this.controller.handle(messages.ReportGetRequest, this.handleReportGetRequest.bind(this)); - this.controller.handle(messages.ReportCreateRequest, this.handleReportCreateRequest.bind(this)); - this.controller.handle(messages.ReportDeleteRequest, this.handleReportDeleteRequest.bind(this)); + controller.subscriptions.handle(messages.ReportUpdatedEvent, plugin.handleReportSubscription.bind(plugin)); + reports.on("update", plugin.reportsUpdated.bind(plugin)); - this.controller.hooks.shutdown.attach(this.name, this.onShutdown.bind(this)); + 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() { @@ -154,5 +154,5 @@ export class ControllerPlugin { } export default async function (context: ControllerPluginContext) { - await new ControllerPlugin(context).init(); + await ControllerPlugin.fromContext(context); } diff --git a/exp_reports/instance.ts b/exp_reports/instance.ts index e5ae3a43..71c90c2b 100644 --- a/exp_reports/instance.ts +++ b/exp_reports/instance.ts @@ -30,20 +30,20 @@ export type IpcReportDelete = { * stopped by then, in which case the answer is dropped. */ export class InstancePlugin { - instance: Instance; - logger: lib.Logger; - name: string; + private constructor( + public instance: Instance, + public logger: lib.Logger, + public name: string, + ) {} - constructor(context: InstancePluginContext) { - this.instance = context.instance; - this.logger = context.logger; - this.name = context.plugin.name; - } + static async fromContext(context: InstancePluginContext) { + const instance = context.instance; + const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); - async init() { - this.instance.server.handle("exp_reports:create", this.handleCreateIPC.bind(this)); - this.instance.server.handle("exp_reports:list", this.handleListIPC.bind(this)); - this.instance.server.handle("exp_reports:delete", this.handleDeleteIPC.bind(this)); + 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; } async handleCreateIPC(event: IpcReportCreate) { @@ -106,5 +106,5 @@ export class InstancePlugin { } export default async function (context: InstancePluginContext) { - await new InstancePlugin(context).init(); + await InstancePlugin.fromContext(context); } diff --git a/exp_reports/test/controller.test.js b/exp_reports/test/controller.test.js index b2dec669..7581cb42 100644 --- a/exp_reports/test/controller.test.js +++ b/exp_reports/test/controller.test.js @@ -46,10 +46,9 @@ async function startPlugin(t2, { config = {} } = {}) { broadcast(event); }; - const plugin = new ControllerPlugin({ plugin: { name: "exp_reports" }, controller, logger }); - plugin.logger = { ...logger, warn: message => state.warnings.push(message) }; + 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 }); }; - await plugin.init(); return { plugin, controller, state }; } diff --git a/exp_reports/test/instance.test.js b/exp_reports/test/instance.test.js index e579dcec..4cfcafa5 100644 --- a/exp_reports/test/instance.test.js +++ b/exp_reports/test/instance.test.js @@ -63,8 +63,7 @@ async function startPlugin(t2, { reports = [report(1, "bob", "alice"), report(2, instance.notifyStatus("running"); state.sent.length = 0; - const plugin = new InstancePlugin({ plugin: { name: "exp_reports" }, instance, host: {}, logger }); - await plugin.init(); + const plugin = await InstancePlugin.fromContext({ plugin: { name: "exp_reports" }, instance, host: {}, logger }); return { plugin, instance, state }; } diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts index 2299de23..f2ed29f3 100644 --- a/exp_roles/controller.ts +++ b/exp_roles/controller.ts @@ -6,59 +6,59 @@ import * as path from "node:path"; const loaded = new WeakMap(); export class ControllerPlugin { - controller: Controller; - logger: lib.Logger; - name: string; - roleMeta!: lib.SubscribableDatastore; - /** The plugin loaded on a controller, for the exp_scenario seed. */ static get(controller: Controller) { return loaded.get(controller); } - constructor(context: ControllerPluginContext) { - this.controller = context.controller; - this.logger = context.logger; - this.name = context.plugin.name; - } + private constructor( + public controller: Controller, + public logger: lib.Logger, + public name: string, + public roleMeta: lib.SubscribableDatastore, + ) {} - async init() { - loaded.set(this.controller, this); - const databaseDirectory = this.controller.config.get("controller.database_directory"); + static async fromContext(context: ControllerPluginContext) { + const controller = context.controller; + const databaseDirectory = controller.config.get("controller.database_directory"); - this.roleMeta = new lib.SubscribableDatastore( + const roleMeta = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "exp_roles", "role_meta.json"), messages.RoleMetaRecord.fromJSON.bind(messages.RoleMetaRecord), ).bootstrap() ); + const plugin = new ControllerPlugin(controller, context.logger, context.plugin.name, roleMeta); + loaded.set(controller, plugin); + // The datastore can be out of step with the roles, either because the // plugin was installed after they were created or because it was // uninstalled while they were deleted - this.ensureRoleMeta(); - this.sweepRoleMeta(); - this.applyAutoAssign(); + plugin.ensureRoleMeta(); + plugin.sweepRoleMeta(); + plugin.applyAutoAssign(); - this.controller.subscriptions.handle(messages.RoleUpdatedEvent, this.handleRoleSubscription.bind(this)); - this.controller.subscriptions.handle( - messages.AssignmentUpdatedEvent, this.handleAssignmentSubscription.bind(this) + controller.subscriptions.handle(messages.RoleUpdatedEvent, plugin.handleRoleSubscription.bind(plugin)); + controller.subscriptions.handle( + messages.AssignmentUpdatedEvent, plugin.handleAssignmentSubscription.bind(plugin) ); - this.roleMeta.on("update", this.roleMetaUpdated.bind(this)); - this.controller.roles.on("update", this.rolesUpdated.bind(this)); - this.controller.users.records.on("update", this.usersUpdated.bind(this)); + roleMeta.on("update", plugin.roleMetaUpdated.bind(plugin)); + controller.roles.on("update", plugin.rolesUpdated.bind(plugin)); + controller.users.records.on("update", plugin.usersUpdated.bind(plugin)); - this.controller.handle(messages.RoleListRequest, this.handleRoleListRequest.bind(this)); - this.controller.handle(messages.RoleMetaUpdateRequest, this.handleRoleMetaUpdateRequest.bind(this)); + controller.handle(messages.RoleListRequest, plugin.handleRoleListRequest.bind(plugin)); + controller.handle(messages.RoleMetaUpdateRequest, plugin.handleRoleMetaUpdateRequest.bind(plugin)); - this.controller.handle(messages.AssignmentListRequest, this.handleAssignmentListRequest.bind(this)); - this.controller.handle(messages.AssignmentUpdateRequest, this.handleAssignmentUpdateRequest.bind(this)); + controller.handle(messages.AssignmentListRequest, plugin.handleAssignmentListRequest.bind(plugin)); + controller.handle(messages.AssignmentUpdateRequest, plugin.handleAssignmentUpdateRequest.bind(plugin)); - const hooks = this.controller.hooks; - hooks.shutdown.attach(this.name, this.onShutdown.bind(this)); - hooks.controllerConfigFieldChanged.attach(this.name, this.onControllerConfigFieldChanged.bind(this)); - hooks.playerEvent.attach(this.name, this.onPlayerEvent.bind(this)); + const hooks = controller.hooks; + hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin)); + hooks.controllerConfigFieldChanged.attach(plugin.name, plugin.onControllerConfigFieldChanged.bind(plugin)); + hooks.playerEvent.attach(plugin.name, plugin.onPlayerEvent.bind(plugin)); + return plugin; } async onShutdown() { @@ -335,5 +335,5 @@ export class ControllerPlugin { } export default async function (context: ControllerPluginContext) { - await new ControllerPlugin(context).init(); + await ControllerPlugin.fromContext(context); } diff --git a/exp_roles/instance.ts b/exp_roles/instance.ts index 488613d6..8cb87e22 100644 --- a/exp_roles/instance.ts +++ b/exp_roles/instance.ts @@ -10,24 +10,24 @@ export type IpcAssignmentUpdate = { }; export class InstancePlugin { - instance: Instance; - logger: lib.Logger; - name: string; + private constructor( + public instance: Instance, + public logger: lib.Logger, + public name: string, + ) {} - constructor(context: InstancePluginContext) { - this.instance = context.instance; - this.logger = context.logger; - this.name = context.plugin.name; - } + static async fromContext(context: InstancePluginContext) { + const instance = context.instance; + const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); - async init() { - this.instance.handle(messages.RoleUpdatedEvent, this.handleRoleUpdatedEvent.bind(this)); - this.instance.handle(messages.AssignmentUpdatedEvent, this.handleAssignmentUpdatedEvent.bind(this)); - this.instance.server.handle("exp_roles:assignment_update", this.handleAssignmentUpdateIPC.bind(this)); + 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 = this.instance.hooks; - hooks.instanceConfigFieldChanged.attach(this.name, this.onInstanceConfigFieldChanged.bind(this)); - hooks.start.attach(this.name, this.onStart.bind(this)); + const hooks = instance.hooks; + hooks.instanceConfigFieldChanged.attach(plugin.name, plugin.onInstanceConfigFieldChanged.bind(plugin)); + hooks.start.attach(plugin.name, plugin.onStart.bind(plugin)); + return plugin; } get syncMode() { @@ -122,5 +122,5 @@ export class InstancePlugin { } export default async function (context: InstancePluginContext) { - await new InstancePlugin(context).init(); + await InstancePlugin.fromContext(context); } diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js index 1231d093..652dca56 100644 --- a/exp_roles/test/controller.test.js +++ b/exp_roles/test/controller.test.js @@ -44,8 +44,7 @@ async function startPlugin(t2, { roles = [] } = {}) { permissionsUpdated(user); }; - const plugin = new ControllerPlugin({ plugin: { name: "exp_roles" }, controller, logger }); - await plugin.init(); + const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_roles" }, controller, logger }); return { plugin, controller, state }; } @@ -62,7 +61,7 @@ t.test("entrypoint", async t2 => { }); t.test("class ControllerPlugin", t2 => { - t2.test(".init() and .sweepRoleMeta() manage the role properties", async t3 => { + t2.test(".fromContext() and .sweepRoleMeta() manage the role properties", async t3 => { const { plugin } = await startPlugin(t3, { roles: [role(0, "Cluster Admin"), role(5, "Moderator")] }); t3.strictSame(plugin.roleMeta.get(0).order, 1, "the first role is ordered first"); diff --git a/exp_roles/test/instance.test.js b/exp_roles/test/instance.test.js index 302e8528..5921adc7 100644 --- a/exp_roles/test/instance.test.js +++ b/exp_roles/test/instance.test.js @@ -69,9 +69,8 @@ async function startPlugin(t2, { syncMode = "bidirectional", roles = sampleRoles return undefined; }; - const plugin = new InstancePlugin({ plugin: { name: "exp_roles" }, instance, host: {}, logger }); - plugin.logger = { ...logger, warn: message => state.warnings.push(message) }; - await plugin.init(); + 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 }; } diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index 7c7547c4..59557a5b 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -10,36 +10,35 @@ import { features, pruneFeatureValues, validateFeatureValues } from "./features. import { SeedRole, SeedGroup, seedRoles, seedGroups, flattenSeedPermissions } from "./seed.js"; export class ControllerPlugin { - controller: Controller; - logger: lib.Logger; - name: string; - features!: lib.SubscribableDatastore; + private constructor( + public controller: Controller, + public logger: lib.Logger, + public name: string, + public features: lib.SubscribableDatastore, + ) {} - constructor(context: ControllerPluginContext) { - this.controller = context.controller; - this.logger = context.logger; - this.name = context.plugin.name; - } - - async init() { - const databaseDirectory = this.controller.config.get("controller.database_directory"); - this.features = new 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() ); - this.reconcileFeatures(); + const plugin = new ControllerPlugin(controller, context.logger, context.plugin.name, features); + plugin.reconcileFeatures(); - this.controller.subscriptions.handle(messages.FeatureUpdatedEvent, this.handleFeatureSubscription.bind(this)); - this.features.on("update", this.featuresUpdated.bind(this)); + controller.subscriptions.handle(messages.FeatureUpdatedEvent, plugin.handleFeatureSubscription.bind(plugin)); + features.on("update", plugin.featuresUpdated.bind(plugin)); - this.controller.handle(messages.SeedRequest, this.handleSeedRequest.bind(this)); - this.controller.handle(messages.FeatureListRequest, this.handleFeatureListRequest.bind(this)); - this.controller.handle(messages.FeatureUpdateRequest, this.handleFeatureUpdateRequest.bind(this)); + controller.handle(messages.SeedRequest, plugin.handleSeedRequest.bind(plugin)); + controller.handle(messages.FeatureListRequest, plugin.handleFeatureListRequest.bind(plugin)); + controller.handle(messages.FeatureUpdateRequest, plugin.handleFeatureUpdateRequest.bind(plugin)); - this.controller.hooks.shutdown.attach(this.name, this.onShutdown.bind(this)); + controller.hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin)); + return plugin; } async onShutdown() { @@ -233,7 +232,7 @@ export class ControllerPlugin { } export default async function (context: ControllerPluginContext) { - await new ControllerPlugin(context).init(); + await ControllerPlugin.fromContext(context); } function newId(datastore: { has(id: number): boolean }) { diff --git a/exp_scenario/instance.ts b/exp_scenario/instance.ts index 7a0f9f5a..0e6a9da8 100644 --- a/exp_scenario/instance.ts +++ b/exp_scenario/instance.ts @@ -5,23 +5,24 @@ import { features, FeatureValue } from "./features.js"; /** Sends feature config to the lua side, see module/features.lua. */ export class InstancePlugin { - instance: Instance; - logger: lib.Logger; - name: string; /** Rcon is up and the lua side can take updates */ started = false; - constructor(context: InstancePluginContext) { - this.instance = context.instance; - this.logger = context.logger; - this.name = context.plugin.name; - } + private constructor( + public instance: Instance, + public logger: lib.Logger, + public name: string, + ) {} - async init() { - this.instance.handle(messages.FeatureUpdatedEvent, this.handleFeatureUpdatedEvent.bind(this)); - this.instance.hooks.start.attach(this.name, this.onStart.bind(this)); - this.instance.hooks.exit.attach(this.name, this.onExit.bind(this)); - this.instance.hooks.controllerConnectionEvent.attach(this.name, this.onControllerConnectionEvent.bind(this)); + 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() { @@ -81,5 +82,5 @@ export class InstancePlugin { } export default async function (context: InstancePluginContext) { - await new InstancePlugin(context).init(); + await InstancePlugin.fromContext(context); } diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index 3c9916b0..ba3d8c41 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -41,13 +41,11 @@ async function startPlugin(t2, { withPlugins = true, databaseDirectory = t2.test if (withPlugins) { for (const [name, Plugin] of [["exp_roles", RolesPlugin], ["exp_groups", GroupsPlugin]]) { - const plugin = new Plugin({ plugin: { name }, controller, logger }); - await plugin.init(); + await Plugin.fromContext({ plugin: { name }, controller, logger }); } } - const plugin = new ControllerPlugin({ plugin: { name: "exp_scenario" }, controller, logger }); - await plugin.init(); + const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_scenario" }, controller, logger }); return { plugin, controller }; } @@ -136,7 +134,7 @@ t.test("class ControllerPlugin", t2 => { t3.notOk([...first.values()].filter(priority => priority === 2).length > 1, "seed priorities skip taken ones"); }); - t2.test(".init() adds every feature with its defaults", async t3 => { + t2.test(".fromContext() adds every feature with its defaults", async t3 => { const { plugin } = await startPlugin(t3, { withPlugins: false }); const records = await plugin.handleFeatureListRequest(); t3.strictSame(records.map(record => record.id), features.map(feature => feature.name), "one record per feature"); @@ -181,7 +179,7 @@ t.test("class ControllerPlugin", t2 => { ); }); - t2.test(".init() keeps stored features across restarts", async t3 => { + t2.test(".fromContext() keeps stored features 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 })); diff --git a/exp_scenario/test/instance.test.js b/exp_scenario/test/instance.test.js index 31beee6d..22f59639 100644 --- a/exp_scenario/test/instance.test.js +++ b/exp_scenario/test/instance.test.js @@ -47,8 +47,7 @@ async function startPlugin(t2, features = [new messages.FeatureRecord("death_mar return request instanceof messages.FeatureListRequest ? features : undefined; }; - const plugin = new InstancePlugin({ plugin: { name: "exp_scenario" }, instance, host: {}, logger }); - await plugin.init(); + const plugin = await InstancePlugin.fromContext({ plugin: { name: "exp_scenario" }, instance, host: {}, logger }); return { plugin, instance, state }; } diff --git a/exp_server_ups/instance.ts b/exp_server_ups/instance.ts index 4ae128e6..b9a04207 100644 --- a/exp_server_ups/instance.ts +++ b/exp_server_ups/instance.ts @@ -2,24 +2,25 @@ import * as lib from "@clusterio/lib"; import type { Instance, InstancePluginContext } from "@clusterio/host"; export class InstancePlugin { - instance: Instance; - logger: lib.Logger; - name: string; private updateInterval?: ReturnType; private gameTimes: number[] = []; - constructor(context: InstancePluginContext) { - this.instance = context.instance; - this.logger = context.logger; - this.name = context.plugin.name; - } + private constructor( + public instance: Instance, + public logger: lib.Logger, + public name: string, + ) {} - init() { - const hooks = this.instance.hooks; - hooks.start.attach(this.name, this.onStart.bind(this)); - hooks.exit.attach(this.name, this.onExit.bind(this)); - hooks.instanceConfigFieldChanged.attach(this.name, this.onInstanceConfigFieldChanged.bind(this)); - hooks.playerEvent.attach(this.name, this.onPlayerEvent.bind(this)); + 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() { @@ -86,5 +87,5 @@ export class InstancePlugin { } export default async function (context: InstancePluginContext) { - new InstancePlugin(context).init(); + await InstancePlugin.fromContext(context); }