Files
factorio-expcluster/exp_scenario/test/controller.test.js
T
bbassieandClaude Opus 5.5 6821d62c10 Keep join messages on the controller and print them from the instance
Join messages were stored in exp_legacy's PlayerData, which nothing
ever synced. The controller now keeps a players.json datastore of
per-player settings keyed by player name, starting with the join
message, and saves it on the save and shutdown hooks. Records with
nothing set are removed.

When a player joins, the instance plugin fetches their record and
prints the message to everyone over rcon in the player's colour, so no
lua is needed to show it. The in-game /set-join-message and
/remove-join-message commands send ipc which the instance forwards to
the controller; /set-join-message without a message asks the instance,
which prints the stored message back to the player.

Greetings is now a feature. Disabling it stops the greeting, the
commands, and the printed join messages. The private greeting with the
community name and Discord link is shown to every joining player, even
those with a join message, since one is a welcome for the player and
the other an announcement to everyone else.

Two controller permissions cover the web interface:
exp_scenario.join_message.set for your own message and .set_other for
anyone's, checked against the requesting user the same way
exp_discord_auth checks unlinking. The seed grants them to Supporter
and Moderator alongside the in-game commands. The user page gets a
Scenario section showing the message with an edit button for users who
may change it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:10:31 +00:00

311 lines
15 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("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("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();
});