import { BaseControllerPlugin } from "@clusterio/controller"; import * as lib from "@clusterio/lib"; import * as messages from "./messages"; import * as path from "node:path"; export class ControllerPlugin extends BaseControllerPlugin { groups!: lib.SubscribableDatastore; roleMappings!: lib.SubscribableDatastore; manualAssignments!: lib.SubscribableDatastore; resolvedAssignments!: lib.SubscribableDatastore; async init() { const databaseDirectory = this.controller.config.get("controller.database_directory"); this.groups = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "exp_groups", "groups.json"), messages.GroupRecord.fromJSON.bind(messages.GroupRecord), ).bootstrap() ); this.roleMappings = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "exp_groups", "role_mappings.json"), messages.RoleMappingRecord.fromJSON.bind(messages.RoleMappingRecord), ).bootstrap() ); this.manualAssignments = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "exp_groups", "assignments.json"), messages.AssignmentRecord.fromJSON.bind(messages.AssignmentRecord), ).bootstrap() ); this.resolvedAssignments = new lib.SubscribableDatastore(); this.controller.subscriptions.handle(messages.GroupUpdatedEvent, this.handleGroupSubscription.bind(this)); this.controller.subscriptions.handle(messages.RoleMappingUpdatedEvent, this.handleRoleMappingSubscription.bind(this)); this.controller.subscriptions.handle(messages.ManualAssignmentUpdatedEvent, this.handleManualAssignmentSubscription.bind(this)); this.controller.subscriptions.handle(messages.ResolvedAssignmentUpdatedEvent, this.handleResolvedAssignmentSubscription.bind(this)); this.groups.on("update", this.groupsUpdated.bind(this)); this.roleMappings.on("update", this.roleMappingsUpdated.bind(this)); this.manualAssignments.on("update", this.manualAssignmentsUpdated.bind(this)); this.resolvedAssignments.on("update", this.resolvedAssignmentsUpdated.bind(this)); this.controller.handle(messages.GroupCreateRequest, this.handleGroupCreateRequest.bind(this)); this.controller.handle(messages.GroupUpdateRequest, this.handleGroupUpdateRequest.bind(this)); this.controller.handle(messages.GroupDeleteRequest, this.handleGroupDeleteRequest.bind(this)); this.controller.handle(messages.GroupGetRequest, this.handleGroupGetRequest.bind(this)); this.controller.handle(messages.GroupListRequest, this.handleGroupListRequest.bind(this)); this.controller.handle(messages.AssignmentCreateRequest, this.handleAssignmentCreateRequest.bind(this)); this.controller.handle(messages.AssignmentUpdateRequest, this.handleAssignmentUpdateRequest.bind(this)); this.controller.handle(messages.AssignmentDeleteRequest, this.handleAssignmentDeleteRequest.bind(this)); this.controller.handle(messages.AssignmentGetRequest, this.handleAssignmentGetRequest.bind(this)); this.controller.handle(messages.AssignmentListRequest, this.handleAssignmentListRequest.bind(this)); this.controller.handle(messages.RoleMappingCreateRequest, this.handleRoleMappingCreateRequest.bind(this)); this.controller.handle(messages.RoleMappingUpdateRequest, this.handleRoleMappingUpdateRequest.bind(this)); this.controller.handle(messages.RoleMappingDeleteRequest, this.handleRoleMappingDeleteRequest.bind(this)); this.controller.handle(messages.RoleMappingGetRequest, this.handleRoleMappingGetRequest.bind(this)); this.controller.handle(messages.RoleMappingListRequest, this.handleRoleMappingListRequest.bind(this)); } async onShutdown() { await Promise.all([ this.groups.save(), this.manualAssignments.save(), this.roleMappings.save(), ]) } /* Subscriptions */ async groupsUpdated(groups: messages.GroupRecord[]) { this.controller.subscriptions.broadcast(new messages.GroupUpdatedEvent(groups)); // We need to do extra work if the group was deleted const deletedGroupIds = groups.filter(g => g.isDeleted).map(g => g.id); if (!deletedGroupIds.length) { return; } // Cascade the delete down to affected role mappings const mappingsToDelete = []; for (const mapping of this.roleMappings.values()) { if (deletedGroupIds.includes(mapping.groupId)) { mappingsToDelete.push(mapping); } } if (mappingsToDelete.length) { this.roleMappings.deleteMany(mappingsToDelete); } // Cascade the delete down to affected manual assignments const affectedPlayers = new Set(); const assignmentsToDelete = []; for (const assignment of this.manualAssignments.values()) { if (deletedGroupIds.includes(assignment.groupId)) { assignmentsToDelete.push(assignment); affectedPlayers.add(assignment.name); } } if (assignmentsToDelete.length) { this.manualAssignments.deleteMany(assignmentsToDelete); } // Find all the affected players who were assigned to this group for (const resolved of this.resolvedAssignments.values()) { if (deletedGroupIds.includes(resolved.groupId)) { affectedPlayers.add(resolved.name); } } if (affectedPlayers.size) { this.resolvedAssignments.setMany(await this.computeResolvedAssignments([...affectedPlayers])); } } async handleGroupSubscription(request: lib.SubscriptionRequest) { const groups = [...this.groups.values()] .filter(group => group.updatedAtMs > request.lastRequestTimeMs); return groups.length ? new messages.GroupUpdatedEvent(groups) : null; } async roleMappingsUpdated(roleMappings: messages.RoleMappingRecord[]) { this.controller.subscriptions.broadcast(new messages.RoleMappingUpdatedEvent(roleMappings)); // Mappings pointing to a deleted group have already been handled // But if any are active, then we still must recompute all assignments let hasActiveGroup = false; for (const roleMapping of roleMappings) { const group = this.groups.get(roleMapping.groupId); if (group && !group.isDeleted) { hasActiveGroup = true; break; } } if (!hasActiveGroup) { return; } // Affected players are those without manual assignments const affectedPlayers = []; for (const resolved of this.resolvedAssignments.values()) { if (!this.manualAssignments.has(resolved.name)) { affectedPlayers.push(resolved.name); } } if (affectedPlayers.length) { this.resolvedAssignments.setMany(await this.computeResolvedAssignments(affectedPlayers)); } } async handleRoleMappingSubscription(request: lib.SubscriptionRequest) { const mappings = [...this.roleMappings.values()] .filter(mapping => mapping.updatedAtMs > request.lastRequestTimeMs); return mappings.length ? new messages.RoleMappingUpdatedEvent(mappings) : null; } async manualAssignmentsUpdated(assignments: messages.AssignmentRecord[]) { this.controller.subscriptions.broadcast(new messages.ManualAssignmentUpdatedEvent(assignments)); // Assignments pointing to a deleted group have already been handled const affectedPlayers: string[] = []; for (const assignment of assignments) { const group = this.groups.get(assignment.groupId); if (!group || group.isDeleted) continue; affectedPlayers.push(assignment.name); } if (affectedPlayers.length) { this.resolvedAssignments.setMany(await this.computeResolvedAssignments(affectedPlayers)); } } async handleManualAssignmentSubscription(request: lib.SubscriptionRequest) { const assignments = [...this.resolvedAssignments.values()] .filter(a => a.updatedAtMs > request.lastRequestTimeMs); return assignments.length ? new messages.ManualAssignmentUpdatedEvent(assignments) : null; } resolvedAssignmentsUpdated(assignments: messages.AssignmentRecord[]) { this.controller.subscriptions.broadcast( new messages.ResolvedAssignmentUpdatedEvent(assignments), assignments.map(assignment => assignment.name), ); } async handleResolvedAssignmentSubscription(request: lib.SubscriptionRequest) { // Check for any missing assignments to be computed on demand const filters = Array.isArray(request.filters) ? request.filters : [request.filters!]; if (request.filters && filters.length) { const missing = filters.filter(name => !this.resolvedAssignments.has(name)); if (missing.length) { this.resolvedAssignments.setMany(await this.computeResolvedAssignments(missing)); } } // Filter the assignments const assignments = (filters.length ? filters.map(name => this.resolvedAssignments.get(name)).filter(Boolean) : [...this.resolvedAssignments.values()] ).filter(a => a.updatedAtMs > request.lastRequestTimeMs); return assignments.length ? new messages.ResolvedAssignmentUpdatedEvent(assignments) : null; } /* Groups */ async handleGroupListRequest() { return [...this.groups.values()]; } async handleGroupCreateRequest(request: messages.GroupCreateRequest) { if ([...this.groups.values()].some(g => g.name === request.name)) { throw new lib.RequestError(`Group with name '${request.name}' already exists`); } let id = Math.random() * 2**31 | 0; while (this.groups.has(id)) { id = Math.random() * 2**31 | 0; } const group = new messages.GroupRecord(id, request.name, request.permissions); this.groups.set(group); } async handleGroupUpdateRequest(request: messages.GroupUpdateRequest) { const group = request.group; if (group.id === undefined || !this.groups.has(group.id)) { throw new lib.RequestError(`Group with ID ${group.id} does not exist`); } this.groups.set(group); } async handleGroupDeleteRequest(request: messages.GroupDeleteRequest) { const { groupId } = request; const group = this.groups.getMutable(groupId); if (!group) { throw new lib.RequestError(`Group with ID ${groupId} does not exist`); } this.groups.delete(group); } async handleGroupGetRequest(request: messages.GroupGetRequest) { const group = this.groups.get(request.groupId); if (!group) { throw new lib.RequestError(`Group with ID ${request.groupId} does not exist`); } return group; } /* Groups */ async handleAssignmentListRequest() { return [...this.manualAssignments.values()]; } async handleAssignmentCreateRequest(request: messages.AssignmentCreateRequest) { const { name, groupId } = request; if (this.manualAssignments.has(name)) { throw new lib.RequestError(`Assignment for '${name}' already exists`); } const assignment = new messages.AssignmentRecord(name, groupId); this.manualAssignments.set(assignment); } async handleAssignmentUpdateRequest(request: messages.AssignmentUpdateRequest) { const assignment = request.assignment; if (!this.manualAssignments.has(assignment.name)) { throw new lib.RequestError(`Assignment for '${assignment.name}' does not exist`); } this.manualAssignments.set(assignment); } async handleAssignmentDeleteRequest(request: messages.AssignmentDeleteRequest) { const { name } = request; const assignment = this.manualAssignments.getMutable(name); if (!assignment) { throw new lib.RequestError(`Assignment for '${name}' does not exist`); } this.manualAssignments.delete(assignment); } async handleAssignmentGetRequest(request: messages.AssignmentGetRequest) { if (request.resolve) { let assignment = this.resolvedAssignments.get(request.name); if (!assignment) { assignment = await this.computeResolvedAssignment(request.name); this.resolvedAssignments.set(assignment); } return assignment; } const assignment = this.manualAssignments.get(request.name); if (!assignment) { throw new lib.RequestError(`Assignment for '${request.name}' does not exist`); } return assignment; } /* Role mappings */ async handleRoleMappingListRequest() { return [...this.roleMappings.values()]; } async handleRoleMappingCreateRequest(request: messages.RoleMappingCreateRequest) { let id = Math.random() * 2**31 | 0; while (this.roleMappings.has(id)) { id = Math.random() * 2**31 | 0; } const mapping = new messages.RoleMappingRecord( id, new Set(request.roleIds), request.groupId, request.priority, request.enabled, ); this.roleMappings.set(mapping); } async handleRoleMappingUpdateRequest(request: messages.RoleMappingUpdateRequest) { const mapping = request.roleMapping; if (mapping.id === undefined || !this.roleMappings.has(mapping.id)) { throw new lib.RequestError(`Role mapping with ID ${mapping.id} does not exist`); } this.roleMappings.set(mapping); } async handleRoleMappingDeleteRequest(request: messages.RoleMappingDeleteRequest) { const { id } = request; const mapping = this.roleMappings.getMutable(id); if (!mapping) { throw new lib.RequestError(`Role mapping with ID ${id} does not exist`); } this.roleMappings.delete(mapping); } async handleRoleMappingGetRequest(request: messages.RoleMappingGetRequest) { const mapping = this.roleMappings.get(request.id); if (!mapping) { throw new lib.RequestError(`Role mapping with ID ${request.id} does not exist`); } return mapping; } /* Calculating assignments */ async computeResolvedAssignment(playerName: string): Promise { // 1) Manual override const manual = this.manualAssignments.get(playerName); if (manual) { return manual; } const user = this.controller.users.get(playerName); const userRoles = user?.roleIds ?? new Set(); // 2) Role mappings let best: messages.RoleMappingRecord | null = null; for (const mapping of this.roleMappings.values()) { if (!mapping.enabled) continue; let matches = true; for (const roleId of mapping.roleIds) { if (!userRoles.has(roleId)) { matches = false; break; } } if (!matches) continue; if (!best || mapping.priority > best.priority) { best = mapping; } } if (best) { return new messages.AssignmentRecord(playerName, best.groupId); } // 3) Default (deleted assignment, assigns to 'Default' in game) return new messages.AssignmentRecord(playerName, 0, 0, true); } async computeResolvedAssignments(playerNames: string[]): Promise { return Promise.all(playerNames.map(name => this.computeResolvedAssignment(name))); } }