Add exp_roles plugin to sync roles from the controller

Clusterio already stores roles, the permissions they grant, and which user holds
which role, along with a web UI for all three. What it does not have is the
properties a role only needs in game, or a way for an instance to learn about
any of it, since role and user updates are only sent to control connections.

This plugin fills both gaps. The controller keeps a record per role holding the
order, priority, short hand, tag, colour and auto assign threshold, created
automatically for any role which does not have one. It then rebroadcasts roles
and assignments on its own events so instances can follow them.

The lua module presents the same interface the legacy expcore.roles module did,
so the call sites can be moved over without being rewritten. Permission checks
translate the legacy action strings using the same mapping exp_scenario defines.

Two things replace features the legacy system had:

- Priority replaces disallow. Only the roles with the highest priority a player
  holds are considered, so Jail can suppress every other role including the
  default one, without needing to take roles away first.
- Assignments made in game are applied locally and then sent to the controller,
  which keeps assign_player synchronous for callers. A role which should never
  leave this map, such as one earned from time on the map, is assigned with
  assign_player_local instead.

Roles earned from online time across the cluster are granted by the controller
from the threshold on the role, using the online time clusterio already tracks.

Nothing requires this plugin yet; moving the call sites off expcore.roles and
removing the legacy config is left for a follow up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
bbassie
2026-07-31 09:28:40 +00:00
co-authored by Claude Opus 5
parent e852abc309
commit bbd3b7c1f2
18 changed files with 2074 additions and 0 deletions
+299
View File
@@ -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<messages.RoleMetaRecord>;
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<lib.Role>) {
const meta = this.roleMeta.get(role.id) ?? new messages.RoleMetaRecord(role.id, role.id);
return new messages.RoleRecord(
role.id,
role.name,
[...role.permissions],
meta,
role.id === this.controller.config.get("controller.default_role_id"),
Math.max(role.updatedAtMs, meta.updatedAtMs),
role.isDeleted,
);
}
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
// 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<lib.UserDetails>) {
const defaultRoleId = this.controller.config.get("controller.default_role_id");
const roleIds = new Set(user.roleIds);
if (defaultRoleId !== null) {
roleIds.delete(defaultRoleId);
}
return new messages.AssignmentRecord(
user.name,
roleIds,
user.updatedAtMs,
user.isDeleted || roleIds.size === 0,
);
}
listAssignmentRecords() {
const assignments = [];
for (const user of this.controller.users.records.values()) {
const assignment = this.buildAssignmentRecord(user);
if (!assignment.isDeleted) {
assignments.push(assignment);
}
}
return assignments;
}
async handleAssignmentListRequest() {
return this.listAssignmentRecords();
}
usersUpdated(users: lib.UserDetails[]) {
this.controller.subscriptions.broadcast(
new messages.AssignmentUpdatedEvent(users.map(user => this.buildAssignmentRecord(user)))
);
this.applyAutoAssign(users.map(user => user.name));
}
async handleAssignmentSubscription(request: lib.SubscriptionRequest) {
const assignments = this.listAssignmentRecords()
.filter(assignment => assignment.updatedAtMs > request.lastRequestTimeMs);
return assignments.length ? new messages.AssignmentUpdatedEvent(assignments) : null;
}
async handleAssignmentUpdateRequest(request: messages.AssignmentUpdateRequest) {
const user = this.controller.users.getByNameMutable(request.name);
if (!user) {
throw new lib.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]);
}
}
}
+77
View File
@@ -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",
};
+104
View File
@@ -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
);
}
}
+389
View File
@@ -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<typeof RoleColor.jsonSchema> {
return { r: this.r, g: this.g, b: this.b };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
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<typeof RoleMetaRecord.jsonSchema> = {
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<typeof this.jsonSchema>) {
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<typeof RoleRecord.jsonSchema> = {
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<typeof this.jsonSchema>) {
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<number>,
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<typeof AssignmentRecord.jsonSchema> = {
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<typeof this.jsonSchema>) {
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<typeof this.jsonSchema>) {
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<typeof this.jsonSchema>) {
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<typeof this.jsonSchema>) {
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<typeof this.jsonSchema>) {
return new this(json.name, json.assign, json.unassign);
}
}
+888
View File
@@ -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<string, ExpRoles.Role>
--- Role names held by each player, includes local assignments
players = {}, --- @type table<string, string[]>
--- Callbacks run when a flag is gained or lost
flags = {}, --- @type table<string, fun(player: LuaPlayer, state: boolean)>
},
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<string, boolean> Permission names granted by this role
--- @field flags table<string, boolean> Legacy flag names granted by this role
--- @field block_auto_assign boolean
--- @class ExpRoles.ScriptData
--- @field roles table<number, ExpRoles.Role> Roles indexed by clusterio id
--- @field synced_players table<string, number[]> Role ids from the controller
--- @field local_players table<string, number[]> Role ids which only exist on this map
--- @field pending table<string, number[]> 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<string, string>
local flag_permissions = {} --- @type table<string, string>
--- 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 = "<server>",
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 "<server>"
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", "<server>", 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
+19
View File
@@ -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,
},
}
+10
View File
@@ -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")
+3
View File
@@ -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__
+13
View File
@@ -0,0 +1,13 @@
{
"name": "exp_roles",
"load": [
"events.lua"
],
"require": [
"globals.lua"
],
"dependencies": {
"clusterio": "*",
"exp_util": "*"
}
}
+3
View File
@@ -0,0 +1,3 @@
-- Access the exports from other modules using require("modules/exp_roles")
return require("modules/exp_roles/control")
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@expcluster/roles",
"version": "6.5.0",
"description": "Clusterio plugin providing syncing of in game roles.",
"author": "Cooldude2606 <https://github.com/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"
]
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.browser.json",
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.browser.json" }
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.node.json",
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
}
+145
View File
@@ -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<RoleFormValues>();
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 <>
<SectionHeader title="In Game Properties" />
<Form form={form} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} disabled={!canUpdate}>
<Form.Item
name="order"
label={<Tooltip title="A lower value is a more privileged role">Order</Tooltip>}
>
<InputNumber />
</Form.Item>
<Form.Item
name="priority"
label={
<Tooltip title="Only the highest priority roles a player holds apply, used by Jail">
Priority
</Tooltip>
}
>
<InputNumber />
</Form.Item>
<Form.Item name="shortHand" label="Short hand">
<Input />
</Form.Item>
<Form.Item name="tag" label="Tag">
<Input />
</Form.Item>
<Form.Item name="color" label="Colour">
<ColorPicker allowClear format="rgb" disabledAlpha />
</Form.Item>
<Form.Item
name="autoAssignHours"
label={
<Tooltip title="Granted once a player reaches this much online time across the cluster">
Auto assign after (hours)
</Tooltip>
}
>
<InputNumber min={0} placeholder="Never" />
</Form.Item>
<Form.Item name="blockAutoAssign" label="Block auto assign" valuePropName="checked">
<Switch />
</Form.Item>
{canUpdate && <Form.Item wrapperCol={{ offset: 8 }}>
<Space>
<Button
type="primary"
loading={applying}
onClick={() => {
setApplying(true);
apply()
.catch(notifyErrorHandler("Error updating role properties"))
.finally(() => setApplying(false));
}}
>Apply</Button>
</Space>
</Form.Item>}
</Form>
</>;
}
+24
View File
@@ -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());
}
}
+34
View File
@@ -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 },
},
}),
],
});
+1
View File
@@ -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/" },