From 0ff07c012c993e71f565463b6a53573274c5054d Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:10:51 +0000 Subject: [PATCH] Export init functions instead of plugin classes clusterio logs "using deprecated class export" for every node entrypoint that exports a Base*Plugin subclass, and the web UI logs "using deprecated class hooks" for WebPlugin classes. Every entrypoint now has a default export function, like exp_auto_restart and the exp_roles web plugin. The classes stay where they hold state, but no longer extend the base classes. The constructor takes the load context and init() attaches the hooks the base class used to attach from the on* methods, so the tests can still call those methods directly. exp_server_ups sends its RCON command through instance.sendRcon with the plugin name, which is what the removed BaseInstancePlugin.sendRcon did. The instance entrypoints of exp_commands, exp_gui, exp_legacy, exp_util and exp_scenario were empty classes, kept only so the host loads the plugin and patches its module into the save. An empty function does the same. The exp_groups web components read the subscriptions through exported useGroups, useAssignments and useRoleMappings hooks instead of looking the plugin up in control.plugins, which only holds class plugins. Co-Authored-By: Claude Opus 5.5 --- exp_commands/instance.ts | 10 +-- exp_groups/controller.ts | 19 ++++- exp_groups/instance.ts | 22 +++++- exp_groups/web/components/AssignmentForm.tsx | 5 +- .../web/components/AssignmentsTable.tsx | 7 +- exp_groups/web/components/GroupViewPage.tsx | 5 +- exp_groups/web/components/GroupsTable.tsx | 9 +-- exp_groups/web/components/RoleMappingForm.tsx | 5 +- .../web/components/RoleMappingsTable.tsx | 7 +- exp_groups/web/index.tsx | 76 ++++++++++--------- exp_gui/instance.ts | 10 +-- exp_legacy/instance.ts | 10 +-- exp_reports/controller.ts | 19 ++++- exp_reports/instance.ts | 19 ++++- exp_reports/test/controller.test.js | 14 +++- exp_reports/test/instance.test.js | 4 +- exp_roles/controller.ts | 22 +++++- exp_roles/instance.ts | 22 +++++- exp_roles/test/controller.test.js | 14 +++- exp_roles/test/instance.test.js | 18 ++++- exp_scenario/controller.ts | 16 +++- exp_scenario/instance.ts | 13 +--- exp_scenario/test/controller.test.js | 4 +- exp_scenario/web/index.tsx | 12 ++- exp_server_ups/instance.ts | 27 ++++++- exp_util/instance.ts | 10 +-- 26 files changed, 268 insertions(+), 131 deletions(-) diff --git a/exp_commands/instance.ts b/exp_commands/instance.ts index e8fa7eeb..95a41ff8 100644 --- a/exp_commands/instance.ts +++ b/exp_commands/instance.ts @@ -1,7 +1,3 @@ -import * as lib from "@clusterio/lib"; -import { BaseInstancePlugin } from "@clusterio/host"; - -export class InstancePlugin extends BaseInstancePlugin { - // This class is empty because an instance plugin must be defined for a module to be injected - // This requirement may change in the future to allow for standalone modules -} +// Empty because an instance entrypoint must be defined for a module to be injected +// This requirement may change in the future to allow for standalone modules +export default async function () {} diff --git a/exp_groups/controller.ts b/exp_groups/controller.ts index 846fc0fd..89f6bd1e 100644 --- a/exp_groups/controller.ts +++ b/exp_groups/controller.ts @@ -1,11 +1,14 @@ -import { BaseControllerPlugin, type Controller } from "@clusterio/controller"; +import type { Controller, ControllerPluginContext } from "@clusterio/controller"; import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; import * as path from "node:path"; const loaded = new WeakMap(); -export class ControllerPlugin extends BaseControllerPlugin { +export class ControllerPlugin { + controller: Controller; + logger: lib.Logger; + name: string; groups!: lib.SubscribableDatastore; roleMappings!: lib.SubscribableDatastore; manualAssignments!: lib.SubscribableDatastore; @@ -16,6 +19,12 @@ export class ControllerPlugin extends BaseControllerPlugin { return loaded.get(controller); } + constructor(context: ControllerPluginContext) { + this.controller = context.controller; + this.logger = context.logger; + this.name = context.plugin.name; + } + async init() { loaded.set(this.controller, this); const databaseDirectory = this.controller.config.get("controller.database_directory"); @@ -70,6 +79,8 @@ export class ControllerPlugin extends BaseControllerPlugin { 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)); + + this.controller.hooks.shutdown.attach(this.name, this.onShutdown.bind(this)); } async onShutdown() { @@ -440,3 +451,7 @@ export class ControllerPlugin extends BaseControllerPlugin { return Promise.all(playerNames.map(name => this.computeResolvedAssignment(name))); } } + +export default async function (context: ControllerPluginContext) { + await new ControllerPlugin(context).init(); +} diff --git a/exp_groups/instance.ts b/exp_groups/instance.ts index 207b969c..594f025d 100644 --- a/exp_groups/instance.ts +++ b/exp_groups/instance.ts @@ -1,4 +1,4 @@ -import { BaseInstancePlugin } from "@clusterio/host"; +import type { Instance, InstancePluginContext } from "@clusterio/host"; import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; @@ -17,7 +17,10 @@ export type IpcPlayerAssignments = { assignments: Record, }; -export class InstancePlugin extends BaseInstancePlugin { +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(); @@ -26,12 +29,23 @@ export class InstancePlugin extends BaseInstancePlugin { // 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; + } + 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)) + + 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)); } async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { @@ -169,3 +183,7 @@ export class InstancePlugin extends BaseInstancePlugin { await this.instance.sendRcon(`/sc exp_groups.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true) } } + +export default async function (context: InstancePluginContext) { + await new InstancePlugin(context).init(); +} diff --git a/exp_groups/web/components/AssignmentForm.tsx b/exp_groups/web/components/AssignmentForm.tsx index f20b21a2..503c2c61 100644 --- a/exp_groups/web/components/AssignmentForm.tsx +++ b/exp_groups/web/components/AssignmentForm.tsx @@ -3,7 +3,7 @@ import { Modal, Form, Select } from "antd"; import { ControlContext, useUsers } from "@clusterio/web_ui"; import * as messages from "../../messages.js"; -import type { WebPlugin } from ".."; +import { useGroups } from ".."; export default function AssignmentForm({ open, setOpen, initial }: { open: boolean, @@ -11,9 +11,8 @@ export default function AssignmentForm({ open, setOpen, initial }: { initial?: messages.AssignmentRecord, }) { const control = useContext(ControlContext); - const plugin = control.plugins.get("exp_groups") as WebPlugin; - const [groups] = plugin.useGroups(); + const [groups] = useGroups(); const [users] = useUsers(); const [form] = Form.useForm(); diff --git a/exp_groups/web/components/AssignmentsTable.tsx b/exp_groups/web/components/AssignmentsTable.tsx index f6997811..2dad4280 100644 --- a/exp_groups/web/components/AssignmentsTable.tsx +++ b/exp_groups/web/components/AssignmentsTable.tsx @@ -4,7 +4,7 @@ import { EditOutlined, SearchOutlined } from "@ant-design/icons"; import { ControlContext, useAccount } from "@clusterio/web_ui"; import { AssignmentDeleteRequest, AssignmentRecord } from "../../messages.js"; -import type { WebPlugin } from ".."; +import { useAssignments, useGroups } from ".."; import AssignmentForm from "./AssignmentForm"; import DeletedConfirm from "./DeleteConfirm"; @@ -13,13 +13,12 @@ const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" export default function AssignmentsTable() { const control = useContext(ControlContext); - const plugin = control.plugins.get("exp_groups") as WebPlugin; const account = useAccount(); const searchInput = useRef(null); - const [assignments, assignmentsSynced] = plugin.useAssignments(); - const [groups, groupsSynced] = plugin.useGroups(); + const [assignments, assignmentsSynced] = useAssignments(); + const [groups, groupsSynced] = useGroups(); const [editing, setEditing] = useState(); const [open, setOpen] = useState(false); diff --git a/exp_groups/web/components/GroupViewPage.tsx b/exp_groups/web/components/GroupViewPage.tsx index 1f5ec8ac..57b909fa 100644 --- a/exp_groups/web/components/GroupViewPage.tsx +++ b/exp_groups/web/components/GroupViewPage.tsx @@ -6,7 +6,7 @@ import { ControlContext, useAccount, useDefaultModPack, PageLayout, PageHeader, import DeletedConfirm from "./DeleteConfirm"; import * as messages from "../../messages.js"; -import type { WebPlugin } from ".."; +import { useGroups } from ".."; const DOMAIN_MAPPING = { "Admin": ["admin", "cheat", "permission", "infinity", "editor", "spawn"], @@ -34,14 +34,13 @@ function getDomain(name: string) { export default function GroupViewPage() { const control = useContext(ControlContext); - const plugin = control.plugins.get("exp_groups") as WebPlugin; const navigate = useNavigate(); const account = useAccount(); const { id } = useParams(); const groupId = Number(id); - const [groups, synced] = plugin.useGroups(); + const [groups, synced] = useGroups(); const group = groups.get(groupId); const [name, setName] = useState(""); diff --git a/exp_groups/web/components/GroupsTable.tsx b/exp_groups/web/components/GroupsTable.tsx index d3f6c9b9..f111119a 100644 --- a/exp_groups/web/components/GroupsTable.tsx +++ b/exp_groups/web/components/GroupsTable.tsx @@ -1,21 +1,18 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import { Table } from "antd"; import { useNavigate } from "react-router-dom"; -import { ControlContext } from "@clusterio/web_ui"; import { GroupRecord } from "../../messages.js"; -import type { WebPlugin } from ".."; +import { useGroups } from ".."; import GroupForm from "./GroupForm"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; export default function GroupsTable() { - const control = useContext(ControlContext); - const plugin = control.plugins.get("exp_groups") as WebPlugin; const navigate = useNavigate(); - const [groups, synced] = plugin.useGroups(); + const [groups, synced] = useGroups(); const [open, setOpen] = useState(false); diff --git a/exp_groups/web/components/RoleMappingForm.tsx b/exp_groups/web/components/RoleMappingForm.tsx index 59918b29..7ebaf8c0 100644 --- a/exp_groups/web/components/RoleMappingForm.tsx +++ b/exp_groups/web/components/RoleMappingForm.tsx @@ -3,7 +3,7 @@ import { Modal, Form, Select, InputNumber, Switch, Alert } from "antd"; import { ControlContext, useRoles } from "@clusterio/web_ui"; import { RoleMappingRecord, RoleMappingCreateRequest, RoleMappingUpdateRequest } from "../../messages.js"; -import type { WebPlugin } from ".."; +import { useGroups } from ".."; export default function RoleMappingForm({ open, setOpen, initial }: { open: boolean, @@ -11,9 +11,8 @@ export default function RoleMappingForm({ open, setOpen, initial }: { initial?: RoleMappingRecord, }) { const control = useContext(ControlContext); - const plugin = control.plugins.get("exp_groups") as WebPlugin; - const [groups] = plugin.useGroups(); + const [groups] = useGroups(); const [roles] = useRoles(); const [form] = Form.useForm(); diff --git a/exp_groups/web/components/RoleMappingsTable.tsx b/exp_groups/web/components/RoleMappingsTable.tsx index ab948960..ecf5329d 100644 --- a/exp_groups/web/components/RoleMappingsTable.tsx +++ b/exp_groups/web/components/RoleMappingsTable.tsx @@ -4,18 +4,17 @@ import { EditOutlined } from "@ant-design/icons"; import { ControlContext, useAccount, useRoles } from "@clusterio/web_ui"; import { RoleMappingRecord, RoleMappingDeleteRequest } from "../../messages.js"; -import type { WebPlugin } from ".."; +import { useGroups, useRoleMappings } from ".."; import RoleMappingForm from "./RoleMappingForm"; import DeletedConfirm from "./DeleteConfirm"; export default function RoleMappingsTable() { const control = useContext(ControlContext); - const plugin = control.plugins.get("exp_groups") as WebPlugin; const account = useAccount(); - const [roleMappings, roleMappingsSynced] = plugin.useRoleMappings(); - const [groups, groupsSynced] = plugin.useGroups(); + const [roleMappings, roleMappingsSynced] = useRoleMappings(); + const [groups, groupsSynced] = useGroups(); const [roles, rolesSynced] = useRoles(); const [editing, setEditing] = useState(); diff --git a/exp_groups/web/index.tsx b/exp_groups/web/index.tsx index 5a2f24cd..1069d388 100644 --- a/exp_groups/web/index.tsx +++ b/exp_groups/web/index.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useSyncExternalStore } from "react"; -import { BaseWebPlugin, PageLayout, PageHeader, useAccount, SectionHeader } from "@clusterio/web_ui"; +import { WebPluginContext, PageLayout, PageHeader, useAccount, SectionHeader } from "@clusterio/web_ui"; import { Button } from "antd"; import * as messages from "../messages.js"; @@ -65,44 +65,46 @@ function ExpGroupsPage() { ; } -export class WebPlugin extends BaseWebPlugin { - groups = new lib.MapSubscriber(messages.GroupUpdatedEvent, this.control); - assignments = new lib.MapSubscriber(messages.ManualAssignmentUpdatedEvent, this.control); - roleMappings = new lib.MapSubscriber(messages.RoleMappingUpdatedEvent, this.control); +let groups: lib.MapSubscriber | undefined; +let assignments: lib.MapSubscriber | undefined; +let roleMappings: lib.MapSubscriber | undefined; - async init() { - this.pages = [ - { - path: "/permission_groups", - sidebarName: "Permission Groups", - permission: (account => account.hasAnyPermission( - "exp_groups.group.list", - "exp_groups.assignment.list", - "exp_groups.role_mapping.list", - )), - content: , - }, - { - path: "/permission_groups/:id/view", - sidebarPath: "/permission_groups", - permission: "exp_groups.group.get", - content: , - }, - ]; - } +export function useGroups() { + const subscribe = useCallback((cb: () => void) => groups!.subscribe(cb), []); + return useSyncExternalStore(subscribe, () => groups!.getSnapshot()); +} - useGroups() { - const subscribe = useCallback((cb: () => void) => this.groups.subscribe(cb), []); - return useSyncExternalStore(subscribe, () => this.groups.getSnapshot()); - } +export function useAssignments() { + const subscribe = useCallback((cb: () => void) => assignments!.subscribe(cb), []); + return useSyncExternalStore(subscribe, () => assignments!.getSnapshot()); +} - useAssignments() { - const subscribe = useCallback((cb: () => void) => this.assignments.subscribe(cb), []); - return useSyncExternalStore(subscribe, () => this.assignments.getSnapshot()); - } +export function useRoleMappings() { + const subscribe = useCallback((cb: () => void) => roleMappings!.subscribe(cb), []); + return useSyncExternalStore(subscribe, () => roleMappings!.getSnapshot()); +} - useRoleMappings() { - const subscribe = useCallback((cb: () => void) => this.roleMappings.subscribe(cb), []); - return useSyncExternalStore(subscribe, () => this.roleMappings.getSnapshot()); - } +export default function registerPlugin(context: WebPluginContext) { + context.control.hooks.pages.attach("exp_groups", () => [ + { + path: "/permission_groups", + sidebarName: "Permission Groups", + permission: (account => account.hasAnyPermission( + "exp_groups.group.list", + "exp_groups.assignment.list", + "exp_groups.role_mapping.list", + )), + content: , + }, + { + path: "/permission_groups/:id/view", + sidebarPath: "/permission_groups", + permission: "exp_groups.group.get", + content: , + }, + ]); + + groups = new lib.MapSubscriber(messages.GroupUpdatedEvent, context.control); + assignments = new lib.MapSubscriber(messages.ManualAssignmentUpdatedEvent, context.control); + roleMappings = new lib.MapSubscriber(messages.RoleMappingUpdatedEvent, context.control); } diff --git a/exp_gui/instance.ts b/exp_gui/instance.ts index e8fa7eeb..95a41ff8 100644 --- a/exp_gui/instance.ts +++ b/exp_gui/instance.ts @@ -1,7 +1,3 @@ -import * as lib from "@clusterio/lib"; -import { BaseInstancePlugin } from "@clusterio/host"; - -export class InstancePlugin extends BaseInstancePlugin { - // This class is empty because an instance plugin must be defined for a module to be injected - // This requirement may change in the future to allow for standalone modules -} +// Empty because an instance entrypoint must be defined for a module to be injected +// This requirement may change in the future to allow for standalone modules +export default async function () {} diff --git a/exp_legacy/instance.ts b/exp_legacy/instance.ts index e8fa7eeb..95a41ff8 100644 --- a/exp_legacy/instance.ts +++ b/exp_legacy/instance.ts @@ -1,7 +1,3 @@ -import * as lib from "@clusterio/lib"; -import { BaseInstancePlugin } from "@clusterio/host"; - -export class InstancePlugin extends BaseInstancePlugin { - // This class is empty because an instance plugin must be defined for a module to be injected - // This requirement may change in the future to allow for standalone modules -} +// Empty because an instance entrypoint must be defined for a module to be injected +// This requirement may change in the future to allow for standalone modules +export default async function () {} diff --git a/exp_reports/controller.ts b/exp_reports/controller.ts index 21ec80c6..53c5adb3 100644 --- a/exp_reports/controller.ts +++ b/exp_reports/controller.ts @@ -1,11 +1,20 @@ -import { BaseControllerPlugin } from "@clusterio/controller"; +import type { Controller, 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 extends BaseControllerPlugin { +export class ControllerPlugin { + controller: Controller; + logger: lib.Logger; + name: string; reports!: 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"); @@ -23,6 +32,8 @@ export class ControllerPlugin extends BaseControllerPlugin { 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)); + + this.controller.hooks.shutdown.attach(this.name, this.onShutdown.bind(this)); } async onShutdown() { @@ -141,3 +152,7 @@ export class ControllerPlugin extends BaseControllerPlugin { } } } + +export default async function (context: ControllerPluginContext) { + await new ControllerPlugin(context).init(); +} diff --git a/exp_reports/instance.ts b/exp_reports/instance.ts index 61c4c38c..e5ae3a43 100644 --- a/exp_reports/instance.ts +++ b/exp_reports/instance.ts @@ -1,4 +1,5 @@ -import { BaseInstancePlugin } from "@clusterio/host"; +import type { Instance, InstancePluginContext } from "@clusterio/host"; +import type * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; /** Sent by the lua side when a player reports another. */ @@ -28,7 +29,17 @@ 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 extends BaseInstancePlugin { +export class InstancePlugin { + instance: Instance; + logger: lib.Logger; + name: string; + + constructor(context: InstancePluginContext) { + this.instance = context.instance; + this.logger = context.logger; + this.name = 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)); @@ -93,3 +104,7 @@ export class InstancePlugin extends BaseInstancePlugin { ); } } + +export default async function (context: InstancePluginContext) { + await new InstancePlugin(context).init(); +} diff --git a/exp_reports/test/controller.test.js b/exp_reports/test/controller.test.js index 8097ee5b..b2dec669 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 { ControllerPlugin } from "../dist/node/controller.js"; +import entrypoint, { ControllerPlugin } from "../dist/node/controller.js"; import * as messages from "../dist/node/messages.js"; import { plugin as pluginDeclaration } from "../dist/node/index.js"; @@ -46,13 +46,23 @@ async function startPlugin(t2, { config = {} } = {}) { broadcast(event); }; - const plugin = new ControllerPlugin({ name: "exp_reports" }, controller, undefined, logger); + const plugin = new ControllerPlugin({ plugin: { name: "exp_reports" }, controller, logger }); plugin.logger = { ...logger, warn: message => state.warnings.push(message) }; plugin.postWebhook = async (url, body) => { state.posts.push({ url, body }); }; await plugin.init(); return { plugin, controller, state }; } +t.test("entrypoint", async t2 => { + const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() }); + const controller = new Controller(logger, [], controllerConfig); + await entrypoint({ controller, logger, plugin: { name: "exp_reports" }, metrics: undefined }); + for (const hook of ["shutdown"]) { + t2.ok([...controller.hooks[hook].attached].includes("exp_reports"), `attached to ${hook}`); + } + 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); diff --git a/exp_reports/test/instance.test.js b/exp_reports/test/instance.test.js index 6fdedcc8..e579dcec 100644 --- a/exp_reports/test/instance.test.js +++ b/exp_reports/test/instance.test.js @@ -10,6 +10,8 @@ for (const Message of pluginDeclaration.messages) { lib.Link.register(Message); } +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + class TestConnector extends lib.BaseConnector { constructor() { super(lib.Address.fromShorthand({ instanceId: 1 }), lib.Address.fromShorthand({ hostId: 1 })); @@ -61,7 +63,7 @@ async function startPlugin(t2, { reports = [report(1, "bob", "alice"), report(2, instance.notifyStatus("running"); state.sent.length = 0; - const plugin = new InstancePlugin({ name: "exp_reports" }, instance, {}); + const plugin = new InstancePlugin({ plugin: { name: "exp_reports" }, instance, host: {}, logger }); await plugin.init(); return { plugin, instance, state }; } diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts index 6d5b46f5..2299de23 100644 --- a/exp_roles/controller.ts +++ b/exp_roles/controller.ts @@ -1,11 +1,14 @@ -import { BaseControllerPlugin, InstanceRecord, type Controller } from "@clusterio/controller"; +import type { Controller, ControllerPluginContext, InstanceRecord } from "@clusterio/controller"; import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; import * as path from "node:path"; const loaded = new WeakMap(); -export class ControllerPlugin extends BaseControllerPlugin { +export class ControllerPlugin { + controller: Controller; + logger: lib.Logger; + name: string; roleMeta!: lib.SubscribableDatastore; /** The plugin loaded on a controller, for the exp_scenario seed. */ @@ -13,6 +16,12 @@ export class ControllerPlugin extends BaseControllerPlugin { return loaded.get(controller); } + constructor(context: ControllerPluginContext) { + this.controller = context.controller; + this.logger = context.logger; + this.name = context.plugin.name; + } + async init() { loaded.set(this.controller, this); const databaseDirectory = this.controller.config.get("controller.database_directory"); @@ -45,6 +54,11 @@ export class ControllerPlugin extends BaseControllerPlugin { this.controller.handle(messages.AssignmentListRequest, this.handleAssignmentListRequest.bind(this)); this.controller.handle(messages.AssignmentUpdateRequest, this.handleAssignmentUpdateRequest.bind(this)); + + 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)); } async onShutdown() { @@ -319,3 +333,7 @@ export class ControllerPlugin extends BaseControllerPlugin { } } } + +export default async function (context: ControllerPluginContext) { + await new ControllerPlugin(context).init(); +} diff --git a/exp_roles/instance.ts b/exp_roles/instance.ts index d57a5819..488613d6 100644 --- a/exp_roles/instance.ts +++ b/exp_roles/instance.ts @@ -1,4 +1,4 @@ -import { BaseInstancePlugin } from "@clusterio/host"; +import type { Instance, InstancePluginContext } from "@clusterio/host"; import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; @@ -9,11 +9,25 @@ export type IpcAssignmentUpdate = { unassign: number[] | undefined, }; -export class InstancePlugin extends BaseInstancePlugin { +export class InstancePlugin { + instance: Instance; + logger: lib.Logger; + name: string; + + constructor(context: InstancePluginContext) { + this.instance = context.instance; + this.logger = context.logger; + this.name = 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)); + + const hooks = this.instance.hooks; + hooks.instanceConfigFieldChanged.attach(this.name, this.onInstanceConfigFieldChanged.bind(this)); + hooks.start.attach(this.name, this.onStart.bind(this)); } get syncMode() { @@ -106,3 +120,7 @@ export class InstancePlugin extends BaseInstancePlugin { ); } } + +export default async function (context: InstancePluginContext) { + await new InstancePlugin(context).init(); +} diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js index 0549c733..1231d093 100644 --- a/exp_roles/test/controller.test.js +++ b/exp_roles/test/controller.test.js @@ -1,7 +1,7 @@ import t from "tap"; import * as lib from "@clusterio/lib"; import { Controller } from "@clusterio/controller"; -import { ControllerPlugin } from "../dist/node/controller.js"; +import entrypoint, { ControllerPlugin } from "../dist/node/controller.js"; import * as messages from "../dist/node/messages.js"; // The controller validates message classes against the link registry @@ -44,13 +44,23 @@ async function startPlugin(t2, { roles = [] } = {}) { permissionsUpdated(user); }; - const plugin = new ControllerPlugin({ name: "exp_roles" }, controller, undefined, logger); + const plugin = new ControllerPlugin({ plugin: { name: "exp_roles" }, controller, logger }); await plugin.init(); return { plugin, controller, state }; } const role = (id, name, permissions = []) => new lib.Role(id, name, "", new Set(permissions)); +t.test("entrypoint", async t2 => { + const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() }); + const controller = new Controller(logger, [], controllerConfig); + await entrypoint({ controller, logger, plugin: { name: "exp_roles" }, metrics: undefined }); + for (const hook of ["shutdown", "controllerConfigFieldChanged", "playerEvent"]) { + t2.ok([...controller.hooks[hook].attached].includes("exp_roles"), `attached to ${hook}`); + } + await controller.hooks.shutdown.invoke(); +}); + t.test("class ControllerPlugin", t2 => { t2.test(".init() and .sweepRoleMeta() manage the role properties", async t3 => { const { plugin } = await startPlugin(t3, { roles: [role(0, "Cluster Admin"), role(5, "Moderator")] }); diff --git a/exp_roles/test/instance.test.js b/exp_roles/test/instance.test.js index e0651358..302e8528 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 { InstancePlugin } from "../dist/node/instance.js"; +import entrypoint, { InstancePlugin } from "../dist/node/instance.js"; import { plugin as pluginDeclaration } from "../dist/node/index.js"; import * as messages from "../dist/node/messages.js"; @@ -69,7 +69,7 @@ async function startPlugin(t2, { syncMode = "bidirectional", roles = sampleRoles return undefined; }; - const plugin = new InstancePlugin({ name: "exp_roles" }, instance, {}); + const plugin = new InstancePlugin({ plugin: { name: "exp_roles" }, instance, host: {}, logger }); plugin.logger = { ...logger, warn: message => state.warnings.push(message) }; await plugin.init(); return { plugin, state }; @@ -81,6 +81,20 @@ function decodeRcon(command) { return { receiver: match[1], payload: JSON.parse(match[2]) }; } +t.test("entrypoint", async t2 => { + const instanceConfig = new lib.InstanceConfig("host"); + instanceConfig.set("instance.id", 1); + instanceConfig.set("instance.name", "test"); + const instance = new Instance( + { assignGamePort: () => 1 }, new TestConnector(), t2.testdir(), "factorioDir", instanceConfig + ); + instance.server = { handle: () => {} }; + await entrypoint({ instance, host: {}, logger, plugin: { name: "exp_roles" } }); + for (const hook of ["instanceConfigFieldChanged", "start"]) { + t2.ok([...instance.hooks[hook].attached].includes("exp_roles"), `attached to ${hook}`); + } +}); + t.test("class InstancePlugin", t2 => { t2.test(".onStart() subscribes and initialises the lua module", async t3 => { const { plugin, state } = await startPlugin(t3, { syncMode: "enabled" }); diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index cd19bd96..2914f98c 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -1,5 +1,5 @@ import * as lib from "@clusterio/lib"; -import { BaseControllerPlugin } from "@clusterio/controller"; +import type { Controller, 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"; @@ -7,7 +7,15 @@ import { ControllerPlugin as GroupsPlugin } from "@expcluster/permission-groups/ import * as messages from "./messages.js"; import { SeedRole, SeedGroup, seedRoles, seedGroups, flattenSeedPermissions } from "./seed.js"; -export class ControllerPlugin extends BaseControllerPlugin { +export class ControllerPlugin { + controller: Controller; + logger: lib.Logger; + + constructor(context: ControllerPluginContext) { + this.controller = context.controller; + this.logger = context.logger; + } + async init() { this.controller.handle(messages.SeedRequest, this.handleSeedRequest.bind(this)); } @@ -148,6 +156,10 @@ export class ControllerPlugin extends BaseControllerPlugin { } } +export default async function (context: ControllerPluginContext) { + await new ControllerPlugin(context).init(); +} + function newId(datastore: { has(id: number): boolean }) { let id = Math.random() * 2 ** 31 | 0; while (datastore.has(id)) { diff --git a/exp_scenario/instance.ts b/exp_scenario/instance.ts index a1f3d1e4..95a41ff8 100644 --- a/exp_scenario/instance.ts +++ b/exp_scenario/instance.ts @@ -1,10 +1,3 @@ -import * as lib from "@clusterio/lib"; -import { BaseInstancePlugin } from "@clusterio/host"; - -export class InstancePlugin extends BaseInstancePlugin { - async init() { - } - - async onStart() { - } -} +// Empty because an instance entrypoint must be defined for a module to be injected +// This requirement may change in the future to allow for standalone modules +export default async function () {} diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index 097365ab..befc28df 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -40,12 +40,12 @@ async function startPlugin(t2, { withPlugins = true } = {}) { if (withPlugins) { for (const [name, Plugin] of [["exp_roles", RolesPlugin], ["exp_groups", GroupsPlugin]]) { - const plugin = new Plugin({ name }, controller, undefined, logger); + const plugin = new Plugin({ plugin: { name }, controller, logger }); await plugin.init(); } } - const plugin = new ControllerPlugin({ name: "exp_scenario" }, controller, undefined, logger); + const plugin = new ControllerPlugin({ plugin: { name: "exp_scenario" }, controller, logger }); await plugin.init(); return { plugin, controller }; } diff --git a/exp_scenario/web/index.tsx b/exp_scenario/web/index.tsx index cecbd7f0..60e26971 100644 --- a/exp_scenario/web/index.tsx +++ b/exp_scenario/web/index.tsx @@ -1,11 +1,9 @@ -import { BaseWebPlugin } from "@clusterio/web_ui"; +import { WebPluginContext } from "@clusterio/web_ui"; import Seed from "./components/Seed"; -export class WebPlugin extends BaseWebPlugin { - async init() { - this.componentExtra = { - RolesPage: Seed, - }; - } +export default function registerPlugin(context: WebPluginContext) { + context.control.hooks.extensionComponents.attach("exp_scenario", () => ({ + RolesPage: Seed, + })); } diff --git a/exp_server_ups/instance.ts b/exp_server_ups/instance.ts index 1783acc3..4ae128e6 100644 --- a/exp_server_ups/instance.ts +++ b/exp_server_ups/instance.ts @@ -1,10 +1,27 @@ import * as lib from "@clusterio/lib"; -import { BaseInstancePlugin } from "@clusterio/host"; +import type { Instance, InstancePluginContext } from "@clusterio/host"; -export class InstancePlugin extends BaseInstancePlugin { +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; + } + + 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)); + } + async onStart() { if (!this.instance.config.get("factorio.settings")["auto_pause"]) { this.setInterval(); @@ -56,7 +73,7 @@ export class InstancePlugin extends BaseInstancePlugin { } try { - const newGameTime = await this.sendRcon(`/_rcon return exp_server_ups.refresh(${ups})`); + const newGameTime = await this.instance.sendRcon(`/_rcon return exp_server_ups.refresh(${ups})`, false, this.name); this.gameTimes.push(Number(newGameTime)); } catch (error: any) { this.logger.error(`Failed to receive new game time: ${error}`); @@ -67,3 +84,7 @@ export class InstancePlugin extends BaseInstancePlugin { } } } + +export default async function (context: InstancePluginContext) { + new InstancePlugin(context).init(); +} diff --git a/exp_util/instance.ts b/exp_util/instance.ts index e8fa7eeb..95a41ff8 100644 --- a/exp_util/instance.ts +++ b/exp_util/instance.ts @@ -1,7 +1,3 @@ -import * as lib from "@clusterio/lib"; -import { BaseInstancePlugin } from "@clusterio/host"; - -export class InstancePlugin extends BaseInstancePlugin { - // This class is empty because an instance plugin must be defined for a module to be injected - // This requirement may change in the future to allow for standalone modules -} +// Empty because an instance entrypoint must be defined for a module to be injected +// This requirement may change in the future to allow for standalone modules +export default async function () {}