- 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>
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>
`loadstring` was removed in Lua 5.2, `load` takes a string the same way.
Warp gui child lookups are optional, so they use the same assert the
rest of the repo does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`LogisticFilter.signal` became `value` in 2.0, so the clear loop broke
on the first slot and outdated signals were left behind.
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>
Autofill stores the whole per entity map against a player.
The readme container is called as a define, so it needs the overload
the other element classes carry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_display_data` is a cache keyed by force name, not a single record.
The auto complete accumulator starts empty, so its fields are optional.
Game commands have no usage beyond their own help text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The top, left and relative element tables are keyed by the define, but
the duplicate registration assert looked up `define.name`, so it never
fired.
GuiIter.remove_element indexed `registered_scopes` by player index
instead of the scope elements it had just fetched, so it removed
nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`config.temp_warning_limit` does not exist, the warnings config defines
`script_warning_limit`, and the locale expects it as `__3__/__4__`, so
the command was printing nil.
map_tags is keyed by the force name joined to the tag number, and
custom_color is the Color union.
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>
`data` on an element is the GuiData store keyed by element, not the
value being stored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>