From 20ca539af362d794837d3a3f7afb3a121550a68a Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:57:21 +0100 Subject: [PATCH] Save controller plugin data on the save hook (#513) exp_groups and exp_roles only saved their datastores on shutdown, and exp_scenario saved the player store but not the feature store on the save hook. The controller runs the save hook on its autosave interval, so a crash or kill lost every change since the controller started. Co-authored-by: Claude Opus 5.5 --- exp_groups/controller.ts | 5 +++-- exp_roles/controller.ts | 5 +++-- exp_roles/test/controller.test.js | 16 +++++++++++++++- exp_scenario/controller.ts | 1 + exp_scenario/test/controller.test.js | 11 +++++++++++ 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/exp_groups/controller.ts b/exp_groups/controller.ts index 63c17413..8e143dbd 100644 --- a/exp_groups/controller.ts +++ b/exp_groups/controller.ts @@ -80,11 +80,12 @@ export class ControllerPlugin { controller.handle(messages.RoleMappingGetRequest, plugin.handleRoleMappingGetRequest.bind(plugin)); controller.handle(messages.RoleMappingListRequest, plugin.handleRoleMappingListRequest.bind(plugin)); - controller.hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin)); + controller.hooks.save.attach(plugin.name, plugin.onSave.bind(plugin)); + controller.hooks.shutdown.attach(plugin.name, plugin.onSave.bind(plugin)); return plugin; } - async onShutdown() { + async onSave() { await Promise.all([ this.groups.save(), this.manualAssignments.save(), diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts index f2ed29f3..fb317696 100644 --- a/exp_roles/controller.ts +++ b/exp_roles/controller.ts @@ -55,13 +55,14 @@ export class ControllerPlugin { controller.handle(messages.AssignmentUpdateRequest, plugin.handleAssignmentUpdateRequest.bind(plugin)); const hooks = controller.hooks; - hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin)); + hooks.save.attach(plugin.name, plugin.onSave.bind(plugin)); + hooks.shutdown.attach(plugin.name, plugin.onSave.bind(plugin)); hooks.controllerConfigFieldChanged.attach(plugin.name, plugin.onControllerConfigFieldChanged.bind(plugin)); hooks.playerEvent.attach(plugin.name, plugin.onPlayerEvent.bind(plugin)); return plugin; } - async onShutdown() { + async onSave() { await this.roleMeta.save(); } diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js index 652dca56..9da7341b 100644 --- a/exp_roles/test/controller.test.js +++ b/exp_roles/test/controller.test.js @@ -1,4 +1,6 @@ import t from "tap"; +import fs from "node:fs/promises"; +import path from "node:path"; import * as lib from "@clusterio/lib"; import { Controller } from "@clusterio/controller"; import entrypoint, { ControllerPlugin } from "../dist/node/controller.js"; @@ -54,7 +56,7 @@ 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"]) { + for (const hook of ["save", "shutdown", "controllerConfigFieldChanged", "playerEvent"]) { t2.ok([...controller.hooks[hook].attached].includes("exp_roles"), `attached to ${hook}`); } await controller.hooks.shutdown.invoke(); @@ -208,6 +210,18 @@ t.test("class ControllerPlugin", t2 => { t3.strictSame(plugin.roleMeta.get(5).shortHand, "Mod", "the properties are stored"); }); + t2.test("role properties are written on the save hook", async t3 => { + const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player"), role(5, "Moderator")] }); + await plugin.handleRoleMetaUpdateRequest(new messages.RoleMetaUpdateRequest( + new messages.RoleMetaRecord(5, 2, 0, "Mod"), + )); + await controller.hooks.save.invoke(); + + const file = path.join(controller.config.get("controller.database_directory"), "exp_roles", "role_meta.json"); + const stored = JSON.parse(await fs.readFile(file, "utf8")); + t3.ok(stored.some(record => record.id === 5 && record.short_hand === "Mod"), "the update is on disk"); + }); + t2.test(".handleAssignmentListRequest() returns the current assignments", async t3 => { const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player"), role(5, "Moderator")], diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index 305d643a..6c391805 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -239,6 +239,7 @@ export default async function (context: ControllerPluginContext) { }); controller.hooks.save.attach(plugin.name, async () => { + await featureStore.save(); await playerStore.save(); }); diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index b8fc495e..04e77678 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -216,6 +216,17 @@ t.test("controller plugin", t2 => { ); }); + t2.test("features are saved on the save hook", async t3 => { + const databaseDirectory = t3.testdir(); + const { updateFeature, save } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + await updateFeature(new messages.FeatureUpdateRequest("afk_kick", false, { afk_minutes: 5 })); + await save(); + + 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 saved record is loaded"); + }); + t2.test("stored features are kept across restarts", async t3 => { const databaseDirectory = t3.testdir(); const { updateFeature, shutdown } = await startPlugin(t3, { withPlugins: false, databaseDirectory });