Move the scenario onto exp_roles and remove the legacy role system - #453
Move the scenario onto exp_roles and remove the legacy role system#453bbassie wants to merge 11 commits into
Conversation
The module no longer presents the interface of the legacy expcore.roles module. Nothing outside this repository depends on it, so rather than carry the legacy action strings and the transform which mapped them onto permission names, call sites now check the clusterio permission name directly. That removes the one invariant which silently broke every check if the lua and typescript transforms drifted, and makes a check in lua greppable against its definition. - player_allowed and player_has_flag become player_has_permission; flags were only permissions with a change trigger, which define_permission_trigger now provides for any permission. - on_role_assigned and on_role_unassigned become one on_player_roles_changed event carrying the assigned and unassigned names. Every consumer registered both for the same handler. It is also raised for connected players when a role is edited on the controller, which the old events never were, and for changes made on the controller to the roles a player holds. - player_outranks and player_outranks_role replace the repeated comparison of highest role indexes, and apply the core.admin bypass consistently, which two of the six call sites did not. - get_role takes a name, clusterio id, or role; get_roles replaces get_roles_ordered. The config views of the roles are gone, with role:get_player_names covering the one use of config.players. - Roles carry a permission group, which the legacy system mapped roles to and the first version of the plugin dropped. A player is moved into the group of their most privileged role which names one. It is edited with the other in game properties. - skip_checks is dropped from assign_player and unassign_player. A player object with index 0 is treated as the server, which is how exp_commands represents rcon. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With the lua side checking permission names directly there is no transform to derive them from, so each permission is listed with its name. This was also the chance to drop the legacy action and flag buckets, which only reflected how the old config was written: - exp_scenario.bypass.* for entity protection, nuke protection, the deconstruction log, and reports. - exp_scenario.decon.* for the two deconstruction levels, with descriptions which say what they gate. - exp_scenario.player.* for admin, spectator, instant respawn, and system commands. - exp_scenario.chat.commands, and exp_scenario.gui.player_list.kick and .ban for the player list buttons which were never commands. Commands derive their permission as exp_scenario.command.<name>, so assign-role, unassign-role, and get-roles get scenario permissions rather than the core ones they mapped to before. The in game command is bounded by the lower role check, while core.user.update_roles is not, so granting it to moderators would have let them change any role from the web ui. Dropped: defer_role_changes, which priority replaced; command/give-warning, which no role held and the player list now checks create_warning for; and command/report, which was never defined. clear-tag/always is renamed to tag_clear.always to match the command it belongs to. _ipc and _sudo are added so every command has a definition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every call site of expcore.roles now uses exp_roles, and the legacy module, its config, and the glue which refreshed guis on role events are deleted. Where a file only renamed the require and the permission strings the change is mechanical; the rest: - Jail is now "give the Jail role" and unjail "take it away". The role has a higher priority than every other so holding it suppresses them, which is what stashing and restoring the roles was for. - The command role authority derives exp_scenario.command.<name> from the command name, and the role parsers use player_outranks rather than comparing indexes with their own root check. - The admin and spectator triggers, and the gui refresh on role changes, live in exp_scenario/control/roles.lua; the system commands trigger stays with the command authority. - The player list warn button is keyed on create_warning, the permission the command behind it already required, and report on create_report. Both were keyed on names no role held, so only root ever saw them. - The warps and tasks configs say exp_roles where they said expcore.roles. - The role tables the readme and player list read are replaced by get_player_names and get_roles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the role properties datastore is empty the plugin has not run before, so the roles the scenario used to define are created on the controller, along with the players the config listed. This replaces the role config which was loaded into every map. The seed keeps the parent relationships of the old config and flattens them into the permissions of each role, since clusterio roles do not inherit. The default and admin roles already exist, so those entries only set the in game properties; the default role gets its permissions through grantByDefault. Roles which already exist by name are reused and only gain the seed permissions, so seeding an existing cluster is safe. Permissions which are not defined are logged rather than refused, in case a plugin is not loaded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cooldude2606
left a comment
There was a problem hiding this comment.
Along with all the changes highlighted in this review; can you also weigh the pros and cons of using oop for the roles vs only using module level methods. At the moment there are a few places where we have mutliple ways to do things or where things can be cumbersome, so we should align on a strong dx api interface for working with roles and covering the use cases we have or would expect.
- Roles are objects and everything done to or with a role is a method on it: assign, unassign, has_player, has_permission, is_higher_than, is_lower_than, get_players, get_player_names, print. Assignment has one entry point, role:assign(player, options), with local_only as an option rather than a second function. - Roles are looked up by clusterio id with get_role; get_role_by_name searches the list for the few places, such as configs, which only know a name. The name map and the ordered list are gone, get_roles sorts on demand and the index field is replaced by the comparison methods. - Players are LuaPlayer objects only, with nil or index 0 for the server. - get_higher_roles and get_lower_roles replace print_to_roles_higher and print_to_roles_lower, call sites loop over them with role:print. - Permission groups are removed from roles again, exp_groups owns the mapping from roles to groups. - Seeding is a SeedRolesRequest behind a button on the roles page rather than running on first start, and creates only the roles; the player assignments are dropped. The seed lists each permission once at the lowest role which has it and lets the parent chain carry it up. - System commands unlock for core.admin rather than a permission of their own. - Role metatables are registered with Storage.register_metatable so the methods survive save and load, which the role records in storage needed. - The player list auth uses Roles.player_outranks directly, and the event carries role ids rather than names. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed bea8d46 covering every point. The api question first, since it decided the shape of the rest. Object methods vs module functionsI weighed it as: module functions are easiest when the role is incidental to the call — a permission check where the caller has a player and a permission name and never thinks about roles, or anything that spans roles such as ordering. Methods are easiest when the caller already holds a role, because there is only one way to say it and the role cannot be given three ways (name, id, object) as it could before. The old api mixed both, which is where the duplication came from: So the split is now by what the caller has in hand:
There is one assignment entry point, The one cost of methods worth naming: a call site that wants the roles of a player and then something about each of them now does two steps, The rest
Tests updated for the new api, 108 assertions passing; emmylua still 0 findings; tsc and the web bundle clean; the instance starts with the full scenario and the methods answer as expected over rcon. |
Cooldude2606
left a comment
There was a problem hiding this comment.
You mention some tests you are running, do you think those could be added to a test dir within this plugin? We are planning to move away from mocha so can you write them using tap. Please attempt to keep all tests independent, although instance reuse is allowed so long as a method is used fetch / start the instance on its first use rather than having a magic global process always running.
| ExpRoles._prototype = Role | ||
|
|
||
| --- Registered so roles keep their methods across save and load | ||
| local role_metatable = Storage.register_metatable("Role", { __index = Role }) |
There was a problem hiding this comment.
To avoid importing storage we can unroll this and also avoid the module name call by using the plugin name.
| --- Sort roles in place, the most privileged first | ||
| --- @param roles ExpRoles.Role[] | ||
| --- @return ExpRoles.Role[] | ||
| local function sort_roles(roles) |
There was a problem hiding this comment.
In a number of places we return sorted roles when there is little reason to. This is an extra cost we can remove. In the places where we need it sorted we can expose an ordered roles method or can optimise further in other ways.
| --- Role ids a player has been given, as a set | ||
| --- @param player_name string | ||
| --- @return table<number, true> | ||
| local function get_held_role_set(player_name) |
There was a problem hiding this comment.
This is just seen from held role ids. Becuase this is an internal method let's just return array, set from the one method as we get both for the same cost.
| local names, seen = {}, {} | ||
| for _, players in pairs{ script_data.synced_players, script_data.local_players } do | ||
| for player_name, role_ids in pairs(players) do | ||
| if not seen[player_name] then |
There was a problem hiding this comment.
This doesnt work becuase a player can be in both lists.
| Roles, the permissions they grant, and the players who hold them are owned by | ||
| the controller. This module keeps a copy of that state and answers permission | ||
| checks from it, so a check such as `Roles.player_has_permission(player, | ||
| "exp_scenario.command.kill")` is answered from the same data the web ui shows. |
There was a problem hiding this comment.
Although not used yet, let's add "has any" and "has all" the same has how the webui has it.
- player_has_any_permission and player_has_all_permission, matching the account checks the web ui has. - Roles are no longer sorted on the way out of every lookup. get_ordered_roles and the public sort_roles cover the two guis and the command which present roles in order, and the highest role is found with a single scan. - get_held_role_ids returns the list and the set it already built, rather than a second function rebuilding the set from the list. - get_player_names collects into a set before listing, so a player holding the role in both the synced and the local list is counted once. - The role metatable is registered directly under the plugin name, dropping the storage import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Run with `pnpm --filter @expcluster/roles test`, using tap as the runner. The lua module is exercised for real: each test file runs in its own lua state, created on first use by the helper, with the factorio surface and the clusterio modules stubbed. The environment is passed to the chunk as an argument so no globals are involved. Covers lookups and comparisons, the permission checks, assignment with confirmation, rejection and local only roles, jail suppression, the sync entry points, and the holder listings, including a player holding a role in both the synced and the local list. The javascript side covers the message records round tripping through their schemas, the indexed permission encoding, and the seed: every permission it grants must be defined by exp_scenario, and every parent must exist and have its permissions carried into its children. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed 8a15745 and e79822b covering all six points. The api changes
Tests
The lua module is tested for real rather than reimplemented: Writing them caught one real thing beyond the These don't run in this repo's ci since the workspace Lint 0 findings, tsc and the web bundle clean, all tests passing. |
Cooldude2606
left a comment
There was a problem hiding this comment.
For the lua side of the test harness can we do the following two things:
- Add a
env.testso we can name specific tests and pass env as a param to the test function, allowing a reset each time. - Make generic stubs and move the file into a folder in the root of the mono repo to allow reuse between plugins. There should still allow a custom env.lua to load additional stubs fpr tests.
For the js tests, can we have coverage of the various plugins parts such as controller and instance. We do not need coverage of the webui.
The list restarts whenever a higher priority is found, rather than collecting every role and filtering afterwards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The generic parts move to test/ in the repository root so other plugins can reuse them: the factorio and clusterio stubs, the test framework, the fengari runner, and clusterio's testMatrix and round trip helpers. A plugin composes them from its own env.lua, which adds its stubs and fixtures. - Tests are declared with Test.test(name, fn) and every test function receives a fresh environment, so nothing carries over between them. A test which errors is reported as a failure rather than aborting the file. - Every stub raises on properties it does not implement, which mirrors the game api. game.player is the one property allowed to read as nil. - Test.deep_eq compares tables recursively with keys checked from both sides. - The message records round trip through the same testMatrix and testRoundTripJsonSerialisable helpers the clusterio tests use, covering every optional field combination of the records, events and requests. - controller.test.js and instance.test.js cover the node side of the plugin against faked controller and instance internals: property creation and sweeping, record building, broadcasts, subscription replay, assignment validation, auto assignment and its blocking role, seeding, the initialise payload, sync mode gating, and the rejection rollback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed 59a0e0b and 24d36db covering all five points. HarnessThe generic parts now live in Tests are declared as Coverage
The web ui is not covered, as requested. Also
1147 assertions passing, lint 0 findings, tsc clean. |
Cooldude2606
left a comment
There was a problem hiding this comment.
The test framework is very close now, still some refinement needed to get a good dx. As for the tests themselves can we structure them similar to clusterio core where we name them after the files / classes / methods (for both lua and ts) this makes it easy to check we have coverage and understand where new tests should be added. This of course only works for unit tests / stubbed tests; integration tests should be process based similar to what has currently been written.
- The registry is a Suite, created around the environment factory it runs its tests with, so the plugin's env.lua reads as: build environments, hand the test files a suite of them. The suite returned there becomes `...` in each test file, which is now said where it happens. - pass and fail are the primitives every other check goes through. eq and deep_eq assert rather than compare, failing with both values in the detail. - Stubs are extended through extend_requires, extend_script, extend_game and extend_defines, a recursive merge which raises when a value already exists, rather than by mutating the tables. The strict labels carry the factorio class names. - The stubs record registered metatables and can save and load the script data the way factorio does: functions are refused and only registered metatables survive. env.save_load() uses it, and a new on_load test shows role methods and held roles surviving the round trip, which only passes because the module registers its metatable. - The names helper moved out of the shared framework, it is role specific. - Tests are named after the function or method they cover, on both sides: the lua tests read as "role:assign applies locally and is sent", and the javascript files wrap their tests in the class they exercise with subtests per method. module.test.js is control.test.js, after the file it covers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed 7adcdde covering all seven points. Naming between the partsThe three parts are now: Stubs (the shared factorio surface), the plugin's env.lua (the module specific extension, building environments and owning the fixtures), and a Suite of tests. The framework's registry is the suite, and it is created around the environment factory — Framework
Stubs
Test namingBoth sides are named after what they cover, clusterio style. The lua tests read as 1159 assertions passing, lint 0 findings. |
| end | ||
|
|
||
| --- Names of an array of roles | ||
| function env.names(roles) |
There was a problem hiding this comment.
From looking closer at where this is used, names can be made more generic and included in the suite. But it should use more generic variable names to make it clear that it works for any table with a name property. Such as players, forces, surfaces, events, roles, commands, etc.
| local players_by_name, players_by_index, connected = {}, {}, {} | ||
| game = Stubs.strict("LuaGameScript", { | ||
| tick = 1, | ||
| players = players_by_name, |
There was a problem hiding this comment.
To work the same way as factorio we need to have a meta table on this that allows indexing by player id.
| return next_event_id | ||
| end, | ||
| raise_event = function(id, data) | ||
| stubs.events[#stubs.events + 1] = { id = id, data = data } |
There was a problem hiding this comment.
We dont need to store id seperatly, rather data.name should be set to the id, as well as data.tick to the current game tick.
|
|
||
| --- Save and load the map, as far as the roles module can tell | ||
| local stub_save_load = env.save_load | ||
| function env.save_load() |
There was a problem hiding this comment.
We dont need this as the test should manually call on_load as this is inline with how we manually call other event handlers.
| local players = setup(env) | ||
| env.R("Moderator"):assign(players.bob, { by_player_name = "alice" }) | ||
| check(#env.sent == 1 and env.sent[1].channel == "exp_roles:assignment_update", "the assignment is sent") | ||
| check(env.sent[1].data.name == "bob", "the payload names the player") |
There was a problem hiding this comment.
We should be using deep_eq for sent messages and factorio events.
| const controller = { | ||
| config: { get: key => configValues[key] }, | ||
| roles: roleStore, | ||
| users: { |
There was a problem hiding this comment.
Why do we use fake users and fake user manager when we import the real versions from controller. The controller itself can also be used directly without side effects if start is not called.
Part two of #448. Moves every call site off
expcore.roles, deletes the legacy module and config, simplifies jail, and seeds the roles on first run. Taking "improve the role api with no restraint, no external consumers other than the mono repo" at its word, the lua api is redesigned rather than kept compatible, and the permission names are tidied up while nothing holds them yet.Lua api
The legacy action strings and the transform which mapped them onto permission names are gone. Call sites check the clusterio permission directly, so
grep exp_scenario.command.killfinds the definition, the check, and the seed, and there is no second transform which silently breaks every check if it drifts.player_allowed(p, "command/kill")player_has_permission(p, "exp_scenario.command.kill")player_has_flag(p, "report-immune")player_has_permission(p, "exp_scenario.bypass.reports")define_flag_trigger(flag, fn)define_permission_trigger(permission, fn)— any permission can have oneevents.on_role_assigned/on_role_unassignedevents.on_player_roles_changedwithassignedandunassignednamesget_player_highest_role(a).index < get_player_highest_role(b).indexplayer_outranks(a, b), plusplayer_outranks_role(a, role)get_role_from_any/get_role_by_name/get_roles_orderedget_role(name | id | role)/get_roles()config.order/config.roles/config.playersrole:get_player_names()covers the one useassign_player(p, roles, by, skip_checks, silent)assign_player(p, roles, by, silent)Flags were only permissions with a change trigger, so the separate concept goes. Every consumer registered both role events for the same handler, so they become one, and it is now also raised when the roles a player holds change on the controller, or when a role they hold is edited there. The old events were never raised for either, which left guis stale after a web ui change.
player_outranksapplies the core.admin bypass consistently; two of the six index comparisons did not.A role now carries a permission group, which the legacy system mapped roles to and #448 dropped. A player is moved into the group of their most privileged role which names one, which is how Jail actually restricts a player. It sits with the other in game properties on the role page.
Permission names
Explicit list in
exp_scenario/permissions.ts, no transform. Theactionandflagbuckets only reflected how the old config was written, so:exp_scenario.bypass.*(entity protection, nuke protection, deconstruction log, reports),exp_scenario.decon.*(standard, fast trees — with descriptions that say what they gate),exp_scenario.player.*(admin, spectator, instant respawn, system commands),exp_scenario.chat.commands, andexp_scenario.gui.player_list.kick/.banfor the player list buttons which were never commands.command.*andgui.*are unchanged apart fromclear_tag.always→tag_clear.alwaysto match the command it belongs to.Commands derive
exp_scenario.command.<name>in the authority, soassign-role,unassign-roleandget-rolesget scenario permissions rather than the core ones #448 mapped them to. The in game command is bounded by the lower role check,core.user.update_rolesis not — granting it to Moderator would have let any mod with a web account change anyone's roles.Dropped:
defer_role_changes(priority replaced it),command/give-warning(no role held it; the player list now keys the warn button oncreate_warning, the permission the command already needed) andcommand/report(never defined; nowcreate_report). Both buttons were only ever visible to root._ipcand_sudoare added so every command has a definition.Jail
jail_playergives the Jail role,unjail_playertakes it away.Jail.old_rolesand the stash/restore are gone, since priority suppresses the other roles for as long as the role is held.Seeding
On the first run (empty role properties datastore) the controller creates the roles the scenario shipped with and gives the listed players their roles. The seed keeps the
parentrelationships from the old config and flattens them, since clusterio roles do not inherit; the flattened sets were diffed against the legacy config and match for every role. Cluster Admin and the default role already exist, so those entries only set properties — the default role gets its permissions throughgrantByDefault, which is checked to equal the old Guest list. Roles which already exist by name are reused and only gain permissions, so seeding a cluster which already has some is safe.Verification
ctl user set-rolesreaching the game.Notes
exp_scenario.command.kill.alwaysandspawn.alwaysstay defined;/killnever checkedkill.alwaysbefore either, its lower role parser already bounds it. Left for a later tidy.controller run --devweb build is not set up here, the form only gains one text field next to the existing ones.🤖 Generated with Claude Code