diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts new file mode 100644 index 00000000..b1177471 --- /dev/null +++ b/exp_roles/controller.ts @@ -0,0 +1,299 @@ +import { BaseControllerPlugin, InstanceRecord } 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 { + roleMeta!: lib.SubscribableDatastore; + + async init() { + const databaseDirectory = this.controller.config.get("controller.database_directory"); + + this.roleMeta = new lib.SubscribableDatastore( + ...await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "exp_roles", "role_meta.json"), + messages.RoleMetaRecord.fromJSON.bind(messages.RoleMetaRecord), + ).bootstrap() + ); + + // Roles created before this plugin was installed have no properties yet + this.ensureRoleMeta(); + + this.controller.subscriptions.handle(messages.RoleUpdatedEvent, this.handleRoleSubscription.bind(this)); + this.controller.subscriptions.handle( + messages.AssignmentUpdatedEvent, this.handleAssignmentSubscription.bind(this) + ); + + this.roleMeta.on("update", this.roleMetaUpdated.bind(this)); + this.controller.roles.on("update", this.rolesUpdated.bind(this)); + this.controller.users.records.on("update", this.usersUpdated.bind(this)); + + this.controller.handle(messages.RoleListRequest, this.handleRoleListRequest.bind(this)); + this.controller.handle(messages.RoleMetaUpdateRequest, this.handleRoleMetaUpdateRequest.bind(this)); + + this.controller.handle(messages.AssignmentListRequest, this.handleAssignmentListRequest.bind(this)); + this.controller.handle(messages.AssignmentUpdateRequest, this.handleAssignmentUpdateRequest.bind(this)); + } + + async onShutdown() { + 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; + } + + /** Combine a clusterio role with its in game properties. */ + buildRoleRecord(role: Readonly) { + 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) { + // Which role is the default is carried on the role records themselves + if (field === "controller.default_role_id") { + this.controller.subscriptions.broadcast(new messages.RoleUpdatedEvent(this.listRoleRecords())); + } + } + + /** 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.RequestError(`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(); + + // Deleting a role leaves its properties behind, which would be reused by + // the next role to be given the same id + const orphaned = []; + for (const role of roles) { + if (role.isDeleted) { + const meta = this.roleMeta.getMutable(role.id); + if (meta) { + orphaned.push(meta); + } + } + } + if (orphaned.length) { + this.roleMeta.deleteMany(orphaned); + } + + // 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) { + 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.RequestError(`User '${request.name}' does not exist`); + } + + for (const roleId of [...request.assign, ...request.unassign]) { + if (!this.controller.roles.has(roleId)) { + throw new lib.RequestError(`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]); + } + } +} diff --git a/exp_roles/index.ts b/exp_roles/index.ts new file mode 100644 index 00000000..96d8cb0a --- /dev/null +++ b/exp_roles/index.ts @@ -0,0 +1,77 @@ +import * as lib from "@clusterio/lib"; +import * as messages from "./messages"; + +declare module "@clusterio/lib" { + export interface InstanceConfigFields { + "exp_roles.sync_mode": "disabled" | "enabled" | "bidirectional"; + } +} + +lib.definePermission({ + name: "exp_roles.role.list", + title: "List In Game Roles", + description: "List the in game properties of all roles.", + grantByDefault: true, +}); +lib.definePermission({ + name: "exp_roles.role.subscribe", + title: "Subscribe to In Game Role Updates", + description: "Receive updates when the in game properties of a role change.", + grantByDefault: true, +}); +lib.definePermission({ + name: "exp_roles.role.update", + title: "Update In Game Roles", + description: "Modify the in game properties of a role, such as its order and colour.", +}); + +lib.definePermission({ + name: "exp_roles.assignment.list", + title: "List Role Assignments", + description: "List the roles held by each player in game.", + grantByDefault: true, +}); +lib.definePermission({ + name: "exp_roles.assignment.subscribe", + title: "Subscribe to Role Assignment Updates", + description: "Receive updates when the roles held by a player change.", + grantByDefault: true, +}); + +export const plugin: lib.PluginDeclaration = { + name: "exp_roles", + title: "ExpGaming - Roles", + description: "Clusterio plugin providing syncing of in game roles", + + features: [ + "SavePatching", + "ScriptCommands", + ], + + messages: [ + messages.RoleUpdatedEvent, + messages.AssignmentUpdatedEvent, + + messages.RoleListRequest, + messages.RoleMetaUpdateRequest, + + messages.AssignmentListRequest, + messages.AssignmentUpdateRequest, + ], + + instanceEntrypoint: "./dist/node/instance", + instanceConfigFields: { + "exp_roles.sync_mode": { + description: "Synchronize in game roles with the controller", + type: "string", + enum: ["disabled", "enabled", "bidirectional"], + initialValue: "bidirectional", + }, + }, + + controllerEntrypoint: "./dist/node/controller", + controllerConfigFields: { + }, + + webEntrypoint: "./web", +}; diff --git a/exp_roles/instance.ts b/exp_roles/instance.ts new file mode 100644 index 00000000..df7a03a4 --- /dev/null +++ b/exp_roles/instance.ts @@ -0,0 +1,104 @@ +import { BaseInstancePlugin } from "@clusterio/host"; +import * as lib from "@clusterio/lib"; +import * as messages from "./messages"; + +/** Sent by the lua side when roles are changed in game. */ +export type IpcAssignmentUpdate = { + name: string, + assign: number[] | undefined, + unassign: number[] | undefined, +}; + +export class InstancePlugin extends BaseInstancePlugin { + async init() { + this.instance.handle(messages.RoleUpdatedEvent, this.handleRoleUpdatedEvent.bind(this)); + this.instance.handle(messages.AssignmentUpdatedEvent, this.handleAssignmentUpdatedEvent.bind(this)); + this.instance.server.handle("exp_roles:assignment_update", this.handleAssignmentUpdateIPC.bind(this)); + } + + get syncMode() { + return this.instance.config.get("exp_roles.sync_mode"); + } + + async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { + switch (field) { + case "exp_roles.sync_mode": + await this.luaSetEmitEvents(curr === "bidirectional"); + break; + } + } + + async onStart() { + if (this.syncMode === "disabled") { + return; + } + + // Date.now() is used because the lua state is initialised from the full + // list below, so only updates made after this point are of interest + const subscribedAtMs = Date.now(); + await this.instance.sendTo("controller", new lib.SubscriptionRequest( + `exp_roles:${messages.RoleUpdatedEvent.name}`, true, subscribedAtMs + )); + await this.instance.sendTo("controller", new lib.SubscriptionRequest( + `exp_roles:${messages.AssignmentUpdatedEvent.name}`, true, subscribedAtMs + )); + + const [roles, assignments] = await Promise.all([ + this.instance.sendTo("controller", new messages.RoleListRequest()), + this.instance.sendTo("controller", new messages.AssignmentListRequest()), + ]); + + await this.luaSend("initialise", { + roles: roles.map(role => role.toJSON()), + assignments: assignments.map(assignment => assignment.toJSON()), + }); + await this.luaSetEmitEvents(this.syncMode === "bidirectional"); + } + + async handleRoleUpdatedEvent(event: messages.RoleUpdatedEvent) { + if (this.syncMode === "disabled") { + return; + } + + await this.luaSend("receive_role_updates", event.updates.map(role => role.toJSON())); + } + + async handleAssignmentUpdatedEvent(event: messages.AssignmentUpdatedEvent) { + if (this.syncMode === "disabled") { + return; + } + + await this.luaSend("receive_assignment_updates", event.updates.map(a => a.toJSON())); + } + + async handleAssignmentUpdateIPC(event: IpcAssignmentUpdate) { + if (this.syncMode !== "bidirectional") { + return; + } + + const assign = event.assign ?? []; + try { + await this.instance.sendTo("controller", new messages.AssignmentUpdateRequest( + event.name, assign, event.unassign ?? [], + )); + } catch (err: any) { + // The roles were already applied in game, so they have to be taken + // back off again now that the controller has refused them + this.logger.warn(`Role change for ${event.name} was rejected: ${err.message}`); + if (assign.length) { + await this.luaSend("reject_assignment", { name: event.name, role_ids: assign }); + } + return; + } + } + + async luaSetEmitEvents(emitEvents: boolean) { + await this.luaSend("set_emit_events", emitEvents); + } + + async luaSend(receiver: string, json: any) { + await this.instance.sendRcon( + `/sc exp_roles.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true + ); + } +} diff --git a/exp_roles/messages.ts b/exp_roles/messages.ts new file mode 100644 index 00000000..dda0c144 --- /dev/null +++ b/exp_roles/messages.ts @@ -0,0 +1,389 @@ +import * as lib from "@clusterio/lib"; +import { Type, Static } from "@sinclair/typebox"; + +/* + Data records +*/ + +/** Colour used for a role's tag and name in game. */ +export class RoleColor { + constructor( + public r: number, + public g: number, + public b: number, + ) {} + + static jsonSchema = Type.Object({ + r: Type.Number(), + g: Type.Number(), + b: Type.Number(), + }); + + toJSON(): Static { + return { r: this.r, g: this.g, b: this.b }; + } + + static fromJSON(json: Static) { + return new this(json.r, json.g, json.b); + } +} + +/** + * In game properties of a role which clusterio's own role does not carry. + * + * Stored on the controller keyed by the clusterio role id, and created + * automatically for any role which does not have one yet. + */ +export class RoleMetaRecord { + constructor( + /** Id of the clusterio role these properties belong to. */ + public id: number, + /** Position in the role order, a lower value is a more privileged role. */ + public order: number, + /** + * Only the roles with the highest priority a player holds are considered, + * which allows a role such as Jail to suppress all others. + */ + public priority: number = 0, + /** Short form of the role name, used where space is limited. */ + public shortHand: string = "", + /** Tag shown next to the names of players with this role. */ + public tag: string = "", + /** Colour used for the role in game, null uses the default colour. */ + public color: RoleColor | null = null, + /** Online time after which this role is granted, null never grants it. */ + public autoAssignOnlineTimeMs: number | null = null, + /** When true holders of this role are never granted roles automatically. */ + public blockAutoAssign: boolean = false, + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + static jsonSchema = Type.Object({ + id: Type.Integer(), + order: Type.Number(), + priority: Type.Optional(Type.Number()), + short_hand: Type.Optional(Type.String()), + tag: Type.Optional(Type.String()), + color: Type.Optional(Type.Union([RoleColor.jsonSchema, Type.Null()])), + auto_assign_online_time_ms: Type.Optional(Type.Union([Type.Number(), Type.Null()])), + block_auto_assign: Type.Optional(Type.Boolean()), + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { + id: this.id, + order: this.order, + }; + + if (this.priority) { + json.priority = this.priority; + } + + if (this.shortHand) { + json.short_hand = this.shortHand; + } + + if (this.tag) { + json.tag = this.tag; + } + + if (this.color) { + json.color = this.color.toJSON(); + } + + if (this.autoAssignOnlineTimeMs !== null) { + json.auto_assign_online_time_ms = this.autoAssignOnlineTimeMs; + } + + if (this.blockAutoAssign) { + json.block_auto_assign = true; + } + + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + + if (this.isDeleted) { + json.is_deleted = true; + } + + return json; + } + + static fromJSON(json: Static) { + return new this( + json.id, + json.order, + json.priority ?? 0, + json.short_hand ?? "", + json.tag ?? "", + json.color ? RoleColor.fromJSON(json.color) : null, + json.auto_assign_online_time_ms ?? null, + json.block_auto_assign ?? false, + json.updated_at_ms ?? 0, + json.is_deleted ?? false, + ); + } +} + +/** + * A clusterio role combined with its in game properties. + * + * This is the form sent to instances, because instances can not subscribe to + * the core role updates which are only sent to control connections. + */ +export class RoleRecord { + constructor( + public id: number, + public name: string, + public permissions: string[], + public meta: RoleMetaRecord, + /** True for the role every player holds, taken from controller.default_role_id. */ + public isDefault: boolean = false, + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + static jsonSchema = Type.Object({ + id: Type.Integer(), + name: Type.String(), + permissions: Type.Array(Type.String()), + meta: RoleMetaRecord.jsonSchema, + is_default: Type.Optional(Type.Boolean()), + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { + id: this.id, + name: this.name, + permissions: this.permissions, + meta: this.meta.toJSON(), + }; + + if (this.isDefault) { + json.is_default = true; + } + + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + + if (this.isDeleted) { + json.is_deleted = true; + } + + return json; + } + + static fromJSON(json: Static) { + return new this( + json.id, + json.name, + json.permissions, + RoleMetaRecord.fromJSON(json.meta), + json.is_default ?? false, + json.updated_at_ms ?? 0, + json.is_deleted ?? false, + ); + } +} + +/** The roles a single player holds, mirroring the roles of their cluster user. */ +export class AssignmentRecord { + constructor( + public name: string, + public roleIds: Set, + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + get id() { + return this.name; + } + + static jsonSchema = Type.Object({ + name: Type.String(), + role_ids: Type.Array(Type.Integer()), + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { + name: this.name, + role_ids: [...this.roleIds], + }; + + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + + if (this.isDeleted) { + json.is_deleted = true; + } + + return json; + } + + static fromJSON(json: Static) { + return new this( + json.name, + new Set(json.role_ids), + json.updated_at_ms ?? 0, + json.is_deleted ?? false, + ); + } +} + +/* + Update events +*/ + +export class RoleUpdatedEvent { + declare ["constructor"]: typeof RoleUpdatedEvent; + static plugin = "exp_roles" as const; + static type = "event" as const; + static src = "controller" as const; + static dst = ["control", "instance"] as const; + static permission = "exp_roles.role.subscribe" as const; + + constructor( + public updates: RoleRecord[], + ) {} + + static jsonSchema = Type.Object({ + updates: Type.Array(RoleRecord.jsonSchema), + }); + + toJSON() { + return { updates: this.updates.map(role => role.toJSON()) }; + } + + static fromJSON(json: Static) { + return new this(json.updates.map(role => RoleRecord.fromJSON(role))); + } +} + +export class AssignmentUpdatedEvent { + declare ["constructor"]: typeof AssignmentUpdatedEvent; + static plugin = "exp_roles" as const; + static type = "event" as const; + static src = "controller" as const; + static dst = ["control", "instance"] as const; + static permission = "exp_roles.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))); + } +} + +/* + Role requests +*/ + +export class RoleListRequest { + declare ["constructor"]: typeof RoleListRequest; + static plugin = "exp_roles" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "exp_roles.role.list" as const; + static Response = lib.jsonArray(RoleRecord); + + constructor() {} +} + +export class RoleMetaUpdateRequest { + declare ["constructor"]: typeof RoleMetaUpdateRequest; + static plugin = "exp_roles" as const; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "exp_roles.role.update" as const; + + constructor( + public meta: RoleMetaRecord, + ) {} + + static jsonSchema = Type.Object({ + meta: RoleMetaRecord.jsonSchema, + }); + + toJSON() { + return { meta: this.meta.toJSON() }; + } + + static fromJSON(json: Static) { + return new this(RoleMetaRecord.fromJSON(json.meta)); + } +} + +/* + Assignment requests +*/ + +export class AssignmentListRequest { + declare ["constructor"]: typeof AssignmentListRequest; + static plugin = "exp_roles" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "exp_roles.assignment.list" as const; + static Response = lib.jsonArray(AssignmentRecord); + + constructor() {} +} + +/** + * Add or remove roles for a player, sent by an instance when a role is changed + * in game. Only accepted when the instance is configured as bidirectional. + */ +export class AssignmentUpdateRequest { + declare ["constructor"]: typeof AssignmentUpdateRequest; + static plugin = "exp_roles" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "core.user.update_roles" as const; + + constructor( + public name: string, + public assign: number[], + public unassign: number[], + ) {} + + static jsonSchema = Type.Object({ + name: Type.String(), + assign: Type.Array(Type.Integer()), + unassign: Type.Array(Type.Integer()), + }); + + toJSON() { + return { + name: this.name, + assign: this.assign, + unassign: this.unassign, + }; + } + + static fromJSON(json: Static) { + return new this(json.name, json.assign, json.unassign); + } +} diff --git a/exp_roles/module/control.lua b/exp_roles/module/control.lua new file mode 100644 index 00000000..0e7af449 --- /dev/null +++ b/exp_roles/module/control.lua @@ -0,0 +1,888 @@ +--[[-- ExpRoles +Replaces the legacy role system with clusterio's roles and permissions. + +Roles and the players who hold them are owned by the controller. This module +mirrors that state into the game and presents the same interface the legacy +`expcore.roles` module did, so existing call sites keep working. + +Assignments made in game are applied locally first and then sent to the +controller, which avoids callers having to deal with the round trip. A local +assignment which is never sent, such as one earned from time on this map, can be +made with `assign_player_local`. +]] + +local clusterio_api = require("modules/clusterio/api") +local compat = require("modules/clusterio/compat") +local Async = require("modules/exp_util/async") + +--- @class ExpRoles +local ExpRoles = { + _prototype = {}, + config = { + --- Role names in order, a lower index is a more privileged role + order = {}, --- @type string[] + --- Roles indexed by name, this table is never replaced + roles = {}, --- @type table + --- Role names held by each player, includes local assignments + players = {}, --- @type table + --- Callbacks run when a flag is gained or lost + flags = {}, --- @type table + }, + events = { + on_role_assigned = script.generate_event_name(), + on_role_unassigned = script.generate_event_name(), + }, +} + +--- @class ExpRoles.Role +--- @field id number Clusterio role id +--- @field name string +--- @field short_hand string +--- @field order number Order given by the controller, which can have gaps +--- @field index number Position within the role order, with no gaps +--- @field priority number Only the highest priority roles a player holds apply +--- @field custom_tag string +--- @field custom_color Color? +--- @field allowed_actions table Permission names granted by this role +--- @field flags table Legacy flag names granted by this role +--- @field block_auto_assign boolean + +--- @class ExpRoles.ScriptData +--- @field roles table Roles indexed by clusterio id +--- @field synced_players table Role ids from the controller +--- @field local_players table Role ids which only exist on this map +--- @field pending table Local role ids waiting to be confirmed +--- @field default_role_id number? Role every player holds +--- @field emit_updates boolean +local script_data = {} + +--[[ + Permission names +]] + +--- Legacy actions which map onto core clusterio permissions +local core_action_permissions = { + ["command/assign-role"] = "core.user.update_roles", + ["command/unassign-role"] = "core.user.update_roles", + ["command/get-roles"] = "core.role.list", +} + +--- Cache for the action to permission mapping, this is a hot path +local action_permissions = {} --- @type table +local flag_permissions = {} --- @type table + +--- Convert a legacy action such as `gui/warp-list/add` into a permission name +--- This must match permissionFromAction in exp_scenario/permissions.ts +--- @param action string +--- @return string +local function permission_from_action(action) + local permission = action_permissions[action] + if permission then return permission end + + permission = core_action_permissions[action] + if not permission then + local body = action:gsub("%-", "_"):gsub("/", ".") + if not body:find(".", 1, true) then + body = "action." .. body + end + permission = "exp_scenario." .. body + end + + action_permissions[action] = permission + return permission +end + +--- Convert a legacy flag such as `report-immune` into a permission name +--- This must match permissionFromFlag in exp_scenario/permissions.ts +--- @param flag string +--- @return string +local function permission_from_flag(flag) + local permission = flag_permissions[flag] + if permission then return permission end + + permission = "exp_scenario.flag." .. (flag:gsub("%-", "_")) + flag_permissions[flag] = permission + return permission +end + +--[[ + Role state +]] + +--- Rebuild config.order and config.roles from the synced roles, in place +local function rebuild_role_views() + local roles = {} + for _, role in pairs(script_data.roles) do + roles[#roles + 1] = role + end + + -- Sorted on the order given by the controller, which can have gaps, the + -- index each role is given is its position within that order + table.sort(roles, function(a, b) + if a.order == b.order then return a.id < b.id end + return a.order < b.order + end) + + local order, by_name = ExpRoles.config.order, ExpRoles.config.roles + for key in pairs(order) do order[key] = nil end + for key in pairs(by_name) do by_name[key] = nil end + + for index, role in ipairs(roles) do + role.index = index + order[index] = role.name + by_name[role.name] = role + end +end + +--- Build a role from the record sent by the controller +--- @param record table +--- @return ExpRoles.Role +local function decode_role(record) + local meta = record.meta + local allowed_actions = {} + for _, permission in pairs(record.permissions) do + allowed_actions[permission] = true + end + + local flags = {} + for permission in pairs(allowed_actions) do + local flag = permission:match("^exp_scenario%.flag%.(.+)$") + if flag then flags[flag] = true end + end + + return setmetatable({ + id = record.id, + name = record.name, + short_hand = meta.short_hand or record.name, + order = meta.order or record.id, + index = meta.order or record.id, + priority = meta.priority or 0, + custom_tag = meta.tag or "", + custom_color = meta.color, + allowed_actions = allowed_actions, + flags = flags, + block_auto_assign = meta.block_auto_assign or false, + }, { __index = ExpRoles._prototype }) +end + +--- Refresh the legacy view of the roles a player holds +--- @param player_name string +local function rebuild_player_view(player_name) + local role_ids = {} + for _, role_id in pairs(script_data.synced_players[player_name] or {}) do + role_ids[#role_ids + 1] = role_id + end + for _, role_id in pairs(script_data.local_players[player_name] or {}) do + role_ids[#role_ids + 1] = role_id + end + + if #role_ids == 0 then + ExpRoles.config.players[player_name] = nil + return + end + + local names, seen = {}, {} + for _, role_id in pairs(role_ids) do + local role = script_data.roles[role_id] + if role and not seen[role.name] then + seen[role.name] = true + names[#names + 1] = role.name + end + end + + ExpRoles.config.players[player_name] = names +end + +--- Rebuild the legacy view for every known player +local function rebuild_all_player_views() + local players = ExpRoles.config.players + for key in pairs(players) do players[key] = nil end + + local seen = {} + for player_name in pairs(script_data.synced_players) do seen[player_name] = true end + for player_name in pairs(script_data.local_players) do seen[player_name] = true end + for player_name in pairs(seen) do rebuild_player_view(player_name) end +end + +--[[ + Role lookup +]] + +--- Get a role by its name +--- @param name string +--- @return ExpRoles.Role? +function ExpRoles.get_role_by_name(name) + return ExpRoles.config.roles[name] +end + +--- Get a role by its position in the role order +--- @param index number +--- @return ExpRoles.Role? +function ExpRoles.get_role_by_order(index) + local name = ExpRoles.config.order[index] + return name and ExpRoles.config.roles[name] +end + +--- Get a role from a name, order index, or role +--- @param any string | number | ExpRoles.Role +--- @return ExpRoles.Role? +function ExpRoles.get_role_from_any(any) + local t_any = type(any) + if t_any == "number" or tonumber(any) then + return ExpRoles.get_role_by_order(tonumber(any)) + elseif t_any == "string" then + return ExpRoles.get_role_by_name(any) + elseif t_any == "table" then + return ExpRoles.get_role_by_name(any.name) + end +end + +--- Get all roles in order, the most privileged first +--- @return ExpRoles.Role[] +function ExpRoles.get_roles_ordered() + local rtn = {} + for index, role_name in ipairs(ExpRoles.config.order) do + rtn[index] = ExpRoles.config.roles[role_name] + end + return rtn +end + +--- Role used when there is no player, such as for commands run by the server +--- It bypasses every permission check the same way the legacy root role did +local server_role = setmetatable({ + id = -1, + name = "", + short_hand = "SRV", + order = 0, + index = 0, + priority = 0, + custom_tag = "", + custom_color = nil, + allowed_actions = { ["core.admin"] = true }, + flags = {}, + block_auto_assign = true, +}, { __index = ExpRoles._prototype }) + +--- Get the roles a player holds, including the default role +--- Only the roles with the highest priority are returned, which lets a role +--- such as Jail suppress every other role a player holds +--- @param player LuaPlayer | string | nil +--- @return ExpRoles.Role[] +function ExpRoles.get_player_roles(player) + local player_name = type(player) == "table" and player.name or player --[[ @as string? ]] + -- The server is not a player and is allowed to do anything + if player_name == nil then return { server_role } end + + local role_ids = {} + if script_data.default_role_id then + role_ids[#role_ids + 1] = script_data.default_role_id + end + for _, role_id in pairs(script_data.synced_players[player_name] or {}) do + role_ids[#role_ids + 1] = role_id + end + for _, role_id in pairs(script_data.local_players[player_name] or {}) do + role_ids[#role_ids + 1] = role_id + end + + local roles, highest_priority, seen = {}, nil, {} + for _, role_id in pairs(role_ids) do + local role = script_data.roles[role_id] + if role and not seen[role_id] then + seen[role_id] = true + if highest_priority == nil or role.priority > highest_priority then + highest_priority = role.priority + end + roles[#roles + 1] = role + end + end + + local rtn = {} + for _, role in pairs(roles) do + if role.priority == highest_priority then + rtn[#rtn + 1] = role + end + end + + table.sort(rtn, function(a, b) return a.index < b.index end) + return rtn +end + +--- Get the most privileged role a player holds +--- @param player LuaPlayer | string | nil +--- @return ExpRoles.Role? +function ExpRoles.get_player_highest_role(player) + return ExpRoles.get_player_roles(player)[1] +end + +--[[ + Permission checks +]] + +--- Check if a player is allowed to perform an action +--- @param player LuaPlayer | string | nil +--- @param action string A legacy action such as `command/kill` +--- @return boolean +function ExpRoles.player_allowed(player, action) + local permission = permission_from_action(action) + for _, role in pairs(ExpRoles.get_player_roles(player)) do + local allowed = role.allowed_actions + if allowed["core.admin"] or allowed[permission] then + return true + end + end + + return false +end + +--- Check if a player has a flag set by at least one of their roles +--- @param player LuaPlayer | string | nil +--- @param flag_name string +--- @return boolean +function ExpRoles.player_has_flag(player, flag_name) + local permission = permission_from_flag(flag_name) + for _, role in pairs(ExpRoles.get_player_roles(player)) do + if role.allowed_actions[permission] then + return true + end + end + + return false +end + +--- Check if a player holds a role +--- @param player LuaPlayer | string | nil +--- @param search_role string | number | ExpRoles.Role +--- @return boolean +function ExpRoles.player_has_role(player, search_role) + local role = ExpRoles.get_role_from_any(search_role) + if not role then return false end + + for _, player_role in pairs(ExpRoles.get_player_roles(player)) do + if player_role.name == role.name then return true end + end + + return false +end + +--- Check if a player bypasses all permission checks +--- This replaces the legacy root role +--- @param player LuaPlayer | string | nil +--- @return boolean +function ExpRoles.is_root(player) + for _, role in pairs(ExpRoles.get_player_roles(player)) do + if role.allowed_actions["core.admin"] then return true end + end + + return false +end + +--[[ + Role prototype +]] + +--- Check if this role allows an action +--- @param action string +--- @return boolean +function ExpRoles._prototype:is_allowed(action) + local allowed = self.allowed_actions + return allowed["core.admin"] or allowed[permission_from_action(action)] or false +end + +--- Check if this role sets a flag +--- @param name string +--- @return boolean +function ExpRoles._prototype:has_flag(name) + return self.allowed_actions[permission_from_flag(name)] or false +end + +--- Get the players who hold this role +--- @param online boolean? When given, filter by connected state +--- @return LuaPlayer[] +function ExpRoles._prototype:get_players(online) + local players = {} + for player_name, role_names in pairs(ExpRoles.config.players) do + for _, role_name in pairs(role_names) do + if role_name == self.name then + local player = game.players[player_name] + if player and (online == nil or player.connected == online) then + players[#players + 1] = player + end + break + end + end + end + + return players +end + +--- Print a message to every online player who holds this role +--- @param message LocalisedString +--- @return number # Number of players the message was sent to +function ExpRoles._prototype:print(message) + local players = self:get_players(true) + for _, player in pairs(players) do + player.print(message) + end + + return #players +end + +--[[ + Printing to roles +]] + +--- Print a message to every player holding one of the given roles +--- @param roles (string | number | ExpRoles.Role)[] +--- @param message LocalisedString +function ExpRoles.print_to_roles(roles, message) + for _, role in pairs(roles) do + role = ExpRoles.get_role_from_any(role) + if role then role:print(message) end + end +end + +--- Print a message to every player holding the given role or a more privileged one +--- @param role string | number | ExpRoles.Role +--- @param message LocalisedString +function ExpRoles.print_to_roles_higher(role, message) + role = ExpRoles.get_role_from_any(role) + if not role then return end + + local roles = {} + for index, role_name in ipairs(ExpRoles.config.order) do + if index <= role.index and role_name ~= ExpRoles.get_default_role_name() then + roles[#roles + 1] = role_name + end + end + + ExpRoles.print_to_roles(roles, message) +end + +--- Print a message to every player holding the given role or a less privileged one +--- @param role string | number | ExpRoles.Role +--- @param message LocalisedString +function ExpRoles.print_to_roles_lower(role, message) + role = ExpRoles.get_role_from_any(role) + if not role then return end + + local roles = {} + for index, role_name in ipairs(ExpRoles.config.order) do + if index >= role.index and role_name ~= ExpRoles.get_default_role_name() then + roles[#roles + 1] = role_name + end + end + + ExpRoles.print_to_roles(roles, message) +end + +--- Get the name of the role every player holds +--- @return string? +function ExpRoles.get_default_role_name() + local role = script_data.default_role_id and script_data.roles[script_data.default_role_id] + return role and role.name or nil +end + +--[[ + Flags +]] + +--- Register a callback run when a player gains or loses a flag +--- @param name string +--- @param callback fun(player: LuaPlayer, state: boolean) +function ExpRoles.define_flag_trigger(name, callback) + ExpRoles.config.flags[name] = Async.register(callback) +end + +--- Run every flag trigger for a player +--- @param player LuaPlayer +local function apply_flag_triggers(player) + for flag, async_function in pairs(ExpRoles.config.flags) do + async_function(player, ExpRoles.player_has_flag(player, flag)) + end +end + +--[[ + Assignment +]] + +--- Raise the assigned or unassigned event and tell the player what changed +--- @param player LuaPlayer +--- @param change_type "assign" | "unassign" +--- @param roles ExpRoles.Role[] +--- @param by_player_name string? +--- @param silent boolean? +local function emit_player_roles_updated(player, change_type, roles, by_player_name, silent) + by_player_name = by_player_name or (game.player and game.player.name) or "" + local by_player = game.players[by_player_name] + + local role_names = {} + for index, role in ipairs(roles) do + role_names[index] = role.name + end + + if not silent then + game.print({ "exp-roles.game-message-" .. change_type, player.name, table.concat(role_names, ", "), by_player_name }) + end + + if change_type == "assign" then + player.play_sound{ path = "utility/achievement_unlocked" } + else + player.play_sound{ path = "utility/game_lost" } + end + + local event = change_type == "assign" and ExpRoles.events.on_role_assigned or ExpRoles.events.on_role_unassigned + script.raise_event(event, { + name = event, + tick = game.tick, + player_index = player.index, + by_player_index = by_player and by_player.index or 0, + roles = role_names, + }) + + apply_flag_triggers(player) +end + +--- Remove a role id from a list, returns true when it was present +--- @param list number[]? +--- @param role_id number +--- @return boolean +local function remove_role_id(list, role_id) + if not list then return false end + for index, value in ipairs(list) do + if value == role_id then + table.remove(list, index) + return true + end + end + return false +end + +--- Add a role id to a list unless it is already present +--- @param list number[] +--- @param role_id number +--- @return boolean +local function add_role_id(list, role_id) + for _, value in ipairs(list) do + if value == role_id then return false end + end + list[#list + 1] = role_id + return true +end + +--- Convert a role, role name, or array of either into an array of roles +--- @param roles any +--- @return ExpRoles.Role[] +local function resolve_roles(roles) + if type(roles) ~= "table" or roles.name then + roles = { roles } + end + + local rtn = {} + for _, role in pairs(roles) do + local resolved = ExpRoles.get_role_from_any(role) + if resolved then rtn[#rtn + 1] = resolved end + end + + return rtn +end + +--- Apply a role change locally and tell the controller about it +--- @param player_name string +--- @param roles ExpRoles.Role[] +--- @param change_type "assign" | "unassign" +--- @param sync boolean +local function apply_local_change(player_name, roles, change_type, sync) + local local_roles = script_data.local_players[player_name] or {} + local pending = script_data.pending[player_name] or {} + local changed = {} + + for _, role in pairs(roles) do + if change_type == "assign" then + local synced = script_data.synced_players[player_name] or {} + local already_synced = false + for _, role_id in pairs(synced) do + if role_id == role.id then already_synced = true break end + end + if not already_synced and add_role_id(local_roles, role.id) then + changed[#changed + 1] = role + if sync then add_role_id(pending, role.id) end + end + else + local removed = remove_role_id(local_roles, role.id) + remove_role_id(pending, role.id) + -- A synced role is only taken away here when the controller is + -- being told as well, otherwise the next update would restore it + if sync then + removed = remove_role_id(script_data.synced_players[player_name], role.id) or removed + end + if removed then + changed[#changed + 1] = role + end + end + end + + script_data.local_players[player_name] = next(local_roles) and local_roles or nil + script_data.pending[player_name] = next(pending) and pending or nil + rebuild_player_view(player_name) + + return changed +end + +--- Send a role change to the controller +--- @param player_name string +--- @param roles ExpRoles.Role[] +--- @param change_type "assign" | "unassign" +local function emit_assignment_update(player_name, roles, change_type) + if not script_data.emit_updates then return end + + local role_ids = {} + for index, role in ipairs(roles) do + role_ids[index] = role.id + end + + clusterio_api.send_json("exp_roles:assignment_update", { + name = player_name, + assign = change_type == "assign" and role_ids or nil, + unassign = change_type == "unassign" and role_ids or nil, + }) +end + +--- Change the roles a player holds +--- @param player LuaPlayer | string +--- @param roles any A role, role name, or array of either +--- @param change_type "assign" | "unassign" +--- @param by_player_name string? +--- @param silent boolean? +--- @param sync boolean +local function change_player_roles(player, roles, change_type, by_player_name, silent, sync) + local player_name = type(player) == "table" and player.name or player --[[ @as string? ]] + if not player_name then return end + + local role_objects = resolve_roles(roles) + if #role_objects == 0 then return end + + local changed = apply_local_change(player_name, role_objects, change_type, sync) + if #changed == 0 then return end + + if sync then + emit_assignment_update(player_name, changed, change_type) + end + + local valid_player = game.get_player(player_name) + if valid_player then + emit_player_roles_updated(valid_player, change_type, changed, by_player_name, silent) + end +end + +--- Give a player one or more roles, the change is sent to the controller +--- @param player LuaPlayer | string +--- @param roles any A role, role name, or array of either +--- @param by_player_name string? +--- @param skip_checks boolean? Unused, kept for compatibility +--- @param silent boolean? +function ExpRoles.assign_player(player, roles, by_player_name, skip_checks, silent) + change_player_roles(player, roles, "assign", by_player_name, silent, true) +end + +--- Take one or more roles from a player, the change is sent to the controller +--- @param player LuaPlayer | string +--- @param roles any A role, role name, or array of either +--- @param by_player_name string? +--- @param skip_checks boolean? Unused, kept for compatibility +--- @param silent boolean? +function ExpRoles.unassign_player(player, roles, by_player_name, skip_checks, silent) + change_player_roles(player, roles, "unassign", by_player_name, silent, true) +end + +--- Give a player one or more roles on this map only, the controller is not told +--- Use this for roles earned from progress which does not leave this map +--- @param player LuaPlayer | string +--- @param roles any A role, role name, or array of either +--- @param by_player_name string? +--- @param silent boolean? +function ExpRoles.assign_player_local(player, roles, by_player_name, silent) + change_player_roles(player, roles, "assign", by_player_name, silent, false) +end + +--- Take one or more local roles from a player, the controller is not told +--- @param player LuaPlayer | string +--- @param roles any A role, role name, or array of either +--- @param by_player_name string? +--- @param silent boolean? +function ExpRoles.unassign_player_local(player, roles, by_player_name, silent) + change_player_roles(player, roles, "unassign", by_player_name, silent, false) +end + +--[[ + Sync entry points +]] + +--- Restore local references to persistent script data after load +function ExpRoles.on_load() + script_data = compat.script_data["exp_roles"] + if script_data then + rebuild_role_views() + rebuild_all_player_views() + end +end + +--- Enable or disable sending role changes back to the controller +--- @param enabled boolean? +function ExpRoles.set_emit_events(enabled) + script_data.emit_updates = enabled ~= false +end + +--- Replace all local state with the state held by the controller +--- @param payload { roles: table[], assignments: table[] } +function ExpRoles.initialise(payload) + local emit_updates = script_data.emit_updates + script_data.emit_updates = false + + script_data.roles = {} + script_data.default_role_id = nil + for _, record in pairs(payload.roles) do + if not record.is_deleted then + script_data.roles[record.id] = decode_role(record) + if record.is_default then + script_data.default_role_id = record.id + end + end + end + + script_data.synced_players = {} + for _, record in pairs(payload.assignments) do + if not record.is_deleted and record.role_ids then + script_data.synced_players[record.name] = record.role_ids + end + end + + -- Anything still pending was sent before the connection was lost, the + -- controller state received here is authoritative + script_data.pending = {} + + rebuild_role_views() + rebuild_all_player_views() + + for _, player in pairs(game.connected_players) do + apply_flag_triggers(player) + end + + script_data.emit_updates = emit_updates +end + +--- Receive changes to roles from the controller +--- @param records table[] +function ExpRoles.receive_role_updates(records) + for _, record in pairs(records) do + if record.is_deleted then + script_data.roles[record.id] = nil + if script_data.default_role_id == record.id then + script_data.default_role_id = nil + end + else + script_data.roles[record.id] = decode_role(record) + if record.is_default then + script_data.default_role_id = record.id + elseif script_data.default_role_id == record.id then + script_data.default_role_id = nil + end + end + end + + rebuild_role_views() + rebuild_all_player_views() + + for _, player in pairs(game.connected_players) do + apply_flag_triggers(player) + end +end + +--- Receive changes to the roles held by players from the controller +--- @param records table[] +function ExpRoles.receive_assignment_updates(records) + for _, record in pairs(records) do + local player_name = record.name + if record.is_deleted then + script_data.synced_players[player_name] = nil + else + script_data.synced_players[player_name] = record.role_ids or {} + end + + -- Anything confirmed by the controller no longer needs to be held locally + local pending = script_data.pending[player_name] + if pending then + for _, role_id in pairs(script_data.synced_players[player_name] or {}) do + if remove_role_id(pending, role_id) then + remove_role_id(script_data.local_players[player_name], role_id) + end + end + if not next(pending) then script_data.pending[player_name] = nil end + end + + local local_roles = script_data.local_players[player_name] + if local_roles and not next(local_roles) then + script_data.local_players[player_name] = nil + end + + rebuild_player_view(player_name) + + local player = game.get_player(player_name) + if player then apply_flag_triggers(player) end + end +end + +--- Roll back a local assignment which the controller rejected +--- @param payload { name: string, role_ids: number[] } +function ExpRoles.reject_assignment(payload) + local player_name = payload.name + local roles = {} + for _, role_id in pairs(payload.role_ids) do + local role = script_data.roles[role_id] + if role then roles[#roles + 1] = role end + end + + if #roles == 0 then return end + + local emit_updates = script_data.emit_updates + script_data.emit_updates = false + change_player_roles(player_name, roles, "unassign", "", true, false) + script_data.emit_updates = emit_updates +end + +--- Get the current script data for debugging purposes +--- @package +function ExpRoles._script_data() + return script_data +end + +--[[ + Event handlers, these are wired up by events.lua +]] + +--- Create persistent script data if this is the first startup +--- @package +function ExpRoles.on_server_startup() + if compat.script_data["exp_roles"] == nil then + --- @type ExpRoles.ScriptData + compat.script_data["exp_roles"] = { + roles = {}, + synced_players = {}, + local_players = {}, + pending = {}, + default_role_id = nil, + emit_updates = false, + } + end + + ExpRoles.on_load() +end + +--- Apply the flag triggers for a player who just joined +--- @package +--- @param event EventData.on_player_joined_game +function ExpRoles.on_player_joined_game(event) + local player = game.get_player(event.player_index) + if player then apply_flag_triggers(player) end +end + +return ExpRoles diff --git a/exp_roles/module/events.lua b/exp_roles/module/events.lua new file mode 100644 index 00000000..6537b5c1 --- /dev/null +++ b/exp_roles/module/events.lua @@ -0,0 +1,19 @@ +--[[-- ExpRoles - Events +Wires the role sync into the factorio event handler. + +This is kept apart from control.lua because `ExpRoles.events` holds the ids of +the events this module raises, which is the interface the legacy role system +had, and event_handler expects `events` to be the handlers to register. +]] + +local clusterio_api = require("modules/clusterio/api") +local ExpRoles = require("modules/exp_roles/control") + +return { + on_load = ExpRoles.on_load, + events = { + [clusterio_api.events.on_server_startup] = ExpRoles.on_server_startup, + [defines.events.on_multiplayer_init] = ExpRoles.on_server_startup, + [defines.events.on_player_joined_game] = ExpRoles.on_player_joined_game, + }, +} diff --git a/exp_roles/module/globals.lua b/exp_roles/module/globals.lua new file mode 100644 index 00000000..10d74063 --- /dev/null +++ b/exp_roles/module/globals.lua @@ -0,0 +1,10 @@ +--[[ +It is best practice to not expose any globals because all modules share a global environment +However, sometimes you need globals, for example to access functions within rcon commands +Therefore, we advise that this should be the only file in your module to expose globals +]] + +--- @diagnostic disable: global-element + +-- Access using `/sc exp_roles.foo()` +exp_roles = require("modules/exp_roles/control") diff --git a/exp_roles/module/locale/en.cfg b/exp_roles/module/locale/en.cfg new file mode 100644 index 00000000..3ea3d06c --- /dev/null +++ b/exp_roles/module/locale/en.cfg @@ -0,0 +1,3 @@ +[exp-roles] +game-message-assign=__1__ has been assigned: __2__ by __3__ +game-message-unassign=__1__ has been unassigned: __2__ by __3__ diff --git a/exp_roles/module/module.json b/exp_roles/module/module.json new file mode 100644 index 00000000..7824e3f9 --- /dev/null +++ b/exp_roles/module/module.json @@ -0,0 +1,13 @@ +{ + "name": "exp_roles", + "load": [ + "events.lua" + ], + "require": [ + "globals.lua" + ], + "dependencies": { + "clusterio": "*", + "exp_util": "*" + } +} diff --git a/exp_roles/module/module_exports.lua b/exp_roles/module/module_exports.lua new file mode 100644 index 00000000..9e4d9285 --- /dev/null +++ b/exp_roles/module/module_exports.lua @@ -0,0 +1,3 @@ + +-- Access the exports from other modules using require("modules/exp_roles") +return require("modules/exp_roles/control") diff --git a/exp_roles/package.json b/exp_roles/package.json new file mode 100644 index 00000000..d6530a11 --- /dev/null +++ b/exp_roles/package.json @@ -0,0 +1,49 @@ +{ + "name": "@expcluster/roles", + "version": "6.5.0", + "description": "Clusterio plugin providing syncing of in game roles.", + "author": "Cooldude2606 ", + "license": "MIT", + "repository": "explosivegaming/ExpCluster", + "main": "dist/node/index.js", + "scripts": { + "prepare": "tsc --build && webpack-cli --env production" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^", + "@clusterio/web_ui": "workspace:^" + }, + "devDependencies": { + "@ant-design/icons": "^6.2.3", + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^", + "@clusterio/web_ui": "workspace:^", + "@types/node": "catalog:", + "@types/react": "catalog:", + "antd": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-router-dom": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" + }, + "dependencies": { + "@sinclair/typebox": "catalog:" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "clusterio", + "clusterio-plugin", + "factorio" + ] +} diff --git a/exp_roles/tsconfig.browser.json b/exp_roles/tsconfig.browser.json new file mode 100644 index 00000000..1e3889e7 --- /dev/null +++ b/exp_roles/tsconfig.browser.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.browser.json", + "include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ], +} diff --git a/exp_roles/tsconfig.json b/exp_roles/tsconfig.json new file mode 100644 index 00000000..303b8f28 --- /dev/null +++ b/exp_roles/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.browser.json" } + ] +} diff --git a/exp_roles/tsconfig.node.json b/exp_roles/tsconfig.node.json new file mode 100644 index 00000000..3218f2e7 --- /dev/null +++ b/exp_roles/tsconfig.node.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./**/*.ts"], + "exclude": ["test/*", "./dist/*"], +} diff --git a/exp_roles/web/components/RoleProperties.tsx b/exp_roles/web/components/RoleProperties.tsx new file mode 100644 index 00000000..8e904f2b --- /dev/null +++ b/exp_roles/web/components/RoleProperties.tsx @@ -0,0 +1,145 @@ +import React, { useContext, useEffect, useState } from "react"; +import { Button, ColorPicker, Form, Input, InputNumber, Space, Switch, Tooltip } from "antd"; + +import * as lib from "@clusterio/lib"; +import { ControlContext, SectionHeader, useAccount, notifyErrorHandler } from "@clusterio/web_ui"; + +import { RoleColor, RoleMetaRecord, RoleMetaUpdateRequest } from "../../messages"; +import type { WebPlugin } from ".."; + +const MS_PER_HOUR = 3600000; + +type RoleFormValues = { + order: number; + priority: number; + shortHand: string; + tag: string; + color: string | { toRgb(): { r: number, g: number, b: number } } | null; + autoAssignHours: number | null; + blockAutoAssign: boolean; +}; + +/** + * In game properties of a role, shown at the end of the core role page. + * + * The name, description and permissions of a role are owned by clusterio, this + * only covers the properties which only mean something in game. + */ +export default function RoleProperties(props: { plugin: WebPlugin, role?: lib.Role }) { + const control = useContext(ControlContext); + const account = useAccount(); + const [form] = Form.useForm(); + const [applying, setApplying] = useState(false); + + const [roles] = props.plugin.useRoles(); + const record = props.role ? roles.get(props.role.id) : undefined; + const meta = record?.meta; + const canUpdate = account.hasPermission("exp_roles.role.update"); + + useEffect(() => { + if (!meta) { + return; + } + form.setFieldsValue({ + order: meta.order, + priority: meta.priority, + shortHand: meta.shortHand, + tag: meta.tag, + color: meta.color ? `rgb(${meta.color.r}, ${meta.color.g}, ${meta.color.b})` : null, + autoAssignHours: meta.autoAssignOnlineTimeMs === null + ? null + : meta.autoAssignOnlineTimeMs / MS_PER_HOUR, + blockAutoAssign: meta.blockAutoAssign, + }); + }, [meta, form]); + + if (!props.role || !meta) { + return null; + } + + async function apply() { + const values = form.getFieldsValue(); + + let color: RoleColor | null = null; + if (values.color && typeof values.color !== "string") { + const rgb = values.color.toRgb(); + color = new RoleColor(Math.round(rgb.r), Math.round(rgb.g), Math.round(rgb.b)); + } else if (typeof values.color === "string") { + const match = values.color.match(/(\d+)\D+(\d+)\D+(\d+)/); + if (match) { + color = new RoleColor(Number(match[1]), Number(match[2]), Number(match[3])); + } + } + + await control.send(new RoleMetaUpdateRequest(new RoleMetaRecord( + props.role!.id, + values.order, + values.priority, + values.shortHand ?? "", + values.tag ?? "", + color, + values.autoAssignHours === null || values.autoAssignHours === undefined + ? null + : values.autoAssignHours * MS_PER_HOUR, + values.blockAutoAssign ?? false, + ))); + } + + return <> + +
+ Order} + > + + + + Priority + + } + > + + + + + + + + + + + + + Auto assign after (hours) + + } + > + + + + + + {canUpdate && + + + + } +
+ ; +} diff --git a/exp_roles/web/index.tsx b/exp_roles/web/index.tsx new file mode 100644 index 00000000..644cd10d --- /dev/null +++ b/exp_roles/web/index.tsx @@ -0,0 +1,24 @@ +import React, { useCallback, useSyncExternalStore } from "react"; +import { BaseWebPlugin } from "@clusterio/web_ui"; + +import * as lib from "@clusterio/lib"; +import * as messages from "../messages"; + +import RoleProperties from "./components/RoleProperties"; + +export class WebPlugin extends BaseWebPlugin { + roles = new lib.MapSubscriber(messages.RoleUpdatedEvent, this.control); + + async init() { + // The core components pass a role and the plugin, which componentExtra + // does not carry in its type + this.componentExtra = { + RoleViewPage: RoleProperties as React.ComponentType, + }; + } + + useRoles() { + const subscribe = useCallback((cb: () => void) => this.roles.subscribe(cb), []); + return useSyncExternalStore(subscribe, () => this.roles.getSnapshot()); + } +} diff --git a/exp_roles/webpack.config.js b/exp_roles/webpack.config.js new file mode 100644 index 00000000..20f6cfb5 --- /dev/null +++ b/exp_roles/webpack.config.js @@ -0,0 +1,34 @@ +"use strict"; +const path = require("path"); +const webpack = require("webpack"); +const { merge } = require("webpack-merge"); + +const common = require("@clusterio/web_ui/webpack.common"); + +module.exports = (env = {}) => merge(common(env), { + context: __dirname, + entry: "./web/index.tsx", + output: { + path: path.resolve(__dirname, "dist", "web"), + }, + plugins: [ + new webpack.container.ModuleFederationPlugin({ + name: "exp_roles", + library: { type: "window", name: "plugin_exp_roles" }, + exposes: { + "./": "./index.ts", + "./package.json": "./package.json", + "./web": "./web/index.tsx", + }, + shared: { + "@clusterio/lib": { import: false }, + "@clusterio/web_ui": { import: false }, + "antd": { import: false }, + "react": { import: false }, + "react-dom": { import: false }, + "react-router": { import: false }, + "react-router-dom": { import: false }, + }, + }), + ], +}); diff --git a/tsconfig.json b/tsconfig.json index 5aeb9f33..8afb8e7c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ { "path": "./exp_groups/" }, { "path": "./exp_gui/" }, { "path": "./exp_legacy/" }, + { "path": "./exp_roles/" }, { "path": "./exp_scenario/" }, { "path": "./exp_server_ups/" }, { "path": "./exp_util/" },