Add controller plugin

This commit is contained in:
Cooldude2606
2026-05-21 00:30:17 +01:00
parent dca5086ce3
commit 230c835103
4 changed files with 477 additions and 28 deletions
+413 -1
View File
@@ -1,6 +1,418 @@
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller"; 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 { export class ControllerPlugin extends BaseControllerPlugin {
groups!: lib.SubscribableDatastore<messages.GroupRecord>;
roleMappings!: lib.SubscribableDatastore<messages.RoleMappingRecord>;
manualAssignments!: lib.SubscribableDatastore<messages.AssignmentRecord>;
resolvedAssignments!: lib.SubscribableDatastore<messages.AssignmentRecord>;
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<string>();
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<messages.AssignmentRecord> {
// 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<number>();
// 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<messages.AssignmentRecord[]> {
return Promise.all(playerNames.map(name => this.computeResolvedAssignment(name)));
}
} }
+1 -1
View File
@@ -133,7 +133,7 @@ export const plugin: lib.PluginDeclaration = {
messages: [ messages: [
messages.GroupUpdatedEvent, messages.GroupUpdatedEvent,
messages.AssignmentUpdatedEvent, messages.ManualAssignmentUpdatedEvent,
messages.RoleMappingUpdatedEvent, messages.RoleMappingUpdatedEvent,
messages.GroupCreateRequest, messages.GroupCreateRequest,
+28 -23
View File
@@ -18,9 +18,15 @@ export type IpcPlayerAssignments = {
}; };
export class InstancePlugin extends BaseInstancePlugin { export class InstancePlugin extends BaseInstancePlugin {
// Once only, don't send permissions for these groups
// This is used for groups created on this instance that only need the controller generated id
skipSendingPermissions = new Set<string>();
// Track known online players so that we only apply assignment updates for them
onlinePlayers = new Set<string>();
async init() { async init() {
this.instance.handle(messages.GroupUpdatedEvent, this.handleGroupUpdatedEvent.bind(this)); this.instance.handle(messages.GroupUpdatedEvent, this.handleGroupUpdatedEvent.bind(this));
this.instance.handle(messages.AssignmentUpdatedEvent, this.handleAssignmentUpdatedEvent.bind(this)); this.instance.handle(messages.ResolvedAssignmentUpdatedEvent, this.handleResolvedAssignmentUpdatedEvent.bind(this));
this.instance.server.handle(`exp_group:group_updated`, this.handleGroupUpdatedIPC.bind(this)) this.instance.server.handle(`exp_group:group_updated`, this.handleGroupUpdatedIPC.bind(this))
this.instance.server.handle(`exp_group:group_deleted`, this.handleGroupDeletedIPC.bind(this)) this.instance.server.handle(`exp_group:group_deleted`, this.handleGroupDeletedIPC.bind(this))
this.instance.server.handle(`exp_group:player_assignments`, this.handlePlayerAssignmentsIPC.bind(this)) this.instance.server.handle(`exp_group:player_assignments`, this.handlePlayerAssignmentsIPC.bind(this))
@@ -35,8 +41,9 @@ export class InstancePlugin extends BaseInstancePlugin {
} }
async onStart() { async onStart() {
// We use Date.now() because we need to manually initialise the groups on the lua side
await this.instance.sendTo("controller", new lib.SubscriptionRequest( await this.instance.sendTo("controller", new lib.SubscriptionRequest(
`exp_groups:${messages.GroupUpdatedEvent.name}`, true `exp_groups:${messages.GroupUpdatedEvent.name}`, true, Date.now()
)); ));
const groups = await this.instance.sendTo("controller", new messages.GroupListRequest()) const groups = await this.instance.sendTo("controller", new messages.GroupListRequest())
await this.luaSendInitialGroups(groups); await this.luaSendInitialGroups(groups);
@@ -46,32 +53,27 @@ export class InstancePlugin extends BaseInstancePlugin {
async onPlayerEvent(event: lib.PlayerEvent) { async onPlayerEvent(event: lib.PlayerEvent) {
switch(event.type) { switch(event.type) {
case "join": case "join":
this.handlePlayerJoin(event.name); this.onlinePlayers.add(event.name);
await this.subscribePlayerAssignment(event.name);
break; break;
case "leave": case "leave":
this.handlePlayerLeave(event.name); this.onlinePlayers.delete(event.name);
await this.unsubscribePlayerAssignment(event.name);
break; break;
} }
} }
async handlePlayerJoin(playerName: string) {
await this.subscribePlayerAssignment(playerName);
await this.requestPlayerAssignment(playerName);
}
async handlePlayerLeave(playerName: string) {
await this.unsubscribePlayerAssignment(playerName);
}
async handleGroupUpdatedEvent(event: messages.GroupUpdatedEvent) { async handleGroupUpdatedEvent(event: messages.GroupUpdatedEvent) {
for (const group of event.updates) { for (const group of event.updates) {
await this.luaSendGroupUpdate(group); await this.luaSendGroupUpdate(group);
} }
} }
async handleAssignmentUpdatedEvent(event: messages.AssignmentUpdatedEvent) { async handleResolvedAssignmentUpdatedEvent(event: messages.ManualAssignmentUpdatedEvent) {
for (const assignment of event.updates) { for (const assignment of event.updates) {
await this.luaSendAssignmentUpdate(assignment); if (this.onlinePlayers.has(assignment.name)) {
await this.luaSendAssignmentUpdate(assignment);
}
} }
} }
@@ -82,6 +84,7 @@ export class InstancePlugin extends BaseInstancePlugin {
); );
if (event.group_id === undefined) { if (event.group_id === undefined) {
this.skipSendingPermissions.add(event.group_name);
await this.instance.sendTo("controller", await this.instance.sendTo("controller",
new messages.GroupCreateRequest(event.group_name, permissions), new messages.GroupCreateRequest(event.group_name, permissions),
); );
@@ -111,21 +114,16 @@ export class InstancePlugin extends BaseInstancePlugin {
async subscribePlayerAssignment(playerName: string) { async subscribePlayerAssignment(playerName: string) {
await this.instance.sendTo("controller", new lib.SubscriptionRequest( await this.instance.sendTo("controller", new lib.SubscriptionRequest(
`exp_groups:${messages.AssignmentUpdatedEvent.name}`, true, 0, playerName `exp_groups:${messages.ResolvedAssignmentUpdatedEvent.name}`, true, 0, playerName
)); ));
} }
async unsubscribePlayerAssignment(playerName: string) { async unsubscribePlayerAssignment(playerName: string) {
await this.instance.sendTo("controller", new lib.SubscriptionRequest( await this.instance.sendTo("controller", new lib.SubscriptionRequest(
`exp_groups:${messages.AssignmentUpdatedEvent.name}`, false, 0, playerName `exp_groups:${messages.ResolvedAssignmentUpdatedEvent.name}`, false, 0, playerName
)); ));
} }
async requestPlayerAssignment(playerName: string) {
const assignment = await this.instance.sendTo("controller", new messages.AssignmentGetRequest(playerName));
await this.luaSendAssignmentUpdate(assignment);
}
async luaSendInitialGroups(groups: messages.GroupRecord[]) { async luaSendInitialGroups(groups: messages.GroupRecord[]) {
if (this.instance.config.get("exp_groups.sync_mode") == "disabled") { if (this.instance.config.get("exp_groups.sync_mode") == "disabled") {
return; return;
@@ -137,7 +135,14 @@ export class InstancePlugin extends BaseInstancePlugin {
if (this.instance.config.get("exp_groups.sync_mode") == "disabled") { if (this.instance.config.get("exp_groups.sync_mode") == "disabled") {
return; return;
} }
await this.instance.sendRcon(`/sc exp_groups.receive_group_update(helpers.json_to_table${JSON.stringify(group)})`)
const json = group.toJSON();
if (this.skipSendingPermissions.has(group.name)) {
this.skipSendingPermissions.delete(group.name);
delete (json as any).permissions;
}
await this.instance.sendRcon(`/sc exp_groups.receive_group_update(helpers.json_to_table${JSON.stringify(json)})`)
} }
async luaSendAssignmentUpdate(assignment: messages.AssignmentRecord) { async luaSendAssignmentUpdate(assignment: messages.AssignmentRecord) {
+35 -3
View File
@@ -85,6 +85,10 @@ export class AssignmentRecord {
public isDeleted: boolean = false, public isDeleted: boolean = false,
) {} ) {}
get id() {
return this.name;
}
static jsonSchema = Type.Object({ static jsonSchema = Type.Object({
name: Type.String(), name: Type.String(),
group_id: Type.Integer(), group_id: Type.Integer(),
@@ -202,8 +206,33 @@ export class GroupUpdatedEvent {
} }
} }
export class AssignmentUpdatedEvent { export class ManualAssignmentUpdatedEvent {
declare ["constructor"]: typeof AssignmentUpdatedEvent; declare ["constructor"]: typeof ManualAssignmentUpdatedEvent;
static plugin = "exp_groups" as const;
static type = "event" as const;
static src = "controller" as const;
static dst = ["control", "instance"] as const;
static permission = "exp_groups.assignment.subscribe" as const;
constructor(
public updates: AssignmentRecord[],
) {}
static jsonSchema = Type.Object({
updates: Type.Array(AssignmentRecord.jsonSchema),
});
toJSON() {
return { updates: this.updates.map(assignment => assignment.toJSON()) };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.updates.map(assignment => AssignmentRecord.fromJSON(assignment)));
}
}
export class ResolvedAssignmentUpdatedEvent {
declare ["constructor"]: typeof ResolvedAssignmentUpdatedEvent;
static plugin = "exp_groups" as const; static plugin = "exp_groups" as const;
static type = "event" as const; static type = "event" as const;
static src = "controller" as const; static src = "controller" as const;
@@ -486,20 +515,23 @@ export class AssignmentGetRequest {
constructor( constructor(
public name: string, public name: string,
public resolve: boolean = false,
) {} ) {}
static jsonSchema = Type.Object({ static jsonSchema = Type.Object({
name: Type.String(), name: Type.String(),
resolve: Type.Boolean(),
}); });
toJSON() { toJSON() {
return { return {
name: this.name, name: this.name,
resolve: this.resolve,
}; };
} }
static fromJSON(json: Static<typeof this.jsonSchema>) { static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name); return new this(json.name, json.resolve);
} }
} }