Files
BastiaanandClaude Opus 5.5 20ca539af3 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 <noreply@anthropic.com>
2026-10-25 21:57:21 +00:00

334 lines
16 KiB
JavaScript

import t from "tap";
import path from "node:path";
import * as lib from "@clusterio/lib";
import { Controller } from "@clusterio/controller";
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";
import * as roles from "@expcluster/roles";
import * as groups from "@expcluster/permission-groups";
import { ControllerPlugin as RolesPlugin } from "@expcluster/roles/dist/node/controller.js";
import { ControllerPlugin as GroupsPlugin } from "@expcluster/permission-groups/dist/node/controller.js";
import { GroupRecord, GroupPermissions, RoleMappingRecord } from "@expcluster/permission-groups";
import { plugin } from "../dist/node/index.js";
// Registering the plugin defines the permissions the seed grants
lib.registerPluginPermissions([plugin]);
// The controller validates message classes against the link registry
for (const Message of [...plugin.messages, ...roles.plugin.messages, ...groups.plugin.messages]) {
lib.Link.register(Message);
}
const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
// Silence the singleton logger to avoid polluting the test output
lib.logger.silent = true;
t.after(() => {
lib.logger.silent = false;
});
/** 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,
"controller.default_role_id": lib.Role.DefaultPlayerRoleId,
});
const controller = new Controller(logger, [], controllerConfig);
controller.roles.set(new lib.Role(0, "Cluster Admin", "", new Set(["core.admin"])));
controller.roles.set(new lib.Role(1, "Player", "", new Set()));
if (withPlugins) {
for (const [name, Plugin] of [["exp_roles", RolesPlugin], ["exp_groups", GroupsPlugin]]) {
await Plugin.fromContext({ plugin: { name }, controller, logger });
}
}
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(),
save: () => controller.hooks.save.invoke(),
/** Call the handler of a message as it would be called for a request or event */
send: message => handlers.get(message.constructor)(message),
};
}
/** 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("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(
seed(),
{ message: "Seeding requires the exp_roles and exp_groups plugins" },
"the request is rejected",
);
});
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 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 seed();
t3.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles");
});
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 seed();
t3.strictSame(groupsPlugin.groups.size, seedGroups.length, "every seed group exists");
const restricted = byName(groupsPlugin.groups, "Restricted");
t3.strictSame(restricted.permissions.isBlacklist, false, "the group only allows the listed actions");
t3.strictSame(restricted.permissions.permissions, ["write_to_console"], "the actions match the seed");
const admin = byName(groupsPlugin.groups, "Admin");
groupsPlugin.groups.set(new GroupRecord(admin.id, admin.name, new GroupPermissions(true, [])));
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"),
"seeding again resets the actions",
);
});
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 seed();
const mapped = seedRoles.filter(role => role.group !== undefined);
t3.strictSame(groupsPlugin.roleMappings.size, mapped.length, "every role with a group is mapped");
const mappingOf = name => [...groupsPlugin.roleMappings.values()]
.find(mapping => mapping.roleIds.has(byName(controller.roles, name).id));
const groupOf = name => groupsPlugin.groups.get(mappingOf(name).groupId).name;
t3.strictSame(groupOf("Player"), "Guest", "the default role maps to the guest group");
t3.strictSame(groupOf("Jail"), "Restricted", "jail maps to the restricted group");
t3.ok(mappingOf("Jail").priority > mappingOf("Senior Administrator").priority, "jail outranks every role");
t3.ok(mappingOf("Moderator").priority > mappingOf("Veteran").priority, "the role order sets the rank");
t3.ok(mappingOf("Veteran").priority > mappingOf("Player").priority, "the default role ranks lowest");
t3.strictSame(mappingOf("Cluster Admin"), undefined, "the admin role keeps the factorio default group");
const priorities = [...groupsPlugin.roleMappings.values()].map(mapping => mapping.priority);
t3.strictSame(priorities.length, new Set(priorities).size, "priorities are unique");
});
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 seed();
const first = new Map([...groupsPlugin.roleMappings.values()].map(mapping => [mapping.id, mapping.priority]));
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("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("startup drops stored values and features which no longer exist", async t3 => {
const databaseDirectory = t3.testdir();
await storeFeatures(databaseDirectory, [
new messages.FeatureRecord("afk_kick", false, { afk_minutes: 5, trust_time: 600 }),
new messages.FeatureRecord("gone", true, {}),
]);
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");
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("FeatureUpdateRequest stores the values which differ from the default", async t3 => {
const { controller, listFeatures, updateFeature, featureSubscription } = await startPlugin(t3, { withPlugins: false });
const broadcasts = [];
controller.subscriptions.broadcast = event => broadcasts.push(event);
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((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 featureSubscription({ lastRequestTimeMs: record.updatedAtMs - 1 });
t3.strictSame(subscription.updates.map(update => update.id), ["death_markers"], "subscribers catch up on the change");
});
t2.test("FeatureUpdateRequest rejects invalid values", async t3 => {
const { updateFeature } = await startPlugin(t3, { withPlugins: false });
await t3.rejects(
updateFeature(new messages.FeatureUpdateRequest("death_markers", true, { unknown: 1 })),
{ message: "Feature death_markers has no setting unknown" },
);
});
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 });
await updateFeature(new messages.FeatureUpdateRequest("afk_kick", false, { afk_minutes: 5 }));
await shutdown();
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");
});
t2.end();
});
/** Read players.json as the next run would. */
async function loadPlayers(databaseDirectory) {
return new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "exp_scenario", "players.json"),
messages.PlayerRecord.fromJSON.bind(messages.PlayerRecord),
).load();
}
t.test("player settings", t2 => {
t2.test("PlayerGetRequest returns an empty record for unknown players", async t3 => {
const { send } = await startPlugin(t3, { withPlugins: false });
const record = await send(new messages.PlayerGetRequest("Alice"));
t3.equal(record.id, "Alice");
t3.strictSame(record.toJSON(), { id: "Alice" }, "nothing is set");
});
t2.test("JoinMessageSetRequest sets, trims, and clears the message", async t3 => {
const { send } = await startPlugin(t3, { withPlugins: false });
await send(new messages.JoinMessageSetRequest("Alice", " Hello there "));
t3.equal((await send(new messages.PlayerGetRequest("Alice"))).joinMessage, "Hello there");
await send(new messages.JoinMessageSetRequest("Alice", null));
t3.equal((await send(new messages.PlayerGetRequest("Alice"))).joinMessage, undefined, "null clears it");
await send(new messages.JoinMessageSetRequest("Alice", "Hi"));
await send(new messages.JoinMessageSetRequest("Alice", " "));
t3.equal((await send(new messages.PlayerGetRequest("Alice"))).joinMessage, undefined, "blank clears it");
});
t2.test("players are saved on the save hook and empty records are removed", async t3 => {
const databaseDirectory = t3.testdir();
const { send, save, shutdown } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
await send(new messages.JoinMessageSetRequest("Alice", "Hi"));
await send(new messages.JoinMessageSetRequest("Bob", "Hey"));
await send(new messages.JoinMessageSetRequest("Bob", null));
await save();
const stored = await loadPlayers(databaseDirectory);
t3.strictSame([...stored.keys()], ["Alice"], "only players with a setting are stored");
t3.equal(stored.get("Alice").joinMessage, "Hi");
await send(new messages.JoinMessageSetRequest("Alice", "Bye"));
await shutdown();
const restarted = await startPlugin(t3, { withPlugins: false, databaseDirectory });
const record = await restarted.send(new messages.PlayerGetRequest("Alice"));
t3.equal(record.joinMessage, "Bye", "kept across restarts");
});
t2.test("PlayerToolbarEvent stores the toolbar and it is saved", async t3 => {
const databaseDirectory = t3.testdir();
const { send, save } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
await send(new messages.PlayerToolbarEvent("Alice", "abc"));
t3.equal((await send(new messages.PlayerGetRequest("Alice"))).toolbar, "abc");
await save();
const stored = await loadPlayers(databaseDirectory);
const alice = stored.get("Alice");
t3.strictSame(alice.toJSON(), { id: "Alice", toolbar: "abc", updated_at_ms: alice.updatedAtMs });
});
t2.test("permissions", async t3 => {
const user = (name, granted = []) => ({
name,
checkPermission(permission) {
if (!granted.includes(permission)) {
throw new lib.PermissionError("denied");
}
},
});
const request = name => ({ data: { player_name: name } });
const set = messages.JoinMessageSetRequest.permission;
t3.throws(() => set(user("Alice"), request("alice")), "own message needs the set permission");
t3.doesNotThrow(() => set(user("Alice", ["exp_scenario.join_message.set"]), request("alice")), "own message");
t3.throws(
() => set(user("Alice", ["exp_scenario.join_message.set"]), request("Bob")),
"someone else's message needs set_other",
);
t3.doesNotThrow(
() => set(user("Alice", ["exp_scenario.join_message.set_other"]), request("Bob")), "with set_other",
);
const get = messages.PlayerGetRequest.permission;
t3.doesNotThrow(() => get(user("Alice"), request("alice")), "own settings");
t3.throws(() => get(user("Alice"), request("Bob")), "someone else's");
t3.doesNotThrow(() => get(user("Alice", ["core.user.get"]), request("Bob")), "with core.user.get");
});
t2.end();
});