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(); });