mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-15 16:24:52 +09:00
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>
311 lines
15 KiB
JavaScript
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();
|
|
});
|