Skip to content

Add exp_roles plugin and scenario permissions - #448

Open
bbassie wants to merge 3 commits into
explosivegaming:mainfrom
bbassie:feature/roles-plugin
Open

Add exp_roles plugin and scenario permissions#448
bbassie wants to merge 3 commits into
explosivegaming:mainfrom
bbassie:feature/roles-plugin

Conversation

@bbassie

@bbassie bbassie commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Replaces the role storage half of exp_legacy/expcore/roles.lua with clusterio's own roles and permissions. Clusterio already stores roles, the permissions they grant, and which user holds which role, plus a web UI for all three — so this adds only what it is missing, rather than reimplementing any of it.

This does not yet move the call sites off expcore.roles; nothing depends on the new plugin. That is deliberate, see "What is left" below.

exp_scenario: permissions for the in game actions

The role config held 105 action strings and 7 flags that only existed as bare strings in lua. Each is now a real clusterio permission, so it appears on the role page and can be granted like any other.

Names come from a deterministic transform, exported so the lua side derives exactly the same name and existing call sites keep working untouched:

command/kill                   -> exp_scenario.command.kill
gui/warp-list/bypass-proximity -> exp_scenario.gui.warp_list.bypass_proximity
fast-tree-decon                -> exp_scenario.action.fast_tree_decon
report-immune          (flag)  -> exp_scenario.flag.report_immune

Flags become permissions too — player_has_flag and player_allowed are the same lookup, the only difference being that flags have change triggers, which stay in lua.

Command descriptions are taken from the existing locale entries rather than written fresh, so they match what players already see in /commands. Two permissions were referenced in code but missing from the role config (command/give-warning, command/collectdata); both are now defined.

The 21 actions the Guest role granted are marked grantByDefault, since the in game default role and clusterio's default role are the same concept.

Three actions have no scenario permission — they map onto core ones instead:

Legacy action Replaced by
command/assign-role core.user.update_roles
command/unassign-role core.user.update_roles
command/get-roles core.role.list

exp_roles: the sync plugin

RoleUpdatesEvent and UserUpdatesEvent are both dst: "control", so instances cannot subscribe to them. The controller plugin rebroadcasts on its own events, the same shape exp_groups uses.

It stores one record per role for the properties clusterio's role does not carry — order, priority, short hand, tag, colour, auto assign threshold, block auto assign — created automatically for any role without one and removed when the role is deleted. These are edited on the core role page through a componentExtra, so there is no second place to manage roles.

There is no default_role_id or root_role_id setting: the default role is controller.default_role_id, and root is core.admin, which already short circuits every permission check.

exp_roles.sync_mode is disabled / enabled / bidirectional, matching exp_groups.

Priority replaces disallow

Only the roles with the highest priority a player holds are considered, and those union as normal. Jail sits above everything else, so holding it suppresses every other role including the implicit default — which is what disallow(default.allowed) was reaching for. It never worked, as the field is allowed_actions, not allowed, so it was passing nil.

This means jail lives on the cluster like any other role, and Jail.old_roles is no longer needed: jailing becomes "add one role", unjailing "remove one role", with no stashing and restoring.

Local assignments

Assignments made in game are applied locally first and then sent up, so assign_player stays synchronous for callers and nothing downstream has to learn about the round trip. Once the controller confirms, the role moves out of the local overlay. If it is refused, the instance rolls the change back.

The same overlay carries assignments which should never leave the map — assign_player_local, for roles earned from time on this map.

Auto assign

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. Per map conditions stay in lua as local assignments. This drops the legacy MapsPlayed / AfkTime conditions in favour of online time alone.

Verification

No factorio server was involved, so the lua was exercised directly under a stubbed environment:

  • The transform was compared against the TypeScript across all 114 names (104 actions, 3 core mapped, 7 flags) — no mismatches. This is the one invariant that silently breaks every permission check if it drifts.
  • 28 behavioural assertions over the real module covering priority suppression, the implicit default role, the local overlay, pending confirmation, rejection rollback, the server bypass, ordering, and the legacy config.players view.
  • Message records round trip through their own typebox schemas.
  • Types, the web bundle, and lua parsing all clean.

What is left

Follow ups, kept separate because they are the risky part and deserve their own review:

  • Move the ~40 files off require("modules.exp_legacy.expcore.roles"), simplify jail, delete the legacy module and config.
  • Seed the existing roles on first run.

override_player_roles is not migrated, and there is no automatic export of live save data — Storage.register keys on debug.getinfo short_src and asserts it is not at runtime, so an rcon helper would register under the wrong name. A manual _export taking the table as an argument can be added if it turns out to be wanted.

Also

One unrelated one line fix: exp-commands_unjail.description was a copy of the /jail text.

🤖 Generated with Claude Code

bbassie and others added 3 commits July 31, 2026 09:11
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) <noreply@anthropic.com>
Clusterio already stores roles, the permissions they grant, and which user holds
which role, along with a web UI for all three. What it does not have is the
properties a role only needs in game, or a way for an instance to learn about
any of it, since role and user updates are only sent to control connections.

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

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

Two things replace features the legacy system had:

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@bbassie

bbassie commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

On the red lint check: it is pre-existing, not from this branch. main at a07be7b — the commit this branches from — already fails it (run), and the job fails on any finding at all.

The repo currently reports 279 findings across 51 files. This branch contributes zero — I ran the same LuaLS 3.18.2 and FMTK factorio library the workflow uses and fixed all 17 the new module initially had.

Happy to leave it, or to fix the pre-existing ones in a separate PR if that would be useful — exp_scenario/module/commands/home.lua (47) and exp_commands/module/module_exports.lua (30) account for a large share of them.

Comment thread exp_roles/index.ts
Comment on lines +10 to +39
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,
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not use new custom permissions here. Instead we should be using core.roles

exp_roles.assignment is not required as we do not need to make assignments visible to control. This is an implimentation detail for allowing instances to interact with the core role and user models.

Comment thread exp_roles/controller.ts
Comment on lines +19 to +20
// Roles created before this plugin was installed have no properties yet
this.ensureRoleMeta();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also need to ensure there are no orpahns as the datastore file might have exist from a previous install of the plugin. In effect we are running all of rolesUpdated on init so it might be useful to have ensureRoleMeta sweepRoleMeta and applyAutoAssign which are all called by rolesUpdated and init

Comment thread exp_roles/controller.ts
Comment on lines +84 to +86
if (field === "controller.default_role_id") {
this.controller.subscriptions.broadcast(new messages.RoleUpdatedEvent(this.listRoleRecords()));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use a switch statement here like we do in instance.ts

Comment on lines +774 to +776
-- Anything still pending was sent before the connection was lost, the
-- controller state received here is authoritative
script_data.pending = {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we also need to clean up local_players as some of the pending assignments may now be confirmed and no longer need to be held within local_players as synced_players now holds it.

Comment on lines +861 to +864
local emit_updates = script_data.emit_updates
script_data.emit_updates = false
change_player_roles(player_name, roles, "unassign", "<server>", true, false)
script_data.emit_updates = emit_updates

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to mutate emit_updates here? The sync param is false which means we never check emit_updates

Comment thread exp_roles/instance.ts
Comment on lines +51 to +54
await this.luaSend("initialise", {
roles: roles.map(role => role.toJSON()),
assignments: assignments.map(assignment => assignment.toJSON()),
});

@Cooldude2606 Cooldude2606 Aug 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If possible, can we benchmark this command. We have recently found that commands containing a large number of string literals can harm performance. In our case it would be the permission strings. Two alternatives would be a permission array to convert an index to string value allowing reuse between roles; another would be a nested structure e.g. { "core": { "roles": [ "list", "subscribe" ] } }. The goal is better time performance. Include your results in your reply and any chalanges you faced in the implimention, commiting the best one. Feel free to explore other alternatives to stay away from custom binary formats that require base64 encoding. If the gains are minimal, then keep this simpler method.

}

return <>
<SectionHeader title="In Game Properties" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we arrange this is a columed format, or other alterntive that is nicer presented. Currently this takes up a lot of room and it is not clear what the apply button does or that it is specific to the in game properties. Also the "block auto assign" should have a tooltip explaining that it blocks other roles from being autoassigned.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants