mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-13 16:14:52 +09:00
exp_legacy recorded each player's locale in PlayerData statistics which nothing read. The lua side now sends one ipc event with the player's locale when they join and when they change language, the instance forwards it, and the controller stores it on the player record. The Scenario section of the user page shows the language by name with a flag made from regional indicator symbols. Locales with a region use it directly, the rest map to a country through a small table covering Factorio's locale list. Arabic, Catalan, Basque, and Esperanto have no single country and get no flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
323 lines
15 KiB
JavaScript
323 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("PlayerLocaleEvent stores the locale next to the join message", async t3 => {
|
|
const { send } = await startPlugin(t3, { withPlugins: false });
|
|
await send(new messages.JoinMessageSetRequest("Alice", "Hi"));
|
|
await send(new messages.PlayerLocaleEvent("Alice", "pt-BR"));
|
|
const record = await send(new messages.PlayerGetRequest("Alice"));
|
|
t3.strictSame([record.joinMessage, record.locale], ["Hi", "pt-BR"]);
|
|
|
|
await send(new messages.JoinMessageSetRequest("Alice", null));
|
|
const cleared = await send(new messages.PlayerGetRequest("Alice"));
|
|
t3.equal(cleared.locale, "pt-BR", "clearing the message keeps the locale");
|
|
});
|
|
|
|
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();
|
|
});
|