From e852abc30956563e2f5e992e43e0afa5dcbf7a32 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:11:26 +0000 Subject: [PATCH 1/6] Define scenario permissions for legacy role actions The role system is moving onto clusterio's own roles and permissions, which requires every in game action to exist as a real permission rather than a bare string held only by the lua config. Adds a permission for each of the 104 actions and 7 flags used by the legacy role config. Names are derived from the legacy action by a deterministic transform, exported so the lua side can apply the same mapping and keep existing call sites working. Command descriptions are taken from the existing locale entries so they match what players already see. Actions held by the Guest role are marked grantByDefault, since the in game default role and clusterio's default role are the same concept. Role management actions have no scenario permission; they map onto core.user.update_roles and core.role.list instead. Also corrects the /unjail description, which was a copy of /jail. Co-Authored-By: Claude Opus 5 (1M context) --- exp_scenario/index.ts | 3 + exp_scenario/module/locale/en.cfg | 2 +- exp_scenario/permissions.ts | 160 ++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 exp_scenario/permissions.ts diff --git a/exp_scenario/index.ts b/exp_scenario/index.ts index 978a372c..b3e4bd81 100644 --- a/exp_scenario/index.ts +++ b/exp_scenario/index.ts @@ -1,6 +1,9 @@ import * as lib from "@clusterio/lib"; // import * as Messages from "./messages"; +// Defines a permission for every in game action and role flag used by the scenario +import "./permissions"; + lib.definePermission({ name: "exp_scenario.config.view", title: "View ExpScenario Config", diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index 085052f9..80e8a1ec 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -89,7 +89,7 @@ jailed=__1__ was jailed by __2__. Reason: __3__ already-jailed=__1__ is already jailed. [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. unjailed=__1__ was unjailed by __2__. not-jailed=__1__ is not currently jailed. diff --git a/exp_scenario/permissions.ts b/exp_scenario/permissions.ts new file mode 100644 index 00000000..567e0c3a --- /dev/null +++ b/exp_scenario/permissions.ts @@ -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 = { + "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 }); +} From bbd3b7c1f23a5f40b77115217b36814dbb6be919 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:28:40 +0000 Subject: [PATCH 2/6] Add exp_roles plugin to sync roles from the controller Clusterio already stores roles, the permissions they grant, and which user holds which role, along with a web UI for all three. What it does not have is the properties a role only needs in game, or a way for an instance to learn about any of it, since role and user updates are only sent to control connections. This plugin fills both gaps. The controller keeps a record per role holding the order, priority, short hand, tag, colour and auto assign threshold, created automatically for any role which does not have one. It then rebroadcasts roles and assignments on its own events so instances can follow them. The lua module presents the same interface the legacy expcore.roles module did, so the call sites can be moved over without being rewritten. Permission checks translate the legacy action strings using the same mapping exp_scenario defines. Two things replace features the legacy system had: - Priority replaces disallow. Only the roles with the highest priority a player holds are considered, so Jail can suppress every other role including the default one, without needing to take roles away first. - Assignments made in game are applied locally and then sent to the controller, which keeps assign_player synchronous for callers. A role which should never leave this map, such as one earned from time on the map, is assigned with assign_player_local instead. Roles earned from online time across the cluster are granted by the controller from the threshold on the role, using the online time clusterio already tracks. Nothing requires this plugin yet; moving the call sites off expcore.roles and removing the legacy config is left for a follow up. Co-Authored-By: Claude Opus 5 (1M context) --- exp_roles/controller.ts | 299 +++++++ exp_roles/index.ts | 77 ++ exp_roles/instance.ts | 104 +++ exp_roles/messages.ts | 389 +++++++++ exp_roles/module/control.lua | 888 ++++++++++++++++++++ exp_roles/module/events.lua | 19 + exp_roles/module/globals.lua | 10 + exp_roles/module/locale/en.cfg | 3 + exp_roles/module/module.json | 13 + exp_roles/module/module_exports.lua | 3 + exp_roles/package.json | 49 ++ exp_roles/tsconfig.browser.json | 4 + exp_roles/tsconfig.json | 7 + exp_roles/tsconfig.node.json | 5 + exp_roles/web/components/RoleProperties.tsx | 145 ++++ exp_roles/web/index.tsx | 24 + exp_roles/webpack.config.js | 34 + tsconfig.json | 1 + 18 files changed, 2074 insertions(+) create mode 100644 exp_roles/controller.ts create mode 100644 exp_roles/index.ts create mode 100644 exp_roles/instance.ts create mode 100644 exp_roles/messages.ts create mode 100644 exp_roles/module/control.lua create mode 100644 exp_roles/module/events.lua create mode 100644 exp_roles/module/globals.lua create mode 100644 exp_roles/module/locale/en.cfg create mode 100644 exp_roles/module/module.json create mode 100644 exp_roles/module/module_exports.lua create mode 100644 exp_roles/package.json create mode 100644 exp_roles/tsconfig.browser.json create mode 100644 exp_roles/tsconfig.json create mode 100644 exp_roles/tsconfig.node.json create mode 100644 exp_roles/web/components/RoleProperties.tsx create mode 100644 exp_roles/web/index.tsx create mode 100644 exp_roles/webpack.config.js diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts new file mode 100644 index 00000000..b1177471 --- /dev/null +++ b/exp_roles/controller.ts @@ -0,0 +1,299 @@ +import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller"; +import * as lib from "@clusterio/lib"; +import * as messages from "./messages"; +import * as path from "node:path"; + +export class ControllerPlugin extends BaseControllerPlugin { + roleMeta!: lib.SubscribableDatastore; + + async init() { + const databaseDirectory = this.controller.config.get("controller.database_directory"); + + this.roleMeta = new lib.SubscribableDatastore( + ...await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "exp_roles", "role_meta.json"), + messages.RoleMetaRecord.fromJSON.bind(messages.RoleMetaRecord), + ).bootstrap() + ); + + // Roles created before this plugin was installed have no properties yet + this.ensureRoleMeta(); + + this.controller.subscriptions.handle(messages.RoleUpdatedEvent, this.handleRoleSubscription.bind(this)); + this.controller.subscriptions.handle( + messages.AssignmentUpdatedEvent, this.handleAssignmentSubscription.bind(this) + ); + + this.roleMeta.on("update", this.roleMetaUpdated.bind(this)); + this.controller.roles.on("update", this.rolesUpdated.bind(this)); + this.controller.users.records.on("update", this.usersUpdated.bind(this)); + + this.controller.handle(messages.RoleListRequest, this.handleRoleListRequest.bind(this)); + this.controller.handle(messages.RoleMetaUpdateRequest, this.handleRoleMetaUpdateRequest.bind(this)); + + this.controller.handle(messages.AssignmentListRequest, this.handleAssignmentListRequest.bind(this)); + this.controller.handle(messages.AssignmentUpdateRequest, this.handleAssignmentUpdateRequest.bind(this)); + } + + async onShutdown() { + await this.roleMeta.save(); + } + + /* + Role properties + */ + + /** Create properties for any role which does not have them yet, ordered after every existing role. */ + ensureRoleMeta() { + const created = []; + let nextOrder = 0; + for (const meta of this.roleMeta.values()) { + nextOrder = Math.max(nextOrder, meta.order); + } + + for (const role of this.controller.roles.values()) { + if (!this.roleMeta.has(role.id)) { + nextOrder += 1; + created.push(new messages.RoleMetaRecord(role.id, nextOrder)); + } + } + + if (created.length) { + this.roleMeta.setMany(created); + } + + return created; + } + + /** Combine a clusterio role with its in game properties. */ + buildRoleRecord(role: Readonly) { + const meta = this.roleMeta.get(role.id) ?? new messages.RoleMetaRecord(role.id, role.id); + return new messages.RoleRecord( + role.id, + role.name, + [...role.permissions], + meta, + role.id === this.controller.config.get("controller.default_role_id"), + Math.max(role.updatedAtMs, meta.updatedAtMs), + role.isDeleted, + ); + } + + async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { + // Which role is the default is carried on the role records themselves + if (field === "controller.default_role_id") { + this.controller.subscriptions.broadcast(new messages.RoleUpdatedEvent(this.listRoleRecords())); + } + } + + /** Every role which currently exists, in the form sent to instances. */ + listRoleRecords() { + return [...this.controller.roles.values()].map(role => this.buildRoleRecord(role)); + } + + async handleRoleListRequest() { + return this.listRoleRecords(); + } + + async handleRoleMetaUpdateRequest(request: messages.RoleMetaUpdateRequest) { + const meta = request.meta; + if (!this.controller.roles.has(meta.id)) { + throw new lib.RequestError(`Role with ID ${meta.id} does not exist`); + } + + this.roleMeta.set(meta); + } + + /** A clusterio role was created, changed or deleted. */ + rolesUpdated(roles: lib.Role[]) { + const created = this.ensureRoleMeta(); + + // Deleting a role leaves its properties behind, which would be reused by + // the next role to be given the same id + const orphaned = []; + for (const role of roles) { + if (role.isDeleted) { + const meta = this.roleMeta.getMutable(role.id); + if (meta) { + orphaned.push(meta); + } + } + } + if (orphaned.length) { + this.roleMeta.deleteMany(orphaned); + } + + // Newly created properties broadcast on their own through roleMetaUpdated + const createdIds = new Set(created.map(meta => meta.id)); + const updates = roles.filter(role => !createdIds.has(role.id)).map(role => this.buildRoleRecord(role)); + if (updates.length) { + this.controller.subscriptions.broadcast(new messages.RoleUpdatedEvent(updates)); + } + + this.applyAutoAssign(); + } + + /** The in game properties of a role changed. */ + roleMetaUpdated(metas: messages.RoleMetaRecord[]) { + const updates = []; + for (const meta of metas) { + const role = this.controller.roles.get(meta.id); + // A deleted role broadcasts through rolesUpdated instead + if (role) { + updates.push(this.buildRoleRecord(role)); + } + } + + if (updates.length) { + this.controller.subscriptions.broadcast(new messages.RoleUpdatedEvent(updates)); + } + + this.applyAutoAssign(); + } + + async handleRoleSubscription(request: lib.SubscriptionRequest) { + const roles = this.listRoleRecords().filter(role => role.updatedAtMs > request.lastRequestTimeMs); + return roles.length ? new messages.RoleUpdatedEvent(roles) : null; + } + + /* + Assignments + */ + + /** + * The roles of a user as seen in game. + * + * The default role is left out because every player has it, which keeps the + * assignments to only those users who have been given a role. + */ + buildAssignmentRecord(user: Readonly) { + const defaultRoleId = this.controller.config.get("controller.default_role_id"); + const roleIds = new Set(user.roleIds); + if (defaultRoleId !== null) { + roleIds.delete(defaultRoleId); + } + + return new messages.AssignmentRecord( + user.name, + roleIds, + user.updatedAtMs, + user.isDeleted || roleIds.size === 0, + ); + } + + listAssignmentRecords() { + const assignments = []; + for (const user of this.controller.users.records.values()) { + const assignment = this.buildAssignmentRecord(user); + if (!assignment.isDeleted) { + assignments.push(assignment); + } + } + return assignments; + } + + async handleAssignmentListRequest() { + return this.listAssignmentRecords(); + } + + usersUpdated(users: lib.UserDetails[]) { + this.controller.subscriptions.broadcast( + new messages.AssignmentUpdatedEvent(users.map(user => this.buildAssignmentRecord(user))) + ); + + this.applyAutoAssign(users.map(user => user.name)); + } + + async handleAssignmentSubscription(request: lib.SubscriptionRequest) { + const assignments = this.listAssignmentRecords() + .filter(assignment => assignment.updatedAtMs > request.lastRequestTimeMs); + return assignments.length ? new messages.AssignmentUpdatedEvent(assignments) : null; + } + + async handleAssignmentUpdateRequest(request: messages.AssignmentUpdateRequest) { + const user = this.controller.users.getByNameMutable(request.name); + if (!user) { + throw new lib.RequestError(`User '${request.name}' does not exist`); + } + + for (const roleId of [...request.assign, ...request.unassign]) { + if (!this.controller.roles.has(roleId)) { + throw new lib.RequestError(`Role with ID ${roleId} does not exist`); + } + } + + const roleIds = new Set(user.roleIds); + for (const roleId of request.assign) { + roleIds.add(roleId); + } + for (const roleId of request.unassign) { + roleIds.delete(roleId); + } + + user.set("roleIds", roleIds); + this.controller.userPermissionsUpdated(user); + } + + /* + Automatic assignment + */ + + /** + * Grant roles which are earned by online time across the cluster. + * + * Roles are only ever granted, never taken away, so a player who earns a + * role keeps it even if their statistics are later reduced. + * + * @param userNames - Users to consider, defaults to every user. + */ + applyAutoAssign(userNames?: string[]) { + const autoAssigned = [...this.roleMeta.values()].filter( + meta => meta.autoAssignOnlineTimeMs !== null && !meta.isDeleted + ); + if (!autoAssigned.length) { + return; + } + + const blocking = new Set( + [...this.roleMeta.values()].filter(meta => meta.blockAutoAssign).map(meta => meta.id) + ); + + const users = userNames + ? userNames.map(name => this.controller.users.getByNameMutable(name)) + : [...this.controller.users.valuesMutable()] + ; + + for (const user of users) { + if (!user || user.isDeleted) { + continue; + } + if ([...user.roleIds].some(roleId => blocking.has(roleId))) { + continue; + } + + const onlineTimeMs = user.playerStats.onlineTimeMs; + const granted = []; + for (const meta of autoAssigned) { + if (!user.roleIds.has(meta.id) && onlineTimeMs >= meta.autoAssignOnlineTimeMs!) { + granted.push(meta.id); + } + } + + if (granted.length) { + // set rather than addRole so that a single update is emitted + user.set("roleIds", new Set([...user.roleIds, ...granted])); + this.controller.userPermissionsUpdated(user); + this.logger.info( + `Granted ${granted.length} role(s) to ${user.name} from their online time` + ); + } + } + } + + async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) { + // Online time is committed to the user record when a player leaves + if (event.type === "leave") { + this.applyAutoAssign([event.name]); + } + } +} diff --git a/exp_roles/index.ts b/exp_roles/index.ts new file mode 100644 index 00000000..96d8cb0a --- /dev/null +++ b/exp_roles/index.ts @@ -0,0 +1,77 @@ +import * as lib from "@clusterio/lib"; +import * as messages from "./messages"; + +declare module "@clusterio/lib" { + export interface InstanceConfigFields { + "exp_roles.sync_mode": "disabled" | "enabled" | "bidirectional"; + } +} + +lib.definePermission({ + name: "exp_roles.role.list", + title: "List In Game Roles", + description: "List the in game properties of all roles.", + grantByDefault: true, +}); +lib.definePermission({ + name: "exp_roles.role.subscribe", + title: "Subscribe to In Game Role Updates", + description: "Receive updates when the in game properties of a role change.", + grantByDefault: true, +}); +lib.definePermission({ + name: "exp_roles.role.update", + title: "Update In Game Roles", + description: "Modify the in game properties of a role, such as its order and colour.", +}); + +lib.definePermission({ + name: "exp_roles.assignment.list", + title: "List Role Assignments", + description: "List the roles held by each player in game.", + grantByDefault: true, +}); +lib.definePermission({ + name: "exp_roles.assignment.subscribe", + title: "Subscribe to Role Assignment Updates", + description: "Receive updates when the roles held by a player change.", + grantByDefault: true, +}); + +export const plugin: lib.PluginDeclaration = { + name: "exp_roles", + title: "ExpGaming - Roles", + description: "Clusterio plugin providing syncing of in game roles", + + features: [ + "SavePatching", + "ScriptCommands", + ], + + messages: [ + messages.RoleUpdatedEvent, + messages.AssignmentUpdatedEvent, + + messages.RoleListRequest, + messages.RoleMetaUpdateRequest, + + messages.AssignmentListRequest, + messages.AssignmentUpdateRequest, + ], + + instanceEntrypoint: "./dist/node/instance", + instanceConfigFields: { + "exp_roles.sync_mode": { + description: "Synchronize in game roles with the controller", + type: "string", + enum: ["disabled", "enabled", "bidirectional"], + initialValue: "bidirectional", + }, + }, + + controllerEntrypoint: "./dist/node/controller", + controllerConfigFields: { + }, + + webEntrypoint: "./web", +}; diff --git a/exp_roles/instance.ts b/exp_roles/instance.ts new file mode 100644 index 00000000..df7a03a4 --- /dev/null +++ b/exp_roles/instance.ts @@ -0,0 +1,104 @@ +import { BaseInstancePlugin } from "@clusterio/host"; +import * as lib from "@clusterio/lib"; +import * as messages from "./messages"; + +/** Sent by the lua side when roles are changed in game. */ +export type IpcAssignmentUpdate = { + name: string, + assign: number[] | undefined, + unassign: number[] | undefined, +}; + +export class InstancePlugin extends BaseInstancePlugin { + async init() { + this.instance.handle(messages.RoleUpdatedEvent, this.handleRoleUpdatedEvent.bind(this)); + this.instance.handle(messages.AssignmentUpdatedEvent, this.handleAssignmentUpdatedEvent.bind(this)); + this.instance.server.handle("exp_roles:assignment_update", this.handleAssignmentUpdateIPC.bind(this)); + } + + get syncMode() { + return this.instance.config.get("exp_roles.sync_mode"); + } + + async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { + switch (field) { + case "exp_roles.sync_mode": + await this.luaSetEmitEvents(curr === "bidirectional"); + break; + } + } + + async onStart() { + if (this.syncMode === "disabled") { + return; + } + + // Date.now() is used because the lua state is initialised from the full + // list below, so only updates made after this point are of interest + const subscribedAtMs = Date.now(); + await this.instance.sendTo("controller", new lib.SubscriptionRequest( + `exp_roles:${messages.RoleUpdatedEvent.name}`, true, subscribedAtMs + )); + await this.instance.sendTo("controller", new lib.SubscriptionRequest( + `exp_roles:${messages.AssignmentUpdatedEvent.name}`, true, subscribedAtMs + )); + + const [roles, assignments] = await Promise.all([ + this.instance.sendTo("controller", new messages.RoleListRequest()), + this.instance.sendTo("controller", new messages.AssignmentListRequest()), + ]); + + await this.luaSend("initialise", { + roles: roles.map(role => role.toJSON()), + assignments: assignments.map(assignment => assignment.toJSON()), + }); + await this.luaSetEmitEvents(this.syncMode === "bidirectional"); + } + + async handleRoleUpdatedEvent(event: messages.RoleUpdatedEvent) { + if (this.syncMode === "disabled") { + return; + } + + await this.luaSend("receive_role_updates", event.updates.map(role => role.toJSON())); + } + + async handleAssignmentUpdatedEvent(event: messages.AssignmentUpdatedEvent) { + if (this.syncMode === "disabled") { + return; + } + + await this.luaSend("receive_assignment_updates", event.updates.map(a => a.toJSON())); + } + + async handleAssignmentUpdateIPC(event: IpcAssignmentUpdate) { + if (this.syncMode !== "bidirectional") { + return; + } + + const assign = event.assign ?? []; + try { + await this.instance.sendTo("controller", new messages.AssignmentUpdateRequest( + event.name, assign, event.unassign ?? [], + )); + } catch (err: any) { + // The roles were already applied in game, so they have to be taken + // back off again now that the controller has refused them + this.logger.warn(`Role change for ${event.name} was rejected: ${err.message}`); + if (assign.length) { + await this.luaSend("reject_assignment", { name: event.name, role_ids: assign }); + } + return; + } + } + + async luaSetEmitEvents(emitEvents: boolean) { + await this.luaSend("set_emit_events", emitEvents); + } + + async luaSend(receiver: string, json: any) { + await this.instance.sendRcon( + `/sc exp_roles.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true + ); + } +} diff --git a/exp_roles/messages.ts b/exp_roles/messages.ts new file mode 100644 index 00000000..dda0c144 --- /dev/null +++ b/exp_roles/messages.ts @@ -0,0 +1,389 @@ +import * as lib from "@clusterio/lib"; +import { Type, Static } from "@sinclair/typebox"; + +/* + Data records +*/ + +/** Colour used for a role's tag and name in game. */ +export class RoleColor { + constructor( + public r: number, + public g: number, + public b: number, + ) {} + + static jsonSchema = Type.Object({ + r: Type.Number(), + g: Type.Number(), + b: Type.Number(), + }); + + toJSON(): Static { + return { r: this.r, g: this.g, b: this.b }; + } + + static fromJSON(json: Static) { + return new this(json.r, json.g, json.b); + } +} + +/** + * In game properties of a role which clusterio's own role does not carry. + * + * Stored on the controller keyed by the clusterio role id, and created + * automatically for any role which does not have one yet. + */ +export class RoleMetaRecord { + constructor( + /** Id of the clusterio role these properties belong to. */ + public id: number, + /** Position in the role order, a lower value is a more privileged role. */ + public order: number, + /** + * Only the roles with the highest priority a player holds are considered, + * which allows a role such as Jail to suppress all others. + */ + public priority: number = 0, + /** Short form of the role name, used where space is limited. */ + public shortHand: string = "", + /** Tag shown next to the names of players with this role. */ + public tag: string = "", + /** Colour used for the role in game, null uses the default colour. */ + public color: RoleColor | null = null, + /** Online time after which this role is granted, null never grants it. */ + public autoAssignOnlineTimeMs: number | null = null, + /** When true holders of this role are never granted roles automatically. */ + public blockAutoAssign: boolean = false, + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + static jsonSchema = Type.Object({ + id: Type.Integer(), + order: Type.Number(), + priority: Type.Optional(Type.Number()), + short_hand: Type.Optional(Type.String()), + tag: Type.Optional(Type.String()), + color: Type.Optional(Type.Union([RoleColor.jsonSchema, Type.Null()])), + auto_assign_online_time_ms: Type.Optional(Type.Union([Type.Number(), Type.Null()])), + block_auto_assign: Type.Optional(Type.Boolean()), + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { + id: this.id, + order: this.order, + }; + + if (this.priority) { + json.priority = this.priority; + } + + if (this.shortHand) { + json.short_hand = this.shortHand; + } + + if (this.tag) { + json.tag = this.tag; + } + + if (this.color) { + json.color = this.color.toJSON(); + } + + if (this.autoAssignOnlineTimeMs !== null) { + json.auto_assign_online_time_ms = this.autoAssignOnlineTimeMs; + } + + if (this.blockAutoAssign) { + json.block_auto_assign = true; + } + + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + + if (this.isDeleted) { + json.is_deleted = true; + } + + return json; + } + + static fromJSON(json: Static) { + return new this( + json.id, + json.order, + json.priority ?? 0, + json.short_hand ?? "", + json.tag ?? "", + json.color ? RoleColor.fromJSON(json.color) : null, + json.auto_assign_online_time_ms ?? null, + json.block_auto_assign ?? false, + json.updated_at_ms ?? 0, + json.is_deleted ?? false, + ); + } +} + +/** + * A clusterio role combined with its in game properties. + * + * This is the form sent to instances, because instances can not subscribe to + * the core role updates which are only sent to control connections. + */ +export class RoleRecord { + constructor( + public id: number, + public name: string, + public permissions: string[], + public meta: RoleMetaRecord, + /** True for the role every player holds, taken from controller.default_role_id. */ + public isDefault: boolean = false, + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + static jsonSchema = Type.Object({ + id: Type.Integer(), + name: Type.String(), + permissions: Type.Array(Type.String()), + meta: RoleMetaRecord.jsonSchema, + is_default: Type.Optional(Type.Boolean()), + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { + id: this.id, + name: this.name, + permissions: this.permissions, + meta: this.meta.toJSON(), + }; + + if (this.isDefault) { + json.is_default = true; + } + + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + + if (this.isDeleted) { + json.is_deleted = true; + } + + return json; + } + + static fromJSON(json: Static) { + return new this( + json.id, + json.name, + json.permissions, + RoleMetaRecord.fromJSON(json.meta), + json.is_default ?? false, + json.updated_at_ms ?? 0, + json.is_deleted ?? false, + ); + } +} + +/** The roles a single player holds, mirroring the roles of their cluster user. */ +export class AssignmentRecord { + constructor( + public name: string, + public roleIds: Set, + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + get id() { + return this.name; + } + + static jsonSchema = Type.Object({ + name: Type.String(), + role_ids: Type.Array(Type.Integer()), + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { + name: this.name, + role_ids: [...this.roleIds], + }; + + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + + if (this.isDeleted) { + json.is_deleted = true; + } + + return json; + } + + static fromJSON(json: Static) { + return new this( + json.name, + new Set(json.role_ids), + json.updated_at_ms ?? 0, + json.is_deleted ?? false, + ); + } +} + +/* + Update events +*/ + +export class RoleUpdatedEvent { + declare ["constructor"]: typeof RoleUpdatedEvent; + static plugin = "exp_roles" as const; + static type = "event" as const; + static src = "controller" as const; + static dst = ["control", "instance"] as const; + static permission = "exp_roles.role.subscribe" as const; + + constructor( + public updates: RoleRecord[], + ) {} + + static jsonSchema = Type.Object({ + updates: Type.Array(RoleRecord.jsonSchema), + }); + + toJSON() { + return { updates: this.updates.map(role => role.toJSON()) }; + } + + static fromJSON(json: Static) { + return new this(json.updates.map(role => RoleRecord.fromJSON(role))); + } +} + +export class AssignmentUpdatedEvent { + declare ["constructor"]: typeof AssignmentUpdatedEvent; + static plugin = "exp_roles" as const; + static type = "event" as const; + static src = "controller" as const; + static dst = ["control", "instance"] as const; + static permission = "exp_roles.assignment.subscribe" as const; + + constructor( + public updates: AssignmentRecord[], + ) {} + + static jsonSchema = Type.Object({ + updates: Type.Array(AssignmentRecord.jsonSchema), + }); + + toJSON() { + return { updates: this.updates.map(assignment => assignment.toJSON()) }; + } + + static fromJSON(json: Static) { + return new this(json.updates.map(assignment => AssignmentRecord.fromJSON(assignment))); + } +} + +/* + Role requests +*/ + +export class RoleListRequest { + declare ["constructor"]: typeof RoleListRequest; + static plugin = "exp_roles" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "exp_roles.role.list" as const; + static Response = lib.jsonArray(RoleRecord); + + constructor() {} +} + +export class RoleMetaUpdateRequest { + declare ["constructor"]: typeof RoleMetaUpdateRequest; + static plugin = "exp_roles" as const; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "exp_roles.role.update" as const; + + constructor( + public meta: RoleMetaRecord, + ) {} + + static jsonSchema = Type.Object({ + meta: RoleMetaRecord.jsonSchema, + }); + + toJSON() { + return { meta: this.meta.toJSON() }; + } + + static fromJSON(json: Static) { + return new this(RoleMetaRecord.fromJSON(json.meta)); + } +} + +/* + Assignment requests +*/ + +export class AssignmentListRequest { + declare ["constructor"]: typeof AssignmentListRequest; + static plugin = "exp_roles" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "exp_roles.assignment.list" as const; + static Response = lib.jsonArray(AssignmentRecord); + + constructor() {} +} + +/** + * Add or remove roles for a player, sent by an instance when a role is changed + * in game. Only accepted when the instance is configured as bidirectional. + */ +export class AssignmentUpdateRequest { + declare ["constructor"]: typeof AssignmentUpdateRequest; + static plugin = "exp_roles" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "core.user.update_roles" as const; + + constructor( + public name: string, + public assign: number[], + public unassign: number[], + ) {} + + static jsonSchema = Type.Object({ + name: Type.String(), + assign: Type.Array(Type.Integer()), + unassign: Type.Array(Type.Integer()), + }); + + toJSON() { + return { + name: this.name, + assign: this.assign, + unassign: this.unassign, + }; + } + + static fromJSON(json: Static) { + return new this(json.name, json.assign, json.unassign); + } +} diff --git a/exp_roles/module/control.lua b/exp_roles/module/control.lua new file mode 100644 index 00000000..0e7af449 --- /dev/null +++ b/exp_roles/module/control.lua @@ -0,0 +1,888 @@ +--[[-- ExpRoles +Replaces the legacy role system with clusterio's roles and permissions. + +Roles and the players who hold them are owned by the controller. This module +mirrors that state into the game and presents the same interface the legacy +`expcore.roles` module did, so existing call sites keep working. + +Assignments made in game are applied locally first and then sent to the +controller, which avoids callers having to deal with the round trip. A local +assignment which is never sent, such as one earned from time on this map, can be +made with `assign_player_local`. +]] + +local clusterio_api = require("modules/clusterio/api") +local compat = require("modules/clusterio/compat") +local Async = require("modules/exp_util/async") + +--- @class ExpRoles +local ExpRoles = { + _prototype = {}, + config = { + --- Role names in order, a lower index is a more privileged role + order = {}, --- @type string[] + --- Roles indexed by name, this table is never replaced + roles = {}, --- @type table + --- Role names held by each player, includes local assignments + players = {}, --- @type table + --- Callbacks run when a flag is gained or lost + flags = {}, --- @type table + }, + events = { + on_role_assigned = script.generate_event_name(), + on_role_unassigned = script.generate_event_name(), + }, +} + +--- @class ExpRoles.Role +--- @field id number Clusterio role id +--- @field name string +--- @field short_hand string +--- @field order number Order given by the controller, which can have gaps +--- @field index number Position within the role order, with no gaps +--- @field priority number Only the highest priority roles a player holds apply +--- @field custom_tag string +--- @field custom_color Color? +--- @field allowed_actions table Permission names granted by this role +--- @field flags table Legacy flag names granted by this role +--- @field block_auto_assign boolean + +--- @class ExpRoles.ScriptData +--- @field roles table Roles indexed by clusterio id +--- @field synced_players table Role ids from the controller +--- @field local_players table Role ids which only exist on this map +--- @field pending table Local role ids waiting to be confirmed +--- @field default_role_id number? Role every player holds +--- @field emit_updates boolean +local script_data = {} + +--[[ + Permission names +]] + +--- Legacy actions which map onto core clusterio permissions +local core_action_permissions = { + ["command/assign-role"] = "core.user.update_roles", + ["command/unassign-role"] = "core.user.update_roles", + ["command/get-roles"] = "core.role.list", +} + +--- Cache for the action to permission mapping, this is a hot path +local action_permissions = {} --- @type table +local flag_permissions = {} --- @type table + +--- Convert a legacy action such as `gui/warp-list/add` into a permission name +--- This must match permissionFromAction in exp_scenario/permissions.ts +--- @param action string +--- @return string +local function permission_from_action(action) + local permission = action_permissions[action] + if permission then return permission end + + permission = core_action_permissions[action] + if not permission then + local body = action:gsub("%-", "_"):gsub("/", ".") + if not body:find(".", 1, true) then + body = "action." .. body + end + permission = "exp_scenario." .. body + end + + action_permissions[action] = permission + return permission +end + +--- Convert a legacy flag such as `report-immune` into a permission name +--- This must match permissionFromFlag in exp_scenario/permissions.ts +--- @param flag string +--- @return string +local function permission_from_flag(flag) + local permission = flag_permissions[flag] + if permission then return permission end + + permission = "exp_scenario.flag." .. (flag:gsub("%-", "_")) + flag_permissions[flag] = permission + return permission +end + +--[[ + Role state +]] + +--- Rebuild config.order and config.roles from the synced roles, in place +local function rebuild_role_views() + local roles = {} + for _, role in pairs(script_data.roles) do + roles[#roles + 1] = role + end + + -- Sorted on the order given by the controller, which can have gaps, the + -- index each role is given is its position within that order + table.sort(roles, function(a, b) + if a.order == b.order then return a.id < b.id end + return a.order < b.order + end) + + local order, by_name = ExpRoles.config.order, ExpRoles.config.roles + for key in pairs(order) do order[key] = nil end + for key in pairs(by_name) do by_name[key] = nil end + + for index, role in ipairs(roles) do + role.index = index + order[index] = role.name + by_name[role.name] = role + end +end + +--- Build a role from the record sent by the controller +--- @param record table +--- @return ExpRoles.Role +local function decode_role(record) + local meta = record.meta + local allowed_actions = {} + for _, permission in pairs(record.permissions) do + allowed_actions[permission] = true + end + + local flags = {} + for permission in pairs(allowed_actions) do + local flag = permission:match("^exp_scenario%.flag%.(.+)$") + if flag then flags[flag] = true end + end + + return setmetatable({ + id = record.id, + name = record.name, + short_hand = meta.short_hand or record.name, + order = meta.order or record.id, + index = meta.order or record.id, + priority = meta.priority or 0, + custom_tag = meta.tag or "", + custom_color = meta.color, + allowed_actions = allowed_actions, + flags = flags, + block_auto_assign = meta.block_auto_assign or false, + }, { __index = ExpRoles._prototype }) +end + +--- Refresh the legacy view of the roles a player holds +--- @param player_name string +local function rebuild_player_view(player_name) + local role_ids = {} + for _, role_id in pairs(script_data.synced_players[player_name] or {}) do + role_ids[#role_ids + 1] = role_id + end + for _, role_id in pairs(script_data.local_players[player_name] or {}) do + role_ids[#role_ids + 1] = role_id + end + + if #role_ids == 0 then + ExpRoles.config.players[player_name] = nil + return + end + + local names, seen = {}, {} + for _, role_id in pairs(role_ids) do + local role = script_data.roles[role_id] + if role and not seen[role.name] then + seen[role.name] = true + names[#names + 1] = role.name + end + end + + ExpRoles.config.players[player_name] = names +end + +--- Rebuild the legacy view for every known player +local function rebuild_all_player_views() + local players = ExpRoles.config.players + for key in pairs(players) do players[key] = nil end + + local seen = {} + for player_name in pairs(script_data.synced_players) do seen[player_name] = true end + for player_name in pairs(script_data.local_players) do seen[player_name] = true end + for player_name in pairs(seen) do rebuild_player_view(player_name) end +end + +--[[ + Role lookup +]] + +--- Get a role by its name +--- @param name string +--- @return ExpRoles.Role? +function ExpRoles.get_role_by_name(name) + return ExpRoles.config.roles[name] +end + +--- Get a role by its position in the role order +--- @param index number +--- @return ExpRoles.Role? +function ExpRoles.get_role_by_order(index) + local name = ExpRoles.config.order[index] + return name and ExpRoles.config.roles[name] +end + +--- Get a role from a name, order index, or role +--- @param any string | number | ExpRoles.Role +--- @return ExpRoles.Role? +function ExpRoles.get_role_from_any(any) + local t_any = type(any) + if t_any == "number" or tonumber(any) then + return ExpRoles.get_role_by_order(tonumber(any)) + elseif t_any == "string" then + return ExpRoles.get_role_by_name(any) + elseif t_any == "table" then + return ExpRoles.get_role_by_name(any.name) + end +end + +--- Get all roles in order, the most privileged first +--- @return ExpRoles.Role[] +function ExpRoles.get_roles_ordered() + local rtn = {} + for index, role_name in ipairs(ExpRoles.config.order) do + rtn[index] = ExpRoles.config.roles[role_name] + end + return rtn +end + +--- Role used when there is no player, such as for commands run by the server +--- It bypasses every permission check the same way the legacy root role did +local server_role = setmetatable({ + id = -1, + name = "", + short_hand = "SRV", + order = 0, + index = 0, + priority = 0, + custom_tag = "", + custom_color = nil, + allowed_actions = { ["core.admin"] = true }, + flags = {}, + block_auto_assign = true, +}, { __index = ExpRoles._prototype }) + +--- Get the roles a player holds, including the default role +--- Only the roles with the highest priority are returned, which lets a role +--- such as Jail suppress every other role a player holds +--- @param player LuaPlayer | string | nil +--- @return ExpRoles.Role[] +function ExpRoles.get_player_roles(player) + local player_name = type(player) == "table" and player.name or player --[[ @as string? ]] + -- The server is not a player and is allowed to do anything + if player_name == nil then return { server_role } end + + local role_ids = {} + if script_data.default_role_id then + role_ids[#role_ids + 1] = script_data.default_role_id + end + for _, role_id in pairs(script_data.synced_players[player_name] or {}) do + role_ids[#role_ids + 1] = role_id + end + for _, role_id in pairs(script_data.local_players[player_name] or {}) do + role_ids[#role_ids + 1] = role_id + end + + local roles, highest_priority, seen = {}, nil, {} + for _, role_id in pairs(role_ids) do + local role = script_data.roles[role_id] + if role and not seen[role_id] then + seen[role_id] = true + if highest_priority == nil or role.priority > highest_priority then + highest_priority = role.priority + end + roles[#roles + 1] = role + end + end + + local rtn = {} + for _, role in pairs(roles) do + if role.priority == highest_priority then + rtn[#rtn + 1] = role + end + end + + table.sort(rtn, function(a, b) return a.index < b.index end) + return rtn +end + +--- Get the most privileged role a player holds +--- @param player LuaPlayer | string | nil +--- @return ExpRoles.Role? +function ExpRoles.get_player_highest_role(player) + return ExpRoles.get_player_roles(player)[1] +end + +--[[ + Permission checks +]] + +--- Check if a player is allowed to perform an action +--- @param player LuaPlayer | string | nil +--- @param action string A legacy action such as `command/kill` +--- @return boolean +function ExpRoles.player_allowed(player, action) + local permission = permission_from_action(action) + for _, role in pairs(ExpRoles.get_player_roles(player)) do + local allowed = role.allowed_actions + if allowed["core.admin"] or allowed[permission] then + return true + end + end + + return false +end + +--- Check if a player has a flag set by at least one of their roles +--- @param player LuaPlayer | string | nil +--- @param flag_name string +--- @return boolean +function ExpRoles.player_has_flag(player, flag_name) + local permission = permission_from_flag(flag_name) + for _, role in pairs(ExpRoles.get_player_roles(player)) do + if role.allowed_actions[permission] then + return true + end + end + + return false +end + +--- Check if a player holds a role +--- @param player LuaPlayer | string | nil +--- @param search_role string | number | ExpRoles.Role +--- @return boolean +function ExpRoles.player_has_role(player, search_role) + local role = ExpRoles.get_role_from_any(search_role) + if not role then return false end + + for _, player_role in pairs(ExpRoles.get_player_roles(player)) do + if player_role.name == role.name then return true end + end + + return false +end + +--- Check if a player bypasses all permission checks +--- This replaces the legacy root role +--- @param player LuaPlayer | string | nil +--- @return boolean +function ExpRoles.is_root(player) + for _, role in pairs(ExpRoles.get_player_roles(player)) do + if role.allowed_actions["core.admin"] then return true end + end + + return false +end + +--[[ + Role prototype +]] + +--- Check if this role allows an action +--- @param action string +--- @return boolean +function ExpRoles._prototype:is_allowed(action) + local allowed = self.allowed_actions + return allowed["core.admin"] or allowed[permission_from_action(action)] or false +end + +--- Check if this role sets a flag +--- @param name string +--- @return boolean +function ExpRoles._prototype:has_flag(name) + return self.allowed_actions[permission_from_flag(name)] or false +end + +--- Get the players who hold this role +--- @param online boolean? When given, filter by connected state +--- @return LuaPlayer[] +function ExpRoles._prototype:get_players(online) + local players = {} + for player_name, role_names in pairs(ExpRoles.config.players) do + for _, role_name in pairs(role_names) do + if role_name == self.name then + local player = game.players[player_name] + if player and (online == nil or player.connected == online) then + players[#players + 1] = player + end + break + end + end + end + + return players +end + +--- Print a message to every online player who holds this role +--- @param message LocalisedString +--- @return number # Number of players the message was sent to +function ExpRoles._prototype:print(message) + local players = self:get_players(true) + for _, player in pairs(players) do + player.print(message) + end + + return #players +end + +--[[ + Printing to roles +]] + +--- Print a message to every player holding one of the given roles +--- @param roles (string | number | ExpRoles.Role)[] +--- @param message LocalisedString +function ExpRoles.print_to_roles(roles, message) + for _, role in pairs(roles) do + role = ExpRoles.get_role_from_any(role) + if role then role:print(message) end + end +end + +--- Print a message to every player holding the given role or a more privileged one +--- @param role string | number | ExpRoles.Role +--- @param message LocalisedString +function ExpRoles.print_to_roles_higher(role, message) + role = ExpRoles.get_role_from_any(role) + if not role then return end + + local roles = {} + for index, role_name in ipairs(ExpRoles.config.order) do + if index <= role.index and role_name ~= ExpRoles.get_default_role_name() then + roles[#roles + 1] = role_name + end + end + + ExpRoles.print_to_roles(roles, message) +end + +--- Print a message to every player holding the given role or a less privileged one +--- @param role string | number | ExpRoles.Role +--- @param message LocalisedString +function ExpRoles.print_to_roles_lower(role, message) + role = ExpRoles.get_role_from_any(role) + if not role then return end + + local roles = {} + for index, role_name in ipairs(ExpRoles.config.order) do + if index >= role.index and role_name ~= ExpRoles.get_default_role_name() then + roles[#roles + 1] = role_name + end + end + + ExpRoles.print_to_roles(roles, message) +end + +--- Get the name of the role every player holds +--- @return string? +function ExpRoles.get_default_role_name() + local role = script_data.default_role_id and script_data.roles[script_data.default_role_id] + return role and role.name or nil +end + +--[[ + Flags +]] + +--- Register a callback run when a player gains or loses a flag +--- @param name string +--- @param callback fun(player: LuaPlayer, state: boolean) +function ExpRoles.define_flag_trigger(name, callback) + ExpRoles.config.flags[name] = Async.register(callback) +end + +--- Run every flag trigger for a player +--- @param player LuaPlayer +local function apply_flag_triggers(player) + for flag, async_function in pairs(ExpRoles.config.flags) do + async_function(player, ExpRoles.player_has_flag(player, flag)) + end +end + +--[[ + Assignment +]] + +--- Raise the assigned or unassigned event and tell the player what changed +--- @param player LuaPlayer +--- @param change_type "assign" | "unassign" +--- @param roles ExpRoles.Role[] +--- @param by_player_name string? +--- @param silent boolean? +local function emit_player_roles_updated(player, change_type, roles, by_player_name, silent) + by_player_name = by_player_name or (game.player and game.player.name) or "" + local by_player = game.players[by_player_name] + + local role_names = {} + for index, role in ipairs(roles) do + role_names[index] = role.name + end + + if not silent then + game.print({ "exp-roles.game-message-" .. change_type, player.name, table.concat(role_names, ", "), by_player_name }) + end + + if change_type == "assign" then + player.play_sound{ path = "utility/achievement_unlocked" } + else + player.play_sound{ path = "utility/game_lost" } + end + + local event = change_type == "assign" and ExpRoles.events.on_role_assigned or ExpRoles.events.on_role_unassigned + script.raise_event(event, { + name = event, + tick = game.tick, + player_index = player.index, + by_player_index = by_player and by_player.index or 0, + roles = role_names, + }) + + apply_flag_triggers(player) +end + +--- Remove a role id from a list, returns true when it was present +--- @param list number[]? +--- @param role_id number +--- @return boolean +local function remove_role_id(list, role_id) + if not list then return false end + for index, value in ipairs(list) do + if value == role_id then + table.remove(list, index) + return true + end + end + return false +end + +--- Add a role id to a list unless it is already present +--- @param list number[] +--- @param role_id number +--- @return boolean +local function add_role_id(list, role_id) + for _, value in ipairs(list) do + if value == role_id then return false end + end + list[#list + 1] = role_id + return true +end + +--- Convert a role, role name, or array of either into an array of roles +--- @param roles any +--- @return ExpRoles.Role[] +local function resolve_roles(roles) + if type(roles) ~= "table" or roles.name then + roles = { roles } + end + + local rtn = {} + for _, role in pairs(roles) do + local resolved = ExpRoles.get_role_from_any(role) + if resolved then rtn[#rtn + 1] = resolved end + end + + return rtn +end + +--- Apply a role change locally and tell the controller about it +--- @param player_name string +--- @param roles ExpRoles.Role[] +--- @param change_type "assign" | "unassign" +--- @param sync boolean +local function apply_local_change(player_name, roles, change_type, sync) + local local_roles = script_data.local_players[player_name] or {} + local pending = script_data.pending[player_name] or {} + local changed = {} + + for _, role in pairs(roles) do + if change_type == "assign" then + local synced = script_data.synced_players[player_name] or {} + local already_synced = false + for _, role_id in pairs(synced) do + if role_id == role.id then already_synced = true break end + end + if not already_synced and add_role_id(local_roles, role.id) then + changed[#changed + 1] = role + if sync then add_role_id(pending, role.id) end + end + else + local removed = remove_role_id(local_roles, role.id) + remove_role_id(pending, role.id) + -- A synced role is only taken away here when the controller is + -- being told as well, otherwise the next update would restore it + if sync then + removed = remove_role_id(script_data.synced_players[player_name], role.id) or removed + end + if removed then + changed[#changed + 1] = role + end + end + end + + script_data.local_players[player_name] = next(local_roles) and local_roles or nil + script_data.pending[player_name] = next(pending) and pending or nil + rebuild_player_view(player_name) + + return changed +end + +--- Send a role change to the controller +--- @param player_name string +--- @param roles ExpRoles.Role[] +--- @param change_type "assign" | "unassign" +local function emit_assignment_update(player_name, roles, change_type) + if not script_data.emit_updates then return end + + local role_ids = {} + for index, role in ipairs(roles) do + role_ids[index] = role.id + end + + clusterio_api.send_json("exp_roles:assignment_update", { + name = player_name, + assign = change_type == "assign" and role_ids or nil, + unassign = change_type == "unassign" and role_ids or nil, + }) +end + +--- Change the roles a player holds +--- @param player LuaPlayer | string +--- @param roles any A role, role name, or array of either +--- @param change_type "assign" | "unassign" +--- @param by_player_name string? +--- @param silent boolean? +--- @param sync boolean +local function change_player_roles(player, roles, change_type, by_player_name, silent, sync) + local player_name = type(player) == "table" and player.name or player --[[ @as string? ]] + if not player_name then return end + + local role_objects = resolve_roles(roles) + if #role_objects == 0 then return end + + local changed = apply_local_change(player_name, role_objects, change_type, sync) + if #changed == 0 then return end + + if sync then + emit_assignment_update(player_name, changed, change_type) + end + + local valid_player = game.get_player(player_name) + if valid_player then + emit_player_roles_updated(valid_player, change_type, changed, by_player_name, silent) + end +end + +--- Give a player one or more roles, the change is sent to the controller +--- @param player LuaPlayer | string +--- @param roles any A role, role name, or array of either +--- @param by_player_name string? +--- @param skip_checks boolean? Unused, kept for compatibility +--- @param silent boolean? +function ExpRoles.assign_player(player, roles, by_player_name, skip_checks, silent) + change_player_roles(player, roles, "assign", by_player_name, silent, true) +end + +--- Take one or more roles from a player, the change is sent to the controller +--- @param player LuaPlayer | string +--- @param roles any A role, role name, or array of either +--- @param by_player_name string? +--- @param skip_checks boolean? Unused, kept for compatibility +--- @param silent boolean? +function ExpRoles.unassign_player(player, roles, by_player_name, skip_checks, silent) + change_player_roles(player, roles, "unassign", by_player_name, silent, true) +end + +--- Give a player one or more roles on this map only, the controller is not told +--- Use this for roles earned from progress which does not leave this map +--- @param player LuaPlayer | string +--- @param roles any A role, role name, or array of either +--- @param by_player_name string? +--- @param silent boolean? +function ExpRoles.assign_player_local(player, roles, by_player_name, silent) + change_player_roles(player, roles, "assign", by_player_name, silent, false) +end + +--- Take one or more local roles from a player, the controller is not told +--- @param player LuaPlayer | string +--- @param roles any A role, role name, or array of either +--- @param by_player_name string? +--- @param silent boolean? +function ExpRoles.unassign_player_local(player, roles, by_player_name, silent) + change_player_roles(player, roles, "unassign", by_player_name, silent, false) +end + +--[[ + Sync entry points +]] + +--- Restore local references to persistent script data after load +function ExpRoles.on_load() + script_data = compat.script_data["exp_roles"] + if script_data then + rebuild_role_views() + rebuild_all_player_views() + end +end + +--- Enable or disable sending role changes back to the controller +--- @param enabled boolean? +function ExpRoles.set_emit_events(enabled) + script_data.emit_updates = enabled ~= false +end + +--- Replace all local state with the state held by the controller +--- @param payload { roles: table[], assignments: table[] } +function ExpRoles.initialise(payload) + local emit_updates = script_data.emit_updates + script_data.emit_updates = false + + script_data.roles = {} + script_data.default_role_id = nil + for _, record in pairs(payload.roles) do + if not record.is_deleted then + script_data.roles[record.id] = decode_role(record) + if record.is_default then + script_data.default_role_id = record.id + end + end + end + + script_data.synced_players = {} + for _, record in pairs(payload.assignments) do + if not record.is_deleted and record.role_ids then + script_data.synced_players[record.name] = record.role_ids + end + end + + -- Anything still pending was sent before the connection was lost, the + -- controller state received here is authoritative + script_data.pending = {} + + rebuild_role_views() + rebuild_all_player_views() + + for _, player in pairs(game.connected_players) do + apply_flag_triggers(player) + end + + script_data.emit_updates = emit_updates +end + +--- Receive changes to roles from the controller +--- @param records table[] +function ExpRoles.receive_role_updates(records) + for _, record in pairs(records) do + if record.is_deleted then + script_data.roles[record.id] = nil + if script_data.default_role_id == record.id then + script_data.default_role_id = nil + end + else + script_data.roles[record.id] = decode_role(record) + if record.is_default then + script_data.default_role_id = record.id + elseif script_data.default_role_id == record.id then + script_data.default_role_id = nil + end + end + end + + rebuild_role_views() + rebuild_all_player_views() + + for _, player in pairs(game.connected_players) do + apply_flag_triggers(player) + end +end + +--- Receive changes to the roles held by players from the controller +--- @param records table[] +function ExpRoles.receive_assignment_updates(records) + for _, record in pairs(records) do + local player_name = record.name + if record.is_deleted then + script_data.synced_players[player_name] = nil + else + script_data.synced_players[player_name] = record.role_ids or {} + end + + -- Anything confirmed by the controller no longer needs to be held locally + local pending = script_data.pending[player_name] + if pending then + for _, role_id in pairs(script_data.synced_players[player_name] or {}) do + if remove_role_id(pending, role_id) then + remove_role_id(script_data.local_players[player_name], role_id) + end + end + if not next(pending) then script_data.pending[player_name] = nil end + end + + local local_roles = script_data.local_players[player_name] + if local_roles and not next(local_roles) then + script_data.local_players[player_name] = nil + end + + rebuild_player_view(player_name) + + local player = game.get_player(player_name) + if player then apply_flag_triggers(player) end + end +end + +--- Roll back a local assignment which the controller rejected +--- @param payload { name: string, role_ids: number[] } +function ExpRoles.reject_assignment(payload) + local player_name = payload.name + local roles = {} + for _, role_id in pairs(payload.role_ids) do + local role = script_data.roles[role_id] + if role then roles[#roles + 1] = role end + end + + if #roles == 0 then return end + + local emit_updates = script_data.emit_updates + script_data.emit_updates = false + change_player_roles(player_name, roles, "unassign", "", true, false) + script_data.emit_updates = emit_updates +end + +--- Get the current script data for debugging purposes +--- @package +function ExpRoles._script_data() + return script_data +end + +--[[ + Event handlers, these are wired up by events.lua +]] + +--- Create persistent script data if this is the first startup +--- @package +function ExpRoles.on_server_startup() + if compat.script_data["exp_roles"] == nil then + --- @type ExpRoles.ScriptData + compat.script_data["exp_roles"] = { + roles = {}, + synced_players = {}, + local_players = {}, + pending = {}, + default_role_id = nil, + emit_updates = false, + } + end + + ExpRoles.on_load() +end + +--- Apply the flag triggers for a player who just joined +--- @package +--- @param event EventData.on_player_joined_game +function ExpRoles.on_player_joined_game(event) + local player = game.get_player(event.player_index) + if player then apply_flag_triggers(player) end +end + +return ExpRoles diff --git a/exp_roles/module/events.lua b/exp_roles/module/events.lua new file mode 100644 index 00000000..6537b5c1 --- /dev/null +++ b/exp_roles/module/events.lua @@ -0,0 +1,19 @@ +--[[-- ExpRoles - Events +Wires the role sync into the factorio event handler. + +This is kept apart from control.lua because `ExpRoles.events` holds the ids of +the events this module raises, which is the interface the legacy role system +had, and event_handler expects `events` to be the handlers to register. +]] + +local clusterio_api = require("modules/clusterio/api") +local ExpRoles = require("modules/exp_roles/control") + +return { + on_load = ExpRoles.on_load, + events = { + [clusterio_api.events.on_server_startup] = ExpRoles.on_server_startup, + [defines.events.on_multiplayer_init] = ExpRoles.on_server_startup, + [defines.events.on_player_joined_game] = ExpRoles.on_player_joined_game, + }, +} diff --git a/exp_roles/module/globals.lua b/exp_roles/module/globals.lua new file mode 100644 index 00000000..10d74063 --- /dev/null +++ b/exp_roles/module/globals.lua @@ -0,0 +1,10 @@ +--[[ +It is best practice to not expose any globals because all modules share a global environment +However, sometimes you need globals, for example to access functions within rcon commands +Therefore, we advise that this should be the only file in your module to expose globals +]] + +--- @diagnostic disable: global-element + +-- Access using `/sc exp_roles.foo()` +exp_roles = require("modules/exp_roles/control") diff --git a/exp_roles/module/locale/en.cfg b/exp_roles/module/locale/en.cfg new file mode 100644 index 00000000..3ea3d06c --- /dev/null +++ b/exp_roles/module/locale/en.cfg @@ -0,0 +1,3 @@ +[exp-roles] +game-message-assign=__1__ has been assigned: __2__ by __3__ +game-message-unassign=__1__ has been unassigned: __2__ by __3__ diff --git a/exp_roles/module/module.json b/exp_roles/module/module.json new file mode 100644 index 00000000..7824e3f9 --- /dev/null +++ b/exp_roles/module/module.json @@ -0,0 +1,13 @@ +{ + "name": "exp_roles", + "load": [ + "events.lua" + ], + "require": [ + "globals.lua" + ], + "dependencies": { + "clusterio": "*", + "exp_util": "*" + } +} diff --git a/exp_roles/module/module_exports.lua b/exp_roles/module/module_exports.lua new file mode 100644 index 00000000..9e4d9285 --- /dev/null +++ b/exp_roles/module/module_exports.lua @@ -0,0 +1,3 @@ + +-- Access the exports from other modules using require("modules/exp_roles") +return require("modules/exp_roles/control") diff --git a/exp_roles/package.json b/exp_roles/package.json new file mode 100644 index 00000000..d6530a11 --- /dev/null +++ b/exp_roles/package.json @@ -0,0 +1,49 @@ +{ + "name": "@expcluster/roles", + "version": "6.5.0", + "description": "Clusterio plugin providing syncing of in game roles.", + "author": "Cooldude2606 ", + "license": "MIT", + "repository": "explosivegaming/ExpCluster", + "main": "dist/node/index.js", + "scripts": { + "prepare": "tsc --build && webpack-cli --env production" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^", + "@clusterio/web_ui": "workspace:^" + }, + "devDependencies": { + "@ant-design/icons": "^6.2.3", + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^", + "@clusterio/web_ui": "workspace:^", + "@types/node": "catalog:", + "@types/react": "catalog:", + "antd": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-router-dom": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" + }, + "dependencies": { + "@sinclair/typebox": "catalog:" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "clusterio", + "clusterio-plugin", + "factorio" + ] +} diff --git a/exp_roles/tsconfig.browser.json b/exp_roles/tsconfig.browser.json new file mode 100644 index 00000000..1e3889e7 --- /dev/null +++ b/exp_roles/tsconfig.browser.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.browser.json", + "include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ], +} diff --git a/exp_roles/tsconfig.json b/exp_roles/tsconfig.json new file mode 100644 index 00000000..303b8f28 --- /dev/null +++ b/exp_roles/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.browser.json" } + ] +} diff --git a/exp_roles/tsconfig.node.json b/exp_roles/tsconfig.node.json new file mode 100644 index 00000000..3218f2e7 --- /dev/null +++ b/exp_roles/tsconfig.node.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./**/*.ts"], + "exclude": ["test/*", "./dist/*"], +} diff --git a/exp_roles/web/components/RoleProperties.tsx b/exp_roles/web/components/RoleProperties.tsx new file mode 100644 index 00000000..8e904f2b --- /dev/null +++ b/exp_roles/web/components/RoleProperties.tsx @@ -0,0 +1,145 @@ +import React, { useContext, useEffect, useState } from "react"; +import { Button, ColorPicker, Form, Input, InputNumber, Space, Switch, Tooltip } from "antd"; + +import * as lib from "@clusterio/lib"; +import { ControlContext, SectionHeader, useAccount, notifyErrorHandler } from "@clusterio/web_ui"; + +import { RoleColor, RoleMetaRecord, RoleMetaUpdateRequest } from "../../messages"; +import type { WebPlugin } from ".."; + +const MS_PER_HOUR = 3600000; + +type RoleFormValues = { + order: number; + priority: number; + shortHand: string; + tag: string; + color: string | { toRgb(): { r: number, g: number, b: number } } | null; + autoAssignHours: number | null; + blockAutoAssign: boolean; +}; + +/** + * In game properties of a role, shown at the end of the core role page. + * + * The name, description and permissions of a role are owned by clusterio, this + * only covers the properties which only mean something in game. + */ +export default function RoleProperties(props: { plugin: WebPlugin, role?: lib.Role }) { + const control = useContext(ControlContext); + const account = useAccount(); + const [form] = Form.useForm(); + const [applying, setApplying] = useState(false); + + const [roles] = props.plugin.useRoles(); + const record = props.role ? roles.get(props.role.id) : undefined; + const meta = record?.meta; + const canUpdate = account.hasPermission("exp_roles.role.update"); + + useEffect(() => { + if (!meta) { + return; + } + form.setFieldsValue({ + order: meta.order, + priority: meta.priority, + shortHand: meta.shortHand, + tag: meta.tag, + color: meta.color ? `rgb(${meta.color.r}, ${meta.color.g}, ${meta.color.b})` : null, + autoAssignHours: meta.autoAssignOnlineTimeMs === null + ? null + : meta.autoAssignOnlineTimeMs / MS_PER_HOUR, + blockAutoAssign: meta.blockAutoAssign, + }); + }, [meta, form]); + + if (!props.role || !meta) { + return null; + } + + async function apply() { + const values = form.getFieldsValue(); + + let color: RoleColor | null = null; + if (values.color && typeof values.color !== "string") { + const rgb = values.color.toRgb(); + color = new RoleColor(Math.round(rgb.r), Math.round(rgb.g), Math.round(rgb.b)); + } else if (typeof values.color === "string") { + const match = values.color.match(/(\d+)\D+(\d+)\D+(\d+)/); + if (match) { + color = new RoleColor(Number(match[1]), Number(match[2]), Number(match[3])); + } + } + + await control.send(new RoleMetaUpdateRequest(new RoleMetaRecord( + props.role!.id, + values.order, + values.priority, + values.shortHand ?? "", + values.tag ?? "", + color, + values.autoAssignHours === null || values.autoAssignHours === undefined + ? null + : values.autoAssignHours * MS_PER_HOUR, + values.blockAutoAssign ?? false, + ))); + } + + return <> + +
+ Order} + > + + + + Priority + + } + > + + + + + + + + + + + + + Auto assign after (hours) + + } + > + + + + + + {canUpdate && + + + + } +
+ ; +} diff --git a/exp_roles/web/index.tsx b/exp_roles/web/index.tsx new file mode 100644 index 00000000..644cd10d --- /dev/null +++ b/exp_roles/web/index.tsx @@ -0,0 +1,24 @@ +import React, { useCallback, useSyncExternalStore } from "react"; +import { BaseWebPlugin } from "@clusterio/web_ui"; + +import * as lib from "@clusterio/lib"; +import * as messages from "../messages"; + +import RoleProperties from "./components/RoleProperties"; + +export class WebPlugin extends BaseWebPlugin { + roles = new lib.MapSubscriber(messages.RoleUpdatedEvent, this.control); + + async init() { + // The core components pass a role and the plugin, which componentExtra + // does not carry in its type + this.componentExtra = { + RoleViewPage: RoleProperties as React.ComponentType, + }; + } + + useRoles() { + const subscribe = useCallback((cb: () => void) => this.roles.subscribe(cb), []); + return useSyncExternalStore(subscribe, () => this.roles.getSnapshot()); + } +} diff --git a/exp_roles/webpack.config.js b/exp_roles/webpack.config.js new file mode 100644 index 00000000..20f6cfb5 --- /dev/null +++ b/exp_roles/webpack.config.js @@ -0,0 +1,34 @@ +"use strict"; +const path = require("path"); +const webpack = require("webpack"); +const { merge } = require("webpack-merge"); + +const common = require("@clusterio/web_ui/webpack.common"); + +module.exports = (env = {}) => merge(common(env), { + context: __dirname, + entry: "./web/index.tsx", + output: { + path: path.resolve(__dirname, "dist", "web"), + }, + plugins: [ + new webpack.container.ModuleFederationPlugin({ + name: "exp_roles", + library: { type: "window", name: "plugin_exp_roles" }, + exposes: { + "./": "./index.ts", + "./package.json": "./package.json", + "./web": "./web/index.tsx", + }, + shared: { + "@clusterio/lib": { import: false }, + "@clusterio/web_ui": { import: false }, + "antd": { import: false }, + "react": { import: false }, + "react-dom": { import: false }, + "react-router": { import: false }, + "react-router-dom": { import: false }, + }, + }), + ], +}); diff --git a/tsconfig.json b/tsconfig.json index 5aeb9f33..8afb8e7c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ { "path": "./exp_groups/" }, { "path": "./exp_gui/" }, { "path": "./exp_legacy/" }, + { "path": "./exp_roles/" }, { "path": "./exp_scenario/" }, { "path": "./exp_server_ups/" }, { "path": "./exp_util/" }, From fcfa174794ff5728b117a31a64c683f192be2cd9 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:39:04 +0000 Subject: [PATCH 3/6] Fix LuaLS findings in the roles module Checked with the same LuaLS version and factorio library CI uses. The module now reports no findings; the 279 which remain are all pre-existing and main is already failing on them. Most were annotations rather than behaviour. Two are worth noting: - The role prototype is now its own class which the role inherits, since defining methods on a table annotated as the role counted as injecting fields into it. - The role change message is built from a literal locale key per branch. Building the key by concatenation gives a plain string where a localised string is wanted, which is the same finding the legacy module reports. Co-Authored-By: Claude Opus 5 (1M context) --- exp_roles/module/control.lua | 55 ++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/exp_roles/module/control.lua b/exp_roles/module/control.lua index 0e7af449..91139f60 100644 --- a/exp_roles/module/control.lua +++ b/exp_roles/module/control.lua @@ -17,7 +17,6 @@ local Async = require("modules/exp_util/async") --- @class ExpRoles local ExpRoles = { - _prototype = {}, config = { --- Role names in order, a lower index is a more privileged role order = {}, --- @type string[] @@ -25,8 +24,8 @@ local ExpRoles = { roles = {}, --- @type table --- Role names held by each player, includes local assignments players = {}, --- @type table - --- Callbacks run when a flag is gained or lost - flags = {}, --- @type table + --- Async handles run when a flag is gained or lost + flags = {}, --- @type table }, events = { on_role_assigned = script.generate_event_name(), @@ -34,7 +33,13 @@ local ExpRoles = { }, } ---- @class ExpRoles.Role +--- 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 @@ -150,6 +155,7 @@ local function decode_role(record) if flag then flags[flag] = true end end + --- @type ExpRoles.Role return setmetatable({ id = record.id, name = record.name, @@ -228,8 +234,9 @@ end --- @return ExpRoles.Role? function ExpRoles.get_role_from_any(any) local t_any = type(any) - if t_any == "number" or tonumber(any) then - return ExpRoles.get_role_by_order(tonumber(any)) + 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 @@ -381,24 +388,27 @@ end ]] --- Check if this role allows an action +--- @param self ExpRoles.Role --- @param action string --- @return boolean -function ExpRoles._prototype:is_allowed(action) +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 ExpRoles._prototype:has_flag(name) +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 ExpRoles._prototype:get_players(online) +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 @@ -416,9 +426,10 @@ function ExpRoles._prototype:get_players(online) 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 ExpRoles._prototype:print(message) +function Role:print(message) local players = self:get_players(true) for _, player in pairs(players) do player.print(message) @@ -436,8 +447,8 @@ end --- @param message LocalisedString function ExpRoles.print_to_roles(roles, message) for _, role in pairs(roles) do - role = ExpRoles.get_role_from_any(role) - if role then role:print(message) end + local resolved = ExpRoles.get_role_from_any(role) + if resolved then resolved:print(message) end end end @@ -445,12 +456,12 @@ end --- @param role string | number | ExpRoles.Role --- @param message LocalisedString function ExpRoles.print_to_roles_higher(role, message) - role = ExpRoles.get_role_from_any(role) - if not role then return end + local 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 <= role.index and role_name ~= ExpRoles.get_default_role_name() then + if index <= resolved.index and role_name ~= ExpRoles.get_default_role_name() then roles[#roles + 1] = role_name end end @@ -462,12 +473,12 @@ end --- @param role string | number | ExpRoles.Role --- @param message LocalisedString function ExpRoles.print_to_roles_lower(role, message) - role = ExpRoles.get_role_from_any(role) - if not role then return end + local 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 >= role.index and role_name ~= ExpRoles.get_default_role_name() then + if index >= resolved.index and role_name ~= ExpRoles.get_default_role_name() then roles[#roles + 1] = role_name end end @@ -521,7 +532,10 @@ local function emit_player_roles_updated(player, change_type, roles, by_player_n end if not silent then - game.print({ "exp-roles.game-message-" .. change_type, player.name, table.concat(role_names, ", "), by_player_name }) + 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 @@ -591,6 +605,7 @@ end --- @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 {} @@ -860,7 +875,6 @@ end ]] --- Create persistent script data if this is the first startup ---- @package function ExpRoles.on_server_startup() if compat.script_data["exp_roles"] == nil then --- @type ExpRoles.ScriptData @@ -878,7 +892,6 @@ function ExpRoles.on_server_startup() end --- Apply the flag triggers for a player who just joined ---- @package --- @param event EventData.on_player_joined_game function ExpRoles.on_player_joined_game(event) local player = game.get_player(event.player_index) From dcba1eef1c6bcb04d76c69e1fdae8f3ba957a641 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:04:36 +0000 Subject: [PATCH 4/6] Address review on the roles plugin - Use the core role permissions rather than defining new ones. Assignments are only sent between the controller and an instance, so they are no longer addressed to control and need no permission at all. - Split the datastore reconciliation into ensureRoleMeta, sweepRoleMeta and applyAutoAssign, all run on init as well as when roles change. Properties left behind by a role deleted while the plugin was not running are now swept up, where before they would be inherited by the next role given that id. - Use a switch in onControllerConfigFieldChanged, matching instance.ts. - Resolve pending assignments on initialise. A pending role is either confirmed, and so now held by synced_players, or it never landed; either way it stops being held locally. Roles assigned with assign_player_local are untouched. - Drop the emit_updates juggling in reject_assignment, sync is already false. - Send each permission name once and reference it by index, see the benchmark in the pull request. Single role updates stay in the plain form. - Lay the role properties out in columns, name the apply button for the section it belongs to, and give every field a tooltip. Co-Authored-By: Claude Opus 5 (1M context) --- exp_roles/controller.ts | 52 ++++--- exp_roles/index.ts | 33 +---- exp_roles/instance.ts | 2 +- exp_roles/messages.ts | 45 ++++-- exp_roles/module/control.lua | 76 +++++++---- exp_roles/web/components/RoleProperties.tsx | 144 ++++++++++++-------- 6 files changed, 212 insertions(+), 140 deletions(-) diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts index b1177471..b9bf3a8b 100644 --- a/exp_roles/controller.ts +++ b/exp_roles/controller.ts @@ -16,8 +16,12 @@ export class ControllerPlugin extends BaseControllerPlugin { ).bootstrap() ); - // Roles created before this plugin was installed have no properties yet + // 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( @@ -65,6 +69,28 @@ export class ControllerPlugin extends BaseControllerPlugin { 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) { const meta = this.roleMeta.get(role.id) ?? new messages.RoleMetaRecord(role.id, role.id); @@ -80,9 +106,11 @@ export class ControllerPlugin extends BaseControllerPlugin { } async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { - // Which role is the default is carried on the role records themselves - if (field === "controller.default_role_id") { - this.controller.subscriptions.broadcast(new messages.RoleUpdatedEvent(this.listRoleRecords())); + 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; } } @@ -107,21 +135,7 @@ export class ControllerPlugin extends BaseControllerPlugin { /** A clusterio role was created, changed or deleted. */ rolesUpdated(roles: lib.Role[]) { const created = this.ensureRoleMeta(); - - // Deleting a role leaves its properties behind, which would be reused by - // the next role to be given the same id - const orphaned = []; - for (const role of roles) { - if (role.isDeleted) { - const meta = this.roleMeta.getMutable(role.id); - if (meta) { - orphaned.push(meta); - } - } - } - if (orphaned.length) { - this.roleMeta.deleteMany(orphaned); - } + this.sweepRoleMeta(); // Newly created properties broadcast on their own through roleMetaUpdated const createdIds = new Set(created.map(meta => meta.id)); diff --git a/exp_roles/index.ts b/exp_roles/index.ts index 96d8cb0a..e3cb4093 100644 --- a/exp_roles/index.ts +++ b/exp_roles/index.ts @@ -7,36 +7,9 @@ declare module "@clusterio/lib" { } } -lib.definePermission({ - name: "exp_roles.role.list", - title: "List In Game Roles", - description: "List the in game properties of all roles.", - grantByDefault: true, -}); -lib.definePermission({ - name: "exp_roles.role.subscribe", - title: "Subscribe to In Game Role Updates", - description: "Receive updates when the in game properties of a role change.", - grantByDefault: true, -}); -lib.definePermission({ - name: "exp_roles.role.update", - title: "Update In Game Roles", - description: "Modify the in game properties of a role, such as its order and colour.", -}); - -lib.definePermission({ - name: "exp_roles.assignment.list", - title: "List Role Assignments", - description: "List the roles held by each player in game.", - grantByDefault: true, -}); -lib.definePermission({ - name: "exp_roles.assignment.subscribe", - title: "Subscribe to Role Assignment Updates", - description: "Receive updates when the roles held by a player change.", - grantByDefault: true, -}); +// 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", diff --git a/exp_roles/instance.ts b/exp_roles/instance.ts index df7a03a4..615fe7c2 100644 --- a/exp_roles/instance.ts +++ b/exp_roles/instance.ts @@ -49,7 +49,7 @@ export class InstancePlugin extends BaseInstancePlugin { ]); await this.luaSend("initialise", { - roles: roles.map(role => role.toJSON()), + ...messages.encodeRolesForLua(roles), assignments: assignments.map(assignment => assignment.toJSON()), }); await this.luaSetEmitEvents(this.syncMode === "bidirectional"); diff --git a/exp_roles/messages.ts b/exp_roles/messages.ts index dda0c144..3886970b 100644 --- a/exp_roles/messages.ts +++ b/exp_roles/messages.ts @@ -240,6 +240,37 @@ export class AssignmentRecord { } } +/** + * 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(); + + 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 */ @@ -250,7 +281,7 @@ export class RoleUpdatedEvent { static type = "event" as const; static src = "controller" as const; static dst = ["control", "instance"] as const; - static permission = "exp_roles.role.subscribe" as const; + static permission = "core.role.subscribe" as const; constructor( public updates: RoleRecord[], @@ -274,8 +305,7 @@ export class AssignmentUpdatedEvent { static plugin = "exp_roles" as const; static type = "event" as const; static src = "controller" as const; - static dst = ["control", "instance"] as const; - static permission = "exp_roles.assignment.subscribe" as const; + static dst = "instance" as const; constructor( public updates: AssignmentRecord[], @@ -304,7 +334,7 @@ export class RoleListRequest { static type = "request" as const; static src = ["control", "instance"] as const; static dst = "controller" as const; - static permission = "exp_roles.role.list" as const; + static permission = "core.role.list" as const; static Response = lib.jsonArray(RoleRecord); constructor() {} @@ -316,7 +346,7 @@ export class RoleMetaUpdateRequest { static type = "request" as const; static src = "control" as const; static dst = "controller" as const; - static permission = "exp_roles.role.update" as const; + static permission = "core.role.update" as const; constructor( public meta: RoleMetaRecord, @@ -343,9 +373,8 @@ export class AssignmentListRequest { declare ["constructor"]: typeof AssignmentListRequest; static plugin = "exp_roles" as const; static type = "request" as const; - static src = ["control", "instance"] as const; + static src = "instance" as const; static dst = "controller" as const; - static permission = "exp_roles.assignment.list" as const; static Response = lib.jsonArray(AssignmentRecord); constructor() {} @@ -359,7 +388,7 @@ export class AssignmentUpdateRequest { declare ["constructor"]: typeof AssignmentUpdateRequest; static plugin = "exp_roles" as const; static type = "request" as const; - static src = ["control", "instance"] as const; + static src = "instance" as const; static dst = "controller" as const; static permission = "core.user.update_roles" as const; diff --git a/exp_roles/module/control.lua b/exp_roles/module/control.lua index 91139f60..59b5696e 100644 --- a/exp_roles/module/control.lua +++ b/exp_roles/module/control.lua @@ -141,12 +141,20 @@ 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) +local function decode_role(record, names) local meta = record.meta local allowed_actions = {} - for _, permission in pairs(record.permissions) do - allowed_actions[permission] = true + 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 = {} @@ -732,6 +740,26 @@ 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"] @@ -748,16 +776,18 @@ function ExpRoles.set_emit_events(enabled) end --- Replace all local state with the state held by the controller ---- @param payload { roles: table[], assignments: table[] } +--- 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) + script_data.roles[record.id] = decode_role(record, names) if record.is_default then script_data.default_role_id = record.id end @@ -771,8 +801,19 @@ function ExpRoles.initialise(payload) end end - -- Anything still pending was sent before the connection was lost, the - -- controller state received here is authoritative + -- 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() @@ -823,22 +864,7 @@ function ExpRoles.receive_assignment_updates(records) script_data.synced_players[player_name] = record.role_ids or {} end - -- Anything confirmed by the controller no longer needs to be held locally - local pending = script_data.pending[player_name] - if pending then - for _, role_id in pairs(script_data.synced_players[player_name] or {}) do - if remove_role_id(pending, role_id) then - remove_role_id(script_data.local_players[player_name], role_id) - end - end - if not next(pending) then script_data.pending[player_name] = nil end - end - - local local_roles = script_data.local_players[player_name] - if local_roles and not next(local_roles) then - script_data.local_players[player_name] = nil - end - + drop_confirmed_local(player_name) rebuild_player_view(player_name) local player = game.get_player(player_name) @@ -858,10 +884,8 @@ function ExpRoles.reject_assignment(payload) if #roles == 0 then return end - local emit_updates = script_data.emit_updates - script_data.emit_updates = false + -- Sync is false, so this is never sent back to the controller change_player_roles(player_name, roles, "unassign", "", true, false) - script_data.emit_updates = emit_updates end --- Get the current script data for debugging purposes diff --git a/exp_roles/web/components/RoleProperties.tsx b/exp_roles/web/components/RoleProperties.tsx index 8e904f2b..a3729ec9 100644 --- a/exp_roles/web/components/RoleProperties.tsx +++ b/exp_roles/web/components/RoleProperties.tsx @@ -1,5 +1,5 @@ import React, { useContext, useEffect, useState } from "react"; -import { Button, ColorPicker, Form, Input, InputNumber, Space, Switch, Tooltip } from "antd"; +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"; @@ -34,7 +34,7 @@ export default function RoleProperties(props: { plugin: WebPlugin, role?: lib.Ro const [roles] = props.plugin.useRoles(); const record = props.role ? roles.get(props.role.id) : undefined; const meta = record?.meta; - const canUpdate = account.hasPermission("exp_roles.role.update"); + const canUpdate = account.hasPermission("core.role.update"); useEffect(() => { if (!meta) { @@ -85,61 +85,93 @@ export default function RoleProperties(props: { plugin: WebPlugin, role?: lib.Ro ))); } + const labelled = (text: string, tip: string) => {text}; + return <> - -
- Order} - > - - - - Priority - - } - > - - - - - - - - - - - - - Auto assign after (hours) - - } - > - - - - - - {canUpdate && - - - - } + { + setApplying(true); + apply() + .catch(notifyErrorHandler("Error updating role properties")) + .finally(() => setApplying(false)); + }} + >Apply in game properties : undefined} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ; } From 1943d8743d2fce24859ab6e613183ccfa8368fc3 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:19:36 +0000 Subject: [PATCH 5/6] Left align the role property labels Co-Authored-By: Claude Opus 5 (1M context) --- exp_roles/web/components/RoleProperties.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/exp_roles/web/components/RoleProperties.tsx b/exp_roles/web/components/RoleProperties.tsx index a3729ec9..36edc352 100644 --- a/exp_roles/web/components/RoleProperties.tsx +++ b/exp_roles/web/components/RoleProperties.tsx @@ -104,6 +104,7 @@ export default function RoleProperties(props: { plugin: WebPlugin, role?: lib.Ro
Date: Thu, 6 Aug 2026 09:55:45 +0100 Subject: [PATCH 6/6] Fix game properties UI on small screens --- exp_roles/web/components/RoleProperties.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/exp_roles/web/components/RoleProperties.tsx b/exp_roles/web/components/RoleProperties.tsx index 36edc352..b63ae707 100644 --- a/exp_roles/web/components/RoleProperties.tsx +++ b/exp_roles/web/components/RoleProperties.tsx @@ -104,19 +104,21 @@ export default function RoleProperties(props: { plugin: WebPlugin, role?: lib.Ro - + + + + - + + + + + + + - + + + +