mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-08-12 16:35:11 +09:00
Merge branch 'explosivegaming:main' into aperx
This commit is contained in:
@@ -17,7 +17,8 @@ jobs:
|
|||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
- name: Install FMTK
|
- name: Install FMTK
|
||||||
run: |
|
run: |
|
||||||
pnpm install factoriomod-debug
|
# Pinned: 2.1.4+ generates typedefs targeting EmmyLua that LuaLS misreads
|
||||||
|
pnpm install factoriomod-debug@2.1.3
|
||||||
pnpm exec fmtk luals-addon
|
pnpm exec fmtk luals-addon
|
||||||
jq '.["workspace.library"] += ["${{ github.workspace }}/factorio/library"] | .["runtime.plugin"] = "${{ github.workspace }}/factorio/plugin.lua"' .luarc.json > temp.luarc.json
|
jq '.["workspace.library"] += ["${{ github.workspace }}/factorio/library"] | .["runtime.plugin"] = "${{ github.workspace }}/factorio/plugin.lua"' .luarc.json > temp.luarc.json
|
||||||
jq -s '.[0] * .[1].settings' temp.luarc.json ${{ github.workspace }}/factorio/config.json > check.luarc.json
|
jq -s '.[0] * .[1].settings' temp.luarc.json ${{ github.workspace }}/factorio/config.json > check.luarc.json
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
// The datastore can be out of step with the roles, either because the
|
||||||
|
// plugin was installed after they were created or because it was
|
||||||
|
// uninstalled while they were deleted
|
||||||
|
this.ensureRoleMeta();
|
||||||
|
this.sweepRoleMeta();
|
||||||
|
this.applyAutoAssign();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete properties left behind by roles which no longer exist.
|
||||||
|
*
|
||||||
|
* Without this they would be picked up by whichever role is next given the
|
||||||
|
* same id, which happens when a role is deleted while the plugin is not
|
||||||
|
* running.
|
||||||
|
*/
|
||||||
|
sweepRoleMeta() {
|
||||||
|
const orphaned = [];
|
||||||
|
for (const meta of this.roleMeta.valuesMutable()) {
|
||||||
|
if (!this.controller.roles.has(meta.id)) {
|
||||||
|
orphaned.push(meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orphaned.length) {
|
||||||
|
this.roleMeta.deleteMany(orphaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
return orphaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) {
|
||||||
|
switch (field) {
|
||||||
|
// Which role is the default is carried on the role records themselves
|
||||||
|
case "controller.default_role_id":
|
||||||
|
this.controller.subscriptions.broadcast(new messages.RoleUpdatedEvent(this.listRoleRecords()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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();
|
||||||
|
this.sweepRoleMeta();
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The in game properties of a role are part of the role, so they are covered by
|
||||||
|
// the core role permissions rather than permissions of their own. Assignments
|
||||||
|
// are only ever sent between the controller and an instance, so they need none.
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
@@ -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", {
|
||||||
|
...messages.encodeRolesForLua(roles),
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode roles for the lua initialise call.
|
||||||
|
*
|
||||||
|
* Roles repeat the same permission names over and over, so each name is sent
|
||||||
|
* once and referenced by index. At 15 roles this is 17 KiB rather than 38 KiB
|
||||||
|
* and decodes in 0.37 ms rather than 0.50 ms; at 60 roles it is 30 KiB rather
|
||||||
|
* than 122 KiB and 0.87 ms rather than 1.52 ms.
|
||||||
|
*
|
||||||
|
* Single role updates stay in the plain form, they have nothing to share.
|
||||||
|
*/
|
||||||
|
export function encodeRolesForLua(roles: RoleRecord[]) {
|
||||||
|
const names: string[] = [];
|
||||||
|
const indexes = new Map<string, number>();
|
||||||
|
|
||||||
|
const encoded = roles.map(role => {
|
||||||
|
const permissions = role.permissions.map(permission => {
|
||||||
|
let index = indexes.get(permission);
|
||||||
|
if (index === undefined) {
|
||||||
|
index = names.length;
|
||||||
|
names.push(permission);
|
||||||
|
indexes.set(permission, index);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...role.toJSON(), permissions };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { permission_names: names, roles: encoded };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
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 = "core.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 = "instance" 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 = "core.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 = "core.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 = "instance" as const;
|
||||||
|
static dst = "controller" 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 = "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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,925 @@
|
|||||||
|
--[[-- 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 = {
|
||||||
|
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[]>
|
||||||
|
--- Async handles run when a flag is gained or lost
|
||||||
|
flags = {}, --- @type table<string, Async.AsyncFunction>
|
||||||
|
},
|
||||||
|
events = {
|
||||||
|
on_role_assigned = script.generate_event_name(),
|
||||||
|
on_role_unassigned = script.generate_event_name(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
--- Methods shared by every role, kept apart from the fields so that defining
|
||||||
|
--- them does not count as injecting fields into the role itself
|
||||||
|
--- @class ExpRoles.RolePrototype
|
||||||
|
local Role = {}
|
||||||
|
ExpRoles._prototype = Role
|
||||||
|
|
||||||
|
--- @class ExpRoles.Role : ExpRoles.RolePrototype
|
||||||
|
--- @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
|
||||||
|
--- @param names string[]? When given, record.permissions holds indexes into it
|
||||||
|
--- @return ExpRoles.Role
|
||||||
|
local function decode_role(record, names)
|
||||||
|
local meta = record.meta
|
||||||
|
local allowed_actions = {}
|
||||||
|
if names then
|
||||||
|
-- Indexes are zero based, having come from javascript
|
||||||
|
for _, index in pairs(record.permissions) do
|
||||||
|
allowed_actions[names[index + 1]] = true
|
||||||
|
end
|
||||||
|
else
|
||||||
|
for _, permission in pairs(record.permissions) do
|
||||||
|
allowed_actions[permission] = true
|
||||||
|
end
|
||||||
|
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
|
||||||
|
|
||||||
|
--- @type ExpRoles.Role
|
||||||
|
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)
|
||||||
|
local as_number = tonumber(any)
|
||||||
|
if as_number then
|
||||||
|
return ExpRoles.get_role_by_order(as_number)
|
||||||
|
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 self ExpRoles.Role
|
||||||
|
--- @param action string
|
||||||
|
--- @return boolean
|
||||||
|
function Role: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 self ExpRoles.Role
|
||||||
|
--- @param name string
|
||||||
|
--- @return boolean
|
||||||
|
function Role:has_flag(name)
|
||||||
|
return self.allowed_actions[permission_from_flag(name)] or false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get the players who hold this role
|
||||||
|
--- @param self ExpRoles.Role
|
||||||
|
--- @param online boolean? When given, filter by connected state
|
||||||
|
--- @return LuaPlayer[]
|
||||||
|
function Role: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 self ExpRoles.Role
|
||||||
|
--- @param message LocalisedString
|
||||||
|
--- @return number # Number of players the message was sent to
|
||||||
|
function Role: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
|
||||||
|
local resolved = ExpRoles.get_role_from_any(role)
|
||||||
|
if resolved then resolved: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)
|
||||||
|
local resolved = ExpRoles.get_role_from_any(role)
|
||||||
|
if not resolved then return end
|
||||||
|
|
||||||
|
local roles = {}
|
||||||
|
for index, role_name in ipairs(ExpRoles.config.order) do
|
||||||
|
if index <= resolved.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)
|
||||||
|
local resolved = ExpRoles.get_role_from_any(role)
|
||||||
|
if not resolved then return end
|
||||||
|
|
||||||
|
local roles = {}
|
||||||
|
for index, role_name in ipairs(ExpRoles.config.order) do
|
||||||
|
if index >= resolved.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
|
||||||
|
local joined = table.concat(role_names, ", ")
|
||||||
|
game.print(change_type == "assign"
|
||||||
|
and { "exp-roles.game-message-assign", player.name, joined, by_player_name }
|
||||||
|
or { "exp-roles.game-message-unassign", player.name, joined, 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
|
||||||
|
--- @return ExpRoles.Role[] # The roles which actually changed
|
||||||
|
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
|
||||||
|
]]
|
||||||
|
|
||||||
|
--- Stop holding a pending role locally once the controller has confirmed it
|
||||||
|
--- Roles assigned with assign_player_local are never pending so are untouched
|
||||||
|
--- @param player_name string
|
||||||
|
local function drop_confirmed_local(player_name)
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
--- 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
|
||||||
|
--- Permission names are sent once and referenced by index, see encodeRolesForLua
|
||||||
|
--- @param payload { permission_names: string[], roles: table[], assignments: table[] }
|
||||||
|
function ExpRoles.initialise(payload)
|
||||||
|
local emit_updates = script_data.emit_updates
|
||||||
|
script_data.emit_updates = false
|
||||||
|
|
||||||
|
local names = payload.permission_names
|
||||||
|
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, names)
|
||||||
|
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
|
||||||
|
|
||||||
|
-- The state received here is authoritative, so a pending role is either
|
||||||
|
-- confirmed and now held by synced_players, or it never landed and has to
|
||||||
|
-- be given up; either way it stops being held locally
|
||||||
|
for player_name, pending in pairs(script_data.pending) do
|
||||||
|
drop_confirmed_local(player_name)
|
||||||
|
for _, role_id in pairs(pending) do
|
||||||
|
remove_role_id(script_data.local_players[player_name], role_id)
|
||||||
|
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
|
||||||
|
end
|
||||||
|
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
|
||||||
|
|
||||||
|
drop_confirmed_local(player_name)
|
||||||
|
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
|
||||||
|
|
||||||
|
-- Sync is false, so this is never sent back to the controller
|
||||||
|
change_player_roles(player_name, roles, "unassign", "<server>", true, false)
|
||||||
|
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
|
||||||
|
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
|
||||||
|
--- @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
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -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__
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "exp_roles",
|
||||||
|
"load": [
|
||||||
|
"events.lua"
|
||||||
|
],
|
||||||
|
"require": [
|
||||||
|
"globals.lua"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"clusterio": "*",
|
||||||
|
"exp_util": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
-- Access the exports from other modules using require("modules/exp_roles")
|
||||||
|
return require("modules/exp_roles/control")
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.browser.json",
|
||||||
|
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.browser.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.node.json",
|
||||||
|
"include": ["./**/*.ts"],
|
||||||
|
"exclude": ["test/*", "./dist/*"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import React, { useContext, useEffect, useState } from "react";
|
||||||
|
import { Button, Col, ColorPicker, Form, Input, InputNumber, Row, 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("core.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,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelled = (text: string, tip: string) => <Tooltip title={tip}>{text}</Tooltip>;
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<SectionHeader
|
||||||
|
title="In Game Properties"
|
||||||
|
extra={canUpdate ? <Button
|
||||||
|
type="primary"
|
||||||
|
loading={applying}
|
||||||
|
onClick={() => {
|
||||||
|
setApplying(true);
|
||||||
|
apply()
|
||||||
|
.catch(notifyErrorHandler("Error updating role properties"))
|
||||||
|
.finally(() => setApplying(false));
|
||||||
|
}}
|
||||||
|
>Apply in game properties</Button> : undefined}
|
||||||
|
/>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
disabled={!canUpdate}
|
||||||
|
labelCol={{ span: 12 }}
|
||||||
|
wrapperCol={{ span: 12 }}
|
||||||
|
labelWrap
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 0]}>
|
||||||
|
<Col xs={24} sm={12} xl={6}>
|
||||||
|
<Form.Item
|
||||||
|
name="order"
|
||||||
|
label={labelled("Order", "A lower value is a more privileged role")}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} xl={6}>
|
||||||
|
<Form.Item
|
||||||
|
name="priority"
|
||||||
|
label={labelled(
|
||||||
|
"Priority",
|
||||||
|
"Only the roles with the highest priority a player holds apply, "
|
||||||
|
+ "which is how Jail suppresses every other role"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} xl={6}>
|
||||||
|
<Form.Item
|
||||||
|
name="shortHand"
|
||||||
|
label={labelled("Short hand", "Short form of the name, used where space is limited")}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} xl={6}>
|
||||||
|
<Form.Item
|
||||||
|
name="tag"
|
||||||
|
label={labelled("Tag", "Shown next to the names of players with this role")}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} xl={6}>
|
||||||
|
<Form.Item
|
||||||
|
name="color"
|
||||||
|
label={labelled("Colour", "Used for the role name and tag in game")}
|
||||||
|
>
|
||||||
|
<ColorPicker allowClear format="rgb" disabledAlpha />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} xl={6}>
|
||||||
|
<Form.Item
|
||||||
|
name="autoAssignHours"
|
||||||
|
label={labelled(
|
||||||
|
"Auto assign after (hours)",
|
||||||
|
"Granted once a player reaches this much online time across the cluster. "
|
||||||
|
+ "Leave empty to never grant it automatically"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} placeholder="Never" style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} xl={6}>
|
||||||
|
<Form.Item
|
||||||
|
name="blockAutoAssign"
|
||||||
|
label={labelled(
|
||||||
|
"Block auto assign",
|
||||||
|
"Players holding this role are never granted any other role automatically"
|
||||||
|
)}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +1,9 @@
|
|||||||
import * as lib from "@clusterio/lib";
|
import * as lib from "@clusterio/lib";
|
||||||
// import * as Messages from "./messages";
|
// import * as Messages from "./messages";
|
||||||
|
|
||||||
|
// Defines a permission for every in game action and role flag used by the scenario
|
||||||
|
import "./permissions";
|
||||||
|
|
||||||
lib.definePermission({
|
lib.definePermission({
|
||||||
name: "exp_scenario.config.view",
|
name: "exp_scenario.config.view",
|
||||||
title: "View ExpScenario Config",
|
title: "View ExpScenario Config",
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ jailed=__1__ was jailed by __2__. Reason: __3__
|
|||||||
already-jailed=__1__ is already jailed.
|
already-jailed=__1__ is already jailed.
|
||||||
|
|
||||||
[exp-commands_unjail]
|
[exp-commands_unjail]
|
||||||
description=Puts a player into jail and removes all other roles.
|
description=Removes a player from jail and restores their previous roles.
|
||||||
arg-player=Player to unjail.
|
arg-player=Player to unjail.
|
||||||
unjailed=__1__ was unjailed by __2__.
|
unjailed=__1__ was unjailed by __2__.
|
||||||
not-jailed=__1__ is not currently jailed.
|
not-jailed=__1__ is not currently jailed.
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import * as lib from "@clusterio/lib";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a legacy role action into its clusterio permission name.
|
||||||
|
*
|
||||||
|
* The exp_roles lua module applies the same transform, so existing call sites such as
|
||||||
|
* `Roles.player_allowed(player, "gui/warp-list/add")` keep working unchanged.
|
||||||
|
*
|
||||||
|
* @param action - Legacy action such as `gui/warp-list/add` or `fast-tree-decon`.
|
||||||
|
* @returns Permission name such as `exp_scenario.gui.warp_list.add`.
|
||||||
|
*/
|
||||||
|
export function permissionFromAction(action: string) {
|
||||||
|
const body = action.replace(/-/g, "_").replace(/\//g, ".");
|
||||||
|
return `exp_scenario.${body.includes(".") ? body : `action.${body}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a legacy role flag into its clusterio permission name.
|
||||||
|
*
|
||||||
|
* @param flag - Legacy flag such as `report-immune`.
|
||||||
|
* @returns Permission name such as `exp_scenario.flag.report_immune`.
|
||||||
|
*/
|
||||||
|
export function permissionFromFlag(flag: string) {
|
||||||
|
return `exp_scenario.flag.${flag.replace(/-/g, "_")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy actions which map onto core clusterio permissions rather than scenario ones. */
|
||||||
|
export const coreActionPermissions: Record<string, string> = {
|
||||||
|
"command/assign-role": "core.user.update_roles",
|
||||||
|
"command/unassign-role": "core.user.update_roles",
|
||||||
|
"command/get-roles": "core.role.list",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Legacy action, user facing title, description, and whether every player gets it. */
|
||||||
|
type ActionDefinition = [action: string, title: string, description: string, grantByDefault?: boolean];
|
||||||
|
|
||||||
|
const actions: ActionDefinition[] = [
|
||||||
|
["bypass-entity-protection", "Bypass entity protection", "Remove entities that the protection filter would block."],
|
||||||
|
["bypass-nukeprotect", "Bypass nuke protection", "Use nukes without the nuke protection restrictions."],
|
||||||
|
["command/_rcon", "/_rcon", "Execute arbitrary code within a custom environment."],
|
||||||
|
["command/admin-chat", "/admin-chat", "Sends a message in chat that only admins can see."],
|
||||||
|
["command/artillery", "/artillery", "Automaticly select enemy target with artillery."],
|
||||||
|
["command/ban", "/ban", "Ban a player from the cluster via the player list."],
|
||||||
|
["command/bring", "/bring", "Teleports a player to you."],
|
||||||
|
["command/chat-commands", "Chat commands", "Use the chat command prefix to trigger auto replies."],
|
||||||
|
["command/clear-blueprints", "/clear-blueprints", "Clear all blueprints."],
|
||||||
|
["command/clear-blueprints-surface", "/clear-blueprints-surface", "Clear all blueprints on the current surface."],
|
||||||
|
["command/clear-ground-items", "/clear-ground-items", "Clear all items on the ground."],
|
||||||
|
["command/clear-inventory", "/clear-inventory", "Clear a player's inventory, moving all items to spawn."],
|
||||||
|
["command/clear-last-warnings", "/clear-last-warnings", "Clears the last warning from a player."],
|
||||||
|
["command/clear-pollution", "/clear-pollution", "Clear pollution from your current surface, or another surface."],
|
||||||
|
["command/clear-reports", "/clear-reports", "Clears all reports from a player or just the report from one player."],
|
||||||
|
["command/clear-script-warnings", "/clear-script-warnings", "Clears all script warnings from a player."],
|
||||||
|
["command/clear-tag/always", "/clear-tag (any player)", "Clear the tag of any player, not just your own."],
|
||||||
|
["command/clear-warnings", "/clear-warnings", "Clears all warnings (and script warnings) from a player."],
|
||||||
|
["command/collectdata", "/collectdata", "Collect data for RCON usage."],
|
||||||
|
["command/commands", "/commands", "List and search all commands for a keyword.", true],
|
||||||
|
["command/connect", "/connect", "Connect to another server.", true],
|
||||||
|
["command/connect-all", "/connect-all", "Connect all players to another server."],
|
||||||
|
["command/connect-player", "/connect-player", "Connect a player to a different server."],
|
||||||
|
["command/create-report", "/create-report", "Reports a player and notifies moderators.", true],
|
||||||
|
["command/create-warning", "/create-warning", "Gives a warning to a player; may lead to automatic script action."],
|
||||||
|
["command/data-preference", "/data-preference", "Allows you to set/get your data saving preference.", true],
|
||||||
|
["command/debug", "/debug", "Opens the debug gui."],
|
||||||
|
["command/follow", "/follow", "Start following a player in spectator."],
|
||||||
|
["command/get-home", "/get-home", "Returns your current home location."],
|
||||||
|
["command/get-reports", "/get-reports", "List the reports against a player, or against every player."],
|
||||||
|
["command/get-warnings", "/get-warnings", "List the warnings against a player, or against every player."],
|
||||||
|
["command/give-warning", "/give-warning", "Give a player a warning via the player list."],
|
||||||
|
["command/goto", "/goto", "Teleports you to a player."],
|
||||||
|
["command/home", "/home", "Teleports you to your home location."],
|
||||||
|
["command/jail", "/jail", "Puts a player into jail and removes all other roles."],
|
||||||
|
["command/kick", "/kick", "Kick a player from the server via the player list."],
|
||||||
|
["command/kill", "/kill", "Kills yourself or another player."],
|
||||||
|
["command/kill-enemies", "/kill-enemies", "Kill all enemy units."],
|
||||||
|
["command/kill/always", "/kill (any player)", "Kill any player, not just yourself."],
|
||||||
|
["command/lawnmower", "/lawnmower", "Clean up biter corpse, decoratives and nuclear hole."],
|
||||||
|
["command/locate", "/locate", "Opens remote view at the location of the player's last location.", true],
|
||||||
|
["command/me", "/me", "Sends an action message in the chat."],
|
||||||
|
["command/protect-area", "/protect-area", "Toggles area protection selection, hold shift to remove protection."],
|
||||||
|
["command/protect-entity", "/protect-entity", "Toggles entity protection selection, hold shift to remove protection."],
|
||||||
|
["command/protect-tag", "/protect-tag", "Toggles protected tag mode, edit and create protected map tags."],
|
||||||
|
["command/rainbow", "/rainbow", "Sends an rainbow message in the chat."],
|
||||||
|
["command/ratio", "/ratio", "Get the input and output ratios of the selected machine.", true],
|
||||||
|
["command/remove-enemies", "/remove-enemies", "Remove all enemy spawners."],
|
||||||
|
["command/remove-join-message", "/remove-join-message", "Removes you custom join message."],
|
||||||
|
["command/repair", "/repair", "Repairs entities on your force around you."],
|
||||||
|
["command/research-all", "/research-all", "Research all technology for your force, or another force."],
|
||||||
|
["command/return", "/return", "Teleports you to previous location."],
|
||||||
|
["command/save-data", "/save-data", "Writes all your player data to a file on your computer.", true],
|
||||||
|
["command/save-quickbar", "/save-quickbar", "Saves your Quickbar preset items to file."],
|
||||||
|
["command/search", "/search", "Display players sorted by the quantity of an item held and playtime."],
|
||||||
|
["command/search-amount", "/search-amount", "Display players sorted by the quantity of an item held."],
|
||||||
|
["command/search-online", "/search-online", "Display online players sorted by item count and playtime."],
|
||||||
|
["command/search-recent", "/search-recent", "Display players who hold an item sorted by join time."],
|
||||||
|
["command/server-ups", "/server-ups", "Toggle the server UPS display.", true],
|
||||||
|
["command/set-always-day", "/set-always-day", "Set always day for your current surface, or another surface."],
|
||||||
|
["command/set-bot-queue", "/set-bot-queue", "Get / Set the construction bot queue limits."],
|
||||||
|
["command/set-cheat-mode", "/set-cheat-mode", "Set cheat mode for your player, or another player."],
|
||||||
|
["command/set-friendly-fire", "/set-friendly-fire", "Set friendly fire for your force, or another force."],
|
||||||
|
["command/set-game-speed", "/set-game-speed", "Set or get the current game speed."],
|
||||||
|
["command/set-home", "/set-home", "Sets your home location to your current position."],
|
||||||
|
["command/set-join-message", "/set-join-message", "Sets / Gets your custom join message."],
|
||||||
|
["command/set-pollution-enabled", "/set-pollution-enabled", "Set polution enabled state for this game."],
|
||||||
|
["command/set-trains-to-automatic", "/set-trains-to-automatic", "Set all trains without passengers to automatic."],
|
||||||
|
["command/spawn", "/spawn", "Teleport to spawn."],
|
||||||
|
["command/spawn/always", "/spawn (any player)", "Teleport any player to spawn, not just yourself."],
|
||||||
|
["command/spectate", "/spectate", "Toggles spectator mode."],
|
||||||
|
["command/tag", "/tag", "Sets your player tag.", true],
|
||||||
|
["command/tag-clear", "/tag-clear", "Clears your tag. Or another player if you are admin.", true],
|
||||||
|
["command/tag-color", "/tag-color", "Sets your player tag color."],
|
||||||
|
["command/teleport", "/teleport", "Teleports a player to another player."],
|
||||||
|
["command/unjail", "/unjail", "Removes a player from jail and restores their previous roles."],
|
||||||
|
["command/vlayer-info", "/vlayer-info", "Print all vlayer information."],
|
||||||
|
["command/waterfill", "/waterfill", "Replace tiles with shallow water."],
|
||||||
|
["fast-tree-decon", "Fast tree deconstruction", "Deconstruct trees and rocks instantly over a large area."],
|
||||||
|
["gui/autofill", "Autofill", "Open the autofill GUI, which fills placed entities from your inventory.", true],
|
||||||
|
["gui/bonus", "Bonus", "Open the bonus GUI to adjust your personal bonuses."],
|
||||||
|
["gui/module", "Module inserter", "Open the module inserter GUI.", true],
|
||||||
|
["gui/player-list", "Player list", "Open the player list GUI.", true],
|
||||||
|
["gui/playerdata", "Player statistics", "Open the player statistics GUI."],
|
||||||
|
["gui/production", "Production statistics", "Open the production statistics GUI.", true],
|
||||||
|
["gui/readme", "Readme", "Open the server readme GUI.", true],
|
||||||
|
["gui/research", "Research milestones", "Open the research milestones GUI.", true],
|
||||||
|
["gui/rocket-info", "Rocket info", "Open the rocket info GUI.", true],
|
||||||
|
["gui/rocket-info/remote_launch", "Rocket info: remote launch", "Launch rockets remotely from the rocket info GUI."],
|
||||||
|
["gui/rocket-info/toggle-active", "Rocket info: toggle active", "Toggle whether a silo launches automatically."],
|
||||||
|
["gui/science-info", "Science production", "Open the science production GUI.", true],
|
||||||
|
["gui/surveillance", "Surveillance", "Open the surveillance GUI showing remote camera views."],
|
||||||
|
["gui/task-list", "Task list", "Open the task list GUI.", true],
|
||||||
|
["gui/task-list/add", "Task list: add", "Add new tasks to the task list."],
|
||||||
|
["gui/task-list/edit", "Task list: edit", "Edit and remove existing tasks on the task list."],
|
||||||
|
["gui/tool", "Quick actions", "Open the quick actions GUI."],
|
||||||
|
["gui/vlayer", "Virtual layer", "Open the virtual layer GUI.", true],
|
||||||
|
["gui/vlayer-edit", "Virtual layer: edit", "Use the edit controls in the virtual layer GUI."],
|
||||||
|
["gui/warp-list", "Warp list", "Open the warp list GUI and warp between locations.", true],
|
||||||
|
["gui/warp-list/add", "Warp list: add", "Add new warp points to the warp list."],
|
||||||
|
["gui/warp-list/bypass-cooldown", "Warp list: bypass cooldown", "Warp without waiting for the warp cooldown."],
|
||||||
|
["gui/warp-list/bypass-proximity", "Warp list: bypass proximity", "Warp without standing near a warp point."],
|
||||||
|
["gui/warp-list/edit", "Warp list: edit", "Edit and remove existing warp points."],
|
||||||
|
["standard-decon", "Standard deconstruction", "Deconstruct trees and rocks."],
|
||||||
|
];
|
||||||
|
|
||||||
|
const flags: ActionDefinition[] = [
|
||||||
|
["deconlog-bypass", "Deconstruction log bypass", "Be excluded from the deconstruction log."],
|
||||||
|
["defer_role_changes", "Defer role changes", "Hold back role changes until this role is removed; used by Jail."],
|
||||||
|
["instant-respawn", "Instant respawn", "Respawn after two seconds instead of the default delay."],
|
||||||
|
["is_admin", "Factorio admin", "Be promoted to Factorio admin while holding a role with this flag."],
|
||||||
|
["is_spectator", "Spectator", "Be placed into Factorio spectator mode."],
|
||||||
|
["is_system", "System commands", "Unlock system commands such as /_rcon and /_sudo."],
|
||||||
|
["report-immune", "Report immune", "Be immune to being reported by other players."],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [action, title, description, grantByDefault] of actions) {
|
||||||
|
lib.definePermission({ name: permissionFromAction(action), title, description, grantByDefault });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [flag, title, description] of flags) {
|
||||||
|
lib.definePermission({ name: permissionFromFlag(flag), title, description });
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
{ "path": "./exp_groups/" },
|
{ "path": "./exp_groups/" },
|
||||||
{ "path": "./exp_gui/" },
|
{ "path": "./exp_gui/" },
|
||||||
{ "path": "./exp_legacy/" },
|
{ "path": "./exp_legacy/" },
|
||||||
|
{ "path": "./exp_roles/" },
|
||||||
{ "path": "./exp_scenario/" },
|
{ "path": "./exp_scenario/" },
|
||||||
{ "path": "./exp_server_ups/" },
|
{ "path": "./exp_server_ups/" },
|
||||||
{ "path": "./exp_util/" },
|
{ "path": "./exp_util/" },
|
||||||
|
|||||||
Reference in New Issue
Block a user