diff --git a/exp_groups/controller.ts b/exp_groups/controller.ts index fc761f62..bdc1860b 100644 --- a/exp_groups/controller.ts +++ b/exp_groups/controller.ts @@ -1,6 +1,418 @@ -import * as lib from "@clusterio/lib"; 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))); + } } diff --git a/exp_groups/index.ts b/exp_groups/index.ts index ae6ba441..2b433065 100644 --- a/exp_groups/index.ts +++ b/exp_groups/index.ts @@ -133,7 +133,7 @@ export const plugin: lib.PluginDeclaration = { messages: [ messages.GroupUpdatedEvent, - messages.AssignmentUpdatedEvent, + messages.ManualAssignmentUpdatedEvent, messages.RoleMappingUpdatedEvent, messages.GroupCreateRequest, diff --git a/exp_groups/instance.ts b/exp_groups/instance.ts index 028663b9..1bfc66ba 100644 --- a/exp_groups/instance.ts +++ b/exp_groups/instance.ts @@ -18,9 +18,15 @@ export type IpcPlayerAssignments = { }; 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(); + // Track known online players so that we only apply assignment updates for them + onlinePlayers = new Set(); + async init() { 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_deleted`, this.handleGroupDeletedIPC.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() { + // We use Date.now() because we need to manually initialise the groups on the lua side 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()) await this.luaSendInitialGroups(groups); @@ -46,32 +53,27 @@ export class InstancePlugin extends BaseInstancePlugin { async onPlayerEvent(event: lib.PlayerEvent) { switch(event.type) { case "join": - this.handlePlayerJoin(event.name); + this.onlinePlayers.add(event.name); + await this.subscribePlayerAssignment(event.name); break; case "leave": - this.handlePlayerLeave(event.name); + this.onlinePlayers.delete(event.name); + await this.unsubscribePlayerAssignment(event.name); 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) { for (const group of event.updates) { await this.luaSendGroupUpdate(group); } } - async handleAssignmentUpdatedEvent(event: messages.AssignmentUpdatedEvent) { + async handleResolvedAssignmentUpdatedEvent(event: messages.ManualAssignmentUpdatedEvent) { 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) { + this.skipSendingPermissions.add(event.group_name); await this.instance.sendTo("controller", new messages.GroupCreateRequest(event.group_name, permissions), ); @@ -111,21 +114,16 @@ export class InstancePlugin extends BaseInstancePlugin { async subscribePlayerAssignment(playerName: string) { 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) { 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[]) { if (this.instance.config.get("exp_groups.sync_mode") == "disabled") { return; @@ -137,7 +135,14 @@ export class InstancePlugin extends BaseInstancePlugin { if (this.instance.config.get("exp_groups.sync_mode") == "disabled") { 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) { diff --git a/exp_groups/messages.ts b/exp_groups/messages.ts index 4e337041..691d8ca3 100644 --- a/exp_groups/messages.ts +++ b/exp_groups/messages.ts @@ -85,6 +85,10 @@ export class AssignmentRecord { public isDeleted: boolean = false, ) {} + get id() { + return this.name; + } + static jsonSchema = Type.Object({ name: Type.String(), group_id: Type.Integer(), @@ -202,8 +206,33 @@ export class GroupUpdatedEvent { } } -export class AssignmentUpdatedEvent { - declare ["constructor"]: typeof AssignmentUpdatedEvent; +export class ManualAssignmentUpdatedEvent { + 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) { + return new this(json.updates.map(assignment => AssignmentRecord.fromJSON(assignment))); + } +} + +export class ResolvedAssignmentUpdatedEvent { + declare ["constructor"]: typeof ResolvedAssignmentUpdatedEvent; static plugin = "exp_groups" as const; static type = "event" as const; static src = "controller" as const; @@ -486,20 +515,23 @@ export class AssignmentGetRequest { constructor( public name: string, + public resolve: boolean = false, ) {} static jsonSchema = Type.Object({ name: Type.String(), + resolve: Type.Boolean(), }); toJSON() { return { name: this.name, + resolve: this.resolve, }; } static fromJSON(json: Static) { - return new this(json.name); + return new this(json.name, json.resolve); } }