Warnings were a moderator tool layered on top of reports and had no
callers left outside their own command and the player list button. The
legacy module, its config and locale, the exp_scenario command and its
permissions, the discord alert block and the player list button are all
removed. Reporting from the player list no longer depends on whether the
player could give warnings, anyone can report a player who is not immune.
Building on the class annotations from the smoke test changes:
- The suite is generic over its environment: Framework.suite is
@generic T : Stubs with the extension returning T, and Suite<T> passes T to
every test function. A test file declares
`local Suite = ... --- @type Suite<ExpRoles.TestEnv>` and env autocompletes
from there.
- The stubs declare their recorded fields, add_player returns Stubs.Player,
and the server is one.
- ExpRoles.TestEnv : Stubs declares the extension fields, so env.R returns
ExpRoles.Role and env.Roles is the module.
The typing immediately paid for itself by flagging real drift in the tests,
now fixed: _script_data is package private so the env exposes script_data()
instead, the join event carries its name and tick, the assignment record type
admits is_deleted, admin_state is a file local rather than an undeclared
field, and the event assertions which indexed [1] without a nil check compare
the whole event list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Suite.empty(value, name) checks a table has no entries and reports what it
held, and Suite.throws(fn, message, name) asserts a function errors with the
given message, so an unrelated error no longer passes as the expected one.
- Scalar comparisons and zero length checks go through eq and empty, and the
announced messages and print output are compared by content, so a failure
shows the actual data.
- get_roles() is asserted through sorted names rather than a count.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The instance tests run against a real Instance with a real InstanceConfig,
matching the controller tests: the spies are sendTo and the server, which
record what leaves the instance, so sendRcon passes through the real script
command gate. The plugin's config fields are registered so the sync mode is
a real field.
- Shallow eq is gone and eq compares recursively; the suite is created with
Framework.suite(extend_env), which hands the extension a fresh set of stubs
per test, so the plugin's env.lua is only the extension.
- add_player assigns continuous indexes itself.
- The lua fixtures are referenced by role name: assignments take names, and
ids in expectations come from the role object. The name lookup in env.R
uses a fixture index rather than searching the roles.
- Test names format functions as name() and methods as .name().
- Branch coverage filled in: sort tie breaking, the highest role assertion
when a player has no roles, has all for the server, the by player default
from game.player, deleted assignment records on initialise, set_emit_events
with no argument, printing to a role with no holders, role property update
validation, the assignment subscription replay, unassignment over the ipc,
and a refused removal having nothing to roll back.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The controller tests run against a real Controller, which is side effect
free while not started, with real users through its UserManager. The only
fakes left are two spies which record broadcasts and permission pushes on
their way through. Since any user update also applies auto assignment, the
leave event is observed directly rather than through a role change.
- raise_event fills in the name and tick of the event the way factorio does,
rather than wrapping the payload, and game.players finds players by index
as well as by name through a metatable.
- names is generic over anything with a name property, so it lives on the
suite; the save and load helper no longer calls on_load, the test calls the
handler itself as it does for the other handlers.
- Sent messages and events are compared whole with deep_eq, which pins the
event shape, the tick, and that the unassign key is absent rather than
empty.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 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>
- 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>
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>
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>
- 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>
- 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>
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>
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>
Locals declared ahead of an assignment inside a callback were inferred
as nil at every use site.
The role event handlers are given a class so the table is not unified
with the other module handler tables.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
emmylua takes self from the owner table, so `@param self ExpRoles.Role`
only applies with dot syntax. Call sites still use a colon.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
emmylua only parses the inline cast as `--[[@as T]]`, the spaced form
`--[[ @as T ]]` is treated as a plain comment, so all 149 of them were
doing nothing.
LuaGuiElement.style is a union because a style name can be assigned to
it, so reading it back needs the cast.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Async ids come from `get_function_name`, which returns a string, but
were documented as numbers.
`setmetatable` in clusterio's compat module loses `LibCompat` on the
module return, so the require is cast.
`GuiData.__index` accepts a `DataKey` but emmylua needs that as an
index signature on the class.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
emmylua deserialises an unknown diagnostic code to a catch all that
matches no checker, so every `invisible`, `nil-check` and
`global-element` suppression was silently doing nothing. Renamed to the
emmylua codes. `name-style-check` has no equivalent and is dropped, it
was already disabled under luals.
The `get_tile` suppressions referenced an api typedef bug from 2024
which no longer reproduces.
need-check-nil is deferred rather than disabled on merit: a third of the
407 findings come from MapPosition and BoundingBox being aliased as
`struct|[double, double]`, so every `entity.position.x` reads as
possibly nil. The rest need per site knowledge of runtime invariants.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- 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) <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>
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>