With the role system finished the jail module has nothing legacy left in
it, so it moves to exp_scenario/module/control/jail.lua and the copy in
exp_legacy is removed. The module takes LuaPlayer only, requires a reason,
returns false rather than nil when there is nothing to do, and exposes its
event ids as Jail.on_player_jailed and Jail.on_player_unjailed in the same
shape as exp_util's selection events.
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>
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>
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>
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>
`fmtk luals-addon <outdir>` treats its argument as the parent and always
appends `factorio`, so the bundle landed in `$RUNNER_TEMP/factorio/factorio`
while the config pointed at `$RUNNER_TEMP/factorio/library`. The factorio
types were never loaded, which is why ci reported 642 undefined-global
and 1204 type-not-found against a tree that is clean locally.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The library path was injected by the workflow, so an editor opened on
this repo could not resolve `modules/clusterio/*`. It now comes from
the environment in both places.
It cannot be written relative to this repo: emmylua does not normalise
`..` in a library path, and an absolute path containing `..` fails the
same way, so the location has to come from outside.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The nil check burn down added guards which turned out redundant once
the surrounding annotations landed. `_has_handlers` is set to true when
the first handler registers, so it is a boolean rather than the literal
false it was inferred as.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`assert` returns every argument it is given, so `return assert(x, msg)`
was leaking the message as a second return value.
`get_tile` is documented as taking x and y. The async function class
declared `@operator call` with no parameters, which made every async
call look over supplied.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
These were carried over as disabled from the luals config but report
nothing, so they only ever masked a future regression.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Guards now cover the statements that follow them, and a couple of reads
are restructured so the narrowing survives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The api documents both as a union with a positional array because both
are accepted as input, which makes every read of `.x` or `.left_top`
optional. Everything read back from the game uses the named form, so
the positional variant is removed and we now always write it that way
too.
The spawn area config offsets and the mine depletion search areas were
the only positional writes left, and apply_offset no longer needs its
fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The player list action setter runs during control setup, before any of
the callbacks that use it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dropdowns are siblings of the button that reads them, and the loop
bounds come from the interface count.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dropdowns are siblings of the button that reads them, and the loop
bounds come from the interface count.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
spawn_area accepts both position forms on purpose, so it asserts that
one of the two is present rather than narrowing the type.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Color.struct leaves every channel optional, so the rainbow command
declares its own fully populated colour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rows and selectors are created alongside the machine selector, so they
exist for anything already in the table.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The interfaces are chests and constant combinators, so the inventory,
logistic sections and first circuit section always exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_player_roles always returns at least the default or root role, so
get_player_highest_role never returns nil, and callers were right to
treat it that way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`debug.getinfo` is optional, but these helpers always ask for a frame
which is on the stack.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A union of table types makes every lookup optional. Separate index
signatures on one class say the same thing without the nil.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Values read back from the game always use the named members, so they
are narrowed once where they enter a function rather than at each use.
`LuaControl.force` is a ForceID union, so reading a force only method
off it needs the cast the rest of the repo already uses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Roles.new_role` returned the result of `error`, which made it nullable,
and `set_permission_group` returned nil for an unknown group, which
broke the config chain with an index error rather than saying so.
Positions and bounding boxes read back from the game always use the
named members, so they are narrowed once where they enter a function.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>