mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-12 07:54:52 +09:00
clusterio#999 removed RequestError. Handlers now throw NotFoundError, ValidationError, ConflictError or UnavailableError, and exp_auto_restart treats any ClientError from the host as a final refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
import type { Controller, ControllerPluginContext, InstanceRecord } from "@clusterio/controller";
|
|
import * as lib from "@clusterio/lib";
|
|
import * as messages from "./messages.js";
|
|
import * as path from "node:path";
|
|
|
|
const loaded = new WeakMap<Controller, ControllerPlugin>();
|
|
|
|
export class ControllerPlugin {
|
|
/** The plugin loaded on a controller, for the exp_scenario seed. */
|
|
static get(controller: Controller) {
|
|
return loaded.get(controller);
|
|
}
|
|
|
|
private constructor(
|
|
public controller: Controller,
|
|
public logger: lib.Logger,
|
|
public name: string,
|
|
public roleMeta: lib.SubscribableDatastore<messages.RoleMetaRecord>,
|
|
) {}
|
|
|
|
static async fromContext(context: ControllerPluginContext) {
|
|
const controller = context.controller;
|
|
const databaseDirectory = controller.config.get("controller.database_directory");
|
|
|
|
const roleMeta = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
|
path.join(databaseDirectory, "exp_roles", "role_meta.json"),
|
|
messages.RoleMetaRecord.fromJSON.bind(messages.RoleMetaRecord),
|
|
));
|
|
|
|
const plugin = new ControllerPlugin(controller, context.logger, context.plugin.name, roleMeta);
|
|
loaded.set(controller, plugin);
|
|
|
|
// The datastore can be out of step with the roles, either because the
|
|
// plugin was installed after they were created or because it was
|
|
// uninstalled while they were deleted
|
|
plugin.ensureRoleMeta();
|
|
plugin.sweepRoleMeta();
|
|
plugin.applyAutoAssign();
|
|
|
|
controller.subscriptions.handle(messages.RoleUpdatedEvent, plugin.handleRoleSubscription.bind(plugin));
|
|
controller.subscriptions.handle(
|
|
messages.AssignmentUpdatedEvent, plugin.handleAssignmentSubscription.bind(plugin)
|
|
);
|
|
|
|
roleMeta.on("update", plugin.roleMetaUpdated.bind(plugin));
|
|
controller.roles.on("update", plugin.rolesUpdated.bind(plugin));
|
|
controller.users.records.on("update", plugin.usersUpdated.bind(plugin));
|
|
|
|
controller.handle(messages.RoleListRequest, plugin.handleRoleListRequest.bind(plugin));
|
|
controller.handle(messages.RoleMetaUpdateRequest, plugin.handleRoleMetaUpdateRequest.bind(plugin));
|
|
|
|
controller.handle(messages.AssignmentListRequest, plugin.handleAssignmentListRequest.bind(plugin));
|
|
controller.handle(messages.AssignmentUpdateRequest, plugin.handleAssignmentUpdateRequest.bind(plugin));
|
|
|
|
const hooks = controller.hooks;
|
|
hooks.save.attach(plugin.name, plugin.onSave.bind(plugin));
|
|
hooks.shutdown.attach(plugin.name, plugin.onSave.bind(plugin));
|
|
hooks.controllerConfigFieldChanged.attach(plugin.name, plugin.onControllerConfigFieldChanged.bind(plugin));
|
|
hooks.playerEvent.attach(plugin.name, plugin.onPlayerEvent.bind(plugin));
|
|
return plugin;
|
|
}
|
|
|
|
async onSave() {
|
|
await this.roleMeta.save();
|
|
}
|
|
|
|
/*
|
|
Role properties
|
|
*/
|
|
|
|
/** Create properties for any role which does not have them yet, ordered after every existing role. */
|
|
ensureRoleMeta() {
|
|
const created = [];
|
|
let nextOrder = 0;
|
|
for (const meta of this.roleMeta.values()) {
|
|
nextOrder = Math.max(nextOrder, meta.order);
|
|
}
|
|
|
|
for (const role of this.controller.roles.values()) {
|
|
if (!this.roleMeta.has(role.id)) {
|
|
nextOrder += 1;
|
|
created.push(new messages.RoleMetaRecord(role.id, nextOrder));
|
|
}
|
|
}
|
|
|
|
if (created.length) {
|
|
this.roleMeta.setMany(created);
|
|
}
|
|
|
|
return created;
|
|
}
|
|
|
|
/**
|
|
* Delete properties left behind by roles which no longer exist.
|
|
*
|
|
* Without this they would be picked up by whichever role is next given the
|
|
* same id, which happens when a role is deleted while the plugin is not
|
|
* running.
|
|
*/
|
|
sweepRoleMeta() {
|
|
const orphaned = [];
|
|
for (const meta of this.roleMeta.valuesMutable()) {
|
|
if (!this.controller.roles.has(meta.id)) {
|
|
orphaned.push(meta);
|
|
}
|
|
}
|
|
|
|
if (orphaned.length) {
|
|
this.roleMeta.deleteMany(orphaned);
|
|
}
|
|
|
|
return orphaned;
|
|
}
|
|
|
|
/** Combine a clusterio role with its in game properties. */
|
|
buildRoleRecord(role: Readonly<lib.Role>) {
|
|
const meta = this.roleMeta.get(role.id) ?? new messages.RoleMetaRecord(role.id, role.id);
|
|
return new messages.RoleRecord(
|
|
role.id,
|
|
role.name,
|
|
[...role.permissions],
|
|
meta,
|
|
role.id === this.controller.config.get("controller.default_role_id"),
|
|
Math.max(role.updatedAtMs, meta.updatedAtMs),
|
|
role.isDeleted,
|
|
);
|
|
}
|
|
|
|
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
|
switch (field) {
|
|
// Which role is the default is carried on the role records themselves
|
|
case "controller.default_role_id":
|
|
this.controller.subscriptions.broadcast(new messages.RoleUpdatedEvent(this.listRoleRecords()));
|
|
break;
|
|
}
|
|
}
|
|
|
|
/** Every role which currently exists, in the form sent to instances. */
|
|
listRoleRecords() {
|
|
return [...this.controller.roles.values()].map(role => this.buildRoleRecord(role));
|
|
}
|
|
|
|
async handleRoleListRequest() {
|
|
return this.listRoleRecords();
|
|
}
|
|
|
|
async handleRoleMetaUpdateRequest(request: messages.RoleMetaUpdateRequest) {
|
|
const meta = request.meta;
|
|
if (!this.controller.roles.has(meta.id)) {
|
|
throw new lib.NotFoundError(`Role with ID ${meta.id} does not exist`);
|
|
}
|
|
|
|
this.roleMeta.set(meta);
|
|
}
|
|
|
|
/** A clusterio role was created, changed or deleted. */
|
|
rolesUpdated(roles: lib.Role[]) {
|
|
const created = this.ensureRoleMeta();
|
|
this.sweepRoleMeta();
|
|
|
|
// Newly created properties broadcast on their own through roleMetaUpdated
|
|
const createdIds = new Set(created.map(meta => meta.id));
|
|
const updates = roles.filter(role => !createdIds.has(role.id)).map(role => this.buildRoleRecord(role));
|
|
if (updates.length) {
|
|
this.controller.subscriptions.broadcast(new messages.RoleUpdatedEvent(updates));
|
|
}
|
|
|
|
this.applyAutoAssign();
|
|
}
|
|
|
|
/** The in game properties of a role changed. */
|
|
roleMetaUpdated(metas: messages.RoleMetaRecord[]) {
|
|
const updates = [];
|
|
for (const meta of metas) {
|
|
const role = this.controller.roles.get(meta.id);
|
|
// A deleted role broadcasts through rolesUpdated instead
|
|
if (role) {
|
|
updates.push(this.buildRoleRecord(role));
|
|
}
|
|
}
|
|
|
|
if (updates.length) {
|
|
this.controller.subscriptions.broadcast(new messages.RoleUpdatedEvent(updates));
|
|
}
|
|
|
|
this.applyAutoAssign();
|
|
}
|
|
|
|
async handleRoleSubscription(request: lib.SubscriptionRequest) {
|
|
const roles = this.listRoleRecords().filter(role => role.updatedAtMs > request.lastRequestTimeMs);
|
|
return roles.length ? new messages.RoleUpdatedEvent(roles) : null;
|
|
}
|
|
|
|
/*
|
|
Assignments
|
|
*/
|
|
|
|
/**
|
|
* The roles of a user as seen in game.
|
|
*
|
|
* The default role is left out because every player has it, which keeps the
|
|
* assignments to only those users who have been given a role.
|
|
*/
|
|
buildAssignmentRecord(user: Readonly<lib.UserDetails>) {
|
|
const defaultRoleId = this.controller.config.get("controller.default_role_id");
|
|
const roleIds = new Set(user.roleIds);
|
|
if (defaultRoleId !== null) {
|
|
roleIds.delete(defaultRoleId);
|
|
}
|
|
|
|
return new messages.AssignmentRecord(
|
|
user.name,
|
|
roleIds,
|
|
user.updatedAtMs,
|
|
user.isDeleted || roleIds.size === 0,
|
|
);
|
|
}
|
|
|
|
listAssignmentRecords() {
|
|
const assignments = [];
|
|
for (const user of this.controller.users.records.values()) {
|
|
const assignment = this.buildAssignmentRecord(user);
|
|
if (!assignment.isDeleted) {
|
|
assignments.push(assignment);
|
|
}
|
|
}
|
|
return assignments;
|
|
}
|
|
|
|
async handleAssignmentListRequest() {
|
|
return this.listAssignmentRecords();
|
|
}
|
|
|
|
usersUpdated(users: lib.UserDetails[]) {
|
|
this.controller.subscriptions.broadcast(
|
|
new messages.AssignmentUpdatedEvent(users.map(user => this.buildAssignmentRecord(user)))
|
|
);
|
|
|
|
this.applyAutoAssign(users.map(user => user.name));
|
|
}
|
|
|
|
async handleAssignmentSubscription(request: lib.SubscriptionRequest) {
|
|
const assignments = this.listAssignmentRecords()
|
|
.filter(assignment => assignment.updatedAtMs > request.lastRequestTimeMs);
|
|
return assignments.length ? new messages.AssignmentUpdatedEvent(assignments) : null;
|
|
}
|
|
|
|
async handleAssignmentUpdateRequest(request: messages.AssignmentUpdateRequest) {
|
|
const user = this.controller.users.getByNameMutable(request.name);
|
|
if (!user) {
|
|
throw new lib.NotFoundError(`User '${request.name}' does not exist`);
|
|
}
|
|
|
|
for (const roleId of [...request.assign, ...request.unassign]) {
|
|
if (!this.controller.roles.has(roleId)) {
|
|
throw new lib.NotFoundError(`Role with ID ${roleId} does not exist`);
|
|
}
|
|
}
|
|
|
|
const roleIds = new Set(user.roleIds);
|
|
for (const roleId of request.assign) {
|
|
roleIds.add(roleId);
|
|
}
|
|
for (const roleId of request.unassign) {
|
|
roleIds.delete(roleId);
|
|
}
|
|
|
|
user.set("roleIds", roleIds);
|
|
this.controller.userPermissionsUpdated(user);
|
|
}
|
|
|
|
/*
|
|
Automatic assignment
|
|
*/
|
|
|
|
/**
|
|
* Grant roles which are earned by online time across the cluster.
|
|
*
|
|
* Roles are only ever granted, never taken away, so a player who earns a
|
|
* role keeps it even if their statistics are later reduced.
|
|
*
|
|
* @param userNames - Users to consider, defaults to every user.
|
|
*/
|
|
applyAutoAssign(userNames?: string[]) {
|
|
const autoAssigned = [...this.roleMeta.values()].filter(
|
|
meta => meta.autoAssignOnlineTimeMs !== null && !meta.isDeleted
|
|
);
|
|
if (!autoAssigned.length) {
|
|
return;
|
|
}
|
|
|
|
const blocking = new Set(
|
|
[...this.roleMeta.values()].filter(meta => meta.blockAutoAssign).map(meta => meta.id)
|
|
);
|
|
|
|
const users = userNames
|
|
? userNames.map(name => this.controller.users.getByNameMutable(name))
|
|
: [...this.controller.users.valuesMutable()]
|
|
;
|
|
|
|
for (const user of users) {
|
|
if (!user || user.isDeleted) {
|
|
continue;
|
|
}
|
|
if ([...user.roleIds].some(roleId => blocking.has(roleId))) {
|
|
continue;
|
|
}
|
|
|
|
const onlineTimeMs = user.playerStats.onlineTimeMs;
|
|
const granted = [];
|
|
for (const meta of autoAssigned) {
|
|
if (!user.roleIds.has(meta.id) && onlineTimeMs >= meta.autoAssignOnlineTimeMs!) {
|
|
granted.push(meta.id);
|
|
}
|
|
}
|
|
|
|
if (granted.length) {
|
|
// set rather than addRole so that a single update is emitted
|
|
user.set("roleIds", new Set([...user.roleIds, ...granted]));
|
|
this.controller.userPermissionsUpdated(user);
|
|
this.logger.info(
|
|
`Granted ${granted.length} role(s) to ${user.name} from their online time`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) {
|
|
// Online time is committed to the user record when a player leaves
|
|
if (event.type === "leave") {
|
|
this.applyAutoAssign([event.name]);
|
|
}
|
|
}
|
|
}
|
|
|
|
export default async function (context: ControllerPluginContext) {
|
|
await ControllerPlugin.fromContext(context);
|
|
}
|