From f805a1e76017c7f2ee3e536bf9abfcccd55ba735 Mon Sep 17 00:00:00 2001 From: PHIDIAS Date: Tue, 8 Sep 2026 19:05:08 +0900 Subject: [PATCH 01/18] fix readme (#470) * chore(l10n): update translation files Add-on: Cleanup translation files Co-authored-by: Weblate Translate-URL: https://translate.aperx.org/projects/expcluster/expscenario/ Translation: ExpCluster/ExpScenario * fix readme * layout * chore(l10n): update English translation chore(l10n): update Chinese (Traditional Han script) translation chore(l10n): update Chinese (Simplified Han script) translation chore(l10n): update Chinese (Traditional Han script) translation chore(l10n): update Chinese (Simplified Han script) translation chore(l10n): update Chinese (Traditional Han script) translation chore(l10n): update Chinese (Simplified Han script) translation chore(l10n): update Chinese (Traditional Han script) translation chore(l10n): update Chinese (Simplified Han script) translation chore(l10n): update English translation Co-authored-by: Weblate Co-authored-by: phidias Language: Chinese (Simplified Han script) Language: Chinese (Traditional Han script) Language: English Progress: 100.0% (1 of 1 strings) Progress: 100.0% (2 of 2 strings) Progress: 100.0% (233 of 233 strings) Progress: 100.0% (4 of 4 strings) Translate-URL: https://translate.aperx.org/projects/expcluster/explegacy/zh_Hans/ Translate-URL: https://translate.aperx.org/projects/expcluster/explegacy/zh_Hant/ Translate-URL: https://translate.aperx.org/projects/expcluster/exprole/en/ Translate-URL: https://translate.aperx.org/projects/expcluster/exprole/zh_Hans/ Translate-URL: https://translate.aperx.org/projects/expcluster/exprole/zh_Hant/ Translate-URL: https://translate.aperx.org/projects/expcluster/expserverups/zh_Hans/ Translate-URL: https://translate.aperx.org/projects/expcluster/expserverups/zh_Hant/ Translate-URL: https://translate.aperx.org/projects/expcluster/exputil/zh_Hans/ Translate-URL: https://translate.aperx.org/projects/expcluster/exputil/zh_Hant/ Translation: ExpCluster/ExpLegacy Translation: ExpCluster/ExpRole Translation: ExpCluster/ExpServerUPS Translation: ExpCluster/ExpUtil * Revert "Translate" * . --------- Co-authored-by: Weblate --- README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 82f48abd..3bc7ad44 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,11 @@ This module is currently not published and therefore can not be installed via `n See [Contributing](CONTRIBUTING.md) for how to make pull requests and issues. -## Translation -translation status -Contact for access. -[translate]: https://translate.aperx.org +Details of the scenario can also be seen on [Wiki](https://wiki.aperx.org/game/factorio/scenario). -## Wiki -[wiki]: https://wiki.aperx.org/game/factorio/scenario +For the locale, access to [Translation](https://translate.aperx.org). + +

translation status

## Releases From 0854a2b7d53d3e303015baf4fbbab7aa8b87d47d Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:20:44 +0200 Subject: [PATCH 02/18] Move the seed into exp_scenario and seed the permission groups (#457) The seed roles were owned by exp_roles, but they describe the scenario, so they now live in exp_scenario/seed.ts next to the permissions they grant. Each seed role names the permission group its holders belong to, and the five groups the legacy config defined (Admin, Trusted, Standard, Guest, Restricted) are seeded through exp_groups along with one role mapping per role. Mapping priorities follow how exp_roles ranks a player's highest role, so Jail lands in Restricted regardless of other roles. SeedRolesRequest becomes exp_scenario's SeedRequest behind a new exp_scenario.seed permission, and the button moves to the scenario's web plugin, which gets a web entrypoint for it. exp_roles no longer depends on exp_scenario, and exp_scenario gains controller tests around the seed. With groups seeded from the controller the legacy expcore.permission_groups module and its config are removed, along with the Group rcon static. --- exp_legacy/module/config/_file_loader.lua | 2 - .../config/expcore/permission_groups.lua | 143 ------- .../module/expcore/permission_groups.lua | 355 ------------------ exp_roles/controller.ts | 64 ---- exp_roles/index.ts | 1 - exp_roles/messages.ts | 12 - exp_roles/package.json | 1 - exp_roles/test/controller.test.js | 22 -- exp_roles/web/components/SeedRoles.tsx | 33 -- exp_roles/web/index.tsx | 2 - exp_scenario/controller.ts | 151 ++++++++ exp_scenario/index.ts | 14 +- exp_scenario/messages.ts | 98 +---- exp_scenario/module/commands/_rcon.lua | 1 - exp_scenario/package.json | 9 +- {exp_roles => exp_scenario}/seed.ts | 82 +++- exp_scenario/test/controller.test.js | 139 +++++++ {exp_roles => exp_scenario}/test/seed.test.js | 29 +- exp_scenario/web/components/Seed.tsx | 33 ++ exp_scenario/web/index.tsx | 11 + 20 files changed, 464 insertions(+), 738 deletions(-) delete mode 100644 exp_legacy/module/config/expcore/permission_groups.lua delete mode 100644 exp_legacy/module/expcore/permission_groups.lua delete mode 100644 exp_roles/web/components/SeedRoles.tsx rename {exp_roles => exp_scenario}/seed.ts (76%) create mode 100644 exp_scenario/test/controller.test.js rename {exp_roles => exp_scenario}/test/seed.test.js (67%) create mode 100644 exp_scenario/web/components/Seed.tsx diff --git a/exp_legacy/module/config/_file_loader.lua b/exp_legacy/module/config/_file_loader.lua index 88fdec5e..4edd462e 100644 --- a/exp_legacy/module/config/_file_loader.lua +++ b/exp_legacy/module/config/_file_loader.lua @@ -24,6 +24,4 @@ return { "modules.gui.vlayer", "modules.graftorio.require", -- graftorio - --- Config Files - "config.expcore.permission_groups", -- loads some predefined permission groups } diff --git a/exp_legacy/module/config/expcore/permission_groups.lua b/exp_legacy/module/config/expcore/permission_groups.lua deleted file mode 100644 index b0e972f3..00000000 --- a/exp_legacy/module/config/expcore/permission_groups.lua +++ /dev/null @@ -1,143 +0,0 @@ ---- Use this file to add new permission groups to the game; --- start with Permission_Groups.new_group('name'); --- then use either :allow_all() or :disallow_all() to set the default for non specified actions; --- then use :allow{} and :disallow{} to specify certain actions to allow/disallow --- @config Permission-Groups - --- local Event = require("modules/exp_legacy/utils/event") -- @dep utils.event -local Groups = require("modules.exp_legacy.expcore.permission_groups") --- @dep expcore.permission_groups - -Groups.new_group("Admin") - :allow_all() - :disallow{ - "add_permission_group", -- admin - "delete_permission_group", - "edit_permission_group", - "import_permissions_string", - "map_editor_action", - "toggle_map_editor", - "change_multiplayer_config", - "set_heat_interface_mode", - "set_heat_interface_temperature", - "set_infinity_container_filter_item", - "set_infinity_container_remove_unfiltered_items", - "set_infinity_pipe_filter", - } - -Groups.new_group("Trusted") - :allow_all() - :disallow{ - "add_permission_group", -- admin - "delete_permission_group", - "edit_permission_group", - "import_permissions_string", - "map_editor_action", - "toggle_map_editor", - "change_multiplayer_config", - "set_heat_interface_mode", - "set_heat_interface_temperature", - "set_infinity_container_filter_item", - "set_infinity_container_remove_unfiltered_items", - "set_infinity_pipe_filter", - "admin_action", -- trusted - } - -Groups.new_group("Standard") - :allow_all() - :disallow{ - "add_permission_group", -- admin - "delete_permission_group", - "edit_permission_group", - "import_permissions_string", - "map_editor_action", - "toggle_map_editor", - "change_multiplayer_config", - "set_heat_interface_mode", - "set_heat_interface_temperature", - "set_infinity_container_filter_item", - "set_infinity_container_remove_unfiltered_items", - "set_infinity_pipe_filter", - "admin_action", -- trusted - "change_programmable_speaker_alert_parameters", -- standard - "drop_item", - "open_new_platform_button_from_rocket_silo", - "set_rocket_silo_send_to_orbit_automated_mode", - } - -Groups.new_group("Guest") - :allow_all() - :disallow{ - "add_permission_group", -- admin - "delete_permission_group", - "edit_permission_group", - "import_permissions_string", - "map_editor_action", - "toggle_map_editor", - "change_multiplayer_config", - "set_heat_interface_mode", - "set_heat_interface_temperature", - "set_infinity_container_filter_item", - "set_infinity_container_remove_unfiltered_items", - "set_infinity_pipe_filter", - "admin_action", -- trusted - "change_programmable_speaker_alert_parameters", -- standard - "drop_item", - "open_new_platform_button_from_rocket_silo", - "set_rocket_silo_send_to_orbit_automated_mode", - "change_programmable_speaker_parameters", -- guest - "change_train_stop_station", - -- 'deconstruct', - "remove_cables", - "remove_train_station", - "reset_assembling_machine", - "rotate_entity", - -- 'use_artillery_remote', -- not in 2.0 - "launch_rocket", - "cancel_research", - -- 'activate_cut', -- not in 2.0 - "flush_opened_entity_fluid", - "flush_opened_entity_specific_fluid", - } - -Groups.new_group("Restricted") - :disallow_all() - :allow("write_to_console") - ---[[ These events are used until a role system is added to make it easier for our admins - -local trusted_time = 60*60*60*10 -- 10 hour -local standard_time = 60*60*60*3 -- 3 hour -local function assign_group(player) - local current_group_name = player.permission_group and player.permission_group.name or 'None' - if player.admin then - Permission_Groups.set_player_group(player,'Admin') - elseif player.online_time > trusted_time or current_group_name == 'Trusted' then - Permission_Groups.set_player_group(player,'Trusted') - elseif player.online_time > standard_time or current_group_name == 'Standard' then - Permission_Groups.set_player_group(player,'Standard') - else - Permission_Groups.set_player_group(player,'Guest') - end -end - -Event.add(defines.events.on_player_joined_game,function(event) - local player = game.players[event.player_index] - assign_group(player) -end) - -Event.add(defines.events.on_player_promoted,function(event) - local player = game.players[event.player_index] - assign_group(player) -end) - -Event.add(defines.events.on_player_demoted,function(event) - local player = game.players[event.player_index] - assign_group(player) -end) - -local check_interval = 60*60*15 -- 15 minutes -Event.on_nth_tick(check_interval,function(event) - for _,player in pairs(game.connected_players) do - assign_group(player) - end -end)]] diff --git a/exp_legacy/module/expcore/permission_groups.lua b/exp_legacy/module/expcore/permission_groups.lua deleted file mode 100644 index edb0a315..00000000 --- a/exp_legacy/module/expcore/permission_groups.lua +++ /dev/null @@ -1,355 +0,0 @@ ---[[-- Core Module - Permission Groups -- Permission group making for factorio so you never have to make one by hand again -@core Groups -@alias Permissions_Groups - -@usage--- Example Group (Allow All) --- here we will create an admin group however we do not want them to use the map editor or mess with the permission groups -Permission_Groups.new_group('Admin') -- this defines a new group called "Admin" -:allow_all() -- this makes the default to allow any input action unless set other wise -:disallow{ -- here we disallow the input action we don't want them to use - 'add_permission_group', - 'delete_permission_group', - 'import_permissions_string', - 'map_editor_action', - 'toggle_map_editor' -} - -@usage--- Example Group (Disallow All) --- here we will create a group that cant do anything but talk in chat -Permission_Groups.new_group('Restricted') -- this defines a new group called "Restricted" -:disallow_all() -- this makes the default to disallow any input action unless set other wise -:allow('write_to_console') -- here we allow them to chat, {} can be used here if we had more than one action - -]] - -local Event = require("modules/exp_legacy/utils/event") -local Async = require("modules/exp_util/async") - -local PermissionsGroups = { - groups = {}, -- store for the different groups that are created - _prototype = {}, -- stores functions that are used on group instances -} - --- Async function to add players to permission groups -local add_to_permission_group_async = - Async.register(function(permission_group, player) - permission_group.add_player(player) - end) -PermissionsGroups.add_to_permission_group_async = add_to_permission_group_async - --- Async function to remove players from permission groups -local remove_from_permission_group_async = - Async.register(function(permission_group, player) - permission_group.remove_player(player) - end) -PermissionsGroups.remove_from_permission_group_async = remove_from_permission_group_async - ---- Getters. --- Functions that get permission groups --- @section getters - ---[[-- Defines a new permission group that can have it actions set in the config -@tparam string name the name of the new group -@treturn Permissions_Groups._prototype the new group made with function to allow and disallow actions - -@usage-- Defining a new permission group -Groups.new_group('Admin') - -]] -function PermissionsGroups.new_group(name) - local group = setmetatable({ - name = name, - actions = {}, - allow_all_actions = true, - }, { - __index = PermissionsGroups._prototype, - }) - PermissionsGroups.groups[name] = group - return group -end - ---[[-- Returns the group with the given name, case sensitive -@tparam string name the name of the group to get -@treturn ?Permissions_Groups._prototype|nil the group with that name or nil if non found - -@usage-- Getting a permision group -local admin_group = Groups.get_group_by_name('Admin') - -]] -function PermissionsGroups.get_group_by_name(name) - return PermissionsGroups.groups[name] -end - ---[[-- Returns the group that a player is in -@tparam LuaPlayer player the player to get the group of can be name index etc -@treturn ?Permissions_Groups._prototype|nil the group with that player or nil if non found - -@usage-- Get your permission group -local group = Groups.get_group_from_player(game.player) - -]] -function PermissionsGroups.get_group_from_player(player) - local group = player.permission_group - if group then - return PermissionsGroups.groups[group.name] - end -end - ---- Setters. --- Functions that control all groups --- @section players - ---[[-- Reloads/creates all permission groups and sets them to they configured state - -@usage-- Reload the permission groups, used internally -Groups.reload_permissions() - -]] -function PermissionsGroups.reload_permissions() - for _, group in pairs(PermissionsGroups.groups) do - group:create() - end -end - ---[[-- Sets a player's group to the one given, a player can only have one group at a time -@tparam LuaPlayer player the player to effect can be name index etc -@tparam string group the name of the group to give to the player -@treturn boolean true if the player was added successfully, false other wise - -@usage-- Set your permission group -Groups.set_player_group(game.player, 'Admin') - -]] -function PermissionsGroups.set_player_group(player, group) - group = PermissionsGroups.get_group_by_name(group) - if not group or not player then return false end - group:add_player(player) - return true -end - ---- Actions. --- Functions that control group actions --- @section actions - ---[[-- Sets the allow state of an action for this group, used internally but is safe to use else where -@tparam ?string|defines.input_action action the action that you want to set the state of -@tparam boolean state the state that you want to set it to, true = allow, false = disallow -@treturn Permissions_Groups._prototype returns self so function can be chained - -@usage-- Set an action to be disallowed -group:set_action('toggle_map_editor', false) - -]] -function PermissionsGroups._prototype:set_action(action, state) - local input_action = defines.input_action[action] --[[@as defines.input_action?]] - if input_action == nil then input_action = action end - assert(type(input_action) == "number", tostring(action) .. " is not a valid input action") - self.actions[input_action] = state - return self -end - ---[[-- Sets an action or actions to be allowed for this group even with disallow_all triggered, Do not use in runtime -@tparam string|Array actions the action or actions that you want to allow for this group -@treturn Permissions_Groups._prototype returns self so function can be chained - -@usage-- Allow some actions -group:allow{ - 'write_to_console' -} - -]] -function PermissionsGroups._prototype:allow(actions) - if type(actions) ~= "table" then - actions = { actions } - end - for _, action in pairs(actions) do - self:set_action(action, true) - end - - return self -end - ---[[-- Sets an action or actions to be disallowed for this group even with allow_all triggered, Do not use in runtime -@tparam string|Array actions the action or actions that you want to disallow for this group -@treturn Permissions_Groups._prototype returns self so function can be chained - -@usage-- Disalow some actions -group:disallow{ - 'add_permission_group', - 'delete_permission_group', - 'import_permissions_string', - 'map_editor_action', - 'toggle_map_editor' -} - -]] -function PermissionsGroups._prototype:disallow(actions) - if type(actions) ~= "table" then - actions = { actions } - end - for _, action in pairs(actions) do - self:set_action(action, false) - end - - return self -end - ---[[-- Sets the default state for any actions not given to be allowed, useful with :disallow -@treturn Permissions_Groups._prototype returns self so function can be chained - -@usage-- Allow all actions unless given by disallow -group:allow_all() - -]] -function PermissionsGroups._prototype:allow_all() - self.allow_all_actions = true - return self -end - ---[[-- Sets the default state for any action not given to be disallowed, useful with :allow -@treturn Permissions_Groups._prototype returns self so function can be chained - -@usage-- Disallow all actions unless given by allow -group:disallow_all() - -]] -function PermissionsGroups._prototype:disallow_all() - self.allow_all_actions = false - return self -end - ---[[-- Returns if an input action is allowed for this group -@tparam ?string|defines.input_action action the action that you want to test for -@treturn boolean true if the group is allowed the action, false other wise - -@usage-- Test if a group is allowed an action -local allowed = group:is_allowed('write_to_console') - -]] -function PermissionsGroups._prototype:is_allowed(action) - if type(action) == "string" then - action = defines.input_action[action] - end - local state = self.actions[action] - if state == nil then - state = self.allow_all_actions - end - return state -end - ---- Players. --- Functions that control group players --- @section players - ---[[-- Creates or updates the permission group with the configured actions, used internally -@treturn LuaPermissionGroup the permission group that was created - -@usage-- Create the permission group so players can be added, used internally -group:create() - -]] -function PermissionsGroups._prototype:create() - local group = self:get_raw() - if not group then - group = game.permissions.create_group(self.name) --[[@as LuaPermissionGroup]] - end - for _, action in pairs(defines.input_action) do - group.set_allows_action(action, self:is_allowed(action)) - end - - return group -end - ---[[-- Returns the LuaPermissionGroup that was created with this group object, used internally -@treturn LuaPermissionGroup the raw lua permission group - -@usage-- Get the factorio api permision group, used internally -local permission_group = group:get_raw() - -]] -function PermissionsGroups._prototype:get_raw() - return game.permissions.get_group(self.name) -end - ---[[-- Adds a player to this group -@tparam LuaPlayer player LuaPlayer the player you want to add to this group can be name or index etc -@treturn boolean true if the player was added successfully, false other wise - -@usage-- Add a player to this permission group -group:add_player(game.player) - -]] -function PermissionsGroups._prototype:add_player(player) - local group = self:get_raw() - if not group or not player then return false end - add_to_permission_group_async(group, player) - return true -end - ---[[-- Removes a player from this group -@tparam LuaPlayer player LuaPlayer the player you want to remove from this group can be name or index etc -@treturn boolean true if the player was removed successfully, false other wise - -@usage-- Remove a player from this permission group -group:remove_player(game.player) - -]] -function PermissionsGroups._prototype:remove_player(player) - local group = self:get_raw() - if not group or not player then return false end - remove_from_permission_group_async(group, player) - return true -end - ---[[-- Returns all player that are in this group with the option to filter to online/offline only -@tparam[opt] boolean online if nil returns all players, if true online players only, if false returns online players only -@treturn table a table of players that are in this group; filtered if online param is given - -@usage-- Get all players in this group -local online_players = group:get_players() - -@usage-- Get all online players in this group -local online_players = group:get_players(true) - -]] -function PermissionsGroups._prototype:get_players(online) - local players = {} - local group = self:get_raw() - if group then - if online == nil then - return group.players - else - for _, player in pairs(group.players) do - if player.connected == online then - table.insert(player, player) - end - end - end - end - return players -end - ---[[-- Prints a message to every player in this group -@tparam string message the message that you want to send to the players -@treturn number the number of players that received the message - -@usage-- Print a message to all players in thie group -group:print('Hello, World!') - -]] -function PermissionsGroups._prototype:print(message) - local players = self:get_players(true) - for _, player in pairs(players) do - player.print(message) - end - - return #players -end - --- when the game starts it will make the permission groups -Event.on_init(function() - PermissionsGroups.reload_permissions() -end) - -return PermissionsGroups diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts index 45b79890..b9bf3a8b 100644 --- a/exp_roles/controller.ts +++ b/exp_roles/controller.ts @@ -1,7 +1,6 @@ import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller"; import * as lib from "@clusterio/lib"; import * as messages from "./messages"; -import { SeedRole, seedRoles, flattenSeedPermissions } from "./seed"; import * as path from "node:path"; export class ControllerPlugin extends BaseControllerPlugin { @@ -35,7 +34,6 @@ export class ControllerPlugin extends BaseControllerPlugin { this.controller.handle(messages.RoleListRequest, this.handleRoleListRequest.bind(this)); this.controller.handle(messages.RoleMetaUpdateRequest, this.handleRoleMetaUpdateRequest.bind(this)); - this.controller.handle(messages.SeedRolesRequest, this.handleSeedRolesRequest.bind(this)); this.controller.handle(messages.AssignmentListRequest, this.handleAssignmentListRequest.bind(this)); this.controller.handle(messages.AssignmentUpdateRequest, this.handleAssignmentUpdateRequest.bind(this)); @@ -45,68 +43,6 @@ export class ControllerPlugin extends BaseControllerPlugin { await this.roleMeta.save(); } - /* - Seeding - */ - - /** - * Create the roles the scenario shipped with. Roles which already exist - * by name are reused and only gain the seed permissions. - */ - async handleSeedRolesRequest() { - for (const [index, seedRole] of seedRoles.entries()) { - const role = this.seedRole(seedRole); - if (!role) { - continue; - } - - this.roleMeta.set(new messages.RoleMetaRecord( - role.id, - index + 1, - seedRole.priority ?? 0, - seedRole.shortHand, - "", - seedRole.color, - seedRole.autoAssignHours === undefined ? null : seedRole.autoAssignHours * 3600000, - seedRole.blockAutoAssign ?? false, - )); - } - - this.logger.info(`Seeded ${seedRoles.length} roles`); - } - - /** Find or create the clusterio role for a seed role, returns undefined if it has no role to use. */ - seedRole(seedRole: SeedRole) { - const roles = this.controller.roles; - if (seedRole.isAdmin) { - return roles.get(lib.Role.DefaultAdminRoleId); - } - if (seedRole.isDefault) { - const defaultRoleId = this.controller.config.get("controller.default_role_id"); - return defaultRoleId !== null ? roles.get(defaultRoleId) : undefined; - } - - const permissions = flattenSeedPermissions(seedRole); - for (const permission of permissions) { - if (!lib.permissions.has(permission)) { - this.logger.warn(`Seed role ${seedRole.name} grants unknown permission ${permission}`); - } - } - - let role = [...roles.valuesMutable()].find(other => other.name === seedRole.name); - if (role) { - for (const permission of permissions) { - role.permissions.add(permission); - } - } else { - const id = Math.max(5, ...[...roles.keys()].map(other => other + 1)); - role = new lib.Role(id, seedRole.name, "", permissions); - this.logger.info(`Created role ${seedRole.name}`); - } - roles.set(role); - return role; - } - /* Role properties */ diff --git a/exp_roles/index.ts b/exp_roles/index.ts index d2c522fe..e3cb4093 100644 --- a/exp_roles/index.ts +++ b/exp_roles/index.ts @@ -27,7 +27,6 @@ export const plugin: lib.PluginDeclaration = { messages.RoleListRequest, messages.RoleMetaUpdateRequest, - messages.SeedRolesRequest, messages.AssignmentListRequest, messages.AssignmentUpdateRequest, diff --git a/exp_roles/messages.ts b/exp_roles/messages.ts index 7ba71459..3886970b 100644 --- a/exp_roles/messages.ts +++ b/exp_roles/messages.ts @@ -365,18 +365,6 @@ export class RoleMetaUpdateRequest { } } -/** Create the roles the scenario shipped with, see seed.ts. */ -export class SeedRolesRequest { - declare ["constructor"]: typeof SeedRolesRequest; - static plugin = "exp_roles" as const; - static type = "request" as const; - static src = "control" as const; - static dst = "controller" as const; - static permission = "core.role.create" as const; - - constructor() {} -} - /* Assignment requests */ diff --git a/exp_roles/package.json b/exp_roles/package.json index aa9f3112..c5f3ae57 100644 --- a/exp_roles/package.json +++ b/exp_roles/package.json @@ -26,7 +26,6 @@ "@clusterio/host": "workspace:^", "@clusterio/lib": "workspace:^", "@clusterio/web_ui": "workspace:^", - "@expcluster/scenario": "workspace:^", "@types/node": "catalog:", "@types/react": "catalog:", "antd": "catalog:", diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js index bf2f843c..4e66ee48 100644 --- a/exp_roles/test/controller.test.js +++ b/exp_roles/test/controller.test.js @@ -4,17 +4,12 @@ const lib = require("@clusterio/lib"); const { Controller } = require("@clusterio/controller"); const { ControllerPlugin } = require("../dist/node/controller"); const messages = require("../dist/node/messages"); -const { seedRoles } = require("../dist/node/seed"); - -// Importing this defines the exp_scenario permissions the seed grants -require("@expcluster/scenario/dist/node/permissions"); // The controller validates message classes against the link registry lib.Link.register(messages.RoleUpdatedEvent); lib.Link.register(messages.AssignmentUpdatedEvent); lib.Link.register(messages.RoleListRequest); lib.Link.register(messages.RoleMetaUpdateRequest); -lib.Link.register(messages.SeedRolesRequest); lib.Link.register(messages.AssignmentListRequest); lib.Link.register(messages.AssignmentUpdateRequest); @@ -229,22 +224,5 @@ t.test("class ControllerPlugin", t2 => { t3.strictSame(none, null, "nothing is replayed when up to date"); }); - t2.test(".handleSeedRolesRequest() creates the roles and reuses them by name", async t3 => { - const { plugin, controller } = await startPlugin(t3, { - roles: [role(0, "Cluster Admin", ["core.admin"]), role(1, "Player")], - }); - - await plugin.handleSeedRolesRequest(); - t3.strictSame(controller.roles.size, seedRoles.length, "every seed role exists"); - - const moderator = [...controller.roles.values()].find(other => other.name === "Moderator"); - t3.ok(moderator.permissions.has("exp_scenario.command.jail"), "parent permissions are flattened in"); - t3.ok(plugin.roleMeta.get(moderator.id), "the role properties are created"); - t3.strictSame(plugin.roleMeta.get(moderator.id).shortHand, "Mod", "the properties match the seed"); - - await plugin.handleSeedRolesRequest(); - t3.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles"); - }); - t2.end(); }); diff --git a/exp_roles/web/components/SeedRoles.tsx b/exp_roles/web/components/SeedRoles.tsx deleted file mode 100644 index 9b7c52b2..00000000 --- a/exp_roles/web/components/SeedRoles.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React, { useContext, useState } from "react"; -import { Button, Popconfirm } from "antd"; - -import { ControlContext, SectionHeader, useAccount, notifyErrorHandler } from "@clusterio/web_ui"; - -import { SeedRolesRequest } from "../../messages"; - -/** Button on the roles page which creates the roles the scenario shipped with. */ -export default function SeedRoles() { - const control = useContext(ControlContext); - const account = useAccount(); - const [seeding, setSeeding] = useState(false); - - if (!account.hasPermission("core.role.create")) { - return null; - } - - return { - setSeeding(true); - control.send(new SeedRolesRequest()) - .catch(notifyErrorHandler("Error seeding roles")) - .finally(() => setSeeding(false)); - }} - > - - } - />; -} diff --git a/exp_roles/web/index.tsx b/exp_roles/web/index.tsx index 1bd55066..644cd10d 100644 --- a/exp_roles/web/index.tsx +++ b/exp_roles/web/index.tsx @@ -5,7 +5,6 @@ import * as lib from "@clusterio/lib"; import * as messages from "../messages"; import RoleProperties from "./components/RoleProperties"; -import SeedRoles from "./components/SeedRoles"; export class WebPlugin extends BaseWebPlugin { roles = new lib.MapSubscriber(messages.RoleUpdatedEvent, this.control); @@ -15,7 +14,6 @@ export class WebPlugin extends BaseWebPlugin { // does not carry in its type this.componentExtra = { RoleViewPage: RoleProperties as React.ComponentType, - RolesPage: SeedRoles, }; } diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index fc761f62..1af4d27b 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -1,6 +1,157 @@ import * as lib from "@clusterio/lib"; import { BaseControllerPlugin } from "@clusterio/controller"; +import type { ControllerPlugin as RolesPlugin } from "@expcluster/roles/dist/node/controller"; +import type { ControllerPlugin as GroupsPlugin } from "@expcluster/permission-groups/dist/node/controller"; +import { RoleMetaRecord } from "@expcluster/roles/dist/node/messages"; +import { GroupRecord, GroupPermissions, RoleMappingRecord } from "@expcluster/permission-groups/dist/node/messages"; +import * as messages from "./messages"; +import { SeedRole, SeedGroup, seedRoles, seedGroups, flattenSeedPermissions } from "./seed"; export class ControllerPlugin extends BaseControllerPlugin { + async init() { + this.controller.handle(messages.SeedRequest, this.handleSeedRequest.bind(this)); + } + /** + * Create the roles and permission groups the scenario shipped with. + * + * Roles which already exist by name are reused and only gain the seed + * permissions, groups which already exist by name are reset to the seed. + */ + async handleSeedRequest() { + const rolesPlugin = this.controller.plugins.get("exp_roles") as RolesPlugin | undefined; + const groupsPlugin = this.controller.plugins.get("exp_groups") as GroupsPlugin | undefined; + if (!rolesPlugin || !groupsPlugin) { + throw new lib.RequestError("Seeding requires the exp_roles and exp_groups plugins"); + } + + const roleIds = new Map(); + for (const [index, seedRole] of seedRoles.entries()) { + const role = this.seedRole(seedRole); + if (!role) { + continue; + } + + roleIds.set(seedRole.name, role.id); + rolesPlugin.roleMeta.set(new RoleMetaRecord( + role.id, + index + 1, + seedRole.priority ?? 0, + seedRole.shortHand, + "", + seedRole.color, + seedRole.autoAssignHours === undefined ? null : seedRole.autoAssignHours * 3600000, + seedRole.blockAutoAssign ?? false, + )); + } + + const groupIds = new Map(); + for (const seedGroup of seedGroups) { + groupIds.set(seedGroup.name, this.seedGroup(groupsPlugin, seedGroup).id); + } + + this.seedRoleMappings(groupsPlugin, roleIds, groupIds); + this.logger.info(`Seeded ${roleIds.size} roles and ${groupIds.size} permission groups`); + } + + /** Find or create the clusterio role for a seed role, returns undefined if it has no role to use. */ + seedRole(seedRole: SeedRole) { + const roles = this.controller.roles; + if (seedRole.isAdmin) { + return roles.get(lib.Role.DefaultAdminRoleId); + } + if (seedRole.isDefault) { + const defaultRoleId = this.controller.config.get("controller.default_role_id"); + return defaultRoleId !== null ? roles.get(defaultRoleId) : undefined; + } + + const permissions = flattenSeedPermissions(seedRole); + for (const permission of permissions) { + if (!lib.permissions.has(permission)) { + this.logger.warn(`Seed role ${seedRole.name} grants unknown permission ${permission}`); + } + } + + let role = [...roles.valuesMutable()].find(other => other.name === seedRole.name); + if (role) { + for (const permission of permissions) { + role.permissions.add(permission); + } + } else { + const id = Math.max(5, ...[...roles.keys()].map(other => other + 1)); + role = new lib.Role(id, seedRole.name, "", permissions); + this.logger.info(`Created role ${seedRole.name}`); + } + roles.set(role); + return role; + } + + /** Find or create the permission group for a seed group. */ + seedGroup(groupsPlugin: GroupsPlugin, seedGroup: SeedGroup) { + const permissions = new GroupPermissions(seedGroup.isBlacklist, [...seedGroup.inputActions]); + const existing = [...groupsPlugin.groups.values()].find(other => other.name === seedGroup.name); + const group = new GroupRecord(existing?.id ?? newId(groupsPlugin.groups), seedGroup.name, permissions); + if (!existing) { + this.logger.info(`Created permission group ${seedGroup.name}`); + } + groupsPlugin.groups.set(group); + return group; + } + + /** + * Map each seed role onto its seed group. + * + * The mapping with the highest priority decides a player's group, so the + * priorities follow how exp_roles picks a player's highest role: role + * priority first, then the role order. + */ + seedRoleMappings(groupsPlugin: GroupsPlugin, roleIds: Map, groupIds: Map) { + // Lowest role first, so the mapping priority rises with the role + const ranked = seedRoles + .filter(seedRole => seedRole.group !== undefined && roleIds.has(seedRole.name)) + .reverse() + .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)); + const seededRoleIds = new Set(ranked.map(seedRole => roleIds.get(seedRole.name)!)); + + // A mapping of a single seed role is reused, every other mapping keeps its priority + const existing = new Map(); + const taken = new Set(); + for (const mapping of groupsPlugin.roleMappings.values()) { + const [roleId] = mapping.roleIds; + if (mapping.roleIds.size === 1 && seededRoleIds.has(roleId)) { + existing.set(roleId, mapping); + } else { + taken.add(mapping.priority); + } + } + + const mappings = []; + let priority = 0; + for (const seedRole of ranked) { + priority += 1; + while (taken.has(priority)) { + priority += 1; + } + + const roleId = roleIds.get(seedRole.name)!; + mappings.push(new RoleMappingRecord( + existing.get(roleId)?.id ?? newId(groupsPlugin.roleMappings), + new Set([roleId]), + groupIds.get(seedRole.group!)!, + priority, + true, + )); + } + + groupsPlugin.roleMappings.setMany(mappings); + return mappings; + } +} + +function newId(datastore: { has(id: number): boolean }) { + let id = Math.random() * 2 ** 31 | 0; + while (datastore.has(id)) { + id = Math.random() * 2 ** 31 | 0; + } + return id; } diff --git a/exp_scenario/index.ts b/exp_scenario/index.ts index b3e4bd81..f09c3c6f 100644 --- a/exp_scenario/index.ts +++ b/exp_scenario/index.ts @@ -1,5 +1,5 @@ import * as lib from "@clusterio/lib"; -// import * as Messages from "./messages"; +import * as messages from "./messages"; // Defines a permission for every in game action and role flag used by the scenario import "./permissions"; @@ -16,6 +16,12 @@ lib.definePermission({ description: "Edit the config for all submodules of ExpScenario", }); +lib.definePermission({ + name: "exp_scenario.seed", + title: "Seed ExpScenario roles and groups", + description: "Create the roles and permission groups the scenario shipped with", +}); + declare module "@clusterio/lib" { } @@ -27,13 +33,9 @@ export const plugin: lib.PluginDeclaration = { controllerEntrypoint: "./dist/node/controller", instanceEntrypoint: "./dist/node/instance", - /* messages: [ + messages.SeedRequest, ], webEntrypoint: "./web", - routes: [ - "/exp_scenario", - ], - */ }; diff --git a/exp_scenario/messages.ts b/exp_scenario/messages.ts index 1eb52057..9c778456 100644 --- a/exp_scenario/messages.ts +++ b/exp_scenario/messages.ts @@ -1,95 +1,11 @@ -import { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } from "@clusterio/lib"; -import { Type, Static } from "@sinclair/typebox"; - -export class PluginExampleEvent { - declare ["constructor"]: typeof PluginExampleEvent; - static type = "event" as const; - static src = ["host", "control"] as const; - static dst = ["controller", "host", "instance"] as const; +/** Create the roles and permission groups the scenario shipped with, see seed.ts. */ +export class SeedRequest { + declare ["constructor"]: typeof SeedRequest; static plugin = "exp_scenario" as const; - static permission = "exp_scenario.example.permission.event"; - - constructor( - public myString: string, - public myNumberArray: number[], - ) { - } - - static jsonSchema = Type.Object({ - "myString": Type.String(), - "myNumberArray": Type.Array(Type.Number()), - }); - - static fromJSON(json: Static) { - return new PluginExampleEvent(json.myString, json.myNumberArray); - } -} - -export class PluginExampleRequest { - declare ["constructor"]: typeof PluginExampleRequest; static type = "request" as const; - static src = ["host", "control"] as const; - static dst = ["controller", "host", "instance"] as const; - static plugin = "exp_scenario" as const; - static permission = "exp_scenario.example.permission.request"; + static src = "control" as const; + static dst = "controller" as const; + static permission = "exp_scenario.seed" as const; - constructor( - public myString: string, - public myNumberArray: number[], - ) { - } - - static jsonSchema = Type.Object({ - "myString": Type.String(), - "myNumberArray": Type.Array(Type.Number()), - }); - - static fromJSON(json: Static) { - return new PluginExampleRequest(json.myString, json.myNumberArray); - } - - static Response = plainJson(Type.Object({ - "myResponseString": Type.String(), - "myResponseNumbers": Type.Array(Type.Number()), - })); -} - -export class ExampleSubscribableValue { - constructor( - public id: string, - public updatedAtMs: number, - public isDeleted: boolean, - ) { - } - - static jsonSchema = Type.Object({ - id: Type.String(), - updatedAtMs: Type.Number(), - isDeleted: Type.Boolean(), - }); - - static fromJSON(json: Static) { - return new this(json.id, json.updatedAtMs, json.isDeleted); - } -} - -export class ExampleSubscribableUpdate { - declare ["constructor"]: typeof ExampleSubscribableUpdate; - static type = "event" as const; - static src = "controller" as const; - static dst = "control" as const; - static plugin = "exp_scenario" as const; - static permission = "exp_scenario.example.permission.subscribe"; - - constructor( - public updates: ExampleSubscribableValue[], - ) { } - - static jsonSchema = Type.Object({ - "updates": Type.Array(ExampleSubscribableValue.jsonSchema), - }); - - static fromJSON(json: Static) { - return new this(json.updates.map(update => ExampleSubscribableValue.fromJSON(update))); - } + constructor() {} } diff --git a/exp_scenario/module/commands/_rcon.lua b/exp_scenario/module/commands/_rcon.lua index ef3febbc..7db7ad1f 100644 --- a/exp_scenario/module/commands/_rcon.lua +++ b/exp_scenario/module/commands/_rcon.lua @@ -7,7 +7,6 @@ local add_static, add_dynamic = Commands.add_rcon_static, Commands.add_rcon_dyna add_static("Gui", require("modules/exp_gui")) -add_static("Group", require("modules.exp_legacy.expcore.permission_groups")) add_static("Roles", require("modules/exp_roles")) add_static("Datastore", require("modules.exp_legacy.expcore.datastore")) add_static("External", require("modules.exp_legacy.expcore.external")) diff --git a/exp_scenario/package.json b/exp_scenario/package.json index 8a00f8b4..1e4d9d65 100644 --- a/exp_scenario/package.json +++ b/exp_scenario/package.json @@ -7,15 +7,19 @@ "repository": "explosivegaming/ExpCluster", "main": "dist/node/index.js", "scripts": { - "prepare": "tsc --build && webpack-cli --env production" + "prepare": "tsc --build && webpack-cli --env production", + "test": "tap --disable-coverage --allow-empty-coverage test/*.test.js", + "coverage": "tap --coverage-report=text test/*.test.js" }, "engines": { "node": ">=18" }, "peerDependencies": { + "@clusterio/controller": "workspace:^", "@clusterio/lib": "workspace:^" }, "devDependencies": { + "@clusterio/controller": "workspace:^", "@clusterio/lib": "workspace:^", "@clusterio/web_ui": "workspace:^", "@types/node": "catalog:", @@ -23,6 +27,7 @@ "antd": "catalog:", "react": "catalog:", "react-dom": "catalog:", + "tap": "^21.1.0", "typescript": "catalog:", "webpack": "catalog:", "webpack-cli": "catalog:", @@ -32,6 +37,8 @@ "@expcluster/lib_commands": "workspace:^", "@expcluster/lib_util": "workspace:^", "@expcluster/lib_gui": "workspace:^", + "@expcluster/permission-groups": "workspace:^", + "@expcluster/roles": "workspace:^", "@sinclair/typebox": "catalog:" }, "publishConfig": { diff --git a/exp_roles/seed.ts b/exp_scenario/seed.ts similarity index 76% rename from exp_roles/seed.ts rename to exp_scenario/seed.ts index b028314a..62cc2ab6 100644 --- a/exp_roles/seed.ts +++ b/exp_scenario/seed.ts @@ -1,4 +1,4 @@ -import { RoleColor } from "./messages"; +import { RoleColor } from "@expcluster/roles/dist/node/messages"; /** * A role created by the seed, as the scenario defined it before roles moved to @@ -20,6 +20,22 @@ export interface SeedRole { /** Name of the role whose permissions are also granted, applied recursively. */ parent?: string; permissions: string[]; + /** + * Name of the seed group holders are placed in by their highest role. + * Without one the holders stay in Factorio's Default group. + */ + group?: string; +} + +/** + * A Factorio permission group created by the seed, as the scenario defined it + * before groups moved to the controller. + */ +export interface SeedGroup { + name: string; + /** When true the input actions are the only ones disallowed, otherwise the only ones allowed. */ + isBlacklist: boolean; + inputActions: string[]; } export const seedRoles: SeedRole[] = [ @@ -32,6 +48,7 @@ export const seedRoles: SeedRole[] = [ }, { name: "Senior Administrator", + group: "Admin", shortHand: "SAdmin", color: new RoleColor(233, 63, 233), parent: "Administrator", @@ -44,6 +61,7 @@ export const seedRoles: SeedRole[] = [ }, { name: "Administrator", + group: "Admin", shortHand: "Admin", color: new RoleColor(233, 63, 233), parent: "Moderator", @@ -55,6 +73,7 @@ export const seedRoles: SeedRole[] = [ }, { name: "Moderator", + group: "Admin", shortHand: "Mod", color: new RoleColor(0, 170, 0), parent: "Trainee", @@ -89,6 +108,7 @@ export const seedRoles: SeedRole[] = [ }, { name: "Trainee", + group: "Admin", shortHand: "TrMod", color: new RoleColor(0, 170, 0), parent: "Veteran", @@ -120,6 +140,7 @@ export const seedRoles: SeedRole[] = [ }, { name: "Board Member", + group: "Trusted", shortHand: "Board", color: new RoleColor(247, 246, 54), parent: "Sponsor", @@ -133,6 +154,7 @@ export const seedRoles: SeedRole[] = [ }, { name: "Senior Backer", + group: "Trusted", shortHand: "Backer", color: new RoleColor(238, 172, 44), parent: "Sponsor", @@ -140,6 +162,7 @@ export const seedRoles: SeedRole[] = [ }, { name: "Sponsor", + group: "Trusted", shortHand: "Spon", color: new RoleColor(238, 172, 44), parent: "Supporter", @@ -158,6 +181,7 @@ export const seedRoles: SeedRole[] = [ }, { name: "Supporter", + group: "Trusted", shortHand: "Sup", color: new RoleColor(230, 99, 34), parent: "Veteran", @@ -172,6 +196,7 @@ export const seedRoles: SeedRole[] = [ }, { name: "Partner", + group: "Trusted", shortHand: "Part", color: new RoleColor(140, 120, 200), parent: "Veteran", @@ -183,6 +208,7 @@ export const seedRoles: SeedRole[] = [ }, { name: "Veteran", + group: "Trusted", shortHand: "Vet", color: new RoleColor(140, 120, 200), parent: "Member", @@ -196,6 +222,7 @@ export const seedRoles: SeedRole[] = [ }, { name: "Member", + group: "Standard", shortHand: "Mem", color: new RoleColor(24, 172, 188), parent: "Regular", @@ -217,6 +244,7 @@ export const seedRoles: SeedRole[] = [ }, { name: "Regular", + group: "Standard", shortHand: "Reg", color: new RoleColor(79, 155, 163), autoAssignHours: 3, @@ -232,6 +260,7 @@ export const seedRoles: SeedRole[] = [ }, { name: "Jail", + group: "Restricted", shortHand: "Jail", color: new RoleColor(50, 50, 50), priority: 1, @@ -240,6 +269,7 @@ export const seedRoles: SeedRole[] = [ }, { name: "Guest", + group: "Guest", shortHand: "", color: new RoleColor(185, 187, 160), isDefault: true, @@ -247,6 +277,56 @@ export const seedRoles: SeedRole[] = [ }, ]; +const adminDisallowed = [ + "add_permission_group", + "delete_permission_group", + "edit_permission_group", + "import_permissions_string", + "map_editor_action", + "toggle_map_editor", + "change_multiplayer_config", + "set_heat_interface_mode", + "set_heat_interface_temperature", + "set_infinity_container_filter_item", + "set_infinity_container_remove_unfiltered_items", + "set_infinity_pipe_filter", +]; + +const trustedDisallowed = [ + ...adminDisallowed, + "admin_action", +]; + +const standardDisallowed = [ + ...trustedDisallowed, + "change_programmable_speaker_alert_parameters", + "drop_item", + "open_new_platform_button_from_rocket_silo", + "set_rocket_silo_send_to_orbit_automated_mode", +]; + +const guestDisallowed = [ + ...standardDisallowed, + "change_programmable_speaker_parameters", + "change_train_stop_station", + "remove_cables", + "remove_train_station", + "reset_assembling_machine", + "rotate_entity", + "launch_rocket", + "cancel_research", + "flush_opened_entity_fluid", + "flush_opened_entity_specific_fluid", +]; + +export const seedGroups: SeedGroup[] = [ + { name: "Admin", isBlacklist: true, inputActions: adminDisallowed }, + { name: "Trusted", isBlacklist: true, inputActions: trustedDisallowed }, + { name: "Standard", isBlacklist: true, inputActions: standardDisallowed }, + { name: "Guest", isBlacklist: true, inputActions: guestDisallowed }, + { name: "Restricted", isBlacklist: false, inputActions: ["write_to_console"] }, +]; + /** The permissions a seed role grants, including those of its parents. */ export function flattenSeedPermissions(role: SeedRole, roles = seedRoles) { const permissions = new Set(); diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js new file mode 100644 index 00000000..a963b157 --- /dev/null +++ b/exp_scenario/test/controller.test.js @@ -0,0 +1,139 @@ +"use strict"; +const t = require("tap"); +const lib = require("@clusterio/lib"); +const { Controller } = require("@clusterio/controller"); +const { ControllerPlugin } = require("../dist/node/controller"); +const messages = require("../dist/node/messages"); +const { seedRoles, seedGroups } = require("../dist/node/seed"); +const roles = require("@expcluster/roles/dist/node"); +const groups = require("@expcluster/permission-groups/dist/node"); +const { ControllerPlugin: RolesPlugin } = require("@expcluster/roles/dist/node/controller"); +const { ControllerPlugin: GroupsPlugin } = require("@expcluster/permission-groups/dist/node/controller"); +const { GroupRecord, GroupPermissions, RoleMappingRecord } = require("@expcluster/permission-groups/dist/node/messages"); + +// Importing this defines the permissions the seed grants +require("../dist/node/permissions"); + +// The controller validates message classes against the link registry +for (const Message of [messages.SeedRequest, ...roles.plugin.messages, ...groups.plugin.messages]) { + lib.Link.register(Message); +} + +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + +// Silence the singleton logger to avoid polluting the test output +lib.logger.silent = true; +t.after(() => { + lib.logger.silent = false; +}); + +/** Build the plugin around a real controller, which is side effect free while not started. */ +async function startPlugin(t2, { withPlugins = true } = {}) { + const controllerConfig = new lib.ControllerConfig("controller", { + "controller.database_directory": t2.testdir(), + "controller.default_role_id": lib.Role.DefaultPlayerRoleId, + }); + const controller = new Controller(logger, [], controllerConfig); + controller.roles.set(new lib.Role(0, "Cluster Admin", "", new Set(["core.admin"]))); + controller.roles.set(new lib.Role(1, "Player", "", new Set())); + + if (withPlugins) { + for (const [name, Plugin] of [["exp_roles", RolesPlugin], ["exp_groups", GroupsPlugin]]) { + const plugin = new Plugin({ name }, controller, undefined, logger); + await plugin.init(); + controller.plugins.set(name, plugin); + } + } + + const plugin = new ControllerPlugin({ name: "exp_scenario" }, controller, undefined, logger); + await plugin.init(); + return { plugin, controller }; +} + +const byName = (datastore, name) => [...datastore.values()].find(other => other.name === name); + +t.test("class ControllerPlugin", t2 => { + t2.test(".handleSeedRequest() throws when the seeded plugins are missing", async t3 => { + const { plugin } = await startPlugin(t3, { withPlugins: false }); + await t3.rejects( + plugin.handleSeedRequest(), + { message: "Seeding requires the exp_roles and exp_groups plugins" }, + "the request is rejected", + ); + }); + + t2.test(".handleSeedRequest() creates the roles and reuses them by name", async t3 => { + const { plugin, controller } = await startPlugin(t3); + const rolesPlugin = controller.plugins.get("exp_roles"); + + await plugin.handleSeedRequest(); + t3.strictSame(controller.roles.size, seedRoles.length, "every seed role exists"); + + const moderator = byName(controller.roles, "Moderator"); + t3.ok(moderator.permissions.has("exp_scenario.command.jail"), "parent permissions are flattened in"); + t3.strictSame(rolesPlugin.roleMeta.get(moderator.id).shortHand, "Mod", "the role properties match the seed"); + + await plugin.handleSeedRequest(); + t3.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles"); + }); + + t2.test(".handleSeedRequest() creates the groups and resets them by name", async t3 => { + const { plugin, controller } = await startPlugin(t3); + const groupsPlugin = controller.plugins.get("exp_groups"); + + await plugin.handleSeedRequest(); + t3.strictSame(groupsPlugin.groups.size, seedGroups.length, "every seed group exists"); + + const restricted = byName(groupsPlugin.groups, "Restricted"); + t3.strictSame(restricted.permissions.isBlacklist, false, "the group only allows the listed actions"); + t3.strictSame(restricted.permissions.permissions, ["write_to_console"], "the actions match the seed"); + + const admin = byName(groupsPlugin.groups, "Admin"); + groupsPlugin.groups.set(new GroupRecord(admin.id, admin.name, new GroupPermissions(true, []))); + await plugin.handleSeedRequest(); + t3.strictSame(groupsPlugin.groups.size, seedGroups.length, "seeding again reuses the groups"); + t3.ok( + byName(groupsPlugin.groups, "Admin").permissions.permissions.includes("toggle_map_editor"), + "seeding again resets the actions", + ); + }); + + t2.test(".handleSeedRequest() maps each role onto its group by rank", async t3 => { + const { plugin, controller } = await startPlugin(t3); + const groupsPlugin = controller.plugins.get("exp_groups"); + + await plugin.handleSeedRequest(); + const mapped = seedRoles.filter(role => role.group !== undefined); + t3.strictSame(groupsPlugin.roleMappings.size, mapped.length, "every role with a group is mapped"); + + const mappingOf = name => [...groupsPlugin.roleMappings.values()] + .find(mapping => mapping.roleIds.has(byName(controller.roles, name).id)); + const groupOf = name => groupsPlugin.groups.get(mappingOf(name).groupId).name; + t3.strictSame(groupOf("Player"), "Guest", "the default role maps to the guest group"); + t3.strictSame(groupOf("Jail"), "Restricted", "jail maps to the restricted group"); + t3.ok(mappingOf("Jail").priority > mappingOf("Senior Administrator").priority, "jail outranks every role"); + t3.ok(mappingOf("Moderator").priority > mappingOf("Veteran").priority, "the role order sets the rank"); + t3.ok(mappingOf("Veteran").priority > mappingOf("Player").priority, "the default role ranks lowest"); + t3.strictSame(mappingOf("Cluster Admin"), undefined, "the admin role keeps the factorio default group"); + + const priorities = [...groupsPlugin.roleMappings.values()].map(mapping => mapping.priority); + t3.strictSame(priorities.length, new Set(priorities).size, "priorities are unique"); + }); + + t2.test(".handleSeedRequest() reuses mappings and keeps clear of other mappings", async t3 => { + const { plugin, controller } = await startPlugin(t3); + const groupsPlugin = controller.plugins.get("exp_groups"); + + groupsPlugin.roleMappings.set(new RoleMappingRecord(99, new Set([0, 1]), 5, 2, true)); + await plugin.handleSeedRequest(); + const first = new Map([...groupsPlugin.roleMappings.values()].map(mapping => [mapping.id, mapping.priority])); + + await plugin.handleSeedRequest(); + const second = new Map([...groupsPlugin.roleMappings.values()].map(mapping => [mapping.id, mapping.priority])); + t3.strictSame(second, first, "seeding again reuses the mappings"); + t3.strictSame(groupsPlugin.roleMappings.get(99).priority, 2, "other mappings are left alone"); + t3.notOk([...first.values()].filter(priority => priority === 2).length > 1, "seed priorities skip taken ones"); + }); + + t2.end(); +}); diff --git a/exp_roles/test/seed.test.js b/exp_scenario/test/seed.test.js similarity index 67% rename from exp_roles/test/seed.test.js rename to exp_scenario/test/seed.test.js index adcb0987..87e198a6 100644 --- a/exp_roles/test/seed.test.js +++ b/exp_scenario/test/seed.test.js @@ -1,10 +1,10 @@ "use strict"; const t = require("tap"); const lib = require("@clusterio/lib"); -const { seedRoles, flattenSeedPermissions } = require("../dist/node/seed"); +const { seedRoles, seedGroups, flattenSeedPermissions } = require("../dist/node/seed"); -// Importing this defines the exp_scenario permissions the seed grants -require("@expcluster/scenario/dist/node/permissions"); +// Importing this defines the permissions the seed grants +require("../dist/node/permissions"); t.test("seedRoles[] grant only defined permissions", t2 => { for (const role of seedRoles) { @@ -23,6 +23,29 @@ t.test("seedRoles[] are unique with one default and one admin", t2 => { t2.end(); }); +t.test("seedRoles[] are placed in defined groups", t2 => { + const groupNames = new Set(seedGroups.map(group => group.name)); + for (const role of seedRoles) { + if (role.group !== undefined) { + t2.ok(groupNames.has(role.group), `${role.name} is placed in defined group ${role.group}`); + } + } + t2.end(); +}); + +t.test("seedGroups[] are unique", t2 => { + const names = seedGroups.map(group => group.name); + t2.strictSame(names.length, new Set(names).size, "names are unique"); + for (const group of seedGroups) { + t2.strictSame( + group.inputActions.length, + new Set(group.inputActions).size, + `${group.name} lists each input action once`, + ); + } + t2.end(); +}); + t.test("flattenSeedPermissions() inherits permissions from parent roles", t2 => { const byName = new Map(seedRoles.map(role => [role.name, role])); for (const role of seedRoles) { diff --git a/exp_scenario/web/components/Seed.tsx b/exp_scenario/web/components/Seed.tsx new file mode 100644 index 00000000..619d79df --- /dev/null +++ b/exp_scenario/web/components/Seed.tsx @@ -0,0 +1,33 @@ +import React, { useContext, useState } from "react"; +import { Button, Popconfirm } from "antd"; + +import { ControlContext, SectionHeader, useAccount, notifyErrorHandler } from "@clusterio/web_ui"; + +import { SeedRequest } from "../../messages"; + +/** Button on the roles page which creates the roles and permission groups the scenario shipped with. */ +export default function Seed() { + const control = useContext(ControlContext); + const account = useAccount(); + const [seeding, setSeeding] = useState(false); + + if (!account.hasPermission("exp_scenario.seed")) { + return null; + } + + return { + setSeeding(true); + control.send(new SeedRequest()) + .catch(notifyErrorHandler("Error seeding roles and groups")) + .finally(() => setSeeding(false)); + }} + > + + } + />; +} diff --git a/exp_scenario/web/index.tsx b/exp_scenario/web/index.tsx index e69de29b..cecbd7f0 100644 --- a/exp_scenario/web/index.tsx +++ b/exp_scenario/web/index.tsx @@ -0,0 +1,11 @@ +import { BaseWebPlugin } from "@clusterio/web_ui"; + +import Seed from "./components/Seed"; + +export class WebPlugin extends BaseWebPlugin { + async init() { + this.componentExtra = { + RolesPage: Seed, + }; + } +} From 24efb82d0f824cf0cc9545868760d86fe776e754 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:47:01 +0200 Subject: [PATCH 03/18] Encode group permissions as a 1-based array (#472) encode_group_permissions filled the whitelist and blacklist starting at index 0. Factorio's table_to_json only serialises a table as a JSON array when its keys run 1..n, so the permissions reached the instance plugin as an object and every GroupUpdateRequest failed schema validation. Co-authored-by: Claude Fable 5.1 --- exp_groups/module/control.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/exp_groups/module/control.lua b/exp_groups/module/control.lua index 232fd6e3..6203b1c5 100644 --- a/exp_groups/module/control.lua +++ b/exp_groups/module/control.lua @@ -102,11 +102,11 @@ local function encode_group_permissions(group) local allows_action = group.allows_action for input_action_name, input_action in pairs(defines.input_action) do if allows_action(input_action) then - whitelist[whitelist_index] = input_action_name whitelist_index = whitelist_index + 1 + whitelist[whitelist_index] = input_action_name else - blacklist[blacklist_index] = input_action_name blacklist_index = blacklist_index + 1 + blacklist[blacklist_index] = input_action_name end end From 3a9155697afdc038277a7fa8eb9121e028321aa3 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:58:09 +0000 Subject: [PATCH 04/18] Reference exp_roles and exp_groups from the exp_scenario build exp_scenario imports types from @expcluster/roles and @expcluster/permission-groups through their dist folders, so building it on its own before those packages are built fails with TS2307. A plain pnpm install runs the prepare scripts in dependency order, but a filtered install or running tsc --build in exp_scenario on a fresh clone does not. Referencing the node projects of both packages makes tsc build them first. Co-Authored-By: Claude Fable 5.1 --- exp_scenario/tsconfig.node.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/exp_scenario/tsconfig.node.json b/exp_scenario/tsconfig.node.json index 3218f2e7..f3a03479 100644 --- a/exp_scenario/tsconfig.node.json +++ b/exp_scenario/tsconfig.node.json @@ -1,5 +1,9 @@ { "extends": "../tsconfig.node.json", + "references": [ + { "path": "../exp_groups/tsconfig.node.json" }, + { "path": "../exp_roles/tsconfig.node.json" }, + ], "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], } From 51786c5df90465c7aba4bd4f0069d8516dc2a468 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:31:31 +0000 Subject: [PATCH 05/18] Export messages and controller types from exp_roles and exp_groups exp_scenario reached into @expcluster/roles/dist/node/* for the message classes and the controller plugin type. The package index now re-exports messages and the ControllerPlugin type, so consumers import from the package name like they do with @clusterio/lib. The controller export is type only, which keeps node code out of the web bundle. The test still requires the controller classes from dist since they cannot be re-exported at runtime. Co-Authored-By: Claude Fable 5.1 --- exp_groups/index.ts | 3 +++ exp_roles/index.ts | 3 +++ exp_scenario/controller.ts | 8 ++++---- exp_scenario/seed.ts | 2 +- exp_scenario/test/controller.test.js | 6 +++--- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/exp_groups/index.ts b/exp_groups/index.ts index 524ac7d9..743f33b9 100644 --- a/exp_groups/index.ts +++ b/exp_groups/index.ts @@ -1,6 +1,9 @@ import * as lib from "@clusterio/lib"; import * as messages from "./messages"; +export * from "./messages"; +export type { ControllerPlugin } from "./controller"; + declare module "@clusterio/lib" { export interface InstanceConfigFields { "exp_groups.sync_mode": "enabled" | "disabled" | "bidirectional" diff --git a/exp_roles/index.ts b/exp_roles/index.ts index e3cb4093..e481fc8f 100644 --- a/exp_roles/index.ts +++ b/exp_roles/index.ts @@ -1,6 +1,9 @@ import * as lib from "@clusterio/lib"; import * as messages from "./messages"; +export * from "./messages"; +export type { ControllerPlugin } from "./controller"; + declare module "@clusterio/lib" { export interface InstanceConfigFields { "exp_roles.sync_mode": "disabled" | "enabled" | "bidirectional"; diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index 1af4d27b..cd04b3db 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -1,9 +1,9 @@ import * as lib from "@clusterio/lib"; import { BaseControllerPlugin } from "@clusterio/controller"; -import type { ControllerPlugin as RolesPlugin } from "@expcluster/roles/dist/node/controller"; -import type { ControllerPlugin as GroupsPlugin } from "@expcluster/permission-groups/dist/node/controller"; -import { RoleMetaRecord } from "@expcluster/roles/dist/node/messages"; -import { GroupRecord, GroupPermissions, RoleMappingRecord } from "@expcluster/permission-groups/dist/node/messages"; +import { RoleMetaRecord, type ControllerPlugin as RolesPlugin } from "@expcluster/roles"; +import { + GroupRecord, GroupPermissions, RoleMappingRecord, type ControllerPlugin as GroupsPlugin, +} from "@expcluster/permission-groups"; import * as messages from "./messages"; import { SeedRole, SeedGroup, seedRoles, seedGroups, flattenSeedPermissions } from "./seed"; diff --git a/exp_scenario/seed.ts b/exp_scenario/seed.ts index 62cc2ab6..37edbd63 100644 --- a/exp_scenario/seed.ts +++ b/exp_scenario/seed.ts @@ -1,4 +1,4 @@ -import { RoleColor } from "@expcluster/roles/dist/node/messages"; +import { RoleColor } from "@expcluster/roles"; /** * A role created by the seed, as the scenario defined it before roles moved to diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index a963b157..18ffedc4 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -5,11 +5,11 @@ const { Controller } = require("@clusterio/controller"); const { ControllerPlugin } = require("../dist/node/controller"); const messages = require("../dist/node/messages"); const { seedRoles, seedGroups } = require("../dist/node/seed"); -const roles = require("@expcluster/roles/dist/node"); -const groups = require("@expcluster/permission-groups/dist/node"); +const roles = require("@expcluster/roles"); +const groups = require("@expcluster/permission-groups"); const { ControllerPlugin: RolesPlugin } = require("@expcluster/roles/dist/node/controller"); const { ControllerPlugin: GroupsPlugin } = require("@expcluster/permission-groups/dist/node/controller"); -const { GroupRecord, GroupPermissions, RoleMappingRecord } = require("@expcluster/permission-groups/dist/node/messages"); +const { GroupRecord, GroupPermissions, RoleMappingRecord } = require("@expcluster/permission-groups"); // Importing this defines the permissions the seed grants require("../dist/node/permissions"); From 23ee68c50aaf3ae2b444a1f45452c40bef5fa8b7 Mon Sep 17 00:00:00 2001 From: PHIDIAS Date: Thu, 10 Sep 2026 02:43:29 +0900 Subject: [PATCH 06/18] Further locale change (#471) * chore(l10n): update translation files Add-on: Cleanup translation files Co-authored-by: Weblate Translate-URL: https://translate.aperx.org/projects/expcluster/expscenario/ Translation: ExpCluster/ExpScenario * Update readme.lua * links * readme * chore(l10n): update English translation chore(l10n): update Chinese (Traditional Han script) translation chore(l10n): update Chinese (Simplified Han script) translation chore(l10n): update Chinese (Traditional Han script) translation chore(l10n): update Chinese (Simplified Han script) translation chore(l10n): update Chinese (Traditional Han script) translation chore(l10n): update Chinese (Simplified Han script) translation chore(l10n): update Chinese (Traditional Han script) translation chore(l10n): update Chinese (Simplified Han script) translation chore(l10n): update English translation Co-authored-by: Weblate Co-authored-by: phidias Language: Chinese (Simplified Han script) Language: Chinese (Traditional Han script) Language: English Progress: 100.0% (1 of 1 strings) Progress: 100.0% (2 of 2 strings) Progress: 100.0% (233 of 233 strings) Progress: 100.0% (4 of 4 strings) Translate-URL: https://translate.aperx.org/projects/expcluster/explegacy/zh_Hans/ Translate-URL: https://translate.aperx.org/projects/expcluster/explegacy/zh_Hant/ Translate-URL: https://translate.aperx.org/projects/expcluster/exprole/en/ Translate-URL: https://translate.aperx.org/projects/expcluster/exprole/zh_Hans/ Translate-URL: https://translate.aperx.org/projects/expcluster/exprole/zh_Hant/ Translate-URL: https://translate.aperx.org/projects/expcluster/expserverups/zh_Hans/ Translate-URL: https://translate.aperx.org/projects/expcluster/expserverups/zh_Hant/ Translate-URL: https://translate.aperx.org/projects/expcluster/exputil/zh_Hans/ Translate-URL: https://translate.aperx.org/projects/expcluster/exputil/zh_Hant/ Translation: ExpCluster/ExpLegacy Translation: ExpCluster/ExpRole Translation: ExpCluster/ExpServerUPS Translation: ExpCluster/ExpUtil * chore(l10n): update Chinese (Traditional Han script) translation chore(l10n): update Chinese (Simplified Han script) translation chore(l10n): update English translation Co-authored-by: phidias Language: Chinese (Simplified Han script) Language: Chinese (Traditional Han script) Language: English Progress: 100.0% (452 of 452 strings) Progress: 95.7% (433 of 452 strings) Translate-URL: https://translate.aperx.org/projects/expcluster/expscenario/en/ Translate-URL: https://translate.aperx.org/projects/expcluster/expscenario/zh_Hans/ Translate-URL: https://translate.aperx.org/projects/expcluster/expscenario/zh_Hant/ Translation: ExpCluster/ExpScenario * chore(l10n): update Chinese (Traditional Han script) translation chore(l10n): update Chinese (Simplified Han script) translation Co-authored-by: phidias Language: Chinese (Simplified Han script) Language: Chinese (Traditional Han script) Progress: 95.7% (433 of 452 strings) Translate-URL: https://translate.aperx.org/projects/expcluster/expscenario/zh_Hans/ Translate-URL: https://translate.aperx.org/projects/expcluster/expscenario/zh_Hant/ Translation: ExpCluster/ExpScenario --------- Co-authored-by: Weblate --- exp_legacy/module/locale/zh-CN.cfg | 4 +- exp_legacy/module/locale/zh-TW.cfg | 4 +- exp_roles/module/locale/en.cfg | 4 +- exp_roles/module/locale/zh-CN.cfg | 4 +- exp_roles/module/locale/zh-TW.cfg | 4 +- exp_scenario/module/gui/player_stats.lua | 4 +- exp_scenario/module/gui/readme.lua | 4 +- exp_scenario/module/locale/en.cfg | 12 +-- exp_scenario/module/locale/zh-CN.cfg | 116 +++++++++++------------ exp_scenario/module/locale/zh-TW.cfg | 116 +++++++++++------------ exp_server_ups/module/locale/zh-CN.cfg | 2 +- exp_server_ups/module/locale/zh-TW.cfg | 2 +- exp_util/module/locale/zh-CN.cfg | 4 +- exp_util/module/locale/zh-TW.cfg | 4 +- 14 files changed, 142 insertions(+), 142 deletions(-) diff --git a/exp_legacy/module/locale/zh-CN.cfg b/exp_legacy/module/locale/zh-CN.cfg index 3f4027b1..b355f354 100644 --- a/exp_legacy/module/locale/zh-CN.cfg +++ b/exp_legacy/module/locale/zh-CN.cfg @@ -10,14 +10,14 @@ patreon=支持我們: __1__ custom-commands=這裹使用了自制指令,如 /tag 和 /me。可使用 /chelp 查看更多。 read-readme=確保你已閱讀相關資訊。按左上 i 圖標可再次查看。 softmod=這裹用了自設情境,是一種軟裝模組。 -redmew= +redmew=We don't talk about redmew here; they beat us to 1000 members ;-; lhd=列車必須是左則通行。這是本服務器長久以來的規則。 # data [join-message] description-add=設定 / 取得你的加入信息。 description-remove=移除你的加入信息。 -arg-message=你的加入信息。 +arg-message=你的加入信息。 greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器可加入 DISCORD: __2__ [/color] message-set=你的加入信息已更新。 message-get=你的加入信息為: __1__ diff --git a/exp_legacy/module/locale/zh-TW.cfg b/exp_legacy/module/locale/zh-TW.cfg index 3f4027b1..b355f354 100644 --- a/exp_legacy/module/locale/zh-TW.cfg +++ b/exp_legacy/module/locale/zh-TW.cfg @@ -10,14 +10,14 @@ patreon=支持我們: __1__ custom-commands=這裹使用了自制指令,如 /tag 和 /me。可使用 /chelp 查看更多。 read-readme=確保你已閱讀相關資訊。按左上 i 圖標可再次查看。 softmod=這裹用了自設情境,是一種軟裝模組。 -redmew= +redmew=We don't talk about redmew here; they beat us to 1000 members ;-; lhd=列車必須是左則通行。這是本服務器長久以來的規則。 # data [join-message] description-add=設定 / 取得你的加入信息。 description-remove=移除你的加入信息。 -arg-message=你的加入信息。 +arg-message=你的加入信息。 greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器可加入 DISCORD: __2__ [/color] message-set=你的加入信息已更新。 message-get=你的加入信息為: __1__ diff --git a/exp_roles/module/locale/en.cfg b/exp_roles/module/locale/en.cfg index 3ea3d06c..14cecef4 100644 --- a/exp_roles/module/locale/en.cfg +++ b/exp_roles/module/locale/en.cfg @@ -1,3 +1,3 @@ [exp-roles] -game-message-assign=__1__ has been assigned: __2__ by __3__ -game-message-unassign=__1__ has been unassigned: __2__ by __3__ +game-message-assign=__1__ has been assigned to __2__ by __3__ +game-message-unassign=__1__ has been unassigned from __2__ by __3__ diff --git a/exp_roles/module/locale/zh-CN.cfg b/exp_roles/module/locale/zh-CN.cfg index 23b5caea..bdc4db73 100644 --- a/exp_roles/module/locale/zh-CN.cfg +++ b/exp_roles/module/locale/zh-CN.cfg @@ -1,3 +1,3 @@ [exp-roles] -game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__ -game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__ +game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__ +game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__ diff --git a/exp_roles/module/locale/zh-TW.cfg b/exp_roles/module/locale/zh-TW.cfg index 23b5caea..bdc4db73 100644 --- a/exp_roles/module/locale/zh-TW.cfg +++ b/exp_roles/module/locale/zh-TW.cfg @@ -1,3 +1,3 @@ [exp-roles] -game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__ -game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__ +game-message-assign=__1__ 已被 __3__ 指派到身分組 __2__ +game-message-unassign=__1__ 已被 __3__ 取消指派到身分組 __2__ diff --git a/exp_scenario/module/gui/player_stats.lua b/exp_scenario/module/gui/player_stats.lua index 5740aa2a..a0deea41 100644 --- a/exp_scenario/module/gui/player_stats.lua +++ b/exp_scenario/module/gui/player_stats.lua @@ -117,7 +117,7 @@ Elements.player_stats_table = Gui.define("player_stats/data_table") width = 135, }) labels[stat_name] = Elements.table_label(data_table, { - caption = { "readme.data-format", value, metadata.unit or "" }, + caption = { "exp-gui_readme.data-format", value, metadata.unit or "" }, tooltip = metadata.value_tooltip or { "exp-statistics." .. stat_name .. "-tooltip" }, width = 105, }) @@ -131,7 +131,7 @@ Elements.player_stats_table = Gui.define("player_stats/data_table") width = 135, }) labels[stat_name] = Elements.table_label(data_table, { - caption = { "readme.data-format", data.default, "" }, + caption = { "exp-gui_readme.data-format", data.default, "" }, tooltip = { "exp-statistics." .. stat_name .. "-tooltip" }, width = 105, }) diff --git a/exp_scenario/module/gui/readme.lua b/exp_scenario/module/gui/readme.lua index 1503ee9b..b8a7bcc6 100644 --- a/exp_scenario/module/gui/readme.lua +++ b/exp_scenario/module/gui/readme.lua @@ -189,7 +189,7 @@ define_tab( local player = Gui.get_player(parent) local server_details = { - name = "ExpGaming S0 - Local", + name = config_server_detail["community_name"] .. " S0 - Local", welcome = "Failed to load description: disconnected from external api.", reset_time = "Not Set", } @@ -338,7 +338,7 @@ define_tab( local external_links = Elements.title_table(scroll_pane, 235, { "exp-gui_readme.servers-external" }, 2) for _, key in ipairs{ "discord", "website", "patreon", "status", "github" } do Gui.elements.centered_label(external_links, 110, key:gsub("^%l", string.upper)) - Gui.elements.centered_label(external_links, 460, { "links." .. key }, { "exp-gui_readme.servers-open-in-browser" }) + Gui.elements.centered_label(external_links, 460, config_server_detail[key], { "exp-gui_readme.servers-open-in-browser" }) end return container diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index 28346036..55d72baf 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -31,7 +31,7 @@ description-pollution-enabled=Set polution enabled state for this game. description-game-speed=Set or get the current game speed. arg-state=State to set, default is to toggle the current value. arg-player=Player to toggle cheat mode for. -arg-force-friendly-fire=Force to research all technology for. +arg-force-friendly-fire=Force to toggle friendly fire. arg-force-research=Force to research all technology for. arg-surface=Surface to toggle always day for. arg-amount=Amount to set, returns the current value if not given. @@ -150,16 +150,16 @@ arg-items-per-second=Target items per second, defaults to output of a single mac not-selecting=Please select an entity with a recipe. item-in=You need __1__ per second of [item=__2__]. fluid-in=You need __1__ per second of [fluid=__2__]. -item-out=This will result in: __1__ [item=__2__] per second. -fluid-out=This will result in: __1__ [fluid=__2__] per second. +item-out=This will result in: __1__ [item=__2__] per second. +fluid-out=This will result in: __1__ [fluid=__2__] per second. machine-count=And you will need __1__ machines (with the same speed as this one) for this. [exp-commands_repair] selection-name=Repair description=Repairs entities on your force around you. # Intentional space left on the two lines below so they can be joined together. -response-revive=__1__ entities were revived. -response-heal=__1__ entities were healed. +response-revive=__1__ entities were revived. +response-heal=__1__ entities were healed. [exp-commands_reports] description-create=Reports a player and notifies moderators @@ -500,7 +500,7 @@ reply-online=There are __1__ players online reply-players=There have been __1__ players on this map reply-dev=Cooldude2606 is a dev for this server and makes the scenario and is not a factorio dev. reply-softmod=A softmod is a custom scenario that runs on this server, an example is the player list. -reply-clusterio=Clusterio is a factorio server hosting platform allowing internet enabled mods. +reply-clusterio=Clusterio is a factorio server hosting platform allowing internet enabled mods. reply-blame=Blame __1__ for what just happened! reply-afk=You're afk? Look at __1__, that player has been afk for: __2__ reply-evolution=Current evolution factor is __1__ diff --git a/exp_scenario/module/locale/zh-CN.cfg b/exp_scenario/module/locale/zh-CN.cfg index a4783e0c..31e68f74 100644 --- a/exp_scenario/module/locale/zh-CN.cfg +++ b/exp_scenario/module/locale/zh-CN.cfg @@ -1,9 +1,9 @@ [exp-commands-authorities_role] -deny=你沒有權限運行該項目 +deny=你沒有權限運行該項目。 [exp-commands-parse_role] -lower-role=用戶組比你現有的更高 -lower-role-player=用戶有更高權限 +lower-role=用戶組比你現有的更高。 +lower-role-player=用戶有更高權限。 [exp-commands_admin-chat] description=一個只有管理員能看到的對話。 @@ -12,31 +12,31 @@ format=[AC] __1__: __2__ [exp-commands_artillery] selection-name=火炮 -description=火炮遙控 -invalid-area=所選地區無效 +description=火炮遙控。 +invalid-area=所選地區無效。 [exp-commands_bot-queue] description=取得或更改建設隊列數量。 -arg-amount=建設隊列數量 +arg-amount=建設隊列數量。 get=建設隊列為 __1__ 個成功嘗試,和 __2__ 個嘗試。 set=__1__ 把建設隊列改成 __2__ 個成功嘗試,和 __3__ 個嘗試。 [exp-commands_cheat] description-cheat-mode=為你或其他玩家切換作弊模式。 -description-always-day=啟用永久白天 -description-friendly-fire=啟用友方傷害 -description-research-all=啟用所有科技 -description-clear-pollution=清除污染 -description-pollution-enabled=啟用污染 -description-game-speed=設定遊戲速度 -arg-state=狀態 -arg-player=用戶 -arg-force-friendly-fire=勢力 -arg-force-research=勢力 -arg-surface=位面 -arg-amount=數量 +description-always-day=啟用永久白天。 +description-friendly-fire=啟用友方傷害。 +description-research-all=啟用所有科技。 +description-clear-pollution=清除污染。 +description-pollution-enabled=啟用污染。 +description-game-speed=設定遊戲速度。 +arg-state=要設定的狀態,預設是切換目前值。 +arg-player=要為其切換作弊模式的玩家。 +arg-force-friendly-fire=為勢力切換友方傷害。 +arg-force-research=為勢力研究所有科技。 +arg-surface=為位面切換永久白晝。 +arg-amount=要設定的數量,如果未指定,則傳回目前值。 cheat-mode=作弊模式現在為 __1__ -always-day=__1__ 把在 __2__ 的位面的永久白天設置為 __3__ 。 +always-day=__1__ 把在 __2__ 的位面的永久白晝設置為 __3__ 。 friendly-fire=__1__ 已為在 __2__ 的勢力設定成 __3__ 。 research-all=__1__ 已為在 __2__ 的勢力啟用所有科技 。 clear-pollution=__1__ 已為在 __2__ 的位面清除所有污染 。 @@ -46,15 +46,15 @@ game-speed-get=現在遊戲速度為 __1__ 。 [exp-commands_clear-inventory] description=清除用戶物品。 -arg-player=用戶 +arg-player=要清除其物品欄的用戶。 [exp-commands_connect] -description=連接到另一服務器 -description-player=讓其他用戶連接到另一服務器 -description-all=將所有玩家連接到另一台服務器 -arg-player=連接到別的服的用戶 -arg-server=輸入為地址 / 名稱 -arg-is-address=輸入為地址 +description=連接到另一服務器。 +description-player=讓其他用戶連接到另一服務器。 +description-all=將所有玩家連接到另一台服務器。 +arg-player=連接到別的服務器的用戶。 +arg-server=輸入為地址或名稱。 +arg-is-address=輸入應該為地址。 too-many-matching=該服務器名稱有多過一個結果: __1__ wrong-version=該服務器版本不同: __1__ same-server=你已連接該服務器: __1__ @@ -65,15 +65,15 @@ none-matching=沒找到服務器。 description=打開偵錯介面。 [exp-commands_enemy] -description-kill=僅移除蟲子 -description-remove=移除蟲子及停止產生 +description-kill=僅移除蟲單位。 +description-remove=移除蟲單位及停止生產器。 arg-surface=位面 [exp-commands_home] -description-home=傳送回家 -description-set=設定目前位置為家 -description-get=取得家的位置 -description-return=傳送回上一個位置 +description-home=傳送回家。 +description-set=設定目前位置為家。 +description-get=取得家的位置。 +description-return=傳送回上一個位置。 no-home=你在 __1__ 上還沒設回程傳送點。 no-return=你還沒在 __1__ 上使用過回程。 home-set=你的回程傳送點為 __1__ 上, x: __2__ y: __3__ @@ -101,18 +101,18 @@ lower-role=用戶組比你現有的更高 [exp-commands_lawnmower] selection-name=割草機 -description=清理屍體、裝飾和核洞 +description=清理屍體、裝飾和核彈洞。 arg-range=半徑 complete=__1__ 格已完成。 [exp-commands_locate] -description=在地圖上查找用戶 +description=在地圖上查找用戶的最後位置。 arg-player=用戶 -arg-remote=若在使用地圖查看模式 +arg-remote=若為 true,則開啟在玩家遠端視圖的位置。 response=__1__ 的最後地點是 [gps=__2__,__3__] [exp-commands_me] -description=在聊天中發送操作訊息 +description=在聊天中發送操作訊息。 arg-action=你想做的動作。 response=* __1__ __2__ * @@ -121,8 +121,8 @@ description-entity=啟用建築保護 description-area=啟用區域保護 enter-entity=現在進入建築保護,選擇一個建築來保護,按住 shift 來選擇則可取消。 enter-area=現在進入區域保護,選擇一個區域來保護,按住 shift 來選擇則可取消。 -exit-entity=已進入建築保護 -exit-area=已進入區域選擇 +exit-entity=已進入建築保護。 +exit-area=已進入區域選擇。 protected-entities=__1__ 個建築現已受保護。 unprotected-entities=__1__ 個建築現不受保護。 already-protected=本地區已受保護。 @@ -131,7 +131,7 @@ unprotected-area=本地區現不受保護。 repeat-offence=__1__ 在 [gps=__3__,__4__] 拆除了 __2__ [exp-commands_tag-protection] -description=切換管理員標記模式,新標記只能由管理員編輯 +description=切換管理員標記模式,新標記只能由管理員編輯。 exit=你已離開管理員地圖標記模式,所有新地圖標記將不受保護。 enter=你已進入管理員地圖標記模式,所有新地圖標記將受保護。 create=你放了一個管理員地圖標記。 @@ -172,10 +172,10 @@ arg-player-clear=用戶 arg-reason=原因 arg-from-player=用戶 player-immune=用戶不能被舉報 -self-report=你不能舉報自己 +self-report=你不能舉報自己。 response=__1__ 因 __2__ 被舉報了。 response-admin=__1__ 因 __3__ 被 __2__ 舉報了。 -already-reported=你已經舉報過該用戶了,你可以讓管理員清除報告來重寫 +already-reported=你已經舉報過該用戶了,你可以讓管理員清除報告來重寫。 not-reported=該用戶沒有被舉報的紀錄。 not-reported-by=該用戶沒有被 __1__ 舉報的紀錄。 reports-title==該用戶現在的被舉報紀錄: @@ -185,8 +185,8 @@ removed-all=__1__ 被舉報的所有紀錄已被 __2__ 清除。 removed=__1__ 被舉報的一個紀錄已被 __2__ 清除。 [exp-commands_roles] -description-assign=為用戶指配用戶組 -description-unassign=為用戶取消指配用戶組 +description-assign=為用戶指配用戶組。 +description-unassign=為用戶取消指配用戶組。 description-get=顯示所有用戶組 arg-player-assign=指配 arg-player-unassign=取消指配 @@ -209,16 +209,16 @@ title=有 [item=__1__] 的用戶: result=\n__1__) __2__ 有 __3__ 個。 (__4__) [exp-commands_spectate] -description-spectate=切換旁觀者模式 -description-follow=開始在旁觀者模式中關注玩家 +description-spectate=切換旁觀者模式。 +description-follow=開始在旁觀者模式中關注玩家。 arg-player=用戶 -follow-self=你不能追蹤自己 +follow-self=你不能追蹤自己。 [exp-commands_surface] selection-name=位面 -description-items=清除地面上的物品 -description-blueprints=清除藍圖 -description-blueprints-surface=清除藍圖 +description-items=清除地面上的物品。 +description-blueprints=清除藍圖。 +description-blueprints-surface=清除本位面上的藍圖。 arg-surface=位面 items=__1__ 清除了所有在 __2__ 中掉地上的東西。 blueprints=__1__ 清除了所有在 __2__ 中在地上的藍圖。 @@ -420,7 +420,7 @@ data-caption-fastest-launch=最快發射 data-tooltip-fastest-launch=最快發射時間 data-caption-total-rockets=總發射次數 data-tooltip-total-rockets=我方總發射次數 -value-tooltip-total-rockets=__1__% 的火箭發射都在本地圖上。 +value-tooltip-total-rockets=__1__% 的火箭發射都在這張地圖上 data-caption-avg-launch=平均時間 data-tooltip-avg-launch=火箭發射平均時間 data-caption-avg-launch-n=平均時間 __1__ @@ -463,8 +463,8 @@ tooltip-cursor-empty=游標為空 caption-main=工作流程 [img=info] tooltip-main=工作流程 tooltip-sub=需要完成的工作流程\n- 你可以用富文本來加上圖片 [img=utility/not_enough_repair_packs_icon] 或 [color=blue] 顏色[/color]。 -caption-no-tasks=沒有工作流程 -tooltip-no-tasks=按加號加入工作流程 +caption-no-tasks=沒有工作流程! +tooltip-no-tasks=按加號加入工作流程! tooltip-last-edit=最後由 __1__ 在 __2__ 修改 tooltip-close=關閉工作流程 tooltip-new=加入工作流程 @@ -487,18 +487,18 @@ kick-message=因地圖中沒有活躍玩家,所以所有人都已被請離。 [exp_chat-auto-reply] chat-reply=[Compilatron] __1__ chat-disallowed=你沒有權限使用這個指令。 -reply-online=現在有 __1__ 人上線。 -reply-players=本地圖現在有 __1__ 人曾上線。 +reply-online=現在有 __1__ 人在線 +reply-players=這張地圖上共有__1__名用戶 reply-dev=Cooldude2606 只是本場境的開發者 reply-softmod=這裹用了自設情境。 reply-clusterio=Clusterio is a factorio server hosting platform allowing internet enabled mods. -reply-blame=責怪 __1__ 吧。 -reply-afk=看看 __1__, 他已掛機 __2__ 。 -reply-evolution=現在敵人進化度為 __1__ 。 +reply-blame=責怪 __1__ 吧! +reply-afk=看看 __1__,他的掛機時長為 __2__ +reply-evolution=現在敵人進化度為 __1__ reply-magic=Fear the admin magic (ノ゚∀゚)ノ⌒・*:.。. .。.:*・゜゚・*☆ reply-aids=≖ ‿ ≖ Fear the aids of a public server ≖ ‿ ≖ reply-riot=(admins) ┬┴┬┴┤ᵒ_ᵒ)├┬┴┬┴ ‹ ‹\(´ω` )/››‹‹\ (  ´)/››‹‹\ ( ´ω`)/›› (rest of server) -reply-loops=架設迴旋處最終後果都不好。 +reply-loops=架設迴旋處最終後果都不好! reply-lenny=( ͡° ͜ʖ ͡°) reply-hodor=Hodor reply-popcorn-1=Heating the oil and waiting for the popping sound... diff --git a/exp_scenario/module/locale/zh-TW.cfg b/exp_scenario/module/locale/zh-TW.cfg index a4783e0c..31e68f74 100644 --- a/exp_scenario/module/locale/zh-TW.cfg +++ b/exp_scenario/module/locale/zh-TW.cfg @@ -1,9 +1,9 @@ [exp-commands-authorities_role] -deny=你沒有權限運行該項目 +deny=你沒有權限運行該項目。 [exp-commands-parse_role] -lower-role=用戶組比你現有的更高 -lower-role-player=用戶有更高權限 +lower-role=用戶組比你現有的更高。 +lower-role-player=用戶有更高權限。 [exp-commands_admin-chat] description=一個只有管理員能看到的對話。 @@ -12,31 +12,31 @@ format=[AC] __1__: __2__ [exp-commands_artillery] selection-name=火炮 -description=火炮遙控 -invalid-area=所選地區無效 +description=火炮遙控。 +invalid-area=所選地區無效。 [exp-commands_bot-queue] description=取得或更改建設隊列數量。 -arg-amount=建設隊列數量 +arg-amount=建設隊列數量。 get=建設隊列為 __1__ 個成功嘗試,和 __2__ 個嘗試。 set=__1__ 把建設隊列改成 __2__ 個成功嘗試,和 __3__ 個嘗試。 [exp-commands_cheat] description-cheat-mode=為你或其他玩家切換作弊模式。 -description-always-day=啟用永久白天 -description-friendly-fire=啟用友方傷害 -description-research-all=啟用所有科技 -description-clear-pollution=清除污染 -description-pollution-enabled=啟用污染 -description-game-speed=設定遊戲速度 -arg-state=狀態 -arg-player=用戶 -arg-force-friendly-fire=勢力 -arg-force-research=勢力 -arg-surface=位面 -arg-amount=數量 +description-always-day=啟用永久白天。 +description-friendly-fire=啟用友方傷害。 +description-research-all=啟用所有科技。 +description-clear-pollution=清除污染。 +description-pollution-enabled=啟用污染。 +description-game-speed=設定遊戲速度。 +arg-state=要設定的狀態,預設是切換目前值。 +arg-player=要為其切換作弊模式的玩家。 +arg-force-friendly-fire=為勢力切換友方傷害。 +arg-force-research=為勢力研究所有科技。 +arg-surface=為位面切換永久白晝。 +arg-amount=要設定的數量,如果未指定,則傳回目前值。 cheat-mode=作弊模式現在為 __1__ -always-day=__1__ 把在 __2__ 的位面的永久白天設置為 __3__ 。 +always-day=__1__ 把在 __2__ 的位面的永久白晝設置為 __3__ 。 friendly-fire=__1__ 已為在 __2__ 的勢力設定成 __3__ 。 research-all=__1__ 已為在 __2__ 的勢力啟用所有科技 。 clear-pollution=__1__ 已為在 __2__ 的位面清除所有污染 。 @@ -46,15 +46,15 @@ game-speed-get=現在遊戲速度為 __1__ 。 [exp-commands_clear-inventory] description=清除用戶物品。 -arg-player=用戶 +arg-player=要清除其物品欄的用戶。 [exp-commands_connect] -description=連接到另一服務器 -description-player=讓其他用戶連接到另一服務器 -description-all=將所有玩家連接到另一台服務器 -arg-player=連接到別的服的用戶 -arg-server=輸入為地址 / 名稱 -arg-is-address=輸入為地址 +description=連接到另一服務器。 +description-player=讓其他用戶連接到另一服務器。 +description-all=將所有玩家連接到另一台服務器。 +arg-player=連接到別的服務器的用戶。 +arg-server=輸入為地址或名稱。 +arg-is-address=輸入應該為地址。 too-many-matching=該服務器名稱有多過一個結果: __1__ wrong-version=該服務器版本不同: __1__ same-server=你已連接該服務器: __1__ @@ -65,15 +65,15 @@ none-matching=沒找到服務器。 description=打開偵錯介面。 [exp-commands_enemy] -description-kill=僅移除蟲子 -description-remove=移除蟲子及停止產生 +description-kill=僅移除蟲單位。 +description-remove=移除蟲單位及停止生產器。 arg-surface=位面 [exp-commands_home] -description-home=傳送回家 -description-set=設定目前位置為家 -description-get=取得家的位置 -description-return=傳送回上一個位置 +description-home=傳送回家。 +description-set=設定目前位置為家。 +description-get=取得家的位置。 +description-return=傳送回上一個位置。 no-home=你在 __1__ 上還沒設回程傳送點。 no-return=你還沒在 __1__ 上使用過回程。 home-set=你的回程傳送點為 __1__ 上, x: __2__ y: __3__ @@ -101,18 +101,18 @@ lower-role=用戶組比你現有的更高 [exp-commands_lawnmower] selection-name=割草機 -description=清理屍體、裝飾和核洞 +description=清理屍體、裝飾和核彈洞。 arg-range=半徑 complete=__1__ 格已完成。 [exp-commands_locate] -description=在地圖上查找用戶 +description=在地圖上查找用戶的最後位置。 arg-player=用戶 -arg-remote=若在使用地圖查看模式 +arg-remote=若為 true,則開啟在玩家遠端視圖的位置。 response=__1__ 的最後地點是 [gps=__2__,__3__] [exp-commands_me] -description=在聊天中發送操作訊息 +description=在聊天中發送操作訊息。 arg-action=你想做的動作。 response=* __1__ __2__ * @@ -121,8 +121,8 @@ description-entity=啟用建築保護 description-area=啟用區域保護 enter-entity=現在進入建築保護,選擇一個建築來保護,按住 shift 來選擇則可取消。 enter-area=現在進入區域保護,選擇一個區域來保護,按住 shift 來選擇則可取消。 -exit-entity=已進入建築保護 -exit-area=已進入區域選擇 +exit-entity=已進入建築保護。 +exit-area=已進入區域選擇。 protected-entities=__1__ 個建築現已受保護。 unprotected-entities=__1__ 個建築現不受保護。 already-protected=本地區已受保護。 @@ -131,7 +131,7 @@ unprotected-area=本地區現不受保護。 repeat-offence=__1__ 在 [gps=__3__,__4__] 拆除了 __2__ [exp-commands_tag-protection] -description=切換管理員標記模式,新標記只能由管理員編輯 +description=切換管理員標記模式,新標記只能由管理員編輯。 exit=你已離開管理員地圖標記模式,所有新地圖標記將不受保護。 enter=你已進入管理員地圖標記模式,所有新地圖標記將受保護。 create=你放了一個管理員地圖標記。 @@ -172,10 +172,10 @@ arg-player-clear=用戶 arg-reason=原因 arg-from-player=用戶 player-immune=用戶不能被舉報 -self-report=你不能舉報自己 +self-report=你不能舉報自己。 response=__1__ 因 __2__ 被舉報了。 response-admin=__1__ 因 __3__ 被 __2__ 舉報了。 -already-reported=你已經舉報過該用戶了,你可以讓管理員清除報告來重寫 +already-reported=你已經舉報過該用戶了,你可以讓管理員清除報告來重寫。 not-reported=該用戶沒有被舉報的紀錄。 not-reported-by=該用戶沒有被 __1__ 舉報的紀錄。 reports-title==該用戶現在的被舉報紀錄: @@ -185,8 +185,8 @@ removed-all=__1__ 被舉報的所有紀錄已被 __2__ 清除。 removed=__1__ 被舉報的一個紀錄已被 __2__ 清除。 [exp-commands_roles] -description-assign=為用戶指配用戶組 -description-unassign=為用戶取消指配用戶組 +description-assign=為用戶指配用戶組。 +description-unassign=為用戶取消指配用戶組。 description-get=顯示所有用戶組 arg-player-assign=指配 arg-player-unassign=取消指配 @@ -209,16 +209,16 @@ title=有 [item=__1__] 的用戶: result=\n__1__) __2__ 有 __3__ 個。 (__4__) [exp-commands_spectate] -description-spectate=切換旁觀者模式 -description-follow=開始在旁觀者模式中關注玩家 +description-spectate=切換旁觀者模式。 +description-follow=開始在旁觀者模式中關注玩家。 arg-player=用戶 -follow-self=你不能追蹤自己 +follow-self=你不能追蹤自己。 [exp-commands_surface] selection-name=位面 -description-items=清除地面上的物品 -description-blueprints=清除藍圖 -description-blueprints-surface=清除藍圖 +description-items=清除地面上的物品。 +description-blueprints=清除藍圖。 +description-blueprints-surface=清除本位面上的藍圖。 arg-surface=位面 items=__1__ 清除了所有在 __2__ 中掉地上的東西。 blueprints=__1__ 清除了所有在 __2__ 中在地上的藍圖。 @@ -420,7 +420,7 @@ data-caption-fastest-launch=最快發射 data-tooltip-fastest-launch=最快發射時間 data-caption-total-rockets=總發射次數 data-tooltip-total-rockets=我方總發射次數 -value-tooltip-total-rockets=__1__% 的火箭發射都在本地圖上。 +value-tooltip-total-rockets=__1__% 的火箭發射都在這張地圖上 data-caption-avg-launch=平均時間 data-tooltip-avg-launch=火箭發射平均時間 data-caption-avg-launch-n=平均時間 __1__ @@ -463,8 +463,8 @@ tooltip-cursor-empty=游標為空 caption-main=工作流程 [img=info] tooltip-main=工作流程 tooltip-sub=需要完成的工作流程\n- 你可以用富文本來加上圖片 [img=utility/not_enough_repair_packs_icon] 或 [color=blue] 顏色[/color]。 -caption-no-tasks=沒有工作流程 -tooltip-no-tasks=按加號加入工作流程 +caption-no-tasks=沒有工作流程! +tooltip-no-tasks=按加號加入工作流程! tooltip-last-edit=最後由 __1__ 在 __2__ 修改 tooltip-close=關閉工作流程 tooltip-new=加入工作流程 @@ -487,18 +487,18 @@ kick-message=因地圖中沒有活躍玩家,所以所有人都已被請離。 [exp_chat-auto-reply] chat-reply=[Compilatron] __1__ chat-disallowed=你沒有權限使用這個指令。 -reply-online=現在有 __1__ 人上線。 -reply-players=本地圖現在有 __1__ 人曾上線。 +reply-online=現在有 __1__ 人在線 +reply-players=這張地圖上共有__1__名用戶 reply-dev=Cooldude2606 只是本場境的開發者 reply-softmod=這裹用了自設情境。 reply-clusterio=Clusterio is a factorio server hosting platform allowing internet enabled mods. -reply-blame=責怪 __1__ 吧。 -reply-afk=看看 __1__, 他已掛機 __2__ 。 -reply-evolution=現在敵人進化度為 __1__ 。 +reply-blame=責怪 __1__ 吧! +reply-afk=看看 __1__,他的掛機時長為 __2__ +reply-evolution=現在敵人進化度為 __1__ reply-magic=Fear the admin magic (ノ゚∀゚)ノ⌒・*:.。. .。.:*・゜゚・*☆ reply-aids=≖ ‿ ≖ Fear the aids of a public server ≖ ‿ ≖ reply-riot=(admins) ┬┴┬┴┤ᵒ_ᵒ)├┬┴┬┴ ‹ ‹\(´ω` )/››‹‹\ (  ´)/››‹‹\ ( ´ω`)/›› (rest of server) -reply-loops=架設迴旋處最終後果都不好。 +reply-loops=架設迴旋處最終後果都不好! reply-lenny=( ͡° ͜ʖ ͡°) reply-hodor=Hodor reply-popcorn-1=Heating the oil and waiting for the popping sound... diff --git a/exp_server_ups/module/locale/zh-CN.cfg b/exp_server_ups/module/locale/zh-CN.cfg index a92404d3..4a9758a1 100644 --- a/exp_server_ups/module/locale/zh-CN.cfg +++ b/exp_server_ups/module/locale/zh-CN.cfg @@ -1,2 +1,2 @@ [exp_server-ups] -description=啟動 UPS 顯示 \ No newline at end of file +description=啟動 UPS 顯示。 \ No newline at end of file diff --git a/exp_server_ups/module/locale/zh-TW.cfg b/exp_server_ups/module/locale/zh-TW.cfg index a92404d3..4a9758a1 100644 --- a/exp_server_ups/module/locale/zh-TW.cfg +++ b/exp_server_ups/module/locale/zh-TW.cfg @@ -1,2 +1,2 @@ [exp_server-ups] -description=啟動 UPS 顯示 \ No newline at end of file +description=啟動 UPS 顯示。 \ No newline at end of file diff --git a/exp_util/module/locale/zh-CN.cfg b/exp_util/module/locale/zh-CN.cfg index fc488718..6d7d17d0 100644 --- a/exp_util/module/locale/zh-CN.cfg +++ b/exp_util/module/locale/zh-CN.cfg @@ -1,5 +1,5 @@ [exp_util] time-symbol-days-short=__1__日 rich-text-color-tag=[color=__1__,__2__,__3__]__4__[/color] -selection_enter=現在進入 __1__ 區域選擇 -selection_exit=已進入 __1__ 區域選擇 +selection_enter=現在進入 __1__ 區域選擇。 +selection_exit=已進入 __1__ 區域選擇。 diff --git a/exp_util/module/locale/zh-TW.cfg b/exp_util/module/locale/zh-TW.cfg index fc488718..6d7d17d0 100644 --- a/exp_util/module/locale/zh-TW.cfg +++ b/exp_util/module/locale/zh-TW.cfg @@ -1,5 +1,5 @@ [exp_util] time-symbol-days-short=__1__日 rich-text-color-tag=[color=__1__,__2__,__3__]__4__[/color] -selection_enter=現在進入 __1__ 區域選擇 -selection_exit=已進入 __1__ 區域選擇 +selection_enter=現在進入 __1__ 區域選擇。 +selection_exit=已進入 __1__ 區域選擇。 From 5487976a213fc3032ae7cc9e1fc7981a7235e9e4 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:56:01 +0000 Subject: [PATCH 07/18] Add a Node.js CI workflow that builds and tests inside clusterio The packages use workspace:^ and catalog: versions, so they only resolve inside a clusterio checkout with this repository under external_plugins. The workflow clones clusterio master, checks this repository out into external_plugins/ExpCluster, runs pnpm install (which runs every prepare script), then runs the tap suites. A last step wipes dist and runs tsc --build in each package on its own. pnpm install builds packages in dependency order, which is why the missing project references fixed in #473 went unnoticed. On the tree before #473 this step fails with the same five TS2307 errors. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/node.yml | 49 ++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/node.yml diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml new file mode 100644 index 00000000..24e1fd98 --- /dev/null +++ b/.github/workflows/node.yml @@ -0,0 +1,49 @@ +name: Node.js CI + +on: + push: + pull_request: + +env: + # The packages use workspace:^ and catalog: versions, so they only resolve + # inside a clusterio checkout with this repository under external_plugins + CLUSTERIO_REPOSITORY: clusterio/clusterio + CLUSTERIO_REF: master + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: external_plugins/ExpCluster + steps: + - name: Checkout clusterio + uses: actions/checkout@v4 + with: + repository: ${{ env.CLUSTERIO_REPOSITORY }} + ref: ${{ env.CLUSTERIO_REF }} + - name: Checkout ExpCluster into the workspace + uses: actions/checkout@v4 + with: + path: external_plugins/ExpCluster + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install and build the workspace + working-directory: . + run: pnpm install --no-frozen-lockfile + - name: Run tests + run: pnpm --filter "@expcluster/*" run test + - name: Build each package on its own + run: | + # pnpm install runs prepare in dependency order, which hides a missing + # project reference (#473). Building from an empty dist one package at + # a time fails unless every cross package import is referenced. + for pkg in exp_*/; do + rm -rf exp_*/dist + echo "::group::${pkg%/}" + (cd "$pkg" && pnpm exec tsc --build) + echo "::endgroup::" + done diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9fa851c4..216a5196 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ For developers wanting to add features please follow these guidelines: - Lua is checked with [emmylua](https://github.com/EmmyLuaLs/emmylua-analyzer-rust), configured by `.emmyrc.json`. Install the EmmyLua extension rather than sumnekolua / luals, and use factoriomod-debug to generate the factorio api types. Note that an inline cast must be written `--[[@as T]]`, the spaced form is not parsed. - Changes should be made on your own fork and merged into `main` through a pull request. - Each pull request should be limited to one feature or a few bug fixes and link to the related issue page. -- Pull requests are automatically linted and documentation checked. +- Pull requests are automatically linted, then built and tested against the latest clusterio `master`. - Pull requests are manually reviewed to maintain code and language quality. - New features should have the branch names: `feature/feature-name` - Bug fixes should have the branch names: `fix/bug-name` From 2048de6244c80bd3494e6913e3f28724d55ddcc5 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:41:04 +0100 Subject: [PATCH 08/18] Small changes to node ci --- .github/workflows/node.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 24e1fd98..80d77eff 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -9,38 +9,40 @@ env: # inside a clusterio checkout with this repository under external_plugins CLUSTERIO_REPOSITORY: clusterio/clusterio CLUSTERIO_REF: master + # Node version to use for the tests. This is used by the setup-node action + NODE_VERSION: lts/* jobs: test: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 5 defaults: run: working-directory: external_plugins/ExpCluster steps: - - name: Checkout clusterio + - name: Checkout Clusterio uses: actions/checkout@v4 with: repository: ${{ env.CLUSTERIO_REPOSITORY }} ref: ${{ env.CLUSTERIO_REF }} - - name: Checkout ExpCluster into the workspace + - name: Checkout ExpCluster uses: actions/checkout@v4 with: path: external_plugins/ExpCluster - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: lts/* - - name: Install and build the workspace + node-version: ${{ env.NODE_VERSION }} + - name: Install and build working-directory: . run: pnpm install --no-frozen-lockfile - name: Run tests run: pnpm --filter "@expcluster/*" run test - - name: Build each package on its own + - name: Run reference checks run: | - # pnpm install runs prepare in dependency order, which hides a missing - # project reference (#473). Building from an empty dist one package at - # a time fails unless every cross package import is referenced. + # pnpm install runs prepare in dependency order, which hides missing + # project references. Building from an empty dist one package at a time + # will fail unless every cross package import is referenced correctly. for pkg in exp_*/; do rm -rf exp_*/dist echo "::group::${pkg%/}" From 30f1b5ea753bab5cbecb57a5ad8bf2dc608d7db1 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:20:29 +0000 Subject: [PATCH 09/18] Declare permissions on the plugin declaration Clusterio #988 added a permissions array to PluginDeclaration and a type level Permissions registry, and made checkPermission and the web UI hasPermission helpers take PermissionName. Move the definePermission calls of exp_groups and exp_scenario into the declaration and add the names to the registry so the web pages type check. The browser tsconfigs include index.ts so the augmentation is visible to the web bundle, matching the in-repo plugins. The exp_scenario table keeps its tuple form and derives the name union from it, so a new row is still one line. Seed role permissions are typed as PermissionName, so a typo in seed.ts is now a compile error rather than a warning at seed time. The tests register the declared permissions through registerPluginPermissions instead of importing permissions.ts for its side effect. Co-Authored-By: Claude Fable 5.1 --- exp_groups/index.ts | 236 ++++++++++++++------------- exp_groups/messages.ts | 24 +++ exp_scenario/index.ts | 48 +++--- exp_scenario/permissions.ts | 18 +- exp_scenario/seed.ts | 5 +- exp_scenario/test/controller.test.js | 6 +- exp_scenario/test/seed.test.js | 6 +- exp_scenario/tsconfig.browser.json | 2 +- 8 files changed, 192 insertions(+), 153 deletions(-) diff --git a/exp_groups/index.ts b/exp_groups/index.ts index 743f33b9..f0f8e5ce 100644 --- a/exp_groups/index.ts +++ b/exp_groups/index.ts @@ -12,123 +12,6 @@ declare module "@clusterio/lib" { } } -// Group permissions - -lib.definePermission({ - name: "exp_groups.group.get", - title: "Get Groups", - description: "Retrieve a specific Factorio permission group by ID.", - grantByDefault: true, -}); -lib.definePermission({ - name: "exp_groups.group.list", - title: "List Groups", - description: "List all Factorio permission groups.", - grantByDefault: true, -}); -lib.definePermission({ - name: "exp_groups.group.subscribe", - title: "Subscribe to Group Updates", - description: "Receive updates when Factorio permission groups change.", - grantByDefault: true, -}); -lib.definePermission({ - name: "exp_groups.group.create", - title: "Create Groups", - description: "Create new Factorio permission groups.", - grantByDefault: false, -}); -lib.definePermission({ - name: "exp_groups.group.update", - title: "Update Groups", - description: "Modify existing Factorio permission groups.", - grantByDefault: false, -}); -lib.definePermission({ - name: "exp_groups.group.delete", - title: "Delete Groups", - description: "Delete Factorio permission groups.", - grantByDefault: false, -}); - -// Assignment permissions - -lib.definePermission({ - name: "exp_groups.assignment.get", - title: "Get Assignments", - description: "Retrieve a specific manual group assignment for a player.", - grantByDefault: true, -}); -lib.definePermission({ - name: "exp_groups.assignment.list", - title: "List Assignments", - description: "List all manual group assignments.", - grantByDefault: true, -}); -lib.definePermission({ - name: "exp_groups.assignment.subscribe", - title: "Subscribe to Assignment Updates", - description: "Receive updates when manual group assignments change.", - grantByDefault: true, -}); -lib.definePermission({ - name: "exp_groups.assignment.create", - title: "Create Assignments", - description: "Manually assign players to groups, overriding role mappings.", - grantByDefault: false, -}); -lib.definePermission({ - name: "exp_groups.assignment.update", - title: "Update Assignments", - description: "Modify existing manual group assignments.", - grantByDefault: false, -}); -lib.definePermission({ - name: "exp_groups.assignment.delete", - title: "Delete Assignments", - description: "Remove manual group assignments.", - grantByDefault: false, -}); - -// Role mapping permissions - -lib.definePermission({ - name: "exp_groups.role_mapping.get", - title: "Get Role Mappings", - description: "Retrieve a specific role mapping rule.", - grantByDefault: true, -}); -lib.definePermission({ - name: "exp_groups.role_mapping.list", - title: "List Role Mappings", - description: "List all role mapping rules.", - grantByDefault: true, -}); -lib.definePermission({ - name: "exp_groups.role_mapping.subscribe", - title: "Subscribe to Role Mapping Updates", - description: "Receive updates when role mapping rules change.", - grantByDefault: true, -}); -lib.definePermission({ - name: "exp_groups.role_mapping.create", - title: "Create Role Mappings", - description: "Create rules that map user roles to Factorio permission groups.", - grantByDefault: false, -}); -lib.definePermission({ - name: "exp_groups.role_mapping.update", - title: "Update Role Mappings", - description: "Modify existing role mapping rules.", - grantByDefault: false, -}); -lib.definePermission({ - name: "exp_groups.role_mapping.delete", - title: "Delete Role Mappings", - description: "Delete role mapping rules.", - grantByDefault: false, -}); - export const plugin: lib.PluginDeclaration = { name: "exp_groups", title: "ExpGaming - Permission Groups", @@ -164,6 +47,125 @@ export const plugin: lib.PluginDeclaration = { messages.RoleMappingListRequest, ], + permissions: [ + // Group permissions + + { + name: "exp_groups.group.get", + title: "Get Groups", + description: "Retrieve a specific Factorio permission group by ID.", + grantByDefault: true, + }, + { + name: "exp_groups.group.list", + title: "List Groups", + description: "List all Factorio permission groups.", + grantByDefault: true, + }, + { + name: "exp_groups.group.subscribe", + title: "Subscribe to Group Updates", + description: "Receive updates when Factorio permission groups change.", + grantByDefault: true, + }, + { + name: "exp_groups.group.create", + title: "Create Groups", + description: "Create new Factorio permission groups.", + grantByDefault: false, + }, + { + name: "exp_groups.group.update", + title: "Update Groups", + description: "Modify existing Factorio permission groups.", + grantByDefault: false, + }, + { + name: "exp_groups.group.delete", + title: "Delete Groups", + description: "Delete Factorio permission groups.", + grantByDefault: false, + }, + + // Assignment permissions + + { + name: "exp_groups.assignment.get", + title: "Get Assignments", + description: "Retrieve a specific manual group assignment for a player.", + grantByDefault: true, + }, + { + name: "exp_groups.assignment.list", + title: "List Assignments", + description: "List all manual group assignments.", + grantByDefault: true, + }, + { + name: "exp_groups.assignment.subscribe", + title: "Subscribe to Assignment Updates", + description: "Receive updates when manual group assignments change.", + grantByDefault: true, + }, + { + name: "exp_groups.assignment.create", + title: "Create Assignments", + description: "Manually assign players to groups, overriding role mappings.", + grantByDefault: false, + }, + { + name: "exp_groups.assignment.update", + title: "Update Assignments", + description: "Modify existing manual group assignments.", + grantByDefault: false, + }, + { + name: "exp_groups.assignment.delete", + title: "Delete Assignments", + description: "Remove manual group assignments.", + grantByDefault: false, + }, + + // Role mapping permissions + + { + name: "exp_groups.role_mapping.get", + title: "Get Role Mappings", + description: "Retrieve a specific role mapping rule.", + grantByDefault: true, + }, + { + name: "exp_groups.role_mapping.list", + title: "List Role Mappings", + description: "List all role mapping rules.", + grantByDefault: true, + }, + { + name: "exp_groups.role_mapping.subscribe", + title: "Subscribe to Role Mapping Updates", + description: "Receive updates when role mapping rules change.", + grantByDefault: true, + }, + { + name: "exp_groups.role_mapping.create", + title: "Create Role Mappings", + description: "Create rules that map user roles to Factorio permission groups.", + grantByDefault: false, + }, + { + name: "exp_groups.role_mapping.update", + title: "Update Role Mappings", + description: "Modify existing role mapping rules.", + grantByDefault: false, + }, + { + name: "exp_groups.role_mapping.delete", + title: "Delete Role Mappings", + description: "Delete role mapping rules.", + grantByDefault: false, + }, + ], + instanceEntrypoint: "./dist/node/instance", instanceConfigFields: { "exp_groups.sync_mode": { diff --git a/exp_groups/messages.ts b/exp_groups/messages.ts index b0e5e986..d5191ca9 100644 --- a/exp_groups/messages.ts +++ b/exp_groups/messages.ts @@ -1,6 +1,30 @@ import * as lib from "@clusterio/lib"; import { Type, Static } from "@sinclair/typebox"; +// Declared here rather than in index.ts because the browser project cannot include index.ts, which re-exports controller.ts +declare module "@clusterio/lib" { + export interface Permissions { + "exp_groups.group.get": never; + "exp_groups.group.list": never; + "exp_groups.group.subscribe": never; + "exp_groups.group.create": never; + "exp_groups.group.update": never; + "exp_groups.group.delete": never; + "exp_groups.assignment.get": never; + "exp_groups.assignment.list": never; + "exp_groups.assignment.subscribe": never; + "exp_groups.assignment.create": never; + "exp_groups.assignment.update": never; + "exp_groups.assignment.delete": never; + "exp_groups.role_mapping.get": never; + "exp_groups.role_mapping.list": never; + "exp_groups.role_mapping.subscribe": never; + "exp_groups.role_mapping.create": never; + "exp_groups.role_mapping.update": never; + "exp_groups.role_mapping.delete": never; + } +} + /* Data records */ diff --git a/exp_scenario/index.ts b/exp_scenario/index.ts index f09c3c6f..5d04b902 100644 --- a/exp_scenario/index.ts +++ b/exp_scenario/index.ts @@ -1,29 +1,14 @@ import * as lib from "@clusterio/lib"; import * as messages from "./messages"; - -// Defines a permission for every in game action and role flag used by the scenario -import "./permissions"; - -lib.definePermission({ - name: "exp_scenario.config.view", - title: "View ExpScenario Config", - description: "View the config for all submodules of ExpScenario", -}); - -lib.definePermission({ - name: "exp_scenario.config.edit", - title: "Edit ExpScenario Config", - description: "Edit the config for all submodules of ExpScenario", -}); - -lib.definePermission({ - name: "exp_scenario.seed", - title: "Seed ExpScenario roles and groups", - description: "Create the roles and permission groups the scenario shipped with", -}); +import { permissions as scenarioPermissions, type ScenarioPermissionName } from "./permissions"; declare module "@clusterio/lib" { - + // Everything checked in game through exp_roles, plus the permissions checked on the controller + export interface Permissions extends Record { + "exp_scenario.config.view": never; + "exp_scenario.config.edit": never; + "exp_scenario.seed": never; + } } export const plugin: lib.PluginDeclaration = { @@ -37,5 +22,24 @@ export const plugin: lib.PluginDeclaration = { messages.SeedRequest, ], + permissions: [ + ...scenarioPermissions, + { + name: "exp_scenario.config.view", + title: "View ExpScenario Config", + description: "View the config for all submodules of ExpScenario", + }, + { + name: "exp_scenario.config.edit", + title: "Edit ExpScenario Config", + description: "Edit the config for all submodules of ExpScenario", + }, + { + name: "exp_scenario.seed", + title: "Seed ExpScenario roles and groups", + description: "Create the roles and permission groups the scenario shipped with", + }, + ], + webEntrypoint: "./web", }; diff --git a/exp_scenario/permissions.ts b/exp_scenario/permissions.ts index bbaafacd..5fc7aa52 100644 --- a/exp_scenario/permissions.ts +++ b/exp_scenario/permissions.ts @@ -1,4 +1,4 @@ -import * as lib from "@clusterio/lib"; +import type * as lib from "@clusterio/lib"; /** * Permissions checked by the scenario in game through exp_roles. @@ -7,9 +7,9 @@ import * as lib from "@clusterio/lib"; * underscores, see module/commands/_authorities.lua. Everything else is checked * by name at its call site. */ -type Definition = [name: string, title: string, description: string, grantByDefault?: boolean]; +type Definition = readonly [name: Name, title: string, description: string, grantByDefault?: boolean]; -const definitions: Definition[] = [ +const definitions = [ ["exp_scenario.bypass.deconstruction_log", "Deconstruction log bypass", "Be excluded from the deconstruction log."], ["exp_scenario.bypass.entity_protection", "Bypass entity protection", "Remove entities that the protection filter would block."], ["exp_scenario.bypass.nuke_protection", "Bypass nuke protection", "Use nukes without the nuke protection restrictions."], @@ -118,8 +118,12 @@ const definitions: Definition[] = [ ["exp_scenario.player.admin", "Factorio admin", "Be promoted to Factorio admin while holding a role with this permission."], ["exp_scenario.player.instant_respawn", "Instant respawn", "Respawn after two seconds instead of the default delay."], ["exp_scenario.player.spectator", "Spectator", "Remove the zoom to world noise effect, as Factorio does for spectators."], -]; +] as const satisfies readonly Definition[]; -for (const [name, title, description, grantByDefault] of definitions) { - lib.definePermission({ name, title, description, grantByDefault }); -} +/** Name of a permission defined by the scenario, added to lib.Permissions in index.ts. */ +export type ScenarioPermissionName = (typeof definitions)[number][0]; + +export const permissions = definitions.map((definition: Definition): lib.PermissionDefinition => { + const [name, title, description, grantByDefault] = definition; + return { name, title, description, grantByDefault }; +}); diff --git a/exp_scenario/seed.ts b/exp_scenario/seed.ts index 37edbd63..64b34550 100644 --- a/exp_scenario/seed.ts +++ b/exp_scenario/seed.ts @@ -1,3 +1,4 @@ +import type * as lib from "@clusterio/lib"; import { RoleColor } from "@expcluster/roles"; /** @@ -19,7 +20,7 @@ export interface SeedRole { isAdmin?: boolean; /** Name of the role whose permissions are also granted, applied recursively. */ parent?: string; - permissions: string[]; + permissions: lib.PermissionName[]; /** * Name of the seed group holders are placed in by their highest role. * Without one the holders stay in Factorio's Default group. @@ -329,7 +330,7 @@ export const seedGroups: SeedGroup[] = [ /** The permissions a seed role grants, including those of its parents. */ export function flattenSeedPermissions(role: SeedRole, roles = seedRoles) { - const permissions = new Set(); + const permissions = new Set(); const seen = new Set(); let current: SeedRole | undefined = role; while (current && !seen.has(current.name)) { diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index 18ffedc4..16206b95 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -11,8 +11,10 @@ const { ControllerPlugin: RolesPlugin } = require("@expcluster/roles/dist/node/c const { ControllerPlugin: GroupsPlugin } = require("@expcluster/permission-groups/dist/node/controller"); const { GroupRecord, GroupPermissions, RoleMappingRecord } = require("@expcluster/permission-groups"); -// Importing this defines the permissions the seed grants -require("../dist/node/permissions"); +const { plugin } = require("../dist/node/index"); + +// Registering the plugin defines the permissions the seed grants +lib.registerPluginPermissions([plugin]); // The controller validates message classes against the link registry for (const Message of [messages.SeedRequest, ...roles.plugin.messages, ...groups.plugin.messages]) { diff --git a/exp_scenario/test/seed.test.js b/exp_scenario/test/seed.test.js index 87e198a6..f1c89203 100644 --- a/exp_scenario/test/seed.test.js +++ b/exp_scenario/test/seed.test.js @@ -3,8 +3,10 @@ const t = require("tap"); const lib = require("@clusterio/lib"); const { seedRoles, seedGroups, flattenSeedPermissions } = require("../dist/node/seed"); -// Importing this defines the permissions the seed grants -require("../dist/node/permissions"); +const { plugin } = require("../dist/node/index"); + +// Registering the plugin defines the permissions the seed grants +lib.registerPluginPermissions([plugin]); t.test("seedRoles[] grant only defined permissions", t2 => { for (const role of seedRoles) { diff --git a/exp_scenario/tsconfig.browser.json b/exp_scenario/tsconfig.browser.json index 1e3889e7..b67577c5 100644 --- a/exp_scenario/tsconfig.browser.json +++ b/exp_scenario/tsconfig.browser.json @@ -1,4 +1,4 @@ { "extends": "../tsconfig.browser.json", - "include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ], + "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "permissions.ts", "package.json" ], } From 7fd66c6ac75ee37ebc9441fe68451703e44d9af6 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:21:41 +0000 Subject: [PATCH 10/18] Migrate to ES modules Clusterio #984 converted its packages to ESM, so a CommonJS plugin can no longer import @clusterio/lib and plugin entrypoints must name the full file. Every package gets "type": "module", entrypoints and relative imports carry the .js extension, and the webpack configs become .cjs as webpack-cli loads them with require. tsconfig.node.json moves from node16 to nodenext to match clusterio, which needs it for the JSON import attributes in its sources. The tap tests and the shared test helpers use import and import.meta.dirname. The test scripts pass --type-strip-only so tap leaves lib's .ts subpath imports to Node's own type stripping, as its TypeScript loader compiled them without a default export. The lua runner resolves fengari from the test file it is given, since a bare import from the shared test directory does not reach the plugin's devDependencies. Co-Authored-By: Claude Fable 5.1 --- exp_commands/index.ts | 2 +- exp_commands/package.json | 1 + .../{webpack.config.js => webpack.config.cjs} | 0 exp_groups/controller.ts | 2 +- exp_groups/index.ts | 10 ++++---- exp_groups/instance.ts | 2 +- exp_groups/package.json | 1 + exp_groups/web/components/AssignmentForm.tsx | 2 +- .../web/components/AssignmentsTable.tsx | 2 +- exp_groups/web/components/GroupForm.tsx | 2 +- exp_groups/web/components/GroupViewPage.tsx | 2 +- exp_groups/web/components/GroupsTable.tsx | 2 +- exp_groups/web/components/RoleMappingForm.tsx | 2 +- .../web/components/RoleMappingsTable.tsx | 2 +- exp_groups/web/index.tsx | 2 +- .../{webpack.config.js => webpack.config.cjs} | 0 exp_gui/index.ts | 2 +- exp_gui/package.json | 1 + .../{webpack.config.js => webpack.config.cjs} | 0 exp_legacy/index.ts | 2 +- exp_legacy/package.json | 1 + .../{webpack.config.js => webpack.config.cjs} | 0 exp_roles/controller.ts | 2 +- exp_roles/index.ts | 10 ++++---- exp_roles/instance.ts | 2 +- exp_roles/package.json | 3 ++- exp_roles/test/controller.test.js | 11 ++++---- exp_roles/test/instance.test.js | 13 +++++----- exp_roles/test/messages.test.js | 7 +++--- exp_roles/test/module.test.js | 11 ++++---- exp_roles/web/components/RoleProperties.tsx | 2 +- exp_roles/web/index.tsx | 2 +- .../{webpack.config.js => webpack.config.cjs} | 0 exp_scenario/controller.ts | 4 +-- exp_scenario/index.ts | 8 +++--- exp_scenario/package.json | 3 ++- exp_scenario/test/controller.test.js | 25 +++++++++---------- exp_scenario/test/seed.test.js | 9 +++---- exp_scenario/web/components/Seed.tsx | 2 +- .../{webpack.config.js => webpack.config.cjs} | 0 exp_server_ups/index.ts | 2 +- exp_server_ups/package.json | 1 + .../{webpack.config.js => webpack.config.cjs} | 0 exp_util/index.ts | 2 +- exp_util/package.json | 1 + .../{webpack.config.js => webpack.config.cjs} | 0 test/common.js | 5 ++-- test/lua/runner.js | 13 +++++----- tsconfig.node.json | 2 +- 49 files changed, 91 insertions(+), 89 deletions(-) rename exp_commands/{webpack.config.js => webpack.config.cjs} (100%) rename exp_groups/{webpack.config.js => webpack.config.cjs} (100%) rename exp_gui/{webpack.config.js => webpack.config.cjs} (100%) rename exp_legacy/{webpack.config.js => webpack.config.cjs} (100%) rename exp_roles/{webpack.config.js => webpack.config.cjs} (100%) rename exp_scenario/{webpack.config.js => webpack.config.cjs} (100%) rename exp_server_ups/{webpack.config.js => webpack.config.cjs} (100%) rename exp_util/{webpack.config.js => webpack.config.cjs} (100%) diff --git a/exp_commands/index.ts b/exp_commands/index.ts index 3e788480..58d11b1f 100644 --- a/exp_commands/index.ts +++ b/exp_commands/index.ts @@ -4,5 +4,5 @@ export const plugin: lib.PluginDeclaration = { name: "exp_commands", title: "exp_commands", description: "Example Description. Plugin. Change me in index.ts", - instanceEntrypoint: "./dist/node/instance", + instanceEntrypoint: "./dist/node/instance.js", }; diff --git a/exp_commands/package.json b/exp_commands/package.json index 7628cfeb..04b5f922 100644 --- a/exp_commands/package.json +++ b/exp_commands/package.json @@ -5,6 +5,7 @@ "author": "Cooldude2606 ", "license": "MIT", "repository": "explosivegaming/ExpCluster", + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --env production" diff --git a/exp_commands/webpack.config.js b/exp_commands/webpack.config.cjs similarity index 100% rename from exp_commands/webpack.config.js rename to exp_commands/webpack.config.cjs diff --git a/exp_groups/controller.ts b/exp_groups/controller.ts index 14d13ef2..a6034433 100644 --- a/exp_groups/controller.ts +++ b/exp_groups/controller.ts @@ -1,6 +1,6 @@ import { BaseControllerPlugin } from "@clusterio/controller"; import * as lib from "@clusterio/lib"; -import * as messages from "./messages"; +import * as messages from "./messages.js"; import * as path from "node:path"; export class ControllerPlugin extends BaseControllerPlugin { diff --git a/exp_groups/index.ts b/exp_groups/index.ts index f0f8e5ce..c92c3e6b 100644 --- a/exp_groups/index.ts +++ b/exp_groups/index.ts @@ -1,8 +1,8 @@ import * as lib from "@clusterio/lib"; -import * as messages from "./messages"; +import * as messages from "./messages.js"; -export * from "./messages"; -export type { ControllerPlugin } from "./controller"; +export * from "./messages.js"; +export type { ControllerPlugin } from "./controller.js"; declare module "@clusterio/lib" { export interface InstanceConfigFields { @@ -166,7 +166,7 @@ export const plugin: lib.PluginDeclaration = { }, ], - instanceEntrypoint: "./dist/node/instance", + instanceEntrypoint: "./dist/node/instance.js", instanceConfigFields: { "exp_groups.sync_mode": { description: "Synchronize permission groups with the controller", @@ -176,7 +176,7 @@ export const plugin: lib.PluginDeclaration = { }, }, - controllerEntrypoint: "./dist/node/controller", + controllerEntrypoint: "./dist/node/controller.js", controllerConfigFields: { }, diff --git a/exp_groups/instance.ts b/exp_groups/instance.ts index d3ed3cc4..207b969c 100644 --- a/exp_groups/instance.ts +++ b/exp_groups/instance.ts @@ -1,6 +1,6 @@ import { BaseInstancePlugin } from "@clusterio/host"; import * as lib from "@clusterio/lib"; -import * as messages from "./messages"; +import * as messages from "./messages.js"; export type IpcGroupUpdated = { group_name: string, diff --git a/exp_groups/package.json b/exp_groups/package.json index 0bd0dae8..6e055307 100644 --- a/exp_groups/package.json +++ b/exp_groups/package.json @@ -5,6 +5,7 @@ "author": "Cooldude2606 ", "license": "MIT", "repository": "explosivegaming/ExpCluster", + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --env production" diff --git a/exp_groups/web/components/AssignmentForm.tsx b/exp_groups/web/components/AssignmentForm.tsx index 41a6a8f2..f20b21a2 100644 --- a/exp_groups/web/components/AssignmentForm.tsx +++ b/exp_groups/web/components/AssignmentForm.tsx @@ -2,7 +2,7 @@ import React, { useContext, useEffect } from "react"; import { Modal, Form, Select } from "antd"; import { ControlContext, useUsers } from "@clusterio/web_ui"; -import * as messages from "../../messages"; +import * as messages from "../../messages.js"; import type { WebPlugin } from ".."; export default function AssignmentForm({ open, setOpen, initial }: { diff --git a/exp_groups/web/components/AssignmentsTable.tsx b/exp_groups/web/components/AssignmentsTable.tsx index 680d9f78..f6997811 100644 --- a/exp_groups/web/components/AssignmentsTable.tsx +++ b/exp_groups/web/components/AssignmentsTable.tsx @@ -3,7 +3,7 @@ import { Table, Button, Space, Input, InputRef } from "antd"; import { EditOutlined, SearchOutlined } from "@ant-design/icons"; import { ControlContext, useAccount } from "@clusterio/web_ui"; -import { AssignmentDeleteRequest, AssignmentRecord } from "../../messages"; +import { AssignmentDeleteRequest, AssignmentRecord } from "../../messages.js"; import type { WebPlugin } from ".."; import AssignmentForm from "./AssignmentForm"; diff --git a/exp_groups/web/components/GroupForm.tsx b/exp_groups/web/components/GroupForm.tsx index 14e3aa1a..945f3c59 100644 --- a/exp_groups/web/components/GroupForm.tsx +++ b/exp_groups/web/components/GroupForm.tsx @@ -3,7 +3,7 @@ import { Modal, Form, Input, Switch } from "antd"; import { useNavigate } from "react-router-dom"; import { ControlContext } from "@clusterio/web_ui"; -import * as messages from "../../messages"; +import * as messages from "../../messages.js"; export default function GroupForm({ open, setOpen }: { open: boolean, diff --git a/exp_groups/web/components/GroupViewPage.tsx b/exp_groups/web/components/GroupViewPage.tsx index 1d5d31fe..1f5ec8ac 100644 --- a/exp_groups/web/components/GroupViewPage.tsx +++ b/exp_groups/web/components/GroupViewPage.tsx @@ -5,7 +5,7 @@ import { Button, Checkbox, Input, Space, Spin, Alert } from "antd"; import { ControlContext, useAccount, useDefaultModPack, PageLayout, PageHeader, notifyErrorHandler } from "@clusterio/web_ui"; import DeletedConfirm from "./DeleteConfirm"; -import * as messages from "../../messages"; +import * as messages from "../../messages.js"; import type { WebPlugin } from ".."; const DOMAIN_MAPPING = { diff --git a/exp_groups/web/components/GroupsTable.tsx b/exp_groups/web/components/GroupsTable.tsx index 1dc711d9..d3f6c9b9 100644 --- a/exp_groups/web/components/GroupsTable.tsx +++ b/exp_groups/web/components/GroupsTable.tsx @@ -3,7 +3,7 @@ import { Table } from "antd"; import { useNavigate } from "react-router-dom"; import { ControlContext } from "@clusterio/web_ui"; -import { GroupRecord } from "../../messages"; +import { GroupRecord } from "../../messages.js"; import type { WebPlugin } from ".."; import GroupForm from "./GroupForm"; diff --git a/exp_groups/web/components/RoleMappingForm.tsx b/exp_groups/web/components/RoleMappingForm.tsx index ee143301..59918b29 100644 --- a/exp_groups/web/components/RoleMappingForm.tsx +++ b/exp_groups/web/components/RoleMappingForm.tsx @@ -2,7 +2,7 @@ import React, { useContext, useEffect } from "react"; import { Modal, Form, Select, InputNumber, Switch, Alert } from "antd"; import { ControlContext, useRoles } from "@clusterio/web_ui"; -import { RoleMappingRecord, RoleMappingCreateRequest, RoleMappingUpdateRequest } from "../../messages"; +import { RoleMappingRecord, RoleMappingCreateRequest, RoleMappingUpdateRequest } from "../../messages.js"; import type { WebPlugin } from ".."; export default function RoleMappingForm({ open, setOpen, initial }: { diff --git a/exp_groups/web/components/RoleMappingsTable.tsx b/exp_groups/web/components/RoleMappingsTable.tsx index 05877426..ab948960 100644 --- a/exp_groups/web/components/RoleMappingsTable.tsx +++ b/exp_groups/web/components/RoleMappingsTable.tsx @@ -3,7 +3,7 @@ import { Table, Button, Space, Tag } from "antd"; import { EditOutlined } from "@ant-design/icons"; import { ControlContext, useAccount, useRoles } from "@clusterio/web_ui"; -import { RoleMappingRecord, RoleMappingDeleteRequest } from "../../messages"; +import { RoleMappingRecord, RoleMappingDeleteRequest } from "../../messages.js"; import type { WebPlugin } from ".."; import RoleMappingForm from "./RoleMappingForm"; diff --git a/exp_groups/web/index.tsx b/exp_groups/web/index.tsx index e9fbc797..5a2f24cd 100644 --- a/exp_groups/web/index.tsx +++ b/exp_groups/web/index.tsx @@ -2,7 +2,7 @@ import React, { useState, useCallback, useSyncExternalStore } from "react"; import { BaseWebPlugin, PageLayout, PageHeader, useAccount, SectionHeader } from "@clusterio/web_ui"; import { Button } from "antd"; -import * as messages from "../messages"; +import * as messages from "../messages.js"; import * as lib from "@clusterio/lib"; import GroupsTable from "./components/GroupsTable"; diff --git a/exp_groups/webpack.config.js b/exp_groups/webpack.config.cjs similarity index 100% rename from exp_groups/webpack.config.js rename to exp_groups/webpack.config.cjs diff --git a/exp_gui/index.ts b/exp_gui/index.ts index eb9596f4..b25ce074 100644 --- a/exp_gui/index.ts +++ b/exp_gui/index.ts @@ -4,5 +4,5 @@ export const plugin: lib.PluginDeclaration = { name: "exp_gui", title: "exp_gui", description: "Example Description. Plugin. Change me in index.ts", - instanceEntrypoint: "./dist/node/instance", + instanceEntrypoint: "./dist/node/instance.js", }; diff --git a/exp_gui/package.json b/exp_gui/package.json index 6679512a..5f344d11 100644 --- a/exp_gui/package.json +++ b/exp_gui/package.json @@ -5,6 +5,7 @@ "author": "Cooldude2606 ", "license": "MIT", "repository": "explosivegaming/ExpCluster", + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --env production" diff --git a/exp_gui/webpack.config.js b/exp_gui/webpack.config.cjs similarity index 100% rename from exp_gui/webpack.config.js rename to exp_gui/webpack.config.cjs diff --git a/exp_legacy/index.ts b/exp_legacy/index.ts index ff35fce5..c49d3201 100644 --- a/exp_legacy/index.ts +++ b/exp_legacy/index.ts @@ -4,5 +4,5 @@ export const plugin: lib.PluginDeclaration = { name: "exp_legacy", title: "exp_legacy", description: "Example Description. Plugin. Change me in index.ts", - instanceEntrypoint: "./dist/node/instance", + instanceEntrypoint: "./dist/node/instance.js", }; diff --git a/exp_legacy/package.json b/exp_legacy/package.json index f5220dc7..601368c5 100644 --- a/exp_legacy/package.json +++ b/exp_legacy/package.json @@ -5,6 +5,7 @@ "author": "Cooldude2606 ", "license": "MIT", "repository": "explosivegaming/ExpCluster", + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --env production" diff --git a/exp_legacy/webpack.config.js b/exp_legacy/webpack.config.cjs similarity index 100% rename from exp_legacy/webpack.config.js rename to exp_legacy/webpack.config.cjs diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts index b9bf3a8b..96acc167 100644 --- a/exp_roles/controller.ts +++ b/exp_roles/controller.ts @@ -1,6 +1,6 @@ import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller"; import * as lib from "@clusterio/lib"; -import * as messages from "./messages"; +import * as messages from "./messages.js"; import * as path from "node:path"; export class ControllerPlugin extends BaseControllerPlugin { diff --git a/exp_roles/index.ts b/exp_roles/index.ts index e481fc8f..211f29c8 100644 --- a/exp_roles/index.ts +++ b/exp_roles/index.ts @@ -1,8 +1,8 @@ import * as lib from "@clusterio/lib"; -import * as messages from "./messages"; +import * as messages from "./messages.js"; -export * from "./messages"; -export type { ControllerPlugin } from "./controller"; +export * from "./messages.js"; +export type { ControllerPlugin } from "./controller.js"; declare module "@clusterio/lib" { export interface InstanceConfigFields { @@ -35,7 +35,7 @@ export const plugin: lib.PluginDeclaration = { messages.AssignmentUpdateRequest, ], - instanceEntrypoint: "./dist/node/instance", + instanceEntrypoint: "./dist/node/instance.js", instanceConfigFields: { "exp_roles.sync_mode": { description: "Synchronize in game roles with the controller", @@ -45,7 +45,7 @@ export const plugin: lib.PluginDeclaration = { }, }, - controllerEntrypoint: "./dist/node/controller", + controllerEntrypoint: "./dist/node/controller.js", controllerConfigFields: { }, diff --git a/exp_roles/instance.ts b/exp_roles/instance.ts index 208a0982..d57a5819 100644 --- a/exp_roles/instance.ts +++ b/exp_roles/instance.ts @@ -1,6 +1,6 @@ import { BaseInstancePlugin } from "@clusterio/host"; import * as lib from "@clusterio/lib"; -import * as messages from "./messages"; +import * as messages from "./messages.js"; /** Sent by the lua side when roles are changed in game. */ export type IpcAssignmentUpdate = { diff --git a/exp_roles/package.json b/exp_roles/package.json index c5f3ae57..6a09a372 100644 --- a/exp_roles/package.json +++ b/exp_roles/package.json @@ -5,10 +5,11 @@ "author": "Cooldude2606 ", "license": "MIT", "repository": "explosivegaming/ExpCluster", + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --env production", - "test": "tap --disable-coverage --allow-empty-coverage test/*.test.js", + "test": "tap --type-strip-only --disable-coverage --allow-empty-coverage test/*.test.js", "coverage": "tap --coverage-report=text test/*.test.js" }, "engines": { diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js index 4e66ee48..0549c733 100644 --- a/exp_roles/test/controller.test.js +++ b/exp_roles/test/controller.test.js @@ -1,9 +1,8 @@ -"use strict"; -const t = require("tap"); -const lib = require("@clusterio/lib"); -const { Controller } = require("@clusterio/controller"); -const { ControllerPlugin } = require("../dist/node/controller"); -const messages = require("../dist/node/messages"); +import t from "tap"; +import * as lib from "@clusterio/lib"; +import { Controller } from "@clusterio/controller"; +import { ControllerPlugin } from "../dist/node/controller.js"; +import * as messages from "../dist/node/messages.js"; // The controller validates message classes against the link registry lib.Link.register(messages.RoleUpdatedEvent); diff --git a/exp_roles/test/instance.test.js b/exp_roles/test/instance.test.js index cb3db308..e0651358 100644 --- a/exp_roles/test/instance.test.js +++ b/exp_roles/test/instance.test.js @@ -1,10 +1,9 @@ -"use strict"; -const t = require("tap"); -const lib = require("@clusterio/lib"); -const { Instance } = require("@clusterio/host"); -const { InstancePlugin } = require("../dist/node/instance"); -const { plugin: pluginDeclaration } = require("../dist/node/index"); -const messages = require("../dist/node/messages"); +import t from "tap"; +import * as lib from "@clusterio/lib"; +import { Instance } from "@clusterio/host"; +import { InstancePlugin } from "../dist/node/instance.js"; +import { plugin as pluginDeclaration } from "../dist/node/index.js"; +import * as messages from "../dist/node/messages.js"; // The instance validates message classes against the link registry, and the // plugin's config fields must be defined before an InstanceConfig can set them diff --git a/exp_roles/test/messages.test.js b/exp_roles/test/messages.test.js index 8ec26550..324d0719 100644 --- a/exp_roles/test/messages.test.js +++ b/exp_roles/test/messages.test.js @@ -1,7 +1,6 @@ -"use strict"; -const t = require("tap"); -const messages = require("../dist/node/messages"); -const { testMatrix, testRoundTripJsonSerialisable } = require("../../test/common"); +import t from "tap"; +import * as messages from "../dist/node/messages.js"; +import { testMatrix, testRoundTripJsonSerialisable } from "../../test/common.js"; const fullMeta = new messages.RoleMetaRecord( 7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false, diff --git a/exp_roles/test/module.test.js b/exp_roles/test/module.test.js index 2e5d4aa5..0acdfc29 100644 --- a/exp_roles/test/module.test.js +++ b/exp_roles/test/module.test.js @@ -1,14 +1,13 @@ -"use strict"; -const path = require("node:path"); -const t = require("tap"); -const { reportLuaTests } = require("../../test/lua/runner"); +import path from "node:path"; +import t from "tap"; +import { reportLuaTests } from "../../test/lua/runner.js"; -const envFile = path.join(__dirname, "module", "env.lua"); +const envFile = path.join(import.meta.dirname, "module", "env.lua"); // Each file runs in its own lua state, and each test in a fresh environment t.test("control.lua", t2 => { for (const file of ["lookup.lua", "players.lua", "assignment.lua", "sync.lua", "holders.lua"]) { - t2.test(file, t3 => reportLuaTests(t3, envFile, path.join(__dirname, "module", file))); + t2.test(file, t3 => reportLuaTests(t3, envFile, path.join(import.meta.dirname, "module", file))); } t2.end(); }); diff --git a/exp_roles/web/components/RoleProperties.tsx b/exp_roles/web/components/RoleProperties.tsx index b63ae707..53f6ca99 100644 --- a/exp_roles/web/components/RoleProperties.tsx +++ b/exp_roles/web/components/RoleProperties.tsx @@ -4,7 +4,7 @@ import { Button, Col, ColorPicker, Form, Input, InputNumber, Row, Switch, Toolti import * as lib from "@clusterio/lib"; import { ControlContext, SectionHeader, useAccount, notifyErrorHandler } from "@clusterio/web_ui"; -import { RoleColor, RoleMetaRecord, RoleMetaUpdateRequest } from "../../messages"; +import { RoleColor, RoleMetaRecord, RoleMetaUpdateRequest } from "../../messages.js"; import type { WebPlugin } from ".."; const MS_PER_HOUR = 3600000; diff --git a/exp_roles/web/index.tsx b/exp_roles/web/index.tsx index 644cd10d..36e9de1d 100644 --- a/exp_roles/web/index.tsx +++ b/exp_roles/web/index.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useSyncExternalStore } from "react"; import { BaseWebPlugin } from "@clusterio/web_ui"; import * as lib from "@clusterio/lib"; -import * as messages from "../messages"; +import * as messages from "../messages.js"; import RoleProperties from "./components/RoleProperties"; diff --git a/exp_roles/webpack.config.js b/exp_roles/webpack.config.cjs similarity index 100% rename from exp_roles/webpack.config.js rename to exp_roles/webpack.config.cjs diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index cd04b3db..05d3303e 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -4,8 +4,8 @@ import { RoleMetaRecord, type ControllerPlugin as RolesPlugin } from "@expcluste import { GroupRecord, GroupPermissions, RoleMappingRecord, type ControllerPlugin as GroupsPlugin, } from "@expcluster/permission-groups"; -import * as messages from "./messages"; -import { SeedRole, SeedGroup, seedRoles, seedGroups, flattenSeedPermissions } from "./seed"; +import * as messages from "./messages.js"; +import { SeedRole, SeedGroup, seedRoles, seedGroups, flattenSeedPermissions } from "./seed.js"; export class ControllerPlugin extends BaseControllerPlugin { async init() { diff --git a/exp_scenario/index.ts b/exp_scenario/index.ts index 5d04b902..203c5721 100644 --- a/exp_scenario/index.ts +++ b/exp_scenario/index.ts @@ -1,6 +1,6 @@ import * as lib from "@clusterio/lib"; -import * as messages from "./messages"; -import { permissions as scenarioPermissions, type ScenarioPermissionName } from "./permissions"; +import * as messages from "./messages.js"; +import { permissions as scenarioPermissions, type ScenarioPermissionName } from "./permissions.js"; declare module "@clusterio/lib" { // Everything checked in game through exp_roles, plus the permissions checked on the controller @@ -15,8 +15,8 @@ export const plugin: lib.PluginDeclaration = { name: "exp_scenario", title: "exp_scenario", description: "Example Description. Plugin. Change me in index.ts", - controllerEntrypoint: "./dist/node/controller", - instanceEntrypoint: "./dist/node/instance", + controllerEntrypoint: "./dist/node/controller.js", + instanceEntrypoint: "./dist/node/instance.js", messages: [ messages.SeedRequest, diff --git a/exp_scenario/package.json b/exp_scenario/package.json index 1e4d9d65..706fef28 100644 --- a/exp_scenario/package.json +++ b/exp_scenario/package.json @@ -5,10 +5,11 @@ "author": "Cooldude2606 ", "license": "MIT", "repository": "explosivegaming/ExpCluster", + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --env production", - "test": "tap --disable-coverage --allow-empty-coverage test/*.test.js", + "test": "tap --type-strip-only --disable-coverage --allow-empty-coverage test/*.test.js", "coverage": "tap --coverage-report=text test/*.test.js" }, "engines": { diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index 16206b95..d7b96add 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -1,17 +1,16 @@ -"use strict"; -const t = require("tap"); -const lib = require("@clusterio/lib"); -const { Controller } = require("@clusterio/controller"); -const { ControllerPlugin } = require("../dist/node/controller"); -const messages = require("../dist/node/messages"); -const { seedRoles, seedGroups } = require("../dist/node/seed"); -const roles = require("@expcluster/roles"); -const groups = require("@expcluster/permission-groups"); -const { ControllerPlugin: RolesPlugin } = require("@expcluster/roles/dist/node/controller"); -const { ControllerPlugin: GroupsPlugin } = require("@expcluster/permission-groups/dist/node/controller"); -const { GroupRecord, GroupPermissions, RoleMappingRecord } = require("@expcluster/permission-groups"); +import t from "tap"; +import * as lib from "@clusterio/lib"; +import { Controller } from "@clusterio/controller"; +import { ControllerPlugin } from "../dist/node/controller.js"; +import * as messages from "../dist/node/messages.js"; +import { seedRoles, seedGroups } from "../dist/node/seed.js"; +import * as roles from "@expcluster/roles"; +import * as groups from "@expcluster/permission-groups"; +import { ControllerPlugin as RolesPlugin } from "@expcluster/roles/dist/node/controller.js"; +import { ControllerPlugin as GroupsPlugin } from "@expcluster/permission-groups/dist/node/controller.js"; +import { GroupRecord, GroupPermissions, RoleMappingRecord } from "@expcluster/permission-groups"; -const { plugin } = require("../dist/node/index"); +import { plugin } from "../dist/node/index.js"; // Registering the plugin defines the permissions the seed grants lib.registerPluginPermissions([plugin]); diff --git a/exp_scenario/test/seed.test.js b/exp_scenario/test/seed.test.js index f1c89203..c201409d 100644 --- a/exp_scenario/test/seed.test.js +++ b/exp_scenario/test/seed.test.js @@ -1,9 +1,8 @@ -"use strict"; -const t = require("tap"); -const lib = require("@clusterio/lib"); -const { seedRoles, seedGroups, flattenSeedPermissions } = require("../dist/node/seed"); +import t from "tap"; +import * as lib from "@clusterio/lib"; +import { seedRoles, seedGroups, flattenSeedPermissions } from "../dist/node/seed.js"; -const { plugin } = require("../dist/node/index"); +import { plugin } from "../dist/node/index.js"; // Registering the plugin defines the permissions the seed grants lib.registerPluginPermissions([plugin]); diff --git a/exp_scenario/web/components/Seed.tsx b/exp_scenario/web/components/Seed.tsx index 619d79df..8131b137 100644 --- a/exp_scenario/web/components/Seed.tsx +++ b/exp_scenario/web/components/Seed.tsx @@ -3,7 +3,7 @@ import { Button, Popconfirm } from "antd"; import { ControlContext, SectionHeader, useAccount, notifyErrorHandler } from "@clusterio/web_ui"; -import { SeedRequest } from "../../messages"; +import { SeedRequest } from "../../messages.js"; /** Button on the roles page which creates the roles and permission groups the scenario shipped with. */ export default function Seed() { diff --git a/exp_scenario/webpack.config.js b/exp_scenario/webpack.config.cjs similarity index 100% rename from exp_scenario/webpack.config.js rename to exp_scenario/webpack.config.cjs diff --git a/exp_server_ups/index.ts b/exp_server_ups/index.ts index cdfc8663..404259e9 100644 --- a/exp_server_ups/index.ts +++ b/exp_server_ups/index.ts @@ -12,7 +12,7 @@ export const plugin: lib.PluginDeclaration = { title: "ExpGaming - Server UPS", description: "Clusterio plugin providing in game server ups counter", - instanceEntrypoint: "./dist/node/instance", + instanceEntrypoint: "./dist/node/instance.js", instanceConfigFields: { "exp_server_ups.update_interval": { title: "Update Interval", diff --git a/exp_server_ups/package.json b/exp_server_ups/package.json index 25baa73f..e2a01051 100644 --- a/exp_server_ups/package.json +++ b/exp_server_ups/package.json @@ -5,6 +5,7 @@ "author": "Cooldude2606 ", "license": "MIT", "repository": "explosivegaming/ExpCluster", + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --env production" diff --git a/exp_server_ups/webpack.config.js b/exp_server_ups/webpack.config.cjs similarity index 100% rename from exp_server_ups/webpack.config.js rename to exp_server_ups/webpack.config.cjs diff --git a/exp_util/index.ts b/exp_util/index.ts index c169aefd..12957919 100644 --- a/exp_util/index.ts +++ b/exp_util/index.ts @@ -4,5 +4,5 @@ export const plugin: lib.PluginDeclaration = { name: "exp_util", title: "ExpGaming Module Utilities", description: "Provides extensions and overrides of base Lua library functions, and provides utility modules for improved module compatibly", - instanceEntrypoint: "./dist/node/instance", + instanceEntrypoint: "./dist/node/instance.js", }; diff --git a/exp_util/package.json b/exp_util/package.json index ec10ff5d..3dfa39e2 100644 --- a/exp_util/package.json +++ b/exp_util/package.json @@ -5,6 +5,7 @@ "author": "Cooldude2606 ", "license": "MIT", "repository": "explosivegaming/ExpCluster", + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --env production" diff --git a/exp_util/webpack.config.js b/exp_util/webpack.config.cjs similarity index 100% rename from exp_util/webpack.config.js rename to exp_util/webpack.config.cjs diff --git a/test/common.js b/test/common.js index bf63b030..f6c476f5 100644 --- a/test/common.js +++ b/test/common.js @@ -1,5 +1,4 @@ -"use strict"; -const { compile } = require("@clusterio/lib"); +import { compile } from "@clusterio/lib"; /** * Generate a flat array of tests from a matrix of inputs. @@ -49,7 +48,7 @@ function testRoundTripJsonSerialisable(t, Class, tests) { } } -module.exports = { +export { testMatrix, testRoundTripJsonSerialisable, }; diff --git a/test/lua/runner.js b/test/lua/runner.js index 51019609..14dbb9d0 100644 --- a/test/lua/runner.js +++ b/test/lua/runner.js @@ -1,7 +1,6 @@ -"use strict"; -const path = require("node:path"); -const fs = require("node:fs"); -const { lua, lauxlib, lualib, to_luastring, to_jsstring } = require("fengari"); +import path from "node:path"; +import fs from "node:fs"; +import { createRequire } from "node:module"; /** * Run one lua test file in its own lua state and return its results. @@ -21,6 +20,8 @@ const { lua, lauxlib, lualib, to_luastring, to_jsstring } = require("fengari"); * @returns {{ name: string, ok: boolean, detail?: string }[]} */ function runLuaTests(envFile, testFile) { + // fengari is a devDependency of the plugin whose tests run, so resolve it from there + const { lua, lauxlib, lualib, to_luastring, to_jsstring } = createRequire(testFile)("fengari"); const L = lauxlib.luaL_newstate(); lualib.luaL_openlibs(L); @@ -38,7 +39,7 @@ function runLuaTests(envFile, testFile) { }; load(envFile); - lua.lua_pushstring(L, to_luastring(__dirname)); + lua.lua_pushstring(L, to_luastring(import.meta.dirname)); call(); // The environment stays on the stack and is passed to the test chunk @@ -60,4 +61,4 @@ function reportLuaTests(t, envFile, testFile) { t.end(); } -module.exports = { runLuaTests, reportLuaTests }; +export { runLuaTests, reportLuaTests }; diff --git a/tsconfig.node.json b/tsconfig.node.json index a2e84c8b..b5f49789 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.base.json", "compilerOptions": { "lib": ["es2023"], - "module": "node16", + "module": "nodenext", "moduleResolution": "node16", "target": "es2022", "forceConsistentCasingInFileNames": true, From 5a6f3b46fee664e60e201af4b537a2bc65577076 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:39:31 +0000 Subject: [PATCH 11/18] Add exp_auto_restart plugin Restarts hosts, and optionally the controller, that report a pending restart once no players have been online for a configurable time. Only loaded on the controller: it counts players from the users online on running instances, sends HostRestartRequest through the existing core path, and starts the instances that were running once the host reports back. Each host process is asked at most once, keyed on its process start time, so a stale system info poll cannot trigger a second restart. Uses the function-style entrypoint clusterio now documents, so it loads without the deprecated class export warning. Co-Authored-By: Claude Fable 5.1 --- exp_auto_restart/README.md | 26 ++ exp_auto_restart/controller.ts | 295 +++++++++++++++++++ exp_auto_restart/index.ts | 50 ++++ exp_auto_restart/package.json | 37 +++ exp_auto_restart/test/controller.test.js | 353 +++++++++++++++++++++++ exp_auto_restart/tsconfig.json | 6 + exp_auto_restart/tsconfig.node.json | 5 + tsconfig.json | 1 + 8 files changed, 773 insertions(+) create mode 100644 exp_auto_restart/README.md create mode 100644 exp_auto_restart/controller.ts create mode 100644 exp_auto_restart/index.ts create mode 100644 exp_auto_restart/package.json create mode 100644 exp_auto_restart/test/controller.test.js create mode 100644 exp_auto_restart/tsconfig.json create mode 100644 exp_auto_restart/tsconfig.node.json diff --git a/exp_auto_restart/README.md b/exp_auto_restart/README.md new file mode 100644 index 00000000..954b9b09 --- /dev/null +++ b/exp_auto_restart/README.md @@ -0,0 +1,26 @@ +# ExpGaming - Auto Restart + +Restarts hosts, and optionally the controller, that need a restart once nobody is playing on them. + +A host needs a restart after a plugin or clusterio update, and after a config change that only applies on restart. The web UI shows this as "Restart Required". This plugin waits until the host has had no players online for a while and then sends it the same restart request the web UI button does. + +Restarting a host stops every instance on it. Once the host is back, the instances that were running are started again. Instances with auto start enabled are started by the host itself. + +## Requirements + +- Hosts must be run with `--can-restart` so their process monitor starts them again. The systemd units and run scripts written by the installer do this. +- `controller.system_metrics_interval` must be above 0. That poll is how the controller learns a host needs a restart. +- Installing a plugin on a host does not yet mark it as needing a restart, only updates and config changes do. Until clusterio does, restart the host by hand after an install. + +## Config + +All fields are on the controller. + +| Field | Default | Description | +|---|---|---| +| `exp_auto_restart.idle_seconds` | 300 | How long a host must have had no players online before it is restarted. | +| `exp_auto_restart.scope` | host | With `cluster`, players on any host hold back every restart. | +| `exp_auto_restart.start_instances` | true | Start the instances that were running again once the host is back. | +| `exp_auto_restart.restart_controller` | false | Also restart the controller when it needs it and no players are online anywhere. | + +Hosts are checked every 10 seconds. A host that refuses the restart, for example because it was not run with `--can-restart`, is logged once and left alone until its process changes. diff --git a/exp_auto_restart/controller.ts b/exp_auto_restart/controller.ts new file mode 100644 index 00000000..130dd2b0 --- /dev/null +++ b/exp_auto_restart/controller.ts @@ -0,0 +1,295 @@ +import type { + Controller, ControllerPluginContext, HostConnection, HostRecord, InstanceRecord, +} from "@clusterio/controller"; +import * as lib from "@clusterio/lib"; + +/** How often hosts are checked for a pending restart */ +export const CHECK_INTERVAL_MS = 10_000; +/** How long to wait for a restarted host to report its instances back before giving up on starting them */ +export const PENDING_TIMEOUT_MS = 10 * 60_000; + +/** A host that was told to restart and has not reported all of its instances back yet */ +export interface PendingRestart { + requestedAtMs: number; + /** Instances that were running when the restart was requested */ + instanceIds: Set; +} + +export class AutoRestart { + controller: Controller; + logger: lib.Logger; + name: string; + + /** When each host, and the cluster as a whole, last became empty of players. Absent while players are online. */ + idleSince = new Map(); + /** Hosts that were told to restart and are waiting for their instances to come back */ + pending = new Map(); + /** Process start time of each host last asked to restart, so the same process is not asked twice */ + requested = new Map(); + /** Hosts already warned about not being restartable */ + warned = new Set(); + controllerRestarting = false; + private checkInterval?: ReturnType; + + constructor(context: ControllerPluginContext) { + this.controller = context.controller; + this.logger = context.logger; + this.name = context.plugin.name; + } + + /** Attach to the controller hooks and start checking for restarts. */ + start() { + const hooks = this.controller.hooks; + hooks.shutdown.attach(this.name, () => this.stop()); + hooks.playerEvent.attach(this.name, (instance, event) => this.onPlayerEvent(instance, event)); + hooks.instanceStatusChanged.attach(this.name, (instance, prev) => this.onInstanceStatusChanged(instance, prev)); + hooks.hostConnectionEvent.attach(this.name, (connection, event) => this.onHostConnectionEvent(connection, event)); + + if (this.controller.config.get("controller.system_metrics_interval") <= 0) { + this.logger.warn( + "controller.system_metrics_interval is 0, so the controller never learns which hosts need a restart" + ); + } + this.refreshIdle(); + this.checkInterval = setInterval(() => { + this.check().catch((err: any) => { + this.logger.error(`Unexpected error checking for restarts:\n${err.stack ?? err.message}`); + }); + }, CHECK_INTERVAL_MS); + this.checkInterval.unref(); + } + + stop() { + clearInterval(this.checkInterval); + } + + onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) { + if (event.type === "join" || event.type === "leave") { + this.refreshIdle(); + } + } + + onInstanceStatusChanged(instance: InstanceRecord, prev?: lib.InstanceStatus) { + this.refreshIdle(); + // A host reports every instance it has when it connects, which is how + // a restarted host announces it is back + if (prev === "unknown") { + this.instanceReported(instance); + } + } + + onHostConnectionEvent(connection: HostConnection, event: "connect" | "drop" | "resume" | "close") { + if (event === "connect" || event === "close") { + this.refreshIdle(); + } + } + + /** Players online per host, counted from the users online on running instances. */ + onlinePlayers() { + const counts = new Map(); + for (const user of this.controller.users.records.values()) { + if (user.isDeleted) { + continue; + } + for (const instanceId of user.instances) { + const instance = this.controller.instances.get(instanceId); + // Nobody is online on an instance that is not running, whatever + // the user records say after a host crashed + if (!instance || instance.status !== "running") { + continue; + } + const hostId = instance.config.get("instance.assigned_host"); + if (hostId === null) { + continue; + } + counts.set(hostId, (counts.get(hostId) ?? 0) + 1); + } + } + return counts; + } + + /** Record when connected hosts and the cluster became empty, from the players online now. */ + refreshIdle(nowMs = Date.now()) { + const counts = this.onlinePlayers(); + for (const host of this.controller.hosts.values()) { + if (host.isDeleted || !host.connected) { + this.idleSince.delete(host.id); + continue; + } + this.markIdle(host.id, !counts.get(host.id), nowMs); + } + + let total = 0; + for (const count of counts.values()) { + total += count; + } + this.markIdle("cluster", total === 0, nowMs); + } + + private markIdle(key: number | "cluster", idle: boolean, nowMs: number) { + if (!idle) { + this.idleSince.delete(key); + } else if (!this.idleSince.has(key)) { + this.idleSince.set(key, nowMs); + } + } + + /** How long a host has been without players, or undefined while it has some. Uses the whole cluster with scope cluster. */ + idleMs(hostId: number, nowMs: number) { + const scope = this.controller.config.get("exp_auto_restart.scope"); + const since = this.idleSince.get(scope === "cluster" ? "cluster" : hostId); + return since === undefined ? undefined : nowMs - since; + } + + /** Restart every host, and the controller if enabled, that needs it and has been empty for long enough. */ + async check(nowMs = Date.now()) { + this.refreshIdle(nowMs); + this.expirePending(nowMs); + const waitMs = this.controller.config.get("exp_auto_restart.idle_seconds") * 1000; + + for (const system of this.controller.systems.values()) { + if (system.id === "controller" || system.isDeleted || !system.restartRequired) { + continue; + } + const hostId = system.id; + const host = this.controller.hosts.get(hostId); + if (!host || host.isDeleted || !host.connected) { + continue; + } + // Already asked, either waiting for it to come back or it refused + if (this.pending.has(hostId) || this.requested.get(hostId) === system.processStartedAtMs) { + continue; + } + if (!system.canRestart) { + this.warnOnce(hostId, `Host ${host.name} needs a restart but is not run with --can-restart`); + continue; + } + const idleMs = this.idleMs(hostId, nowMs); + if (idleMs === undefined || idleMs < waitMs) { + continue; + } + await this.restartHost(host, system, idleMs, nowMs); + } + + if (this.controller.config.get("exp_auto_restart.restart_controller")) { + await this.checkController(nowMs, waitMs); + } + } + + async restartHost(host: Readonly, system: lib.SystemInfo, idleMs: number, nowMs: number) { + const running = [...this.controller.instances.values()].filter( + instance => instance.status === "running" && instance.config.get("instance.assigned_host") === host.id + ); + this.requested.set(host.id, system.processStartedAtMs); + if (running.length && this.controller.config.get("exp_auto_restart.start_instances")) { + this.pending.set(host.id, { + requestedAtMs: nowMs, + instanceIds: new Set(running.map(instance => instance.id)), + }); + } + this.logger.info( + `Restarting host ${host.name} after ${Math.round(idleMs / 1000)} s without players, ` + + `${running.length} instance(s) running` + ); + + try { + await this.controller.sendTo({ hostId: host.id }, new lib.HostRestartRequest()); + } catch (err: any) { + this.pending.delete(host.id); + // The host refusing is final for this process, anything else is retried + if (!(err instanceof lib.RequestError)) { + this.requested.delete(host.id); + } + this.logger.error(`Failed to restart host ${host.name}: ${err.message}`); + } + } + + /** An instance on a host that reconnected has reported its status. */ + instanceReported(instance: InstanceRecord) { + const hostId = instance.config.get("instance.assigned_host"); + if (hostId === null) { + return; + } + const pending = this.pending.get(hostId); + if (!pending?.instanceIds.delete(instance.id)) { + return; + } + if (!pending.instanceIds.size) { + this.pending.delete(hostId); + } + + // The host starts these itself right after reporting them + if (instance.status !== "stopped" || instance.config.get("instance.auto_start")) { + return; + } + this.startInstance(instance); + } + + async startInstance(instance: InstanceRecord) { + const name = instance.config.get("instance.name"); + this.logger.info(`Starting ${name} again after its host restarted`); + try { + await this.controller.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest()); + } catch (err: any) { + this.logger.error(`Failed to start ${name} after its host restarted: ${err.message}`); + } + } + + /** Forget hosts that were restarted but never reported their instances back. */ + expirePending(nowMs: number) { + for (const [hostId, pending] of this.pending) { + if (nowMs - pending.requestedAtMs >= PENDING_TIMEOUT_MS) { + this.pending.delete(hostId); + this.logger.warn( + `Host ${hostId} has not reported back since it was restarted, giving up on starting its instances` + ); + } + } + } + + async checkController(nowMs: number, waitMs: number) { + if (this.controllerRestarting) { + return; + } + const system = this.controller.systems.get("controller"); + if (!system?.restartRequired) { + return; + } + if (!system.canRestart) { + this.warnOnce("controller", "The controller needs a restart but is not run with --can-restart"); + return; + } + const since = this.idleSince.get("cluster"); + if (since === undefined || nowMs - since < waitMs) { + return; + } + const downgrade = await this.controller.checkRestartDowngrade(); + if (downgrade) { + this.warnOnce( + "controller", + `Not restarting the controller because installed version ${downgrade.installedVersion} ` + + `is older than running version ${downgrade.runningVersion}` + ); + return; + } + + this.controllerRestarting = true; + this.logger.info(`Restarting controller after ${Math.round((nowMs - since) / 1000)} s without players`); + this.controller.shouldRestart = true; + this.controller.stop().catch((err: any) => { + this.logger.error(`Failed to restart the controller: ${err.message}`); + }); + } + + private warnOnce(key: number | "controller", message: string) { + if (this.warned.has(key)) { + return; + } + this.warned.add(key); + this.logger.warn(message); + } +} + +export default async function (context: ControllerPluginContext) { + new AutoRestart(context).start(); +} diff --git a/exp_auto_restart/index.ts b/exp_auto_restart/index.ts new file mode 100644 index 00000000..27420411 --- /dev/null +++ b/exp_auto_restart/index.ts @@ -0,0 +1,50 @@ +import * as lib from "@clusterio/lib"; + +declare module "@clusterio/lib" { + export interface ControllerConfigFields { + "exp_auto_restart.idle_seconds": number; + "exp_auto_restart.scope": "host" | "cluster"; + "exp_auto_restart.start_instances": boolean; + "exp_auto_restart.restart_controller": boolean; + } +} + +export const plugin: lib.PluginDeclaration = { + name: "exp_auto_restart", + title: "ExpGaming - Auto Restart", + description: "Clusterio plugin restarting hosts and the controller once no players are online", + + controllerEntrypoint: "./dist/node/controller.js", + controllerConfigFields: { + "exp_auto_restart.idle_seconds": { + title: "Idle Seconds", + description: "How long a host must have had no players online before it is restarted", + type: "number", + initialValue: 300, + validator: (value: number) => { + if (value < 0) { + throw new Error("Idle seconds cannot be negative"); + } + }, + }, + "exp_auto_restart.scope": { + title: "Scope", + description: "Whether players on other hosts also hold back a restart", + type: "string", + enum: ["host", "cluster"], + initialValue: "host", + }, + "exp_auto_restart.start_instances": { + title: "Start Instances", + description: "Start the instances that were running again once the host is back", + type: "boolean", + initialValue: true, + }, + "exp_auto_restart.restart_controller": { + title: "Restart Controller", + description: "Also restart the controller when it needs it and no players are online anywhere", + type: "boolean", + initialValue: false, + }, + }, +}; diff --git a/exp_auto_restart/package.json b/exp_auto_restart/package.json new file mode 100644 index 00000000..57df55ac --- /dev/null +++ b/exp_auto_restart/package.json @@ -0,0 +1,37 @@ +{ + "name": "@expcluster/auto-restart", + "version": "7.0.1", + "description": "Clusterio plugin restarting hosts and the controller once no players are online.", + "author": "Cooldude2606 ", + "license": "MIT", + "repository": "explosivegaming/ExpCluster", + "type": "module", + "main": "dist/node/index.js", + "scripts": { + "prepare": "tsc --build", + "test": "tap --type-strip-only --disable-coverage --allow-empty-coverage test/*.test.js", + "coverage": "tap --coverage-report=text test/*.test.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/lib": "workspace:^" + }, + "devDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/lib": "workspace:^", + "@types/node": "catalog:", + "tap": "^21.1.0", + "typescript": "catalog:" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "clusterio", + "clusterio-plugin", + "factorio" + ] +} diff --git a/exp_auto_restart/test/controller.test.js b/exp_auto_restart/test/controller.test.js new file mode 100644 index 00000000..e7461f53 --- /dev/null +++ b/exp_auto_restart/test/controller.test.js @@ -0,0 +1,353 @@ +import t from "tap"; +import * as lib from "@clusterio/lib"; +import { Controller, HostRecord, InstanceRecord } from "@clusterio/controller"; +import entrypoint, { AutoRestart, PENDING_TIMEOUT_MS } from "../dist/node/controller.js"; +import { plugin as pluginDeclaration } from "../dist/node/index.js"; + +// The plugin's config fields must be defined before a ControllerConfig can set them +lib.addPluginConfigFields([pluginDeclaration]); + +// Silence the singleton logger to avoid polluting the test output +lib.logger.silent = true; +t.after(() => { + lib.logger.silent = false; +}); + +/** Build the plugin around a real controller, which is side effect free while not started. */ +async function startPlugin(t2, { config = {}, canRestart = false } = {}) { + const logs = []; + const logger = { + child: () => logger, + info: message => logs.push(["info", message]), + warn: message => logs.push(["warn", message]), + error: message => logs.push(["error", message]), + verbose: () => {}, + }; + const controllerConfig = new lib.ControllerConfig("controller", { + "controller.database_directory": t2.testdir(), + ...config, + }); + const controller = new Controller(logger, [], controllerConfig, canRestart); + + // Record what would be sent to hosts and instances instead of sending it + const sent = []; + controller.sendTo = async (address, message) => { + sent.push({ address, message }); + }; + + const plugin = new AutoRestart({ controller, logger, plugin: pluginDeclaration, metrics: undefined }); + plugin.start(); + t2.teardown(() => plugin.stop()); + return { plugin, controller, sent, logs }; +} + +function addHost(controller, id, connected = true) { + controller.hosts.set(new HostRecord(id, `host-${id}`, "2.0.0", new Map(), connected)); +} + +function addInstance(controller, id, hostId, status = "running", fields = {}) { + const config = new lib.InstanceConfig("controller", { + "instance.id": id, + "instance.name": `instance-${id}`, + "instance.assigned_host": hostId, + ...fields, + }); + const record = new InstanceRecord(config, status); + controller.instances.records.set(record); + return record; +} + +function addSystem(controller, id, { restartRequired = true, canRestart = true, processStartedAtMs = 1000 } = {}) { + controller.systems.set(new lib.SystemInfo( + id, "box", "v22", "Linux", "x64", "cpu", [], 0, 0, 0, 0, + canRestart, restartRequired, 0, processStartedAtMs, Date.now(), false, + )); +} + +const join = (controller, name, instanceId) => controller.users.getOrCreateUser(name).notifyJoin(instanceId); +const leave = (controller, name, instanceId) => controller.users.getOrCreateUser(name).notifyLeave(instanceId); +const restartsSent = sent => sent.filter(({ message }) => message instanceof lib.HostRestartRequest); +const startsSent = sent => sent.filter(({ message }) => message instanceof lib.InstanceStartRequest); + +t.test("entrypoint", async t2 => { + const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() }); + const controller = new Controller(logger, [], controllerConfig); + await entrypoint({ controller, logger, plugin: pluginDeclaration, metrics: undefined }); + for (const hook of ["shutdown", "playerEvent", "instanceStatusChanged", "hostConnectionEvent"]) { + t2.ok([...controller.hooks[hook].attached].includes("exp_auto_restart"), `attached to ${hook}`); + } + await controller.hooks.shutdown.invoke(); +}); + +t.test("class AutoRestart", t2 => { + t2.test(".onlinePlayers() counts players on running instances per host", async t3 => { + const { plugin, controller } = await startPlugin(t3); + addHost(controller, 1); + addHost(controller, 2); + addInstance(controller, 10, 1); + addInstance(controller, 11, 1, "stopped"); + addInstance(controller, 20, 2); + join(controller, "alice", 10); + join(controller, "bob", 10); + join(controller, "carol", 11); + join(controller, "dave", 20); + + t3.strictSame(plugin.onlinePlayers(), new Map([[1, 2], [2, 1]]), "players on stopped instances are not counted"); + }); + + t2.test(".refreshIdle() tracks when hosts and the cluster became empty", async t3 => { + const { plugin, controller } = await startPlugin(t3); + addHost(controller, 1); + addHost(controller, 2); + addHost(controller, 3, false); + addInstance(controller, 10, 1); + join(controller, "alice", 10); + + plugin.refreshIdle(1000); + t3.notOk(plugin.idleSince.has(1), "a host with players is not idle"); + t3.strictSame(plugin.idleSince.get(2), 1000, "an empty host is idle from now"); + t3.notOk(plugin.idleSince.has(3), "a disconnected host is not tracked"); + t3.notOk(plugin.idleSince.has("cluster"), "the cluster is not idle while anyone is online"); + + leave(controller, "alice", 10); + plugin.refreshIdle(2000); + t3.strictSame(plugin.idleSince.get(1), 2000, "a host becomes idle when its last player leaves"); + t3.strictSame(plugin.idleSince.get(2), 1000, "an idle host keeps its original time"); + t3.strictSame(plugin.idleSince.get("cluster"), 2000, "the cluster becomes idle with the last player"); + + join(controller, "alice", 10); + plugin.refreshIdle(3000); + t3.notOk(plugin.idleSince.has(1), "a join clears the idle time"); + t3.notOk(plugin.idleSince.has("cluster")); + }); + + t2.test(".check() restarts a host once it has been empty for long enough", async t3 => { + const { plugin, controller, sent } = await startPlugin(t3, { + config: { "exp_auto_restart.idle_seconds": 60 }, + }); + const now = Date.now(); + addHost(controller, 1); + addInstance(controller, 10, 1); + addInstance(controller, 11, 1, "stopped"); + addSystem(controller, 1); + join(controller, "alice", 10); + + await plugin.check(now); + t3.strictSame(restartsSent(sent).length, 0, "not restarted while a player is online"); + + leave(controller, "alice", 10); + await plugin.check(now + 10_000); + t3.strictSame(restartsSent(sent).length, 0, "not restarted before the idle time has passed"); + + await plugin.check(now + 70_000); + t3.strictSame(restartsSent(sent).length, 1, "restarted once the idle time has passed"); + t3.strictSame(restartsSent(sent)[0].address, { hostId: 1 }, "the restart is sent to the host"); + t3.strictSame([...plugin.pending.get(1).instanceIds], [10], "only the running instances are remembered"); + + await plugin.check(now + 80_000); + t3.strictSame(restartsSent(sent).length, 1, "not restarted again while waiting for it to come back"); + }); + + t2.test(".check() asks each host process only once", async t3 => { + const { plugin, controller, sent } = await startPlugin(t3, { + config: { "exp_auto_restart.idle_seconds": 0 }, + }); + const now = Date.now(); + addHost(controller, 1); + addSystem(controller, 1, { processStartedAtMs: 1000 }); + + await plugin.check(now); + t3.strictSame(restartsSent(sent).length, 1, "restarted with no running instances"); + t3.notOk(plugin.pending.has(1), "nothing to wait for without running instances"); + + await plugin.check(now + 10_000); + t3.strictSame(restartsSent(sent).length, 1, "the same process is not asked again"); + + addSystem(controller, 1, { restartRequired: false, processStartedAtMs: 2000 }); + await plugin.check(now + 20_000); + t3.strictSame(restartsSent(sent).length, 1, "the new process does not need a restart"); + + addSystem(controller, 1, { processStartedAtMs: 2000 }); + await plugin.check(now + 30_000); + t3.strictSame(restartsSent(sent).length, 2, "the new process is restarted when it needs it"); + }); + + t2.test(".check() leaves hosts alone that cannot or need not restart", async t3 => { + const { plugin, controller, sent, logs } = await startPlugin(t3, { + config: { "exp_auto_restart.idle_seconds": 0 }, + }); + const now = Date.now(); + addHost(controller, 1); + addSystem(controller, 1, { canRestart: false }); + addHost(controller, 2); + addSystem(controller, 2, { restartRequired: false }); + addHost(controller, 3, false); + addSystem(controller, 3); + + await plugin.check(now); + await plugin.check(now + 10_000); + t3.strictSame(restartsSent(sent).length, 0, "no host is restarted"); + const warnings = logs.filter(([level, message]) => level === "warn" && message.includes("--can-restart")); + t3.strictSame(warnings.length, 1, "a host without a process monitor is warned about once"); + }); + + t2.test(".check() with scope cluster waits for the whole cluster to be empty", async t3 => { + const { plugin, controller, sent } = await startPlugin(t3, { + config: { "exp_auto_restart.idle_seconds": 0, "exp_auto_restart.scope": "cluster" }, + }); + const now = Date.now(); + addHost(controller, 1); + addSystem(controller, 1); + addHost(controller, 2); + addInstance(controller, 20, 2); + join(controller, "alice", 20); + + await plugin.check(now); + t3.strictSame(restartsSent(sent).length, 0, "a player on another host holds back the restart"); + + leave(controller, "alice", 20); + await plugin.check(now + 10_000); + t3.strictSame(restartsSent(sent).length, 1, "restarted once the cluster is empty"); + }); + + t2.test(".restartHost() retries transport errors but not refusals", async t3 => { + const { plugin, controller, sent, logs } = await startPlugin(t3, { + config: { "exp_auto_restart.idle_seconds": 0 }, + }); + const now = Date.now(); + addHost(controller, 1); + addInstance(controller, 10, 1); + addSystem(controller, 1); + + controller.sendTo = async () => { throw new lib.RequestError("Cannot restart"); }; + await plugin.check(now); + await plugin.check(now + 10_000); + t3.strictSame(logs.filter(([level]) => level === "error").length, 1, "a refusal is logged once"); + t3.notOk(plugin.pending.has(1), "a refused restart is not waited on"); + + addSystem(controller, 1, { processStartedAtMs: 2000 }); + let attempts = 0; + controller.sendTo = async () => { attempts += 1; throw new Error("Connection lost"); }; + await plugin.check(now + 20_000); + await plugin.check(now + 30_000); + t3.strictSame(attempts, 2, "a transport error is retried on the next check"); + }); + + t2.test(".instanceReported() starts the instances that were running", async t3 => { + const { plugin, controller, sent } = await startPlugin(t3, { + config: { "exp_auto_restart.idle_seconds": 0 }, + }); + const now = Date.now(); + addHost(controller, 1); + const manual = addInstance(controller, 10, 1); + const auto = addInstance(controller, 11, 1, "running", { "instance.auto_start": true }); + const stopped = addInstance(controller, 12, 1, "stopped"); + addSystem(controller, 1); + await plugin.check(now); + t3.strictSame([...plugin.pending.get(1).instanceIds], [10, 11]); + + // The host comes back and reports every instance as stopped + for (const instance of [manual, auto, stopped]) { + instance.status = "stopped"; + await plugin.onInstanceStatusChanged(instance, "unknown"); + } + t3.strictSame(startsSent(sent).map(({ address }) => address), [{ instanceId: 10 }], "only the instance the host will not start itself is started"); + t3.notOk(plugin.pending.has(1), "the host is no longer waited on"); + + // An instance reporting in without a pending restart is ignored + await plugin.onInstanceStatusChanged(manual, "unknown"); + t3.strictSame(startsSent(sent).length, 1); + }); + + t2.test(".restartHost() does not wait on instances when they are not to be started", async t3 => { + const { plugin, controller, sent } = await startPlugin(t3, { + config: { "exp_auto_restart.idle_seconds": 0, "exp_auto_restart.start_instances": false }, + }); + addHost(controller, 1); + const instance = addInstance(controller, 10, 1); + addSystem(controller, 1); + await plugin.check(Date.now()); + t3.strictSame(restartsSent(sent).length, 1); + t3.notOk(plugin.pending.has(1), "nothing is waited on"); + + instance.status = "stopped"; + await plugin.onInstanceStatusChanged(instance, "unknown"); + t3.strictSame(startsSent(sent).length, 0, "nothing is started"); + }); + + t2.test(".expirePending() gives up on hosts that never come back", async t3 => { + const { plugin, controller, logs } = await startPlugin(t3, { + config: { "exp_auto_restart.idle_seconds": 0 }, + }); + const now = Date.now(); + addHost(controller, 1); + addInstance(controller, 10, 1); + addSystem(controller, 1); + await plugin.check(now); + t3.ok(plugin.pending.has(1)); + + await plugin.check(now + PENDING_TIMEOUT_MS - 1); + t3.ok(plugin.pending.has(1), "still waited on before the timeout"); + await plugin.check(now + PENDING_TIMEOUT_MS); + t3.notOk(plugin.pending.has(1), "no longer waited on after the timeout"); + t3.ok(logs.some(([level, message]) => level === "warn" && message.includes("giving up")), "giving up is logged"); + }); + + t2.test(".checkController() restarts the controller when the cluster is empty", async t3 => { + const { plugin, controller } = await startPlugin(t3, { + config: { "exp_auto_restart.idle_seconds": 60, "exp_auto_restart.restart_controller": true }, + canRestart: true, + }); + const now = Date.now(); + let stopped = 0; + controller.stop = async () => { stopped += 1; }; + controller.checkRestartDowngrade = async () => null; + addHost(controller, 1); + addInstance(controller, 10, 1); + addSystem(controller, "controller"); + join(controller, "alice", 10); + + await plugin.check(now); + t3.strictSame(stopped, 0, "not restarted while a player is online"); + + leave(controller, "alice", 10); + await plugin.check(now + 10_000); + t3.strictSame(stopped, 0, "not restarted before the idle time has passed"); + + await plugin.check(now + 70_000); + t3.strictSame(stopped, 1, "restarted once the cluster has been empty for long enough"); + t3.ok(controller.shouldRestart, "stopped with the intent to restart"); + + await plugin.check(now + 80_000); + t3.strictSame(stopped, 1, "not restarted twice"); + }); + + t2.test(".checkController() refuses downgrades and missing process monitors", async t3 => { + const { plugin, controller, logs } = await startPlugin(t3, { + config: { "exp_auto_restart.idle_seconds": 0, "exp_auto_restart.restart_controller": true }, + canRestart: true, + }); + let stopped = 0; + controller.stop = async () => { stopped += 1; }; + controller.checkRestartDowngrade = async () => ({ installedVersion: "2.0.0", runningVersion: "2.0.1" }); + addSystem(controller, "controller"); + + await plugin.check(Date.now()); + t3.strictSame(stopped, 0, "not restarted into an older version"); + t3.ok(logs.some(([level, message]) => level === "warn" && message.includes("older")), "the downgrade is logged"); + + plugin.warned.clear(); + addSystem(controller, "controller", { canRestart: false }); + await plugin.check(Date.now()); + t3.strictSame(stopped, 0, "not restarted without a process monitor"); + t3.ok(logs.some(([level, message]) => level === "warn" && message.includes("--can-restart"))); + }); + + t2.test(".start() warns when system metrics are disabled", async t3 => { + const { logs } = await startPlugin(t3, { config: { "controller.system_metrics_interval": 0 } }); + t3.ok(logs.some(([level, message]) => level === "warn" && message.includes("system_metrics_interval"))); + }); + + t2.end(); +}); diff --git a/exp_auto_restart/tsconfig.json b/exp_auto_restart/tsconfig.json new file mode 100644 index 00000000..a0473e75 --- /dev/null +++ b/exp_auto_restart/tsconfig.json @@ -0,0 +1,6 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" } + ] +} diff --git a/exp_auto_restart/tsconfig.node.json b/exp_auto_restart/tsconfig.node.json new file mode 100644 index 00000000..3218f2e7 --- /dev/null +++ b/exp_auto_restart/tsconfig.node.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./**/*.ts"], + "exclude": ["test/*", "./dist/*"], +} diff --git a/tsconfig.json b/tsconfig.json index 8afb8e7c..94818dee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "files": [], "references": [ + { "path": "./exp_auto_restart/" }, { "path": "./exp_commands/" }, { "path": "./exp_groups/" }, { "path": "./exp_gui/" }, From 311506372b32bf9675420e6e65bf58b17c7edbc8 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:34:17 +0100 Subject: [PATCH 12/18] Fix following upstream plugin changes --- exp_roles/web/components/RoleProperties.tsx | 6 ++--- exp_roles/web/index.tsx | 26 ++++++++++----------- exp_scenario/controller.ts | 3 +++ 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/exp_roles/web/components/RoleProperties.tsx b/exp_roles/web/components/RoleProperties.tsx index 53f6ca99..c68d5f49 100644 --- a/exp_roles/web/components/RoleProperties.tsx +++ b/exp_roles/web/components/RoleProperties.tsx @@ -5,7 +5,7 @@ import * as lib from "@clusterio/lib"; import { ControlContext, SectionHeader, useAccount, notifyErrorHandler } from "@clusterio/web_ui"; import { RoleColor, RoleMetaRecord, RoleMetaUpdateRequest } from "../../messages.js"; -import type { WebPlugin } from ".."; +import { useRoles } from ".."; const MS_PER_HOUR = 3600000; @@ -25,13 +25,13 @@ type RoleFormValues = { * The name, description and permissions of a role are owned by clusterio, this * only covers the properties which only mean something in game. */ -export default function RoleProperties(props: { plugin: WebPlugin, role?: lib.Role }) { +export default function RoleProperties(props: { role?: lib.Role }) { const control = useContext(ControlContext); const account = useAccount(); + const [roles] = useRoles(); const [form] = Form.useForm(); const [applying, setApplying] = useState(false); - const [roles] = props.plugin.useRoles(); const record = props.role ? roles.get(props.role.id) : undefined; const meta = record?.meta; const canUpdate = account.hasPermission("core.role.update"); diff --git a/exp_roles/web/index.tsx b/exp_roles/web/index.tsx index 36e9de1d..0753cf9c 100644 --- a/exp_roles/web/index.tsx +++ b/exp_roles/web/index.tsx @@ -1,24 +1,22 @@ import React, { useCallback, useSyncExternalStore } from "react"; -import { BaseWebPlugin } from "@clusterio/web_ui"; +import { WebPluginContext } from "@clusterio/web_ui"; import * as lib from "@clusterio/lib"; import * as messages from "../messages.js"; import RoleProperties from "./components/RoleProperties"; -export class WebPlugin extends BaseWebPlugin { - roles = new lib.MapSubscriber(messages.RoleUpdatedEvent, this.control); +let roles: lib.MapSubscriber | undefined; - async init() { - // The core components pass a role and the plugin, which componentExtra - // does not carry in its type - this.componentExtra = { - RoleViewPage: RoleProperties as React.ComponentType, - }; - } +export function useRoles() { + const subscribe = useCallback((cb: () => void) => roles!.subscribe(cb), []); + return useSyncExternalStore(subscribe, () => roles!.getSnapshot()); +} - useRoles() { - const subscribe = useCallback((cb: () => void) => this.roles.subscribe(cb), []); - return useSyncExternalStore(subscribe, () => this.roles.getSnapshot()); - } +export default function registerPlugin(context: WebPluginContext) { + context.control.hooks.extensionComponents.attach("exp_roles", () => ({ + RoleViewPage: RoleProperties, + })); + + roles = new lib.MapSubscriber(messages.RoleUpdatedEvent, context.control); } diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index 05d3303e..e76d0c8e 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -19,6 +19,8 @@ export class ControllerPlugin extends BaseControllerPlugin { * permissions, groups which already exist by name are reset to the seed. */ async handleSeedRequest() { + throw new lib.RequestError("Seeding is temporarily disabled, please contact the server administrator to seed the roles and groups"); + /* const rolesPlugin = this.controller.plugins.get("exp_roles") as RolesPlugin | undefined; const groupsPlugin = this.controller.plugins.get("exp_groups") as GroupsPlugin | undefined; if (!rolesPlugin || !groupsPlugin) { @@ -52,6 +54,7 @@ export class ControllerPlugin extends BaseControllerPlugin { this.seedRoleMappings(groupsPlugin, roleIds, groupIds); this.logger.info(`Seeded ${roleIds.size} roles and ${groupIds.size} permission groups`); + */ } /** Find or create the clusterio role for a seed role, returns undefined if it has no role to use. */ From c4ca3d6738b5d561d033370d763699a6096373c0 Mon Sep 17 00:00:00 2001 From: PHIDIAS Date: Sun, 27 Sep 2026 05:35:30 +0900 Subject: [PATCH 13/18] chore(l10n): update Chinese (Traditional Han script) translation (#45) (#478) chore(l10n): update Chinese (Simplified Han script) translation chore(l10n): update Chinese (Traditional Han script) translation chore(l10n): update Chinese (Simplified Han script) translation chore(l10n): update Chinese (Traditional Han script) translation chore(l10n): update Chinese (Simplified Han script) translation chore(l10n): update English translation Language: Chinese (Simplified Han script) Language: Chinese (Traditional Han script) Language: English Progress: 100.0% (233 of 233 strings) Progress: 100.0% (452 of 452 strings) Progress: 95.7% (433 of 452 strings) Translate-URL: https://translate.aperx.org/projects/expcluster/explegacy/zh_Hans/ Translate-URL: https://translate.aperx.org/projects/expcluster/explegacy/zh_Hant/ Translate-URL: https://translate.aperx.org/projects/expcluster/expscenario/en/ Translate-URL: https://translate.aperx.org/projects/expcluster/expscenario/zh_Hans/ Translate-URL: https://translate.aperx.org/projects/expcluster/expscenario/zh_Hant/ Translation: ExpCluster/ExpLegacy Translation: ExpCluster/ExpScenario Co-authored-by: aperx-translate[bot] <328484987+aperx-translate[bot]@users.noreply.github.com> Co-authored-by: Weblate --- exp_legacy/module/locale/zh-CN.cfg | 6 ++-- exp_legacy/module/locale/zh-TW.cfg | 6 ++-- exp_scenario/module/locale/en.cfg | 16 ++++----- exp_scenario/module/locale/zh-CN.cfg | 50 ++++++++++++++++++++-------- exp_scenario/module/locale/zh-TW.cfg | 50 ++++++++++++++++++++-------- 5 files changed, 88 insertions(+), 40 deletions(-) diff --git a/exp_legacy/module/locale/zh-CN.cfg b/exp_legacy/module/locale/zh-CN.cfg index b355f354..02a1efba 100644 --- a/exp_legacy/module/locale/zh-CN.cfg +++ b/exp_legacy/module/locale/zh-CN.cfg @@ -8,7 +8,7 @@ status=伺服器狀態: __1__ github=增加功能或回報錯誤: __1__ patreon=支持我們: __1__ custom-commands=這裹使用了自制指令,如 /tag 和 /me。可使用 /chelp 查看更多。 -read-readme=確保你已閱讀相關資訊。按左上 i 圖標可再次查看。 +read-readme=確保你已閱讀相關資訊。按左上 i 圖標可再次查看 softmod=這裹用了自設情境,是一種軟裝模組。 redmew=We don't talk about redmew here; they beat us to 1000 members ;-; lhd=列車必須是左則通行。這是本服務器長久以來的規則。 @@ -18,7 +18,7 @@ lhd=列車必須是左則通行。這是本服務器長久以來的規則。 description-add=設定 / 取得你的加入信息。 description-remove=移除你的加入信息。 arg-message=你的加入信息。 -greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器可加入 DISCORD: __2__ [/color] +greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器可加入 Discord: __2__ [/color] message-set=你的加入信息已更新。 message-get=你的加入信息為: __1__ message-cleared=你的加入信息已清除。 @@ -127,7 +127,7 @@ Locale-tooltip=用戶介面語言 # expcore [expcore-data] description-preference=允許寫入資料。 -description-data=把用戶資料寫入電腦 +description-data=把用戶資料寫入電腦。 arg-option="All", "Statistics", "Settings", "Required" 之中 set-preference=你的喜好已設定為 __1__ 。在你重新加入前將不會有任何改動。 get-preference=你的喜好為 __1__ 。使用 /set-preference 來更改,使用 /save-data 來獲得一份資料副本。 diff --git a/exp_legacy/module/locale/zh-TW.cfg b/exp_legacy/module/locale/zh-TW.cfg index b355f354..02a1efba 100644 --- a/exp_legacy/module/locale/zh-TW.cfg +++ b/exp_legacy/module/locale/zh-TW.cfg @@ -8,7 +8,7 @@ status=伺服器狀態: __1__ github=增加功能或回報錯誤: __1__ patreon=支持我們: __1__ custom-commands=這裹使用了自制指令,如 /tag 和 /me。可使用 /chelp 查看更多。 -read-readme=確保你已閱讀相關資訊。按左上 i 圖標可再次查看。 +read-readme=確保你已閱讀相關資訊。按左上 i 圖標可再次查看 softmod=這裹用了自設情境,是一種軟裝模組。 redmew=We don't talk about redmew here; they beat us to 1000 members ;-; lhd=列車必須是左則通行。這是本服務器長久以來的規則。 @@ -18,7 +18,7 @@ lhd=列車必須是左則通行。這是本服務器長久以來的規則。 description-add=設定 / 取得你的加入信息。 description-remove=移除你的加入信息。 arg-message=你的加入信息。 -greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器可加入 DISCORD: __2__ [/color] +greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器可加入 Discord: __2__ [/color] message-set=你的加入信息已更新。 message-get=你的加入信息為: __1__ message-cleared=你的加入信息已清除。 @@ -127,7 +127,7 @@ Locale-tooltip=用戶介面語言 # expcore [expcore-data] description-preference=允許寫入資料。 -description-data=把用戶資料寫入電腦 +description-data=把用戶資料寫入電腦。 arg-option="All", "Statistics", "Settings", "Required" 之中 set-preference=你的喜好已設定為 __1__ 。在你重新加入前將不會有任何改動。 get-preference=你的喜好為 __1__ 。使用 /set-preference 來更改,使用 /save-data 來獲得一份資料副本。 diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index 55d72baf..5dc39d3b 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -507,23 +507,23 @@ reply-evolution=Current evolution factor is __1__ reply-magic=Fear the admin magic (ノ゚∀゚)ノ⌒・*:.。. .。.:*・゜゚・*☆ reply-aids=≖ ‿ ≖ Fear the aids of a public server ≖ ‿ ≖ reply-riot=(admins) ┬┴┬┴┤ᵒ_ᵒ)├┬┴┬┴ ‹ ‹\(´ω` )/››‹‹\ (  ´)/››‹‹\ ( ´ω`)/›› (rest of server) -reply-loops=NO LOOPS; LOOPS ARE BAD; JUST NO LOOPS!!!!!; IF YOU MAKE A LOOP.... IT WILL NOT END WELL!!!!!!! +reply-loops=NO LOOPS; LOOPS ARE BAD; JUST NO LOOPS!!!; IF YOU MAKE A LOOP… IT WILL NOT END WELL!!! reply-lenny=( ͡° ͜ʖ ͡°) reply-hodor=Hodor -reply-popcorn-1=Heating the oil and waiting for the popping sound... +reply-popcorn-1=Heating the oil and waiting for the popping sound… reply-popcorn-2=__1__ your popcorn is finished. Lean backwards and watch the drama unfold. -reply-snaps-1=Pouring the glasses and finding the correct song book... -reply-snaps-2=Singing a song...🎤🎶 +reply-snaps-1=Pouring the glasses and finding the correct song book… +reply-snaps-2=Singing a song…🎤🎶 reply-snaps-3=schkål, my friends! -reply-cocktail-1= 🍸 Inintiating mind reading unit... 🍸 +reply-cocktail-1= 🍸 Inintiating mind reading unit… 🍸 reply-cocktail-2= 🍸 Mixing favourite ingredients of __1__ 🍸 reply-cocktail-3=🍸 __1__ your cocktail is done.🍸 -reply-coffee-1= ☕ Boiling the water and grinding the coffee beans... ☕ +reply-coffee-1= ☕ Boiling the water and grinding the coffee beans… ☕ reply-coffee-2= ☕ __1__ we ran out of coffe beans! Have some tea instead. ☕ -reply-pizza-1= 🍕 Finding nearest pizza supplier... 🍕 +reply-pizza-1= 🍕 Finding nearest pizza supplier… 🍕 reply-pizza-2= 🍕 Figuring out the favourite pizza of __1__ 🍕 reply-pizza-3= 🍕 __1__ your pizza is here! 🍕 -reply-tea-1= ☕ Boiling the water... ☕ +reply-tea-1= ☕ Boiling the water… ☕ reply-tea-2= ☕ __1__ your tea is done! ☕ reply-mead-1= Filling the drinking horn reply-mead-2= Skål! diff --git a/exp_scenario/module/locale/zh-CN.cfg b/exp_scenario/module/locale/zh-CN.cfg index 31e68f74..1b776db8 100644 --- a/exp_scenario/module/locale/zh-CN.cfg +++ b/exp_scenario/module/locale/zh-CN.cfg @@ -84,7 +84,7 @@ home-get=你的回程傳送點在 __1__ 上, x: __2__ y: __3__ description=把用戶送監而禁止其行動。 arg-player=用戶 arg-reason=原因 -jailed=__1__ 已被 __2__ ,因 __3__ 而被禁止行動。 +jailed=__1__ 已被 __2__ ,因 __3__ 而被禁止行動 already-jailed=__1__ 已被禁止行動。 [exp-commands_unjail] @@ -129,6 +129,8 @@ already-protected=本地區已受保護。 protected-area=本地區現已受保護。 unprotected-area=本地區現不受保護。 repeat-offence=__1__ 在 [gps=__3__,__4__] 拆除了 __2__ +enter-entity-selection = Entered entity selection, select entites to protect, hold shift to remove protection. +enter-area-selection = Entered area selection, select areas to protect, hold shift to remove protection. [exp-commands_tag-protection] description=切換管理員標記模式,新標記只能由管理員編輯。 @@ -153,6 +155,7 @@ fluid-in=你每秒需要 __1__ 來做 [fluid=__2__]。 item-out=結果: __1__ [item=__2__] 每秒。 fluid-out=結果: __1__ [fluid=__2__] 每秒。 machines=你需要 __1__ 部同樣的機器。 +machine-count = And you will need __1__ machines (with the same speed as this one) for this. [exp-commands_repair] selection-name=維修 @@ -235,9 +238,9 @@ response=__1__ 把 __2__ 個火車設置為自動模式。 description-teleport=將一名玩家傳送到另一名玩家。 description-bring=將一名玩家傳送到你身邊。 description-goto=將你傳送到玩家身邊。 -description-spawn=傳送到出生點。 +description-spawn=傳送到出生點 arg-player-teleport=傳送的玩家,若沒有目標,你會被傳送到這。 -arg-player-from=傳送的玩家 +arg-player-from=傳送的玩家。 arg-player-to=傳送的玩家目標 unavailable=沒找到空間進行傳送 same-player=沒辦法傳送到自己 @@ -389,6 +392,7 @@ data-statistics=數據 data-required=需要 data-misc=雜項 data-format=__1____2__ +welcome-title = Welcome to __1__ [exp-gui_research-milestones] tooltip-main=研究介面 @@ -480,16 +484,17 @@ tooltip-edit-none=現沒有被人修改 caption-create-footer=加入工作流程 caption-edit-footer=修改工作流程 caption-view-footer=工作流程細節 +tooltip-confirm = Save changes (minimum of 5 characters long) [exp_afk-kick] kick-message=因地圖中沒有活躍玩家,所以所有人都已被請離。 [exp_chat-auto-reply] chat-reply=[Compilatron] __1__ -chat-disallowed=你沒有權限使用這個指令。 +chat-disallowed=You can't use global chat commands reply-online=現在有 __1__ 人在線 reply-players=這張地圖上共有__1__名用戶 -reply-dev=Cooldude2606 只是本場境的開發者 +reply-dev=Cooldude2606 is a dev for this server and makes the scenario and is not a factorio dev. reply-softmod=這裹用了自設情境。 reply-clusterio=Clusterio is a factorio server hosting platform allowing internet enabled mods. reply-blame=責怪 __1__ 吧! @@ -498,23 +503,23 @@ reply-evolution=現在敵人進化度為 __1__ reply-magic=Fear the admin magic (ノ゚∀゚)ノ⌒・*:.。. .。.:*・゜゚・*☆ reply-aids=≖ ‿ ≖ Fear the aids of a public server ≖ ‿ ≖ reply-riot=(admins) ┬┴┬┴┤ᵒ_ᵒ)├┬┴┬┴ ‹ ‹\(´ω` )/››‹‹\ (  ´)/››‹‹\ ( ´ω`)/›› (rest of server) -reply-loops=架設迴旋處最終後果都不好! +reply-loops=NO LOOPS; LOOPS ARE BAD; JUST NO LOOPS!!!; IF YOU MAKE A LOOP… IT WILL NOT END WELL!!! reply-lenny=( ͡° ͜ʖ ͡°) reply-hodor=Hodor -reply-popcorn-1=Heating the oil and waiting for the popping sound... +reply-popcorn-1=Heating the oil and waiting for the popping sound… reply-popcorn-2=__1__ your popcorn is finished. Lean backwards and watch the drama unfold. -reply-snaps-1=Pouring the glasses and finding the correct song book... -reply-snaps-2=Singing a song...🎤🎶 +reply-snaps-1=Pouring the glasses and finding the correct song book… +reply-snaps-2=Singing a song…🎤🎶 reply-snaps-3=schkål, my friends! -reply-cocktail-1= 🍸 Inintiating mind reading unit... 🍸 +reply-cocktail-1= 🍸 Inintiating mind reading unit… 🍸 reply-cocktail-2= 🍸 Mixing favourite ingredients of __1__ 🍸 reply-cocktail-3=🍸 __1__ your cocktail is done.🍸 -reply-coffee-1= ☕ Boiling the water and grinding the coffee beans... ☕ +reply-coffee-1= ☕ Boiling the water and grinding the coffee beans… ☕ reply-coffee-2= ☕ __1__ we ran out of coffe beans! Have some tea instead. ☕ -reply-pizza-1= 🍕 Finding nearest pizza supplier... 🍕 +reply-pizza-1= 🍕 Finding nearest pizza supplier… 🍕 reply-pizza-2= 🍕 Figuring out the favourite pizza of __1__ 🍕 reply-pizza-3= 🍕 __1__ your pizza is here! 🍕 -reply-tea-1= ☕ Boiling the water... ☕ +reply-tea-1= ☕ Boiling the water… ☕ reply-tea-2= ☕ __1__ your tea is done! ☕ reply-mead-1= Filling the drinking horn reply-mead-2= Skål! @@ -546,3 +551,22 @@ chat-jailed=__1__ 因被多次拆除受保護物體而被禁止行動。請等 [exp_report-jail] chat-jailed=__1__ 因被多次舉報而被禁止行動。請等候管理員作出下一步處理。 + + +[exp-gui_player-list] +main-tooltip = Player List +open-action-bar = Options +close-action-bar = Close Options +reason-confirm = Confirm Reason +reason-entry = Enter Reason +goto-player = Goto player +bring-player = Bring player +report-player = Report player +jail-player = Jail player +kick-player = Kick player +ban-player = Ban player +afk-time = __1__% of total map time\nLast moved __2__ ago +open-map = __1__ __2__\n__3__\nClick to open map + +[exp_spectate] +follow-label = Following __1__.\nClick here or press escape to stop following. \ No newline at end of file diff --git a/exp_scenario/module/locale/zh-TW.cfg b/exp_scenario/module/locale/zh-TW.cfg index 31e68f74..1b776db8 100644 --- a/exp_scenario/module/locale/zh-TW.cfg +++ b/exp_scenario/module/locale/zh-TW.cfg @@ -84,7 +84,7 @@ home-get=你的回程傳送點在 __1__ 上, x: __2__ y: __3__ description=把用戶送監而禁止其行動。 arg-player=用戶 arg-reason=原因 -jailed=__1__ 已被 __2__ ,因 __3__ 而被禁止行動。 +jailed=__1__ 已被 __2__ ,因 __3__ 而被禁止行動 already-jailed=__1__ 已被禁止行動。 [exp-commands_unjail] @@ -129,6 +129,8 @@ already-protected=本地區已受保護。 protected-area=本地區現已受保護。 unprotected-area=本地區現不受保護。 repeat-offence=__1__ 在 [gps=__3__,__4__] 拆除了 __2__ +enter-entity-selection = Entered entity selection, select entites to protect, hold shift to remove protection. +enter-area-selection = Entered area selection, select areas to protect, hold shift to remove protection. [exp-commands_tag-protection] description=切換管理員標記模式,新標記只能由管理員編輯。 @@ -153,6 +155,7 @@ fluid-in=你每秒需要 __1__ 來做 [fluid=__2__]。 item-out=結果: __1__ [item=__2__] 每秒。 fluid-out=結果: __1__ [fluid=__2__] 每秒。 machines=你需要 __1__ 部同樣的機器。 +machine-count = And you will need __1__ machines (with the same speed as this one) for this. [exp-commands_repair] selection-name=維修 @@ -235,9 +238,9 @@ response=__1__ 把 __2__ 個火車設置為自動模式。 description-teleport=將一名玩家傳送到另一名玩家。 description-bring=將一名玩家傳送到你身邊。 description-goto=將你傳送到玩家身邊。 -description-spawn=傳送到出生點。 +description-spawn=傳送到出生點 arg-player-teleport=傳送的玩家,若沒有目標,你會被傳送到這。 -arg-player-from=傳送的玩家 +arg-player-from=傳送的玩家。 arg-player-to=傳送的玩家目標 unavailable=沒找到空間進行傳送 same-player=沒辦法傳送到自己 @@ -389,6 +392,7 @@ data-statistics=數據 data-required=需要 data-misc=雜項 data-format=__1____2__ +welcome-title = Welcome to __1__ [exp-gui_research-milestones] tooltip-main=研究介面 @@ -480,16 +484,17 @@ tooltip-edit-none=現沒有被人修改 caption-create-footer=加入工作流程 caption-edit-footer=修改工作流程 caption-view-footer=工作流程細節 +tooltip-confirm = Save changes (minimum of 5 characters long) [exp_afk-kick] kick-message=因地圖中沒有活躍玩家,所以所有人都已被請離。 [exp_chat-auto-reply] chat-reply=[Compilatron] __1__ -chat-disallowed=你沒有權限使用這個指令。 +chat-disallowed=You can't use global chat commands reply-online=現在有 __1__ 人在線 reply-players=這張地圖上共有__1__名用戶 -reply-dev=Cooldude2606 只是本場境的開發者 +reply-dev=Cooldude2606 is a dev for this server and makes the scenario and is not a factorio dev. reply-softmod=這裹用了自設情境。 reply-clusterio=Clusterio is a factorio server hosting platform allowing internet enabled mods. reply-blame=責怪 __1__ 吧! @@ -498,23 +503,23 @@ reply-evolution=現在敵人進化度為 __1__ reply-magic=Fear the admin magic (ノ゚∀゚)ノ⌒・*:.。. .。.:*・゜゚・*☆ reply-aids=≖ ‿ ≖ Fear the aids of a public server ≖ ‿ ≖ reply-riot=(admins) ┬┴┬┴┤ᵒ_ᵒ)├┬┴┬┴ ‹ ‹\(´ω` )/››‹‹\ (  ´)/››‹‹\ ( ´ω`)/›› (rest of server) -reply-loops=架設迴旋處最終後果都不好! +reply-loops=NO LOOPS; LOOPS ARE BAD; JUST NO LOOPS!!!; IF YOU MAKE A LOOP… IT WILL NOT END WELL!!! reply-lenny=( ͡° ͜ʖ ͡°) reply-hodor=Hodor -reply-popcorn-1=Heating the oil and waiting for the popping sound... +reply-popcorn-1=Heating the oil and waiting for the popping sound… reply-popcorn-2=__1__ your popcorn is finished. Lean backwards and watch the drama unfold. -reply-snaps-1=Pouring the glasses and finding the correct song book... -reply-snaps-2=Singing a song...🎤🎶 +reply-snaps-1=Pouring the glasses and finding the correct song book… +reply-snaps-2=Singing a song…🎤🎶 reply-snaps-3=schkål, my friends! -reply-cocktail-1= 🍸 Inintiating mind reading unit... 🍸 +reply-cocktail-1= 🍸 Inintiating mind reading unit… 🍸 reply-cocktail-2= 🍸 Mixing favourite ingredients of __1__ 🍸 reply-cocktail-3=🍸 __1__ your cocktail is done.🍸 -reply-coffee-1= ☕ Boiling the water and grinding the coffee beans... ☕ +reply-coffee-1= ☕ Boiling the water and grinding the coffee beans… ☕ reply-coffee-2= ☕ __1__ we ran out of coffe beans! Have some tea instead. ☕ -reply-pizza-1= 🍕 Finding nearest pizza supplier... 🍕 +reply-pizza-1= 🍕 Finding nearest pizza supplier… 🍕 reply-pizza-2= 🍕 Figuring out the favourite pizza of __1__ 🍕 reply-pizza-3= 🍕 __1__ your pizza is here! 🍕 -reply-tea-1= ☕ Boiling the water... ☕ +reply-tea-1= ☕ Boiling the water… ☕ reply-tea-2= ☕ __1__ your tea is done! ☕ reply-mead-1= Filling the drinking horn reply-mead-2= Skål! @@ -546,3 +551,22 @@ chat-jailed=__1__ 因被多次拆除受保護物體而被禁止行動。請等 [exp_report-jail] chat-jailed=__1__ 因被多次舉報而被禁止行動。請等候管理員作出下一步處理。 + + +[exp-gui_player-list] +main-tooltip = Player List +open-action-bar = Options +close-action-bar = Close Options +reason-confirm = Confirm Reason +reason-entry = Enter Reason +goto-player = Goto player +bring-player = Bring player +report-player = Report player +jail-player = Jail player +kick-player = Kick player +ban-player = Ban player +afk-time = __1__% of total map time\nLast moved __2__ ago +open-map = __1__ __2__\n__3__\nClick to open map + +[exp_spectate] +follow-label = Following __1__.\nClick here or press escape to stop following. \ No newline at end of file From 61359af5bce0fec877dd27efe6aaf0a6fc625cde Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:04:53 +0100 Subject: [PATCH 14/18] Remove Spectate Fixes Removed API - LuaPlayer::spectator Fixes #479 --- exp_scenario/module/control/roles.lua | 3 --- 1 file changed, 3 deletions(-) diff --git a/exp_scenario/module/control/roles.lua b/exp_scenario/module/control/roles.lua index 1c4ecf66..0ffdc3a0 100644 --- a/exp_scenario/module/control/roles.lua +++ b/exp_scenario/module/control/roles.lua @@ -9,9 +9,6 @@ Roles.define_permission_trigger("exp_scenario.player.admin", function(player, st player.admin = state end) -Roles.define_permission_trigger("exp_scenario.player.spectator", function(player, state) - player.spectator = state -end) return { events = { From 68b2dda190243f47f90d9c900ffb9805bc67c70f Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:50:27 +0000 Subject: [PATCH 15/18] Move the warp list into exp_scenario The warp system was split across a legacy control module and a legacy gui, both built on expcore.datastore. They are now one exp_scenario gui, exp_scenario/module/gui/warp_list.lua, in the shape of the task list: per force warps and per player cooldown and proximity state live on the container's element data, rows are element defines which carry their warp in element data, and refreshes compute the player's status once and apply it to every row. Warps are referenced as objects rather than string ids, the last editor is a LuaPlayer, and the cooldown counts ticks. The locale moves to [exp-gui_warp-list] in en, zh-CN and zh-TW with keys named by region and use, and the legacy [warp-list] sections are removed along with the two legacy modules and the file loader entry. The config stays in exp_legacy/config/gui/warps.lua for now like the other gui configs. --- exp_legacy/module/config/_file_loader.lua | 1 - exp_legacy/module/locale/en.cfg | 31 - exp_legacy/module/locale/zh-CN.cfg | 31 - exp_legacy/module/locale/zh-TW.cfg | 31 - exp_legacy/module/modules/control/warps.lua | 488 -------- exp_legacy/module/modules/gui/warp-list.lua | 909 -------------- exp_scenario/module/control.lua | 1 + exp_scenario/module/gui/warp_list.lua | 1218 +++++++++++++++++++ exp_scenario/module/locale/en.cfg | 31 + exp_scenario/module/locale/zh-CN.cfg | 31 + exp_scenario/module/locale/zh-TW.cfg | 31 + 11 files changed, 1312 insertions(+), 1491 deletions(-) delete mode 100644 exp_legacy/module/modules/control/warps.lua delete mode 100644 exp_legacy/module/modules/gui/warp-list.lua create mode 100644 exp_scenario/module/gui/warp_list.lua diff --git a/exp_legacy/module/config/_file_loader.lua b/exp_legacy/module/config/_file_loader.lua index 4edd462e..31fe77ed 100644 --- a/exp_legacy/module/config/_file_loader.lua +++ b/exp_legacy/module/config/_file_loader.lua @@ -20,7 +20,6 @@ return { --"modules.data.toolbar", --- GUI - "modules.gui.warp-list", "modules.gui.vlayer", "modules.graftorio.require", -- graftorio diff --git a/exp_legacy/module/locale/en.cfg b/exp_legacy/module/locale/en.cfg index 708296e0..af6bca63 100644 --- a/exp_legacy/module/locale/en.cfg +++ b/exp_legacy/module/locale/en.cfg @@ -158,37 +158,6 @@ ban-player=Ban player afk-time=__1__% of total map time\nLast moved __2__ ago open-map=__1__ __2__\n__3__\nClick to open map -[warp-list] -main-caption=Warp List [img=info] -main-tooltip=Warp List -sub-tooltip=Warps can only be used every __1__ seconds and when within __2__ tiles\n__3__\n__4__\n__5__\n__6__\n__7__\n__8__ -sub-tooltip-current= - __1__ This is your current warp point; -sub-tooltip-connected= - __1__ You can travel to this warp point; -sub-tooltip-different= - __1__ This warp is on a different network; -sub-tooltip-cooldown= - __1__ You are currently on cooldown; -sub-tooltip-not_available= - __1__ You are not in range of a warp point; -sub-tooltip-bypass= - __1__ Your role allows you to travel here; -too-close=Cannot create warp; too close to existing warp point: __1__ -too-close-to-water=Cannot create warp; too close to water, please move __1__ tiles away from the water body or landfill it -too-close-to-entities=Cannot create warp; too close to other entities, please move __1__ tiles away from the entities or remove them -last-edit=Last edited by __1__ at __2__\nClick to view on map -add-tooltip=Add new warp -confirm-tooltip=Save changes -cancel-tooltip=Discard changes -edit-tooltip=Currently being edited by: __1__ -edit-tooltip-none=Currently being edited by: Nobody -remove-tooltip=Remove warp -timer-tooltip=Please wait __1__ seconds before you can warp -timer-tooltip-zero=After each warp you will need to wait __1__ seconds before you can warp again -goto-tooltip=Go to x __1__ y __2__ -goto-bypass=Go to x __1__ y __2__, bypass mode -goto-bypass-different-network=Go to x __1__ y __2__, bypass mode different network -goto-same-warp=You are already on this warp -goto-different-network=This warp is on a different network, use power poles to connect it -goto-cooldown=You are on cooldown, wait for your cooldown to recharge -goto-disabled=You are not on a warp point, walk to a warp point -goto-edit=Edit warp icon - [bonus] description=Get / Set the amount of bonus you receive. arg-amount=Amount to set your bonus to, 0 will disable bonus. diff --git a/exp_legacy/module/locale/zh-CN.cfg b/exp_legacy/module/locale/zh-CN.cfg index 02a1efba..28178f71 100644 --- a/exp_legacy/module/locale/zh-CN.cfg +++ b/exp_legacy/module/locale/zh-CN.cfg @@ -158,37 +158,6 @@ ban-player=封禁用戶 afk-time=__1__% 地圖時間\n上次移動: __2__ open-map=__1__ __2__\n__3__\n按下打開地圖 -[warp-list] -main-caption=傳送陣清單 [img=info] -main-tooltip=傳送陣清單 -sub-tooltip=傳送陣在 __1__ 秒內只能使用一次。 亦需在 __2__ 格之內使用\n__3__\n__4__\n__5__\n__6__\n__7__\n__8__ -sub-tooltip-current= - __1__ 你目前的傳送陣; -sub-tooltip-connected= - __1__ 你可以到本傳送陣; -sub-tooltip-different= - __1__ 該傳送陣未連接; -sub-tooltip-cooldown= - __1__ 傳送冷卻中; -sub-tooltip-not_available= - __1__ 你不在傳送陣範圍; -sub-tooltip-bypass= - __1__ 你可以傳送到這裏; -too-close=無法新建傳送陣; 太接近 __1__ 了 -too-close-to-water=無法新建傳送陣; 太接近水源了, 請移動 __1__ 格又或者填平該地方 -too-close-to-entities=無法新建傳送陣; 太接近其他東西, 請移動 __1__ 格又或者拆除該東西 -last-edit=最後由 __1__ 在 __2__ 更改\n按這在地圖上看 -add-tooltip=新建傳送陣 -confirm-tooltip=儲存更改 -cancel-tooltip=放棄更改 -edit-tooltip=現被 __1__ 修改中 -edit-tooltip-none=現沒有被人修改 -remove-tooltip=拆除傳送陣 -timer-tooltip=請等 __1__ 秒才再次傳送 -timer-tooltip-zero=你要等 __1__ 秒才可以再次傳送 -goto-tooltip=到 X __1__ Y __2__ -goto-bypass=到 X __1__ Y __2__, 繞行模式 -goto-bypass-different-network=到 X __1__ Y __2__, 繞行模式 (不同網絡) -goto-same-warp=你已在該傳送陣 -goto-different-network=該傳送陣未連接, 你可使用電纜連接 -goto-cooldown=傳送冷卻中, 請等候冷卻 -goto-disabled=你不在傳送陣, 請移至任意傳送陣 -goto-edit=修改傳送陣圖案 - [bonus] description=取得 / 設定 Bonus 量。 arg-amount=Bonus 數量, 0 來停用。 diff --git a/exp_legacy/module/locale/zh-TW.cfg b/exp_legacy/module/locale/zh-TW.cfg index 02a1efba..28178f71 100644 --- a/exp_legacy/module/locale/zh-TW.cfg +++ b/exp_legacy/module/locale/zh-TW.cfg @@ -158,37 +158,6 @@ ban-player=封禁用戶 afk-time=__1__% 地圖時間\n上次移動: __2__ open-map=__1__ __2__\n__3__\n按下打開地圖 -[warp-list] -main-caption=傳送陣清單 [img=info] -main-tooltip=傳送陣清單 -sub-tooltip=傳送陣在 __1__ 秒內只能使用一次。 亦需在 __2__ 格之內使用\n__3__\n__4__\n__5__\n__6__\n__7__\n__8__ -sub-tooltip-current= - __1__ 你目前的傳送陣; -sub-tooltip-connected= - __1__ 你可以到本傳送陣; -sub-tooltip-different= - __1__ 該傳送陣未連接; -sub-tooltip-cooldown= - __1__ 傳送冷卻中; -sub-tooltip-not_available= - __1__ 你不在傳送陣範圍; -sub-tooltip-bypass= - __1__ 你可以傳送到這裏; -too-close=無法新建傳送陣; 太接近 __1__ 了 -too-close-to-water=無法新建傳送陣; 太接近水源了, 請移動 __1__ 格又或者填平該地方 -too-close-to-entities=無法新建傳送陣; 太接近其他東西, 請移動 __1__ 格又或者拆除該東西 -last-edit=最後由 __1__ 在 __2__ 更改\n按這在地圖上看 -add-tooltip=新建傳送陣 -confirm-tooltip=儲存更改 -cancel-tooltip=放棄更改 -edit-tooltip=現被 __1__ 修改中 -edit-tooltip-none=現沒有被人修改 -remove-tooltip=拆除傳送陣 -timer-tooltip=請等 __1__ 秒才再次傳送 -timer-tooltip-zero=你要等 __1__ 秒才可以再次傳送 -goto-tooltip=到 X __1__ Y __2__ -goto-bypass=到 X __1__ Y __2__, 繞行模式 -goto-bypass-different-network=到 X __1__ Y __2__, 繞行模式 (不同網絡) -goto-same-warp=你已在該傳送陣 -goto-different-network=該傳送陣未連接, 你可使用電纜連接 -goto-cooldown=傳送冷卻中, 請等候冷卻 -goto-disabled=你不在傳送陣, 請移至任意傳送陣 -goto-edit=修改傳送陣圖案 - [bonus] description=取得 / 設定 Bonus 量。 arg-amount=Bonus 數量, 0 來停用。 diff --git a/exp_legacy/module/modules/control/warps.lua b/exp_legacy/module/modules/control/warps.lua deleted file mode 100644 index fdca5b62..00000000 --- a/exp_legacy/module/modules/control/warps.lua +++ /dev/null @@ -1,488 +0,0 @@ ---[[-- Control Module - Warps -- Stores warps for each force. -@control Warps -@alias Warps - -@usage-- Making a new spawn warp -local player = game.player -local force = player.force -local spawn_id = Warps.add_warp(force.name, player.physical_surface, player.physical_position, player.name, 'Spawn') - -Warps.set_spawn_warp(spawn_id, force) -Warps.make_warp_tag(spawn_id) - -@usage-- Making a new warp with a warp area -local player = game.player -local force = player.force -local warp_id = Warps.add_warp(force.name, player.physical_surface, player.physical_position, player.name) - -Warps.make_warp_area(warp_id) -Warps.make_warp_tag(warp_id) - -]] - -local Datastore = require("modules.exp_legacy.expcore.datastore") --- @dep expcore.datastore -local Storage = require("modules/exp_util/storage") -local config = require("modules.exp_legacy.config.gui.warps") --- @dep config.warps - ---- Stores all data for the warp system -local WrapData = Datastore.connect("WrapData") -WrapData:set_serializer(function(raw_key) return raw_key.warp_id end) - -local Warps = {} - ---- @class ExpScenario_Warps.force_warps: { spawn: string, [number]: string } - --- Storage lookup table for force name to task ids -local force_warps = { _uid = 1 } ---- @cast force_warps table -Storage.register(force_warps, function(tbl) - force_warps = tbl -end) - --- Create an array of entity names that will be added to the remove filter -local remove_warp_area_entity_names = {} -for _, entity in pairs(config.entities) do - table.insert(remove_warp_area_entity_names, entity[1]) -end - --- When a warp is updated change its chat tag and restore the warp order -WrapData:on_update(function(warp_id, warp, old_warp) - if warp then - warp.updates = warp.updates + 1 - -- Update the map chart tag if there is one - if warp.tag then - Warps.make_warp_tag(warp_id) - end - - -- Check that the name of the warp has been changed - if not old_warp or warp.name == old_warp.name then return end - - -- Get the names of all the warp points for this force - local force_name = warp.force_name - local warp_ids = force_warps[force_name] - local spawn_id = warp_ids.spawn - - local warp_names = {} --- @type table - for _, next_warp_id in pairs(warp_ids) do - local next_warp = WrapData:get(next_warp_id) - if next_warp_id ~= spawn_id then - warp_names[next_warp.name .. next_warp_id] = next_warp_id - end - end - - -- Sort the warp names in alphabetical order - local new_warp_ids = table.get_values(table.key_sort(warp_names)) - --- @cast new_warp_ids ExpScenario_Warps.force_warps - table.insert(new_warp_ids, 1, spawn_id) - new_warp_ids.spawn = spawn_id - force_warps[force_name] = new_warp_ids - end -end) - ---- Map Integration. --- functions used to create and alter warps with in the map --- @section mapIntegration - ---[[-- Add or update the chat tag for this warp -@tparam string warp_id the uid of the warp you want the chart tag for -@treturn boolean true if a new tag was made, false if it was updated - -@usage-- Adding a chart tag for a new warp -local tag_added = Warps.make_warp_tag(warp_id) - -]] -function Warps.make_warp_tag(warp_id) - local warp = WrapData:get(warp_id) - local name = warp.name - local icon = warp.icon - - -- Edit the existing tag if it is present - local tag = warp.tag - - if tag and tag.valid then - tag.text = "Warp: " .. name - tag.icon = icon - return false - end - - -- Make a new tag if one did not exist - local force = game.forces[warp.force_name] - local surface = warp.surface - local position = warp.position - - tag = force.add_chart_tag(surface, { - position = { position.x + 0.5, position.y + 0.5 }, - text = "Warp: " .. name, - icon = icon, - }) - - -- Add the tag to this warp, store.update not needed as we dont want it to trigger - warp.tag = tag - return true -end - ---[[-- Remove the chart tag for this warp -@tparam string warp_id the uid for the warp that you want to remove the chart tag from -@treturn boolean true if the tag was valid and was removed, false if the tag was invalid - -@usage-- Removing the chart tag from a warp -local removed = Warps.remove_warp_tag(warp_id) - -]] -function Warps.remove_warp_tag(warp_id) - local warp = WrapData:get(warp_id) - - -- Check there is a tag to remove - local tag = warp.tag - if not tag or not tag.valid then - warp.tag = nil - return false - end - - -- Remove the warp chart tag if it is valid - tag.destroy() - warp.tag = nil - - return true -end - ---[[-- Add a warp area for the warp, purely cosmetic -@tparam string warp_id the uid of the warp you want the area for - -@usage-- Adding a warp area for a warp -Warps.make_warp_area(warp_id) - -]] -function Warps.make_warp_area(warp_id) - local warp = WrapData:get(warp_id) - local surface = warp.surface - local position = warp.position - local posx = position.x - local posy = position.y - - -- Get the tile that is being replaced, store.update not needed as we don't want it to trigger - local old_tile = surface.get_tile(position).name - warp.old_tile = old_tile - - -- Add a tile pattern on top of the base - local tiles = {} - for _, tile in pairs(config.tiles) do - table.insert(tiles, { name = tile[1], position = { tile[2] + posx, tile[3] + posy } }) - end - - surface.set_tiles(tiles) - - -- Add entities to the warp structure - for _, entity in pairs(config.entities) do - entity = surface.create_entity{ - name = entity[1], - position = { entity[2] + posx, entity[3] + posy }, - force = "neutral", - } - entity.destructible = false - entity.health = 0 - entity.minable_flag = false - entity.rotatable = false - - -- Save reference of the last power pole - if entity.type == "electric-pole" then - warp.electric_pole = entity - end - end -end - ---[[-- Remove the warp area for a warp -@tparam string warp_id the uid of the warp that you want to remove the area for - -@usage-- Remove the warp area for a warp -Warps.remove_warp_area(warp_id) - -]] -function Warps.remove_warp_area(warp_id) - local warp = WrapData:get(warp_id) - local position = warp.position - local surface = warp.surface - local radius = config.standard_proximity_radius - - -- Check that a warp area was created previously - local old_tile = warp.old_tile - if not old_tile then return end - - -- Restore the original tiles before the creation of the warp - local tiles = {} - - for _, tile in pairs(config.tiles) do - table.insert(tiles, { name = old_tile, position = { tile[2] + position.x, tile[3] + position.y } }) - end - - surface.set_tiles(tiles) - - local area = { - { position.x - radius, position.y - radius }, - { position.x + radius, position.y + radius }, - } - - -- Remove warp structure entities - local entities = surface.find_entities_filtered{ force = "neutral", area = area, name = remove_warp_area_entity_names } - for _, entity in pairs(entities) do - -- Destroy them, this will leave corpses of the entities that it destroyed. - if entity and entity.valid and entity.destructible == false then - entity.destructible = true - entity.die(entity.force) - end - end - - -- Rechart map area, useful if warp is not covered by a radar - game.forces[warp.force_name].chart(surface, area) -end - ---[[-- Set a warp to be the spawn point for a force, force must own this warp -@tparam string warp_id the uid of the warp that you want to be the spawn for the force -@tparam LuaForce force the force that you want to set the spawn for - -@usage-- Set your forces spawn to a warp -Warps.set_spawn_warp(warp_id, game.player.force) - -]] -function Warps.set_spawn_warp(warp_id, force) - -- Check the force owns this warp - local warp = WrapData:get(warp_id) - if warp.force_name ~= force.name then - return - end - -- Set this warp as the spawn - local warp_ids = force_warps[warp.force_name] - if not warp_ids then - warp_ids = {} - force_warps[warp.force_name] = warp_ids - end - warp_ids.spawn = warp_id - -- Set the forces spawn to this warp - force.set_spawn_position(warp.position, warp.surface) -end - ---[[-- Teleport a player to a warp point -@tparam string warp_id the uid of the warp to send the player to -@tparam LuaPlayer player the player to teleport to the warp - -@usage-- Teleport yourself to a warp point -Warps.teleport_player(warp_id, game.player) - -]] -function Warps.teleport_player(warp_id, player) - local warp = WrapData:get(warp_id) - local surface = warp.surface - local position = { - x = warp.position.x + 0.5, - y = warp.position.y + 0.5, - } - - if player.vehicle then - -- Teleport the entity - local entity = player.vehicle - local goto_position = surface.find_non_colliding_position(entity.name, position, 32, 1) - -- Surface teleport can only be done for players and cars at the moment. (with surface as an peramitor it gives this error) - if entity.type == "car" then - entity.teleport(goto_position, surface) - elseif surface.index == entity.surface.index then - -- Try teleport the entity - if not entity.teleport(goto_position) then - player.driving = false - -- Need to calculate new goto_position because entities have different collision boxes - goto_position = surface.find_non_colliding_position("character", position, 32, 1) - player.teleport(goto_position, surface) - end - end - else - -- Teleport the player - local goto_position = surface.find_non_colliding_position("character", position, 32, 1) - if player.driving then player.driving = false end - player.teleport(goto_position, surface) - end -end - ---- Setters. --- functions used to created and alter warps --- @section setters - ---[[-- Add a new warp for a force, the warp must have a surface and a position -@tparam string force_name the name of the force to add the warp for -@tparam LuaSurface surface the surface that the warp will be on -@tparam Concepts.Position position the position that the warp will be on -@tparam[opt] string player_name the name of the player that is adding the warp -@tparam[opt] string warp_name the name of the warp that is being added, if omited default is used -@treturn string the uid of the warp which was created - -@usage-- Adding a new warp for your force at your position -local player = game.player -local warp_id = Warps.add_warp(player.force.name, player.physical_surface, player.physical_position, player.name) - -]] -function Warps.add_warp(force_name, surface, position, player_name, warp_name) - -- Get new warp id - local warp_id = tostring(force_warps._uid) - force_warps._uid = force_warps._uid + 1 - warp_name = warp_name or "New warp" - - -- Get the existing warps for this force - local warp_ids = force_warps[force_name] - if not warp_ids then - warp_ids = {} - force_warps[force_name] = warp_ids - end - - -- Insert the warp id into the force warps - table.insert(warp_ids, warp_id) - - -- Create the editing table - local editing = {} - if player_name then - editing[player_name] = true - end - - -- Add the new warp to the store - WrapData:set(warp_id, { - warp_id = warp_id, - force_name = force_name, - name = warp_name, - icon = { type = config.default_icon.type, name = config.default_icon.name }, - surface = surface, - position = { - x = math.floor(position.x), - y = math.floor(position.y), - }, - last_edit_name = player_name or "", - last_edit_time = game.tick, - currently_editing = editing, - updates = 0, - }) - - return warp_id -end - ---[[-- Removes a warp and any data linked to it -@tparam string warp_id the uid of the warp that you want to remove - -@usage-- Removing a warp -Warps.remove_warp(warp_id) - -]] -function Warps.remove_warp(warp_id) - local warp = WrapData:get(warp_id) - local force_name = warp.force_name - Warps.remove_warp_tag(warp_id) - Warps.remove_warp_area(warp_id) - WrapData:remove(warp_id) - table.remove_element(force_warps[force_name], warp_id) -end - ---[[-- Update the name and icon for a warp -@tparam string warp_id the uid of the warp that you want to update -@tparam[opt] string new_name the new name that you want the warp to have -@tparam[opt] string new_icon the new icon that you want the warp to have -@tparam[opt='server'] string player_name the name of the player that made the edit - -@usage-- Changing the name and icon for a warp -Warps.update_warp(warp_id, 'My Warp', 'iron-plate', game.player.name) - -]] -function Warps.update_warp(warp_id, new_name, new_icon, player_name) - WrapData:update(warp_id, function(_, warp) - -- If the icon is not valid then replace with the old icon - if new_icon and not new_icon.name or not new_icon.type then - new_icon = warp.icon - end - - warp.last_edit_name = player_name or "" - warp.last_edit_time = game.tick - warp.name = new_name or warp.name - warp.icon = new_icon or warp.icon - end) -end - ---[[-- Set the editing state for a player, can be used as a warning or to display a text field -@tparam string warp_id the uid of the warp that you want to effect -@tparam string player_name the name of the player you want to set the state for -@tparam boolean state the new state to set editing to - -@usage-- Setting your editing state to true -Warps.set_editing(warp_id, game.player.name, true) - -]] -function Warps.set_editing(warp_id, player_name, state) - WrapData:update(warp_id, function(_, warp) - warp.currently_editing[player_name] = state - end) -end - ---[[-- Adds an update handler for when a warp is added, removed, or updated -@tparam function handler the handler which is called when a warp is updated - -@usage-- Add a game print when a warp is updated -Warps.on_update(function(warp) - game.print(warp.force_name..' now has the warp: '..warp.name) -end) - -]] -function Warps.on_update(handler) - WrapData:on_update(handler) -end - ---- Getters. --- function used to get information about warps --- @section getters - ---[[-- Gets the warp information that is linked with this id -@tparam string warp_id the uid of the warp you want to get -@treturn table the warp information - -@usage-- Getting warp information outside of on_update -local warp = Warps.get_warp(warp_id) - -]] -function Warps.get_warp(warp_id) - return WrapData:get(warp_id) -end - ---[[-- Gets all the warp ids that a force has -@tparam string force_name the name of the force that you want the warp ids for -@treturn table an array of all the warp ids - -@usage-- Getting the warp ids for a force -local warp_ids = Warps.get_force_warp_ids(game.player.force.name) - -]] -function Warps.get_force_warp_ids(force_name) - return force_warps[force_name] or {} -end - ---[[-- Get the id of the spawn warp -@tparam string force_name the name of the force that you want to get the spawn warp for -@treturn ?string|nil the uid of the spawn warp for this force if there is one - -@usage-- Getting the spawn warp id -local spawn_id = Warps.get_spawn_warp_id(game.player.force.name) - -]] -function Warps.get_spawn_warp_id(force_name) - local warp_ids = force_warps[force_name] or {} - return warp_ids.spawn -end - ---[[-- Gets the editing state for a player -@tparam string warp_id the uid of the warp you want to check -@tparam string player_name the name of the player that you want to check -@treturn boolean weather the player is currently editing this warp - -@usage-- Check if a player is editing a warp or not -local editing = Warps.get_editing(warp_id, game.player.name) - -]] -function Warps.get_editing(warp_id, player_name) - local warp = WrapData:get(warp_id) - return warp.currently_editing[player_name] -end - --- Module return -return Warps diff --git a/exp_legacy/module/modules/gui/warp-list.lua b/exp_legacy/module/modules/gui/warp-list.lua deleted file mode 100644 index 5c456558..00000000 --- a/exp_legacy/module/modules/gui/warp-list.lua +++ /dev/null @@ -1,909 +0,0 @@ ---[[-- Gui Module - Warp List - - Adds a warp list gui which allows players to add and remove warp points - @gui Warps-List - @alias warp_list -]] - -local ExpUtil = require("modules/exp_util") -local Gui = require("modules/exp_gui") -local Datastore = require("modules.exp_legacy.expcore.datastore") --- @dep expcore.datastore -local Storage = require("modules/exp_util/storage") -local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event -local Roles = require("modules/exp_roles") -local Colors = require("modules/exp_util/include/color") -local config = require("modules.exp_legacy.config.gui.warps") --- @dep config.gui.warps -local Warps = require("modules.exp_legacy.modules.control.warps") --- @dep modules.control.warps - -local format_time = ExpUtil.format_time_factory_locale{ format = "short", hours = true, minutes = true } - ---- Stores all data for the warp gui -local WrapGuiData = Datastore.connect("WrapGuiData") -WrapGuiData:set_serializer(Datastore.name_serializer) -local PlayerInRange = WrapGuiData:combine("PlayerInRange") -PlayerInRange:set_default(false) -local PlayerCooldown = WrapGuiData:combine("PlayerCooldown") -PlayerCooldown:set_default(0) - ---- Table that stores a boolean value of weather to keep the warp gui open -local keep_gui_open = {} -Storage.register(keep_gui_open, function(tbl) - keep_gui_open = tbl -end) - ---- Styles used for sprite buttons -local Styles = { - sprite22 = { height = 22, width = 22, padding = -2 }, - sprite32 = { height = 32, width = 32, left_margin = 1 }, -} ---- Status icon of a warp -local warp_status_icons = { - cooldown = "[img=utility/multiplayer_waiting_icon]", - not_available = "[img=utility/set_bar_slot]", - bypass = "[img=utility/side_menu_bonus_icon]", - current = "[img=utility/side_menu_map_icon]", - connected = "[img=utility/logistic_network_panel_white]", - different = "[img=utility/warning_white]", -} - ---- Returns if a player is allowed to edit the given warp ---- If a player is allowed to use the edit buttons -local function check_player_permissions(player, action, warp) - -- Check if the action is allow edit and then check bypass settings - if action == "allow_edit_warp" then - -- Check if the warp is the spawn then it cant be edited - local spawn_id = Warps.get_spawn_warp_id(player.force.name) - if spawn_id == warp.warp_id then - return false - end - - -- Check if the player being the last to edit will override existing permisisons - if config.user_can_edit_own_warps and warp.last_edit_name == player.name then - return true - end - end - - -- Check player has permission based on value in the config - local action_config = config[action] - if action_config == "all" then - return true - elseif action_config == "admin" then - return player.admin - elseif action_config == "exp_roles" then - return Roles.player_has_permission(player, config["exp_roles_" .. action]) - end - - -- Return false as all other conditions have not been met - return false -end - ---- Will add a new warp to the list, checks if the player is too close to an existing one --- @element add_new_warp -local add_new_warp = Gui.define("add_new_warp") - :draw{ - type = "sprite-button", - sprite = "utility/add", - tooltip = { "warp-list.add-tooltip" }, - style = "shortcut_bar_button", - name = Gui.from_name, - } - :style(Styles.sprite22) - :on_click(function(def, player, element) - if player.controller_type ~= defines.controllers.character then return end - -- Add the new warp - local force_name = player.force.name - local surface = player.physical_surface - local position = player.physical_position - - -- Check if the warp is too close to water - local water_tiles = surface.find_tiles_filtered{ collision_mask = "water_tile", radius = config.standard_proximity_radius + 1, position = position } - if #water_tiles > 0 then - player.print( - { "expcore-commands.command-fail", { "warp-list.too-close-to-water", config.standard_proximity_radius + 1 } }, - { color = Colors.orange_red } - ) - if game.player then game.player.play_sound{ path = "utility/wire_pickup" } end - for _, tile in pairs(water_tiles) do - rendering.draw_sprite{ - sprite = "utility/rail_path_not_possible", - x_scale = 0.5, - y_scale = 0.5, - target = tile.position, - surface = surface, - players = { player }, - time_to_live = 60, - } - end - - return - end - - -- Check if there are player entities in the way (has a bigger radius because the enities that can be placed by a player are larger) - local entities = surface.find_entities_filtered{ - radius = config.standard_proximity_radius + 2.5, - position = position, - collision_mask = { - "item", "object", "player", "water_tile", - }, - } - -- Remove 1 because that is the current player - if #entities > 1 then - player.print( - { "expcore-commands.command-fail", { "warp-list.too-close-to-entities", config.standard_proximity_radius + 2.5 } }, - { color = Colors.orange_red } - ) - if game.player then game.player.play_sound{ path = "utility/wire_pickup" } end - local character = player.character - for _, entity in pairs(entities) do - if entity ~= character then - rendering.draw_sprite{ - sprite = "utility/rail_path_not_possible", - x_scale = 0.5, - y_scale = 0.5, - target = entity, - surface = surface, - players = { player }, - time_to_live = 60, - } - end - end - - return - end - - -- Create the warp - local warp_id = Warps.add_warp(force_name, surface, position, player.name) - Warps.make_warp_tag(warp_id) - Warps.make_warp_area(warp_id) - end) - ---- Warp icon button, this will trigger a warp when the player is able to --- @element warp_icon_button -local warp_icon_button = Gui.define("warp_icon_button") - :draw(function(def, parent, warp) - local warp_position = warp.position - - -- The SpritePath type is not the same as the SignalID type - local sprite = warp.icon.type .. "/" .. warp.icon.name - if warp.icon.type == "virtual" then - sprite = "virtual-signal/" .. warp.icon.name - end - - -- Draw the element - return parent.add{ - type = "sprite-button", - sprite = sprite, - name = def.name, - tooltip = { "warp-list.goto-tooltip", warp_position.x, warp_position.y }, - style = "slot_button", - } - end) - :style(Styles.sprite32) - :on_click(function(def, player, element) - if element.type == "choose-elem-button" then return end - local warp_id = assert(element.parent).caption - Warps.teleport_player(warp_id, player) - - -- Reset the warp cooldown if the player does not have unlimited warps - if not check_player_permissions(player, "bypass_warp_cooldown") then - PlayerCooldown:set(player, config.update_smoothing * config.cooldown_duration) - end - - PlayerInRange:set(player, warp_id) - end) - ---- The button that is visible when the warp is in edit state --- @element warp_icon_editing -local warp_icon_editing = Gui.define("warp_icon_editing") - :draw(function(def, parent, warp) - return parent.add{ - name = def.name, - type = "choose-elem-button", - elem_type = "signal", - signal = { type = warp.icon.type, name = warp.icon.name }, - tooltip = { "warp-list.goto-edit" }, - } - end) - :style(Styles.sprite32) - ---- Warp label, visible if the player is not in edit state --- @element warp_label -local warp_label = Gui.define("warp_label") - :draw(function(def, parent, warp) - local last_edit_name = warp.last_edit_name - local last_edit_time = warp.last_edit_time - -- Draw the element - return parent.add{ - type = "label", - caption = warp.name, - tooltip = { "warp-list.last-edit", last_edit_name, format_time(last_edit_time) }, - name = def.name, - } - end) - :style{ - single_line = true, - left_padding = 2, - right_padding = 2, - horizontally_stretchable = true, - } - :on_click(function(def, player, element) - local warp_id = assert(element.parent).caption - local warp = Warps.get_warp(warp_id) - player.set_controller{ type = defines.controllers.remote, position = warp.position, surface = warp.surface } - end) - ---- Warp status, visible if the player is not in edit state ---- This will show if the warp is connected or not --- @element warp_status -local warp_status = Gui.define("warp_status") - :draw{ - type = "label", - caption = "[img=utility/electricity_icon_unplugged]", -- Temporary icon - name = Gui.from_name, - } - :style{ - -- When editing mode because textbox is larger the icon would move up. - top_padding = 1, - single_line = false, - } - ---- Warp textfield, visible if the player is in edit state --- @element warp_textfield -local warp_textfield = Gui.define("warp_textfield") - :draw(function(def, parent, warp) - -- Draw the element - return parent.add{ - type = "textfield", - text = warp.name, - clear_and_focus_on_right_click = true, - name = def.name, - } - end) - :style{ - -- Required fields to make it squashable and strechable. - minimal_width = 10, - maximal_width = 300, - horizontally_squashable = true, - horizontally_stretchable = true, - -- Other styling - height = 22, - padding = -2, - left_margin = 2, - right_margin = 2, - } - :on_confirmed(function(def, player, element) - local warp_id = assert(element.parent).caption - local warp_name = element.text - local warp_icon = assert(element.parent).parent["icon-" .. warp_id][warp_icon_editing.name].elem_value --[[@as SignalID]] - if warp_icon.type == nil then warp_icon.type = "item" end - Warps.set_editing(warp_id, player.name) - Warps.update_warp(warp_id, warp_name, warp_icon, player.name) - end) - ---- Confirms the edit to name or icon of the warp --- @element confirm_edit_button -local confirm_edit_button = Gui.define("confirm_edit_button") - :draw{ - type = "sprite-button", - sprite = "utility/confirm_slot", - tooltip = { "warp-list.confirm-tooltip" }, - style = "shortcut_bar_button_green", - name = Gui.from_name, - } - :style(Styles.sprite22) - :on_click(function(def, player, element) - local parent = assert(element.parent) - local grandparent = assert(parent.parent) - local warp_id = parent.caption --[[@as string]] - local name_flow = grandparent["name-" .. warp_id] - local icon_flow = grandparent["icon-" .. warp_id] - local warp_name = name_flow[warp_textfield.name].text - local warp_icon = icon_flow[warp_icon_editing.name].elem_value --[[@as SignalID]] - if warp_icon.type == nil then warp_icon.type = "item" end - Warps.set_editing(warp_id, player.name) - Warps.update_warp(warp_id, warp_name, warp_icon, player.name) - end) - ---- Cancels the editing changes of the selected warp name or icon --- @element cancel_edit_button -local cancel_edit_button = Gui.define("cancel_edit_button") - :draw{ - type = "sprite-button", - sprite = "utility/close_black", - tooltip = { "warp-list.cancel-tooltip" }, - style = "shortcut_bar_button_red", - name = Gui.from_name, - } - :style(Styles.sprite22) - :on_click(function(def, player, element) - local warp_id = assert(element.parent).caption - -- Check if this is the first edit, if so remove the warp. - local warp = Warps.get_warp(warp_id) - if warp.updates == 1 then - Warps.remove_warp(warp_id) - return - end - Warps.set_editing(warp_id, player.name) - end) - ---- Removes a warp from the list, including the physical area and map tag --- @element remove_warp_button -local remove_warp_button = Gui.define("remove_warp_button") - :draw{ - type = "sprite-button", - sprite = "utility/trash", - tooltip = { "warp-list.remove-tooltip" }, - style = "shortcut_bar_button_red", - name = Gui.from_name, - } - :style(Styles.sprite22) - :on_click(function(def, player, element) - local warp_id = assert(element.parent).caption - Warps.remove_warp(warp_id) - end) - ---- Opens edit mode for the warp --- @element edit_warp_button -local edit_warp_button = Gui.define("edit_warp_button") - :draw{ - type = "sprite-button", - sprite = "utility/rename_icon", - tooltip = { "warp-list.edit-tooltip-none" }, - style = "shortcut_bar_button", - name = Gui.from_name, - } - :style(Styles.sprite22) - :on_click(function(def, player, element) - local warp_id = assert(element.parent).caption - Warps.set_editing(warp_id, player.name, true) - end) - -local update_all_warp_elements ---- Set of three elements which make up each row of the warp table --- @element add_warp_elements -local add_warp_elements = Gui.define("add_warp_elements") - :draw(function(_, parent, warp) - -- Add icon flow, this will contain the warp button and warp icon edit button - local icon_flow = parent.add{ - name = "icon-" .. warp.warp_id, - type = "flow", - caption = warp.warp_id, - } - icon_flow.style.padding = 0 - - -- Add the button and the icon edit button - warp_icon_button(icon_flow, warp) - warp_icon_editing(icon_flow, warp) - - -- Add name flow, this will contain the warp label and textbox - local name_flow = parent.add{ - type = "flow", - name = "name-" .. warp.warp_id, - caption = warp.warp_id, - } - name_flow.style.padding = 0 - - -- Add the label and textfield of the warp - warp_status(name_flow) - warp_label(name_flow, warp) - warp_textfield(name_flow, warp) - - -- Add button flow, this will contain buttons to manage this specific warp - local button_flow = parent.add{ - type = "flow", - name = "button-" .. warp.warp_id, - caption = warp.warp_id, - } - button_flow.style.padding = 0 - - -- Add both edit state buttons - confirm_edit_button(button_flow) - cancel_edit_button(button_flow) - edit_warp_button(button_flow) - remove_warp_button(button_flow) - - return Gui.no_return() - end) - --- Removes the three elements that are added as part of the warp base -local function remove_warp_elements(parent, warp_id) - Gui.destroy_if_valid(parent["icon-" .. warp_id]) - Gui.destroy_if_valid(parent["name-" .. warp_id]) - Gui.destroy_if_valid(parent["button-" .. warp_id]) -end - ---- This timer controls when a player is able to warp, eg every 60 seconds --- @element warp_timer -local warp_timer = Gui.define("warp_timer") - :draw{ - type = "progressbar", - name = Gui.from_name, - tooltip = { "warp-list.timer-tooltip-zero", config.cooldown_duration }, - minimum_value = 0, - maximum_value = config.cooldown_duration * config.update_smoothing, - } - :style{ - horizontally_stretchable = true, - color = Colors.light_blue, - } - -local warp_list_container - --- Helper function to style and enable or disable a button element -local function update_warp_elements(element, warp, warp_player_is_on, on_cooldown, bypass_warp_proximity) - -- Check if button element is valid - if not element or not element.valid then return end - - local label_style = assert(element.parent).parent["name-" .. warp.warp_id][warp_label.name].style - local warp_status_element = assert(element.parent).parent["name-" .. warp.warp_id][warp_status.name] - - -- If player is not on a warp - if not warp_player_is_on then - -- If player is allowed to warp without being on a warp. If not then disable the warp location - if bypass_warp_proximity then - local position = warp.position - element.tooltip = { "warp-list.goto-bypass", position.x, position.y } - element.enabled = true - warp_status_element.tooltip = { "warp-list.goto-bypass", position.x, position.y } - warp_status_element.caption = warp_status_icons.bypass - label_style.font = "default-semibold" - else - element.tooltip = { "warp-list.goto-disabled" } - element.enabled = false - warp_status_element.tooltip = { "warp-list.goto-disabled" } - warp_status_element.caption = warp_status_icons.not_available - label_style.font = "default" - end - -- If player is on the warp that is being updated - elseif warp_player_is_on.warp_id == warp.warp_id then - element.tooltip = { "warp-list.goto-same-warp" } - element.enabled = false - warp_status_element.tooltip = { "warp-list.goto-same-warp" } - warp_status_element.caption = warp_status_icons.current - label_style.font = "default" - -- If player is on cooldown - elseif on_cooldown then - element.tooltip = { "warp-list.goto-cooldown" } - element.enabled = false - warp_status_element.tooltip = { "warp-list.goto-cooldown" } - warp_status_element.caption = warp_status_icons.cooldown - label_style.font = "default" - else - -- If the warp the player is standing on is the same as the warp that is being updated - local warp_electric_network_id = warp.electric_pole and warp.electric_pole.electric_network_id or -1 - local player_warp_electric_network_id = warp_player_is_on.electric_pole and warp_player_is_on.electric_pole.electric_network_id or -2 - if warp_electric_network_id == player_warp_electric_network_id then - local position = warp.position - element.tooltip = { "warp-list.goto-tooltip", position.x, position.y } - element.enabled = true - warp_status_element.tooltip = { "warp-list.goto-tooltip", position.x, position.y } - warp_status_element.caption = warp_status_icons.connected - label_style.font = "default-semibold" - -- If the warp is not on the same network but the player is allowed to warp without being on a warp - elseif bypass_warp_proximity then - local position = warp.position - element.tooltip = { "warp-list.goto-bypass-different-network", position.x, position.y } - element.enabled = true - warp_status_element.tooltip = { "warp-list.goto-bypass-different-network", position.x, position.y } - warp_status_element.caption = warp_status_icons.bypass - label_style.font = "default-semibold" - -- If the warp is on a different network than the one the player is standing on - else - element.tooltip = { "warp-list.goto-different-network" } - element.enabled = false - warp_status_element.tooltip = { "warp-list.goto-different-network" } - warp_status_element.caption = warp_status_icons.different - label_style.font = "default" - end - end -end - ---- Update the warp buttons for a player -function update_all_warp_elements(player, timer, warp_id) - -- Get the warp table - local container = Gui.get_left_element(warp_list_container, player) - local scroll_table = container.frame.scroll.table - - -- Check if the player is currently on cooldown - timer = timer or PlayerCooldown:get(player) - local on_cooldown = timer > 0 - -- Get the warp the player is on - warp_id = warp_id or PlayerInRange:get(player) - local warp_player_is_on = warp_id and Warps.get_warp(warp_id) or nil - -- Check player permission - local bypass_warp_proximity = check_player_permissions(player, "bypass_warp_proximity") - - -- Change the enabled state of the warp buttons - local warp_ids = Warps.get_force_warp_ids(player.force.name) - for _, next_warp_id in pairs(warp_ids) do - local element = scroll_table["icon-" .. next_warp_id][warp_icon_button.name] - local next_warp = Warps.get_warp(next_warp_id) - update_warp_elements(element, next_warp, warp_player_is_on, on_cooldown, bypass_warp_proximity) - end -end - ---- Updates a warp for a player -local function update_warp(player, warp_table, warp_id) - local warp = Warps.get_warp(warp_id) - - -- If the warp no longer exists then remove the warp elements from the warp table - if not warp then - remove_warp_elements(warp_table, warp_id) - return - end - - -- Create the warp elements if they do not already exist - if not warp_table["icon-" .. warp_id] then - add_warp_elements(warp_table, warp) - end - local icon_flow = warp_table["icon-" .. warp_id] - local name_flow = warp_table["name-" .. warp_id] - local button_flow = warp_table["button-" .. warp_id] - - -- Create local references to the elements for this warp - local warp_icon_element = icon_flow[warp_icon_button.name] - local warp_icon_edit_element = icon_flow[warp_icon_editing.name] - - local label_element = name_flow[warp_label.name] - local textfield_element = name_flow[warp_textfield.name] - - local cancel_edit_element = button_flow[cancel_edit_button.name] - local confirm_edit_element = button_flow[confirm_edit_button.name] - - local edit_warp_element = button_flow[edit_warp_button.name] - local remove_warp_element = button_flow[remove_warp_button.name] - - -- Hide the edit button if the player is not allowed to edit the warp - local player_allowed_edit = check_player_permissions(player, "allow_edit_warp", warp) - local players_editing = table.get_keys(warp.currently_editing) - edit_warp_element.visible = player_allowed_edit - - -- Set the tooltip of the edit button - if #players_editing > 0 then - edit_warp_element.hovered_sprite = "utility/warning_icon" - edit_warp_element.tooltip = { "warp-list.edit-tooltip", table.concat(players_editing, ", ") } - else - edit_warp_element.hovered_sprite = edit_warp_element.sprite - edit_warp_element.tooltip = { "warp-list.edit-tooltip-none" } - end - - -- Set the visibility of the warp elements based on whether the user is editing or not - local player_is_editing = warp.currently_editing[player.name] - if player_is_editing then - -- Set the icon elements visibility - warp_icon_element.visible = false - warp_icon_edit_element.visible = true - -- Set the name elements visibility - label_element.visible = false - textfield_element.visible = true - textfield_element.focus() - warp_table.parent.scroll_to_element(textfield_element, "top-third") - -- Set the edit buttons - cancel_edit_element.visible = true - confirm_edit_element.visible = true - -- Set the warp buttons - edit_warp_element.visible = false - remove_warp_element.visible = false - else - -- Set the icon elements visibility - warp_icon_element.visible = true - -- The SpritePath type is not the same as the SignalID type - local sprite = warp.icon.type .. "/" .. warp.icon.name - if warp.icon.type == "virtual" then - sprite = "virtual-signal/" .. warp.icon.name - end - warp_icon_element.sprite = sprite - -- Set icon edit to the warps icon - warp_icon_edit_element.elem_value = warp.icon - warp_icon_edit_element.visible = false - -- Set the name elements visibility - label_element.visible = true - label_element.caption = warp.name - textfield_element.visible = false - textfield_element.text = warp.name - -- Set the edit buttons - cancel_edit_element.visible = false - confirm_edit_element.visible = false - -- Set the warp buttons - edit_warp_element.visible = true and player_allowed_edit - remove_warp_element.visible = true and player_allowed_edit - end - - local timer = PlayerCooldown:get(player) - local current_warp_id = PlayerInRange:get(player) - local to_warp = current_warp_id and Warps.get_warp(current_warp_id) or nil - local bypass_warp_proximity = check_player_permissions(player, "bypass_warp_proximity") - update_warp_elements(warp_icon_element, warp, to_warp, timer > 0, bypass_warp_proximity) -end - --- Update all the warps for a player -local function update_all_warps(player, warp_table) - local warp_ids = Warps.get_force_warp_ids(player.force.name) - warp_table.clear() - for _, warp_id in ipairs(warp_ids) do - update_warp(player, warp_table, warp_id) - end -end - --- Update all warps for all players on a force -local function update_all_warp_force(force) - local warp_ids = Warps.get_force_warp_ids(force.name) - for _, player in pairs(force.connected_players) do - local container = Gui.get_left_element(warp_list_container, player) - local warp_table = container.frame.scroll.table - - warp_table.clear() -- Needed to re-sort the warps - for _, warp_id in ipairs(warp_ids) do - update_warp(player, warp_table, warp_id) - end - end -end - ---- Main warp list container for the left flow --- @element warp_list_container -warp_list_container = Gui.define("warp_list_container") - :draw(function(def, parent) - local player = Gui.get_player(parent) - -- Check if user has permission to add warps - local allow_add_warp = check_player_permissions(player, "allow_add_warp") - - -- Draw the internal container - local container = Gui.elements.container(parent, allow_add_warp and 268 or 220) - - -- Draw the header - local header = Gui.elements.header(container, { - name = "header", - caption = { "warp-list.main-caption" }, - tooltip = { - "warp-list.sub-tooltip", - config.cooldown_duration, - config.standard_proximity_radius, - { "warp-list.sub-tooltip-current", warp_status_icons.current }, - { "warp-list.sub-tooltip-connected", warp_status_icons.connected }, - { "warp-list.sub-tooltip-different", warp_status_icons.different }, - { "warp-list.sub-tooltip-cooldown", warp_status_icons.cooldown }, - { "warp-list.sub-tooltip-not_available", warp_status_icons.not_available }, - { "warp-list.sub-tooltip-bypass", warp_status_icons.bypass }, - }, - }) - - -- Draw the new warp button - local add_new_warp_element = add_new_warp(header) - add_new_warp_element.visible = allow_add_warp - - -- Draw the scroll table for the warps - local scroll_table = Gui.elements.scroll_table(container, 250, 3, "scroll") - -- Set the scroll panel to always show the scrollbar (not doing this will result in a changing gui size) - assert(scroll_table.parent).vertical_scroll_policy = "always" - - -- Change the style of the scroll table - local scroll_table_style = scroll_table.style - scroll_table_style.top_cell_padding = 3 - scroll_table_style.bottom_cell_padding = 3 - - -- Draw the warp cooldown progress bar - local warp_timer_element = warp_timer(container) - - -- Change the progress of the warp timer - local timer = PlayerCooldown:get(player) - if timer > 0 then - warp_timer_element.tooltip = { "warp-list.timer-tooltip", math.floor(timer / config.update_smoothing) } - warp_timer_element.value = 1 - (timer / config.update_smoothing / config.cooldown_duration) - else - warp_timer_element.tooltip = { "warp-list.timer-tooltip-zero", config.cooldown_duration } - warp_timer_element.value = 1 - end - - -- Add any existing warps - update_all_warps(player, scroll_table) - - -- Return the external container - return container.parent - end) - ---- Add the element to the left flow with a toolbar button -Gui.add_left_element(warp_list_container, false) -Gui.toolbar.create_button{ - name = "warp_list_toggle", - left_element = warp_list_container, - sprite = config.default_icon.type .. "/" .. config.default_icon.name, - tooltip = { "warp-list.main-tooltip" }, - visible = function(player, element) - return Roles.player_has_permission(player, "exp_scenario.gui.warp_list") - end -}:on_click(function(def, player, element) - -- Set gui keep open state for player that clicked the button: true if visible, false if invisible - keep_gui_open[player.name] = Gui.toolbar.get_button_toggled_state(def, player) -end) - ---- When the name of a warp is updated this is triggered -Warps.on_update(function(_, warp, old_warp) - -- Get the force to update, warp is nil when removed - if warp then - update_all_warp_force(game.forces[warp.force_name]) - else - update_all_warp_force(game.forces[old_warp.force_name]) - end -end) - ---- When the player leaves or enters range of a warp this is triggered -PlayerInRange:on_update(function(player_name, warp_id) - local player = game.players[player_name] - - -- Change if the frame is visible based on if the player is in range - if not keep_gui_open[player.name] then - Gui.toolbar.set_left_element_visible_state(warp_list_container, player, warp_id ~= nil) - end - - update_all_warp_elements(player, nil, warp_id) -end) - ---- Update the warp cooldown progress bars to match the current cooldown -PlayerCooldown:on_update(function(player_name, player_cooldown) - -- Get the progress bar element - local player = game.players[player_name] - local container = Gui.get_left_element(warp_list_container, player) - local warp_timer_element = container.frame[warp_timer.name] - - -- Set the progress - if player_cooldown and player_cooldown > 0 then - warp_timer_element.tooltip = { "warp-list.timer-tooltip", math.floor(player_cooldown / config.update_smoothing) } - warp_timer_element.value = 1 - (player_cooldown / config.update_smoothing / config.cooldown_duration) - else - warp_timer_element.tooltip = { "warp-list.timer-tooltip-zero", config.cooldown_duration } - warp_timer_element.value = 1 - end - - -- Trigger update of buttons if cooldown is now 0 - if player_cooldown == 0 then - update_all_warp_elements(player, player_cooldown, nil) - end -end) - ---- Handles updating the timer and checking distance from a warp -local r2 = config.standard_proximity_radius ^ 2 -local rs2 = config.spawn_proximity_radius ^ 2 -local mr2 = config.minimum_distance ^ 2 -Event.on_nth_tick(math.floor(60 / config.update_smoothing), function() - PlayerCooldown:update_all(function(_, player_cooldown) - if player_cooldown > 0 then return player_cooldown - 1 end - end) - - local force_warps = {} - local warps = {} - for _, player in pairs(game.connected_players) do - local was_in_range = PlayerInRange:get(player) - - -- Get the ids of all the warps on the players force - local force_name = player.force.name - local warp_ids = force_warps[force_name] - if not warp_ids then - warp_ids = Warps.get_force_warp_ids(force_name) - force_warps[force_name] = warp_ids - end - - -- Check if the force has any warps - local closest_warp = nil --- @type { warp_id: string, name: string }? - local closest_distance = nil - if #warp_ids > 0 then - local surface = player.surface - local pos = player.physical_position - local px, py = pos.x, pos.y - - -- Loop over each warp - for _, warp_id in ipairs(warp_ids) do - -- Check if warp id is cached - local warp = warps[warp_id] - if not warp then - warp = Warps.get_warp(warp_id) - warps[warp_id] = warp - end - - -- Check if the player is within range - local warp_pos = warp.position - if warp.surface == surface then - local dx, dy = px - warp_pos.x, py - warp_pos.y - local dist = (dx * dx) + (dy * dy) - if closest_distance == nil or dist < closest_distance then - closest_warp = warp - closest_distance = dist - if dist < r2 then break end - end - end - end - - -- Check the dist to the closest warp - local in_range = closest_warp ~= nil and (closest_warp.warp_id == warp_ids.spawn and closest_distance < rs2 or closest_distance < r2) - if was_in_range and not in_range then - PlayerInRange:set(player, nil) - elseif not was_in_range and in_range then - --- @cast closest_warp -nil - PlayerInRange:set(player, closest_warp.warp_id) - end - - -- Change the enabled state of the add warp button - local container = Gui.get_left_element(warp_list_container, player) - local add_warp_element = container.frame.header[add_new_warp.name] - local old_closest_warp_name = add_warp_element.tooltip[2] or closest_warp and closest_warp.name - local was_able_to_make_warp = add_warp_element.enabled - local can_make_warp = closest_distance == nil or closest_distance > mr2 - if can_make_warp and not was_able_to_make_warp then - add_warp_element.enabled = true - add_warp_element.tooltip = { "warp-list.add-tooltip" } - elseif not can_make_warp and was_able_to_make_warp or closest_warp and (old_closest_warp_name ~= closest_warp.name) then - --- @cast closest_warp -nil - add_warp_element.enabled = false - add_warp_element.tooltip = { "warp-list.too-close", closest_warp.name } - end - end - end -end) - ---- When a player is created make sure that there is a spawn warp created -Event.add(defines.events.on_player_created, function(event) - -- If the force has no spawn then make a spawn warp - local player = game.players[event.player_index] - local force = player.force --[[@as LuaForce]] - local spawn_id = Warps.get_spawn_warp_id(force.name) - if not spawn_id then - local spawn_position = force.get_spawn_position(player.surface) - spawn_id = Warps.add_warp(force.name, player.surface, spawn_position, nil, "Spawn") - Warps.set_spawn_warp(spawn_id, force) - Warps.make_warp_tag(spawn_id) - - local entities = player.surface.find_entities_filtered{ type = "electric-pole", position = spawn_position, radius = 20, limit = 1 } - if entities and entities[1] then - local warp = Warps.get_warp(spawn_id) - warp.electric_pole = entities[1] - end - end -end) - ---- Update the warps when the player joins -Event.add(defines.events.on_player_joined_game, function(event) - local player = game.players[event.player_index] - local container = Gui.get_left_element(warp_list_container, player) - local scroll_table = container.frame.scroll.table - update_all_warps(player, scroll_table) -end) - ---- Makes sure the right buttons are present when roles change -local function role_update_event(event) - local player = game.players[event.player_index] - local frame = Gui.get_left_element(warp_list_container, player).frame - - -- Check if user has permission to add warps - local allow_add_warp = check_player_permissions(player, "allow_add_warp") - -- Update container size depending on whether the player is allowed to add warps - assert(frame.parent).style.width = allow_add_warp and 268 or 220 - - -- Update the warps, in case the user can now edit them - local scroll_table = frame.scroll.table - update_all_warps(player, scroll_table) - - -- Update the new warp button in case the user can now add them - local add_new_warp_element = frame.header[add_new_warp.name] - add_new_warp_element.visible = allow_add_warp -end - -Event.add(Roles.events.on_player_roles_changed, role_update_event) - ---- When a chart tag is removed or edited make sure it is not one that belongs to a warp -local function maintain_tag(event) - if not event.player_index then return end - local tag = event.tag - local force_name = event.force.name - local warp_ids = Warps.get_force_warp_ids(force_name) - for _, warp_id in pairs(warp_ids) do - local warp = Warps.get_warp(warp_id) - local warp_tag = warp.tag - if not warp_tag or not warp_tag.valid or warp_tag == tag then - if event.name == defines.events.on_chart_tag_removed then - warp.tag = nil - end - Warps.make_warp_tag(warp_id) - end - end -end - -Event.add(defines.events.on_chart_tag_modified, maintain_tag) -Event.add(defines.events.on_chart_tag_removed, maintain_tag) diff --git a/exp_scenario/module/control.lua b/exp_scenario/module/control.lua index 1cd47d66..62c4e943 100644 --- a/exp_scenario/module/control.lua +++ b/exp_scenario/module/control.lua @@ -83,5 +83,6 @@ add(require("modules/exp_scenario/gui/rocket_info")) add(require("modules/exp_scenario/gui/science_production")) add(require("modules/exp_scenario/gui/surveillance")) add(require("modules/exp_scenario/gui/task_list")) +add(require("modules/exp_scenario/gui/warp_list")) add(require("modules/exp_scenario/gui/debug/shim")) add(require("modules/exp_scenario/gui/debug/event_view")) diff --git a/exp_scenario/module/gui/warp_list.lua b/exp_scenario/module/gui/warp_list.lua new file mode 100644 index 00000000..f76faed4 --- /dev/null +++ b/exp_scenario/module/gui/warp_list.lua @@ -0,0 +1,1218 @@ +--[[-- Gui - Warp List +Adds a list of warp points which players can travel between, add, and edit +]] + +local Gui = require("modules/exp_gui") +local Roles = require("modules/exp_roles") +local Colors = require("modules/exp_util/include/color") +local config = require("modules.exp_legacy.config.gui.warps") + +local ExpUtil = require("modules/exp_util") +local format_time = ExpUtil.format_time_factory_locale{ format = "short", hours = true, minutes = true } + +local floor = math.floor +local ceil = math.ceil + +--- @class ExpGui_WarpList.elements +local Elements = {} + +local Styles = { + sprite22 = { height = 22, width = 22, padding = -2 }, + sprite32 = { height = 32, width = 32, left_margin = 1 }, +} + +--- Rich text icons shown next to a warp name to describe why it can or can not be used +local status_icons = { + cooldown = "[img=utility/multiplayer_waiting_icon]", + not_available = "[img=utility/set_bar_slot]", + bypass = "[img=utility/side_menu_bonus_icon]", + current = "[img=utility/side_menu_map_icon]", + connected = "[img=utility/logistic_network_panel_white]", + different = "[img=utility/warning_white]", +} + +local update_interval = floor(60 / config.update_smoothing) +local cooldown_ticks = config.cooldown_duration * 60 +local proximity_radius_sq = config.standard_proximity_radius ^ 2 +local spawn_radius_sq = config.spawn_proximity_radius ^ 2 +local minimum_distance_sq = config.minimum_distance ^ 2 + +--- Names of the entities which make up a warp area, used to find them when the area is removed +local area_entity_names = {} --- @type string[] +for _, entity in ipairs(config.entities) do + area_entity_names[#area_entity_names + 1] = entity[1] +end + +--- @class ExpGui_WarpList.Warp +--- @field id number +--- @field force LuaForce +--- @field name string +--- @field icon SignalID +--- @field surface LuaSurface +--- @field position MapPosition.struct The tile the warp is centred on +--- @field last_user LuaPlayer? Nil for warps made by the scenario +--- @field last_edit_tick number +--- @field editing table Names of the players editing the warp keyed by their index +--- @field tag LuaCustomChartTag? +--- @field electric_pole LuaEntity? Pole within the warp area, warps on the same network can be travelled between +--- @field old_tile string? The tile the warp area replaced, nil when no area was made +--- @field new boolean? True until the first edit is confirmed, discarding that edit removes the warp + +--- @class ExpGui_WarpList.PlayerStatus +--- @field in_range ExpGui_WarpList.Warp? The warp the player is standing on +--- @field on_cooldown boolean +--- @field bypass_proximity boolean + +--- Check a permission from the config, which says who an action applies to +--- @param player LuaPlayer +--- @param action "bypass_warp_cooldown" | "bypass_warp_proximity" | "allow_add_warp" | "allow_edit_warp" +--- @return boolean +local function has_config_permission(player, action) + local setting = config[action] + if setting == "all" then + return true + elseif setting == "admin" then + return player.admin + elseif setting == "exp_roles" then + return Roles.player_has_permission(player, config["exp_roles_" .. action]) + end + + return false +end + +--- Check if a player can edit a warp, the spawn warp can never be edited +--- @param player LuaPlayer +--- @param warp ExpGui_WarpList.Warp +--- @return boolean +local function has_permission_edit_warp(player, warp) + if Elements.container.get_spawn_warp(warp.force) == warp then + return false + end + + if config.user_can_edit_own_warps and warp.last_user == player then + return true + end + + return has_config_permission(player, "allow_edit_warp") +end + +--- The sprite path for a warp icon, signal ids and sprite paths name virtual signals differently +--- @param icon SignalID +--- @return SpritePath +local function icon_sprite(icon) + if icon.type == "virtual" then + return "virtual-signal/" .. icon.name + end + return (icon.type or "item") .. "/" .. icon.name +end + +--- Draw a cross over something that stops a warp being added +--- @param surface LuaSurface +--- @param player LuaPlayer +--- @param target LuaEntity | MapPosition +local function draw_blocker(surface, player, target) + rendering.draw_sprite{ + sprite = "utility/rail_path_not_possible", + x_scale = 0.5, + y_scale = 0.5, + target = target, + surface = surface, + players = { player }, + time_to_live = 60, + } +end + +--- Button in the header which adds a warp at the player +--- @class ExpGui_WarpList.elements.add_warp_button: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.add_warp_button = Gui.define("warp_list/add_warp_button") + :track_all_elements() + :draw{ + type = "sprite-button", + sprite = "utility/add", + tooltip = { "exp-gui_warp-list.tooltip-add" }, + style = "shortcut_bar_button", + } + :style(Styles.sprite22) + :on_click(function(_, player) + if player.controller_type ~= defines.controllers.character then return end + local surface = player.physical_surface + local position = player.physical_position + + local water_tiles = surface.find_tiles_filtered{ + collision_mask = "water_tile", + radius = config.standard_proximity_radius + 1, + position = position, + } + if #water_tiles > 0 then + player.print({ "exp-gui_warp-list.add-warning-water", config.standard_proximity_radius + 1 }, Colors.orange_red) + player.play_sound{ path = "utility/wire_pickup" } + for _, tile in pairs(water_tiles) do + draw_blocker(surface, player, tile.position) + end + return + end + + -- A larger radius than the area because the entities a player can place are larger than tiles + local entities = surface.find_entities_filtered{ + radius = config.standard_proximity_radius + 2.5, + position = position, + collision_mask = { "item", "object", "player", "water_tile" }, + } + if #entities > 1 then -- The player's own character is always found + player.print({ "exp-gui_warp-list.add-warning-entities", config.standard_proximity_radius + 2.5 }, Colors.orange_red) + player.play_sound{ path = "utility/wire_pickup" } + local character = player.character + for _, entity in pairs(entities) do + if entity ~= character then + draw_blocker(surface, player, entity) + end + end + return + end + + local force = player.force --[[@as LuaForce]] + local warp = Elements.container.add_warp(force, surface, position, player) + Elements.container.set_editing(warp, player, true) + end) --[[@as any]] + +--- Refresh whether the add button is enabled based on the closest warp +--- @param player LuaPlayer +--- @param blocking ExpGui_WarpList.Warp? The warp too close to add another, nil when a warp can be added +function Elements.add_warp_button.refresh_player_blocking(player, blocking) + local tooltip = blocking and { "exp-gui_warp-list.tooltip-add-too-close", blocking.name } or { "exp-gui_warp-list.tooltip-add" } + for _, add_warp_button in Elements.add_warp_button:tracked_elements(player) do + add_warp_button.enabled = blocking == nil + add_warp_button.tooltip = tooltip + end +end + +--- @class ExpGui_WarpList.elements.warp_button.element_data +--- @field warp ExpGui_WarpList.Warp + +--- Button which travels to the warp, enabled when the player is allowed to use it +--- @class ExpGui_WarpList.elements.warp_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement +Elements.warp_button = Gui.define("warp_list/warp_button") + :draw(function(_, parent, warp) + --- @cast warp ExpGui_WarpList.Warp + return parent.add{ + type = "sprite-button", + sprite = icon_sprite(warp.icon), + style = "slot_button", + } + end) + :style(Styles.sprite32) + :element_data{ + warp = Gui.from_argument(1), + } + :on_click(function(def, player, warp_button) + --- @cast def ExpGui_WarpList.elements.warp_button + Elements.container.teleport_player(player, def.data[warp_button].warp) + end) --[[@as any]] + +--- @class ExpGui_WarpList.elements.icon_picker.element_data +--- @field warp ExpGui_WarpList.Warp + +--- Signal picker shown in place of the warp button while editing +--- @class ExpGui_WarpList.elements.icon_picker: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement +Elements.icon_picker = Gui.define("warp_list/icon_picker") + :draw(function(_, parent, warp) + --- @cast warp ExpGui_WarpList.Warp + return parent.add{ + type = "choose-elem-button", + elem_type = "signal", + signal = warp.icon, + tooltip = { "exp-gui_warp-list.tooltip-icon" }, + } + end) + :style(Styles.sprite32) + :element_data{ + warp = Gui.from_argument(1), + } --[[@as any]] + +--- Get the icon chosen in a picker, the game omits the type for items +--- @param icon_picker LuaGuiElement +--- @return SignalID +function Elements.icon_picker.get_icon(icon_picker) + local icon = icon_picker.elem_value --[[@as SignalID?]] + if not icon then + return Elements.icon_picker.data[icon_picker].warp.icon + end + icon.type = icon.type or "item" + return icon +end + +--- Icon describing whether the warp can be used +--- @class ExpGui_WarpList.elements.status_label: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.status_label = Gui.define("warp_list/status_label") + :draw{ + type = "label", + caption = status_icons.not_available, + } + :style{ + top_padding = 1, -- Keeps the icon in place when the taller textfield is shown + single_line = false, + } --[[@as any]] + +--- @class ExpGui_WarpList.elements.name_label.element_data +--- @field warp ExpGui_WarpList.Warp + +--- Warp name, clicking it opens the map at the warp +--- @class ExpGui_WarpList.elements.name_label: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement +Elements.name_label = Gui.define("warp_list/name_label") + :draw(function(_, parent, warp) + --- @cast warp ExpGui_WarpList.Warp + return parent.add{ + type = "label", + caption = warp.name, + } + end) + :style{ + single_line = true, + left_padding = 2, + right_padding = 2, + horizontally_stretchable = true, + } + :element_data{ + warp = Gui.from_argument(1), + } + :on_click(function(def, player, name_label) + --- @cast def ExpGui_WarpList.elements.name_label + local warp = def.data[name_label].warp + player.set_controller{ type = defines.controllers.remote, position = warp.position, surface = warp.surface } + end) --[[@as any]] + +--- Refresh the name and last edit of the label +--- @param name_label LuaGuiElement +--- @param warp ExpGui_WarpList.Warp +function Elements.name_label.refresh(name_label, warp) + local last_user = warp.last_user and warp.last_user.name or "" + name_label.caption = warp.name + name_label.tooltip = { "exp-gui_warp-list.tooltip-last-edit", last_user, format_time(warp.last_edit_tick) } +end + +--- @class ExpGui_WarpList.elements.name_field.element_data +--- @field warp ExpGui_WarpList.Warp +--- @field icon_picker LuaGuiElement + +--- Textfield shown in place of the name label while editing, confirming it saves the edit +--- @class ExpGui_WarpList.elements.name_field: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp, icon_picker: LuaGuiElement): LuaGuiElement +Elements.name_field = Gui.define("warp_list/name_field") + :draw(function(_, parent, warp) + --- @cast warp ExpGui_WarpList.Warp + return parent.add{ + type = "textfield", + text = warp.name, + clear_and_focus_on_right_click = true, + } + end) + :style{ + minimal_width = 10, + maximal_width = 300, + horizontally_squashable = true, + horizontally_stretchable = true, + height = 22, + padding = -2, + left_margin = 2, + right_margin = 2, + } + :element_data{ + warp = Gui.from_argument(1), + icon_picker = Gui.from_argument(2), + } + :on_confirmed(function(def, player, name_field) + --- @cast def ExpGui_WarpList.elements.name_field + local elements = def.data[name_field] + local icon = Elements.icon_picker.get_icon(elements.icon_picker) + Elements.container.update_warp(elements.warp, name_field.text, icon, player) + end) --[[@as any]] + +--- @class ExpGui_WarpList.elements.confirm_edit_button.element_data +--- @field warp ExpGui_WarpList.Warp +--- @field name_field LuaGuiElement +--- @field icon_picker LuaGuiElement + +--- Button which saves the name and icon being edited +--- @class ExpGui_WarpList.elements.confirm_edit_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp, name_field: LuaGuiElement, icon_picker: LuaGuiElement): LuaGuiElement +Elements.confirm_edit_button = Gui.define("warp_list/confirm_edit_button") + :draw{ + type = "sprite-button", + sprite = "utility/confirm_slot", + tooltip = { "exp-gui_warp-list.tooltip-confirm" }, + style = "shortcut_bar_button_green", + } + :style(Styles.sprite22) + :element_data{ + warp = Gui.from_argument(1), + name_field = Gui.from_argument(2), + icon_picker = Gui.from_argument(3), + } + :on_click(function(def, player, confirm_edit_button) + --- @cast def ExpGui_WarpList.elements.confirm_edit_button + local elements = def.data[confirm_edit_button] + local icon = Elements.icon_picker.get_icon(elements.icon_picker) + Elements.container.update_warp(elements.warp, elements.name_field.text, icon, player) + end) --[[@as any]] + +--- @class ExpGui_WarpList.elements.discard_edit_button.element_data +--- @field warp ExpGui_WarpList.Warp + +--- Button which discards an edit, a warp which was never confirmed is removed +--- @class ExpGui_WarpList.elements.discard_edit_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement +Elements.discard_edit_button = Gui.define("warp_list/discard_edit_button") + :draw{ + type = "sprite-button", + sprite = "utility/close_black", + tooltip = { "exp-gui_warp-list.tooltip-discard" }, + style = "shortcut_bar_button_red", + } + :style(Styles.sprite22) + :element_data{ + warp = Gui.from_argument(1), + } + :on_click(function(def, player, discard_edit_button) + --- @cast def ExpGui_WarpList.elements.discard_edit_button + local warp = def.data[discard_edit_button].warp + if warp.new then + Elements.container.remove_warp(warp) + else + Elements.container.set_editing(warp, player, false) + end + end) --[[@as any]] + +--- @class ExpGui_WarpList.elements.edit_warp_button.element_data +--- @field warp ExpGui_WarpList.Warp + +--- Button which starts editing a warp +--- @class ExpGui_WarpList.elements.edit_warp_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement +Elements.edit_warp_button = Gui.define("warp_list/edit_warp_button") + :draw{ + type = "sprite-button", + sprite = "utility/rename_icon", + tooltip = { "exp-gui_warp-list.tooltip-edit-none" }, + style = "shortcut_bar_button", + } + :style(Styles.sprite22) + :element_data{ + warp = Gui.from_argument(1), + } + :on_click(function(def, player, edit_warp_button) + --- @cast def ExpGui_WarpList.elements.edit_warp_button + Elements.container.set_editing(def.data[edit_warp_button].warp, player, true) + end) --[[@as any]] + +--- Refresh the tooltip which lists who is editing the warp +--- @param edit_warp_button LuaGuiElement +--- @param warp ExpGui_WarpList.Warp +function Elements.edit_warp_button.refresh(edit_warp_button, warp) + if next(warp.editing) then + local player_names = table.get_values(warp.editing) + edit_warp_button.hovered_sprite = "utility/warning_icon" + edit_warp_button.tooltip = { "exp-gui_warp-list.tooltip-edit", table.concat(player_names, ", ") } + else + edit_warp_button.hovered_sprite = "utility/rename_icon" + edit_warp_button.tooltip = { "exp-gui_warp-list.tooltip-edit-none" } + end +end + +--- @class ExpGui_WarpList.elements.remove_warp_button.element_data +--- @field warp ExpGui_WarpList.Warp + +--- Button which removes a warp along with its area and map tag +--- @class ExpGui_WarpList.elements.remove_warp_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, warp: ExpGui_WarpList.Warp): LuaGuiElement +Elements.remove_warp_button = Gui.define("warp_list/remove_warp_button") + :draw{ + type = "sprite-button", + sprite = "utility/trash", + tooltip = { "exp-gui_warp-list.tooltip-remove" }, + style = "shortcut_bar_button_red", + } + :style(Styles.sprite22) + :element_data{ + warp = Gui.from_argument(1), + } + :on_click(function(def, _, remove_warp_button) + --- @cast def ExpGui_WarpList.elements.remove_warp_button + Elements.container.remove_warp(def.data[remove_warp_button].warp) + end) --[[@as any]] + +--- @class ExpGui_WarpList.elements.warp_table.row +--- @field warp ExpGui_WarpList.Warp +--- @field icon_flow LuaGuiElement +--- @field name_flow LuaGuiElement +--- @field action_flow LuaGuiElement +--- @field warp_button LuaGuiElement +--- @field icon_picker LuaGuiElement +--- @field status_label LuaGuiElement +--- @field name_label LuaGuiElement +--- @field name_field LuaGuiElement +--- @field confirm_edit_button LuaGuiElement +--- @field discard_edit_button LuaGuiElement +--- @field edit_warp_button LuaGuiElement +--- @field remove_warp_button LuaGuiElement + +--- @class ExpGui_WarpList.elements.warp_table.element_data +--- @field rows table Keyed by warp id + +--- Table with a row of icon, name, and actions for each warp of the player's force +--- @class ExpGui_WarpList.elements.warp_table: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.warp_table = Gui.define("warp_list/warp_table") + :track_all_elements() + :element_data{ rows = {} } + :draw(function(_, parent) + local warp_table = Gui.elements.scroll_table(parent, 250, 3, "scroll") + -- Always showing the scrollbar stops the gui changing width as warps are added + assert(warp_table.parent).vertical_scroll_policy = "always" + return warp_table + end) + :style{ + top_cell_padding = 3, + bottom_cell_padding = 3, + } --[[@as any]] + +--- Add a row for a warp +--- @param warp_table LuaGuiElement +--- @param warp ExpGui_WarpList.Warp +--- @return ExpGui_WarpList.elements.warp_table.row +function Elements.warp_table.add_row(warp_table, warp) + local icon_flow = warp_table.add{ type = "flow" } + icon_flow.style.padding = 0 + local warp_button = Elements.warp_button(icon_flow, warp) + local icon_picker = Elements.icon_picker(icon_flow, warp) + + local name_flow = warp_table.add{ type = "flow" } + name_flow.style.padding = 0 + local status_label = Elements.status_label(name_flow) + local name_label = Elements.name_label(name_flow, warp) + local name_field = Elements.name_field(name_flow, warp, icon_picker) + + local action_flow = warp_table.add{ type = "flow" } + action_flow.style.padding = 0 + local confirm_edit_button = Elements.confirm_edit_button(action_flow, warp, name_field, icon_picker) + local discard_edit_button = Elements.discard_edit_button(action_flow, warp) + local edit_warp_button = Elements.edit_warp_button(action_flow, warp) + local remove_warp_button = Elements.remove_warp_button(action_flow, warp) + + local row = { + warp = warp, + icon_flow = icon_flow, + name_flow = name_flow, + action_flow = action_flow, + warp_button = warp_button, + icon_picker = icon_picker, + status_label = status_label, + name_label = name_label, + name_field = name_field, + confirm_edit_button = confirm_edit_button, + discard_edit_button = discard_edit_button, + edit_warp_button = edit_warp_button, + remove_warp_button = remove_warp_button, + } + + Elements.warp_table.data[warp_table].rows[warp.id] = row + return row +end + +--- Remove the row for a warp +--- @param warp_table LuaGuiElement +--- @param warp ExpGui_WarpList.Warp +function Elements.warp_table.remove_row(warp_table, warp) + local rows = Elements.warp_table.data[warp_table].rows + local row = rows[warp.id] + if not row then return end + rows[warp.id] = nil + row.icon_flow.destroy() + row.name_flow.destroy() + row.action_flow.destroy() +end + +--- Refresh whether a warp can be used from where the player is +--- @param row ExpGui_WarpList.elements.warp_table.row +--- @param status ExpGui_WarpList.PlayerStatus +function Elements.warp_table._refresh_row_status(row, status) + local warp = row.warp + local in_range = status.in_range + local position = warp.position + local tooltip, icon, enabled + + if not in_range then + if status.bypass_proximity then + tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-bypass", position.x, position.y }, status_icons.bypass, true + else + tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-disabled" }, status_icons.not_available, false + end + elseif in_range == warp then + tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-same-warp" }, status_icons.current, false + elseif status.on_cooldown then + tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-cooldown" }, status_icons.cooldown, false + else + -- Warps are connected when their poles share an electric network, missing poles never match + local network_id = warp.electric_pole and warp.electric_pole.valid and warp.electric_pole.electric_network_id or -1 + local in_range_network_id = in_range.electric_pole and in_range.electric_pole.valid and in_range.electric_pole.electric_network_id or -2 + if network_id == in_range_network_id then + tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto", position.x, position.y }, status_icons.connected, true + elseif status.bypass_proximity then + tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-bypass-different-network", position.x, position.y }, status_icons.bypass, true + else + tooltip, icon, enabled = { "exp-gui_warp-list.tooltip-goto-different-network" }, status_icons.different, false + end + end + + row.warp_button.tooltip = tooltip + row.warp_button.enabled = enabled + row.status_label.tooltip = tooltip + row.status_label.caption = icon + row.name_label.style.font = enabled and "default-semibold" or "default" +end + +--- Refresh a row to match its warp, the player's permissions, and where the player is +--- @param warp_table LuaGuiElement +--- @param row ExpGui_WarpList.elements.warp_table.row +--- @param status ExpGui_WarpList.PlayerStatus +function Elements.warp_table.refresh_row(warp_table, row, status) + local player = Gui.get_player(warp_table) + local warp = row.warp + local can_edit = has_permission_edit_warp(player, warp) + local editing = warp.editing[player.index] ~= nil + + Elements.edit_warp_button.refresh(row.edit_warp_button, warp) + Elements.name_label.refresh(row.name_label, warp) + + row.warp_button.visible = not editing + row.icon_picker.visible = editing + row.name_label.visible = not editing + row.name_field.visible = editing + row.confirm_edit_button.visible = editing + row.discard_edit_button.visible = editing + row.edit_warp_button.visible = can_edit and not editing + row.remove_warp_button.visible = can_edit and not editing + + if editing then + row.name_field.focus() + assert(warp_table.parent).scroll_to_element(row.name_field, "top-third") + else + row.warp_button.sprite = icon_sprite(warp.icon) + row.icon_picker.elem_value = warp.icon + row.name_field.text = warp.name + end + + Elements.warp_table._refresh_row_status(row, status) +end + +--- Rebuild every row in the order of the warps +--- @param warp_table LuaGuiElement +--- @param warps ExpGui_WarpList.Warp[] +--- @param status ExpGui_WarpList.PlayerStatus +function Elements.warp_table.rebuild(warp_table, warps, status) + warp_table.clear() + Elements.warp_table.data[warp_table].rows = {} + for _, warp in ipairs(warps) do + local row = Elements.warp_table.add_row(warp_table, warp) + Elements.warp_table.refresh_row(warp_table, row, status) + end +end + +--- Refresh the row of one warp for the online players of its force +--- @param warp ExpGui_WarpList.Warp +function Elements.warp_table.refresh_row_force(warp) + for player, warp_table in Elements.warp_table:online_elements(warp.force) do + --- @cast player -nil + local row = Elements.warp_table.data[warp_table].rows[warp.id] + if row then + Elements.warp_table.refresh_row(warp_table, row, Elements.container.get_status(player)) + end + end +end + +--- Refresh whether each warp can be used from where the player is +--- @param player LuaPlayer +function Elements.warp_table.refresh_status_player(player) + local status = Elements.container.get_status(player) + for _, warp_table in Elements.warp_table:tracked_elements(player) do + for _, row in pairs(Elements.warp_table.data[warp_table].rows) do + Elements.warp_table._refresh_row_status(row, status) + end + end +end + +--- Rebuild the rows for a player, used when their force or permissions change +--- @param player LuaPlayer +function Elements.warp_table.rebuild_player(player) + local warps = Elements.container.get_warps(player.force --[[@as LuaForce]]) + local status = Elements.container.get_status(player) + for _, warp_table in Elements.warp_table:tracked_elements(player) do + Elements.warp_table.rebuild(warp_table, warps, status) + end +end + +--- Rebuild the rows for the online players of a force, used when the warps change +--- @param force LuaForce +function Elements.warp_table.rebuild_force(force) + local warps = Elements.container.get_warps(force) + for player, warp_table in Elements.warp_table:online_elements(force) do + --- @cast player -nil + Elements.warp_table.rebuild(warp_table, warps, Elements.container.get_status(player)) + end +end + +--- Progress bar which fills as the warp cooldown runs out +--- @class ExpGui_WarpList.elements.cooldown_bar: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.cooldown_bar = Gui.define("warp_list/cooldown_bar") + :track_all_elements() + :draw{ + type = "progressbar", + value = 1, + tooltip = { "exp-gui_warp-list.tooltip-cooldown-none", config.cooldown_duration }, + } + :style{ + horizontally_stretchable = true, + color = Colors.light_blue, + } --[[@as any]] + +--- Refresh the bar to show the ticks left on the cooldown +--- @param cooldown_bar LuaGuiElement +--- @param cooldown number +function Elements.cooldown_bar.refresh(cooldown_bar, cooldown) + if cooldown > 0 then + cooldown_bar.value = 1 - cooldown / cooldown_ticks + cooldown_bar.tooltip = { "exp-gui_warp-list.tooltip-cooldown", ceil(cooldown / 60) } + else + cooldown_bar.value = 1 + cooldown_bar.tooltip = { "exp-gui_warp-list.tooltip-cooldown-none", config.cooldown_duration } + end +end + +--- Refresh the bars of a player +--- @param player LuaPlayer +--- @param cooldown number +function Elements.cooldown_bar.refresh_player(player, cooldown) + for _, cooldown_bar in Elements.cooldown_bar:tracked_elements(player) do + Elements.cooldown_bar.refresh(cooldown_bar, cooldown) + end +end + +--- @class ExpGui_WarpList.elements.container.elements +--- @field frame LuaGuiElement +--- @field add_warp_button LuaGuiElement +--- @field warp_table LuaGuiElement +--- @field cooldown_bar LuaGuiElement + +--- @class ExpGui_WarpList.elements.container.force_data +--- @field warps ExpGui_WarpList.Warp[] The spawn warp first, then by name +--- @field spawn ExpGui_WarpList.Warp? +--- @field next_warp_id number + +--- @class ExpGui_WarpList.elements.container.player_data +--- @field cooldown number Ticks until the player can warp again +--- @field in_range ExpGui_WarpList.Warp? The warp the player is standing on +--- @field blocking ExpGui_WarpList.Warp? The warp too close for another to be added +--- @field keep_open boolean True once the player toggles the list themselves, which stops it hiding when they leave a warp + +--- @class ExpGui_WarpList.elements.container.data +--- @field [LuaGuiElement] ExpGui_WarpList.elements.container.elements +--- @field [LuaForce] ExpGui_WarpList.elements.container.force_data +--- @field [LuaPlayer] ExpGui_WarpList.elements.container.player_data + +--- Container added to the left gui flow +--- @class ExpGui_WarpList.elements.container: ExpElement +--- @field data ExpGui_WarpList.elements.container.data +Elements.container = Gui.define("warp_list/container") + :draw(function(def, parent) + --- @cast def ExpGui_WarpList.elements.container + local player = Gui.get_player(parent) + local allow_add_warp = has_config_permission(player, "allow_add_warp") + local container = Gui.elements.container(parent, allow_add_warp and 268 or 220) + + local header = Gui.elements.header(container, { + caption = { "exp-gui_warp-list.caption-main" }, + tooltip = { + "exp-gui_warp-list.tooltip-sub", + config.cooldown_duration, + config.standard_proximity_radius, + { "exp-gui_warp-list.tooltip-sub-current", status_icons.current }, + { "exp-gui_warp-list.tooltip-sub-connected", status_icons.connected }, + { "exp-gui_warp-list.tooltip-sub-different", status_icons.different }, + { "exp-gui_warp-list.tooltip-sub-cooldown", status_icons.cooldown }, + { "exp-gui_warp-list.tooltip-sub-not-available", status_icons.not_available }, + { "exp-gui_warp-list.tooltip-sub-bypass", status_icons.bypass }, + }, + }) + + local add_warp_button = Elements.add_warp_button(header) + add_warp_button.visible = allow_add_warp + + local warp_table = Elements.warp_table(container) + local force = player.force --[[@as LuaForce]] + Elements.warp_table.rebuild(warp_table, def.get_warps(force), def.get_status(player)) + + local cooldown_bar = Elements.cooldown_bar(container) + Elements.cooldown_bar.refresh(cooldown_bar, def._get_player_data(player).cooldown) + + local root = Gui.elements.container.get_root_element(container) + def.data[root] = { + frame = container, + add_warp_button = add_warp_button, + warp_table = warp_table, + cooldown_bar = cooldown_bar, + } + + return root + end) --[[@as any]] + +--- Get or create the data for a force +--- @param force LuaForce +--- @return ExpGui_WarpList.elements.container.force_data +function Elements.container._get_force_data(force) + local force_data = Elements.container.data[force] + if not force_data then + force_data = { warps = {}, next_warp_id = 1 } + Elements.container.data[force] = force_data + end + return force_data +end + +--- Get or create the data for a player +--- @param player LuaPlayer +--- @return ExpGui_WarpList.elements.container.player_data +function Elements.container._get_player_data(player) + local player_data = Elements.container.data[player] + if not player_data then + player_data = { cooldown = 0, keep_open = false } + Elements.container.data[player] = player_data + end + return player_data +end + +--- Get the warps of a force, the spawn warp first and then by name +--- @param force LuaForce +--- @return ExpGui_WarpList.Warp[] +function Elements.container.get_warps(force) + return Elements.container._get_force_data(force).warps +end + +--- Get the spawn warp of a force +--- @param force LuaForce +--- @return ExpGui_WarpList.Warp? +function Elements.container.get_spawn_warp(force) + return Elements.container._get_force_data(force).spawn +end + +--- Get what decides which warps a player can use +--- @param player LuaPlayer +--- @return ExpGui_WarpList.PlayerStatus +function Elements.container.get_status(player) + local player_data = Elements.container._get_player_data(player) + return { + in_range = player_data.in_range, + on_cooldown = player_data.cooldown > 0, + bypass_proximity = has_config_permission(player, "bypass_warp_proximity"), + } +end + +--- Keep the warps of a force in display order +--- @param force_data ExpGui_WarpList.elements.container.force_data +function Elements.container._sort_warps(force_data) + local spawn = force_data.spawn + table.sort(force_data.warps, function(a, b) + if a == spawn then return true end + if b == spawn then return false end + if a.name == b.name then return a.id < b.id end + return a.name < b.name + end) +end + +--- Add or update the map tag of a warp +--- @param warp ExpGui_WarpList.Warp +function Elements.container._make_tag(warp) + local tag = warp.tag + if tag and tag.valid then + tag.text = "Warp: " .. warp.name + tag.icon = warp.icon + return + end + + local position = warp.position + warp.tag = warp.force.add_chart_tag(warp.surface, { + position = { x = position.x + 0.5, y = position.y + 0.5 }, + text = "Warp: " .. warp.name, + icon = warp.icon, + }) +end + +--- Remove the map tag of a warp +--- @param warp ExpGui_WarpList.Warp +function Elements.container._remove_tag(warp) + local tag = warp.tag + if tag and tag.valid then + tag.destroy() + end + warp.tag = nil +end + +--- Place the tiles and entities which mark a warp on the ground +--- @param warp ExpGui_WarpList.Warp +function Elements.container._make_area(warp) + local surface = warp.surface + local position = warp.position + warp.old_tile = surface.get_tile(position.x, position.y).name + + local tiles = {} + for index, tile in ipairs(config.tiles) do + tiles[index] = { name = tile[1], position = { x = tile[2] + position.x, y = tile[3] + position.y } } + end + surface.set_tiles(tiles) + + for _, entity_config in ipairs(config.entities) do + local entity = assert(surface.create_entity{ + name = entity_config[1], + position = { x = entity_config[2] + position.x, y = entity_config[3] + position.y }, + force = "neutral", + }) + entity.destructible = false + entity.health = 0 + entity.minable_flag = false + entity.rotatable = false + + if entity.type == "electric-pole" then + warp.electric_pole = entity + end + end +end + +--- Restore the ground under a warp and remove its entities +--- @param warp ExpGui_WarpList.Warp +function Elements.container._remove_area(warp) + local old_tile = warp.old_tile + if not old_tile then return end + + local surface = warp.surface + local position = warp.position + local tiles = {} + for index, tile in ipairs(config.tiles) do + tiles[index] = { name = old_tile, position = { x = tile[2] + position.x, y = tile[3] + position.y } } + end + surface.set_tiles(tiles) + + local radius = config.standard_proximity_radius + local area = { + left_top = { x = position.x - radius, y = position.y - radius }, + right_bottom = { x = position.x + radius, y = position.y + radius }, + } + local entities = surface.find_entities_filtered{ force = "neutral", area = area, name = area_entity_names } + for _, entity in pairs(entities) do + if entity.destructible == false then + entity.destructible = true + entity.die(entity.force) + end + end + + -- The area may not be covered by a radar + warp.force.chart(surface, area) +end + +--- Add a warp for a force, when added by a player it is new until they confirm their first edit +--- @param force LuaForce +--- @param surface LuaSurface +--- @param position MapPosition.struct +--- @param player LuaPlayer? +--- @param name string? +--- @return ExpGui_WarpList.Warp +function Elements.container.add_warp(force, surface, position, player, name) + local force_data = Elements.container._get_force_data(force) + local id = force_data.next_warp_id + force_data.next_warp_id = id + 1 + + --- @type ExpGui_WarpList.Warp + local warp = { + id = id, + force = force, + name = name or "New warp", + icon = { type = config.default_icon.type, name = config.default_icon.name }, + surface = surface, + position = { x = floor(position.x), y = floor(position.y) }, + last_user = player, + last_edit_tick = game.tick, + editing = {}, + } + + -- Warps added by a player start in edit mode, discarding that edit removes them again + if player then + warp.new = true + Elements.container._make_area(warp) + end + + force_data.warps[#force_data.warps + 1] = warp + Elements.container._sort_warps(force_data) + Elements.container._make_tag(warp) + + Elements.warp_table.rebuild_force(force) + return warp +end + +--- Remove a warp along with its area and map tag +--- @param warp ExpGui_WarpList.Warp +function Elements.container.remove_warp(warp) + local force_data = Elements.container._get_force_data(warp.force) + table.remove_element(force_data.warps, warp) + if force_data.spawn == warp then + force_data.spawn = nil + end + + Elements.container._remove_tag(warp) + Elements.container._remove_area(warp) + + -- Offline players are corrected by their next proximity update + for _, player in pairs(warp.force.connected_players) do + local player_data = Elements.container._get_player_data(player) + if player_data.in_range == warp then player_data.in_range = nil end + if player_data.blocking == warp then player_data.blocking = nil end + end + + for _, warp_table in Elements.warp_table:online_elements(warp.force) do + Elements.warp_table.remove_row(warp_table, warp) + end +end + +--- Save a new name and icon for a warp, which also ends the player's edit +--- @param warp ExpGui_WarpList.Warp +--- @param name string +--- @param icon SignalID +--- @param player LuaPlayer +function Elements.container.update_warp(warp, name, icon, player) + warp.name = name + warp.icon = icon + warp.last_user = player + warp.last_edit_tick = game.tick + warp.editing[player.index] = nil + warp.new = nil + + Elements.container._sort_warps(Elements.container._get_force_data(warp.force)) + Elements.container._make_tag(warp) + Elements.warp_table.rebuild_force(warp.force) +end + +--- Start or stop a player editing a warp +--- @param warp ExpGui_WarpList.Warp +--- @param player LuaPlayer +--- @param editing boolean +function Elements.container.set_editing(warp, player, editing) + warp.editing[player.index] = editing and player.name or nil + Elements.warp_table.refresh_row_force(warp) +end + +--- Make a warp the spawn of its force, the force respawns there +--- @param warp ExpGui_WarpList.Warp +function Elements.container.set_spawn_warp(warp) + local force_data = Elements.container._get_force_data(warp.force) + force_data.spawn = warp + Elements.container._sort_warps(force_data) + warp.force.set_spawn_position(warp.position, warp.surface) +end + +--- Move a player to a warp and start their cooldown +--- @param player LuaPlayer +--- @param warp ExpGui_WarpList.Warp +function Elements.container.teleport_player(player, warp) + local surface = warp.surface + local position = { x = warp.position.x + 0.5, y = warp.position.y + 0.5 } + + local vehicle = player.vehicle + if vehicle then + local vehicle_position = surface.find_non_colliding_position(vehicle.name, position, 32, 1) + -- Only cars can be teleported between surfaces + if vehicle.type == "car" then + vehicle.teleport(vehicle_position, surface) + elseif surface == vehicle.surface then + if not vehicle.teleport(vehicle_position) then + player.driving = false + player.teleport(surface.find_non_colliding_position("character", position, 32, 1), surface) + end + end + else + player.teleport(surface.find_non_colliding_position("character", position, 32, 1), surface) + end + + local player_data = Elements.container._get_player_data(player) + if not has_config_permission(player, "bypass_warp_cooldown") then + player_data.cooldown = cooldown_ticks + Elements.cooldown_bar.refresh_player(player, cooldown_ticks) + end + player_data.in_range = warp + Elements.warp_table.refresh_status_player(player) +end + +--- Refresh the parts of a player's gui which depend on their permissions +--- @param player LuaPlayer +function Elements.container.refresh_player(player) + local allow_add_warp = has_config_permission(player, "allow_add_warp") + for _, container in Elements.container:tracked_elements(player) do + local elements = Elements.container.data[container] + elements.frame.style.minimal_width = allow_add_warp and 268 or 220 + elements.add_warp_button.visible = allow_add_warp + end + Elements.warp_table.rebuild_player(player) +end + +--- Count down the cooldown of a player +--- @param player LuaPlayer +--- @param player_data ExpGui_WarpList.elements.container.player_data +function Elements.container._update_cooldown(player, player_data) + if player_data.cooldown == 0 then return end + local cooldown = player_data.cooldown - update_interval + if cooldown < 0 then cooldown = 0 end + player_data.cooldown = cooldown + + Elements.cooldown_bar.refresh_player(player, cooldown) + if cooldown == 0 then + Elements.warp_table.refresh_status_player(player) + end +end + +--- Find which warp a player is standing on and whether one can be added where they are +--- @param player LuaPlayer +--- @param player_data ExpGui_WarpList.elements.container.player_data +function Elements.container._update_proximity(player, player_data) + local force_data = Elements.container._get_force_data(player.force --[[@as LuaForce]]) + local surface = player.physical_surface + local position = player.physical_position + local px, py = position.x, position.y + + local closest, closest_distance = nil, nil --- @type ExpGui_WarpList.Warp?, number? + for _, warp in ipairs(force_data.warps) do + if warp.surface == surface then + local dx, dy = px - warp.position.x, py - warp.position.y + local distance = dx * dx + dy * dy + if not closest_distance or distance < closest_distance then + closest, closest_distance = warp, distance + end + end + end + + local in_range = nil --- @type ExpGui_WarpList.Warp? + if closest and closest_distance < (closest == force_data.spawn and spawn_radius_sq or proximity_radius_sq) then + in_range = closest + end + if in_range ~= player_data.in_range then + player_data.in_range = in_range + if not player_data.keep_open then + Gui.toolbar.set_left_element_visible_state(Elements.container, player, in_range ~= nil) + end + Elements.warp_table.refresh_status_player(player) + end + + local blocking = nil --- @type ExpGui_WarpList.Warp? + if closest and closest_distance <= minimum_distance_sq then + blocking = closest + end + if blocking ~= player_data.blocking then + player_data.blocking = blocking + Elements.add_warp_button.refresh_player_blocking(player, blocking) + end +end + +--- Add the element to the left flow with a toolbar button +Gui.add_left_element(Elements.container, false) +Gui.toolbar.create_button{ + name = "toggle_warp_list", + left_element = Elements.container, + sprite = config.default_icon.type .. "/" .. config.default_icon.name, + tooltip = { "exp-gui_warp-list.tooltip-main" }, + visible = function(player, element) + return Roles.player_has_permission(player, "exp_scenario.gui.warp_list") + end +}:on_click(function(def, player) + -- A list the player opened themselves stays open when they leave a warp + Elements.container._get_player_data(player).keep_open = Gui.toolbar.get_button_toggled_state(def, player) +end) + +--- Give a force a spawn warp when its first player is created +--- @param event EventData.on_player_created +local function on_player_created(event) + local player = Gui.get_player(event) + local force = player.force --[[@as LuaForce]] + if Elements.container.get_spawn_warp(force) then return end + + local surface = player.physical_surface + local position = force.get_spawn_position(surface) + local warp = Elements.container.add_warp(force, surface, position, nil, "Spawn") + Elements.container.set_spawn_warp(warp) + + -- Spawn has no warp area, so any pole nearby connects it to the network + local poles = surface.find_entities_filtered{ type = "electric-pole", position = position, radius = 20, limit = 1 } + warp.electric_pole = poles[1] +end + +--- Rebuild the gui for a player as it may be outdated +--- @param event EventData.on_player_joined_game | EventData.on_player_changed_force +local function refresh_player_warps(event) + local player = Gui.get_player(event) + Elements.container.refresh_player(player) + Elements.cooldown_bar.refresh_player(player, Elements.container._get_player_data(player).cooldown) +end + +--- Rebuild the gui for a player as their permissions may have changed +--- @param event EventData.ExpRoles.on_player_roles_changed +local function refresh_player_permissions(event) + Elements.container.refresh_player(Gui.get_player(event)) +end + +--- Restore the map tag of a warp when a player edits or removes it +--- @param event EventData.on_chart_tag_modified | EventData.on_chart_tag_removed +local function on_chart_tag_changed(event) + if not event.player_index then return end + for _, warp in ipairs(Elements.container.get_warps(event.force)) do + local tag = warp.tag + if not tag or not tag.valid or tag == event.tag then + if event.name == defines.events.on_chart_tag_removed then + warp.tag = nil + end + Elements.container._make_tag(warp) + end + end +end + +--- Count down cooldowns and check where each player is +local function update_players() + for _, player in pairs(game.connected_players) do + local player_data = Elements.container._get_player_data(player) + Elements.container._update_cooldown(player, player_data) + Elements.container._update_proximity(player, player_data) + end +end + +local e = defines.events + +return { + elements = Elements, + events = { + [e.on_player_created] = on_player_created, + [e.on_player_joined_game] = refresh_player_warps, + [e.on_player_changed_force] = refresh_player_warps, + [e.on_chart_tag_modified] = on_chart_tag_changed, + [e.on_chart_tag_removed] = on_chart_tag_changed, + [Roles.events.on_player_roles_changed] = refresh_player_permissions, + }, + on_nth_tick = { + [update_interval] = update_players, + } +} diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index 5dc39d3b..a4884bd1 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -490,6 +490,37 @@ caption-create-footer=Create task caption-edit-footer=Edit task caption-view-footer=Task details +[exp-gui_warp-list] +caption-main=Warp List [img=info] +tooltip-main=Warp List +tooltip-sub=Warps can only be used every __1__ seconds and when within __2__ tiles\n__3__\n__4__\n__5__\n__6__\n__7__\n__8__ +tooltip-sub-current= - __1__ This is your current warp point; +tooltip-sub-connected= - __1__ You can travel to this warp point; +tooltip-sub-different= - __1__ This warp is on a different network; +tooltip-sub-cooldown= - __1__ You are currently on cooldown; +tooltip-sub-not-available= - __1__ You are not in range of a warp point; +tooltip-sub-bypass= - __1__ Your role allows you to travel here; +tooltip-add=Add new warp +tooltip-add-too-close=Cannot create warp; too close to existing warp point: __1__ +add-warning-water=Cannot create warp; too close to water, please move __1__ tiles away from the water body or landfill it +add-warning-entities=Cannot create warp; too close to other entities, please move __1__ tiles away from the entities or remove them +tooltip-icon=Edit warp icon +tooltip-last-edit=Last edited by __1__ at __2__\nClick to view on map +tooltip-confirm=Save changes +tooltip-discard=Discard changes +tooltip-edit=Currently being edited by: __1__ +tooltip-edit-none=Currently being edited by: Nobody +tooltip-remove=Remove warp +tooltip-cooldown=Please wait __1__ seconds before you can warp +tooltip-cooldown-none=After each warp you will need to wait __1__ seconds before you can warp again +tooltip-goto=Go to x __1__ y __2__ +tooltip-goto-bypass=Go to x __1__ y __2__, bypass mode +tooltip-goto-bypass-different-network=Go to x __1__ y __2__, bypass mode different network +tooltip-goto-same-warp=You are already on this warp +tooltip-goto-different-network=This warp is on a different network, use power poles to connect it +tooltip-goto-cooldown=You are on cooldown, wait for your cooldown to recharge +tooltip-goto-disabled=You are not on a warp point, walk to a warp point + [exp_afk-kick] kick-message=All players were kicked because everyone was AFK. diff --git a/exp_scenario/module/locale/zh-CN.cfg b/exp_scenario/module/locale/zh-CN.cfg index 1b776db8..1c56589d 100644 --- a/exp_scenario/module/locale/zh-CN.cfg +++ b/exp_scenario/module/locale/zh-CN.cfg @@ -486,6 +486,37 @@ caption-edit-footer=修改工作流程 caption-view-footer=工作流程細節 tooltip-confirm = Save changes (minimum of 5 characters long) +[exp-gui_warp-list] +caption-main=傳送陣清單 [img=info] +tooltip-main=傳送陣清單 +tooltip-sub=傳送陣在 __1__ 秒內只能使用一次。 亦需在 __2__ 格之內使用\n__3__\n__4__\n__5__\n__6__\n__7__\n__8__ +tooltip-sub-current= - __1__ 你目前的傳送陣; +tooltip-sub-connected= - __1__ 你可以到本傳送陣; +tooltip-sub-different= - __1__ 該傳送陣未連接; +tooltip-sub-cooldown= - __1__ 傳送冷卻中; +tooltip-sub-not-available= - __1__ 你不在傳送陣範圍; +tooltip-sub-bypass= - __1__ 你可以傳送到這裏; +tooltip-add=新建傳送陣 +tooltip-add-too-close=無法新建傳送陣; 太接近 __1__ 了 +add-warning-water=無法新建傳送陣; 太接近水源了, 請移動 __1__ 格又或者填平該地方 +add-warning-entities=無法新建傳送陣; 太接近其他東西, 請移動 __1__ 格又或者拆除該東西 +tooltip-icon=修改傳送陣圖案 +tooltip-last-edit=最後由 __1__ 在 __2__ 更改\n按這在地圖上看 +tooltip-confirm=儲存更改 +tooltip-discard=放棄更改 +tooltip-edit=現被 __1__ 修改中 +tooltip-edit-none=現沒有被人修改 +tooltip-remove=拆除傳送陣 +tooltip-cooldown=請等 __1__ 秒才再次傳送 +tooltip-cooldown-none=你要等 __1__ 秒才可以再次傳送 +tooltip-goto=到 X __1__ Y __2__ +tooltip-goto-bypass=到 X __1__ Y __2__, 繞行模式 +tooltip-goto-bypass-different-network=到 X __1__ Y __2__, 繞行模式 (不同網絡) +tooltip-goto-same-warp=你已在該傳送陣 +tooltip-goto-different-network=該傳送陣未連接, 你可使用電纜連接 +tooltip-goto-cooldown=傳送冷卻中, 請等候冷卻 +tooltip-goto-disabled=你不在傳送陣, 請移至任意傳送陣 + [exp_afk-kick] kick-message=因地圖中沒有活躍玩家,所以所有人都已被請離。 diff --git a/exp_scenario/module/locale/zh-TW.cfg b/exp_scenario/module/locale/zh-TW.cfg index 1b776db8..1c56589d 100644 --- a/exp_scenario/module/locale/zh-TW.cfg +++ b/exp_scenario/module/locale/zh-TW.cfg @@ -486,6 +486,37 @@ caption-edit-footer=修改工作流程 caption-view-footer=工作流程細節 tooltip-confirm = Save changes (minimum of 5 characters long) +[exp-gui_warp-list] +caption-main=傳送陣清單 [img=info] +tooltip-main=傳送陣清單 +tooltip-sub=傳送陣在 __1__ 秒內只能使用一次。 亦需在 __2__ 格之內使用\n__3__\n__4__\n__5__\n__6__\n__7__\n__8__ +tooltip-sub-current= - __1__ 你目前的傳送陣; +tooltip-sub-connected= - __1__ 你可以到本傳送陣; +tooltip-sub-different= - __1__ 該傳送陣未連接; +tooltip-sub-cooldown= - __1__ 傳送冷卻中; +tooltip-sub-not-available= - __1__ 你不在傳送陣範圍; +tooltip-sub-bypass= - __1__ 你可以傳送到這裏; +tooltip-add=新建傳送陣 +tooltip-add-too-close=無法新建傳送陣; 太接近 __1__ 了 +add-warning-water=無法新建傳送陣; 太接近水源了, 請移動 __1__ 格又或者填平該地方 +add-warning-entities=無法新建傳送陣; 太接近其他東西, 請移動 __1__ 格又或者拆除該東西 +tooltip-icon=修改傳送陣圖案 +tooltip-last-edit=最後由 __1__ 在 __2__ 更改\n按這在地圖上看 +tooltip-confirm=儲存更改 +tooltip-discard=放棄更改 +tooltip-edit=現被 __1__ 修改中 +tooltip-edit-none=現沒有被人修改 +tooltip-remove=拆除傳送陣 +tooltip-cooldown=請等 __1__ 秒才再次傳送 +tooltip-cooldown-none=你要等 __1__ 秒才可以再次傳送 +tooltip-goto=到 X __1__ Y __2__ +tooltip-goto-bypass=到 X __1__ Y __2__, 繞行模式 +tooltip-goto-bypass-different-network=到 X __1__ Y __2__, 繞行模式 (不同網絡) +tooltip-goto-same-warp=你已在該傳送陣 +tooltip-goto-different-network=該傳送陣未連接, 你可使用電纜連接 +tooltip-goto-cooldown=傳送冷卻中, 請等候冷卻 +tooltip-goto-disabled=你不在傳送陣, 請移至任意傳送陣 + [exp_afk-kick] kick-message=因地圖中沒有活躍玩家,所以所有人都已被請離。 From 862fe49b608f7cf6e2c6eddaaec85aea07233911 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:46:08 +0200 Subject: [PATCH 16/18] Move the virtual layer into exp_scenario (#463) The virtual layer was a legacy control module and a legacy gui built on the legacy event system. The control module moves to exp_scenario/module/control/vlayer.lua in the event_handler shape, with the data typed and the allowed item table built locally rather than by rewriting the config. The gui moves to exp_scenario/module/gui/vlayer.lua in the shape of the other scenario guis: statistics as label and bar defines refreshed from display data computed once, and the interface controls as a define which owns its dropdowns. Api changes while moving: interfaces are removed by entity rather than by searching a position, the dead circuit wiring parameter is gone since connect_neighbour no longer exists, and remove_item now takes only the unallocated amount out of storage and counts that production back out of the total, which the legacy module got wrong. The locale moves to [exp-gui_vlayer] in en, zh-CN and zh-TW and the legacy [vlayer] sections, modules and file loader entries are removed. Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> --- exp_legacy/module/config/_file_loader.lua | 8 - exp_legacy/module/locale/en.cfg | 39 - exp_legacy/module/locale/zh-CN.cfg | 39 - exp_legacy/module/locale/zh-TW.cfg | 39 - exp_legacy/module/modules/control/vlayer.lua | 807 ------------------- exp_legacy/module/modules/gui/vlayer.lua | 547 ------------- exp_scenario/module/commands/vlayer.lua | 4 +- exp_scenario/module/control.lua | 2 + exp_scenario/module/control/vlayer.lua | 673 ++++++++++++++++ exp_scenario/module/gui/player_bonus.lua | 6 +- exp_scenario/module/gui/vlayer.lua | 531 ++++++++++++ exp_scenario/module/locale/en.cfg | 33 + exp_scenario/module/locale/zh-CN.cfg | 33 + exp_scenario/module/locale/zh-TW.cfg | 33 + 14 files changed, 1310 insertions(+), 1484 deletions(-) delete mode 100644 exp_legacy/module/modules/control/vlayer.lua delete mode 100644 exp_legacy/module/modules/gui/vlayer.lua create mode 100644 exp_scenario/module/control/vlayer.lua create mode 100644 exp_scenario/module/gui/vlayer.lua diff --git a/exp_legacy/module/config/_file_loader.lua b/exp_legacy/module/config/_file_loader.lua index 31fe77ed..d9eb6ad4 100644 --- a/exp_legacy/module/config/_file_loader.lua +++ b/exp_legacy/module/config/_file_loader.lua @@ -6,9 +6,6 @@ return { "expcore.player_data", -- must be loaded first to register event handlers - -- Control - "modules.control.vlayer", - --- Data "modules.data.statistics", "modules.data.player-colours", @@ -17,10 +14,5 @@ return { "modules.data.alt-view", "modules.data.tag", "modules.data.language", - --"modules.data.toolbar", - - --- GUI - "modules.gui.vlayer", - "modules.graftorio.require", -- graftorio } diff --git a/exp_legacy/module/locale/en.cfg b/exp_legacy/module/locale/en.cfg index af6bca63..58870d42 100644 --- a/exp_legacy/module/locale/en.cfg +++ b/exp_legacy/module/locale/en.cfg @@ -184,42 +184,3 @@ display-fwrsb=Robot Storage display-fwrsb-tooltip=Force worker robots storage bonus display-ffrlm=Robot Lifetime display-ffrlm-tooltip=Force following robots lifetime modifier - -[vlayer] -main-tooltip=vlayer -description-pbr=Recharge Player Battery upto a portion with vlayer -description-vi=Vlayer Info -pbr-not-running=vlayer need to be running -display-item-solar=[img=entity/solar-panel] Solar Panel -display-item-accumulator=[img=entity/accumulator] Accumulator -display-current-production=[virtual-signal=signal-P] Current Production -display-sustained-production=[virtual-signal=signal-S] Sustained Production -display-max-capacity=[virtual-signal=signal-C] Battery Capacity -display-current-capacity=[virtual-signal=signal-E] Battery Charge -display-remaining-surface-area=[virtual-signal=signal-R] Remaining Surface -display-item-solar-tooltip=The amount of Solar Panel -display-item-accumulator-tooltip=The amount of Accumulator -display-current-production-tooltip=Current Power Production -display-sustained-production-tooltip=Sustained Power Production -display-max-capacity-tooltip=Battery Max Capacity -display-current-capacity-tooltip=Battery Current Charge -display-remaining-surface-area-tooltip=Remaining Surface. Insert Landfill to increase -steel-chest-detect=No steel chest detected -steel-chest-empty=Chest is not emptied -control-refresh=Refresh List -control-see=See Interface -control-build=Build Interface -control-remove=Remove Interface -result-build=built -result-remove=removed -result-interface-location=__1__ interface was located at __2__ -interface-result=__1__ __2__ __4__ interface at __3__ -result-unable=Unable to build __1__ interface, because of __2__ -result-multiple=Multiple steel chest detected -result-limit=Max limit exceeded -result-space=Not enough space -control-type-energy=Energy -control-type-circuit=Circuit -control-type-storage-input=Storage Input -control-type-storage-output=Storage Output -power-on-space-research=You need to research [technology=__1__] at __2__ level for the space platform vlayer feature. diff --git a/exp_legacy/module/locale/zh-CN.cfg b/exp_legacy/module/locale/zh-CN.cfg index 28178f71..c86023f1 100644 --- a/exp_legacy/module/locale/zh-CN.cfg +++ b/exp_legacy/module/locale/zh-CN.cfg @@ -185,45 +185,6 @@ display-fwrsb-tooltip=勢力機械人儲存 display-ffrlm=機械人生命 display-ffrlm-tooltip=勢力戰鬥機械人生命時間 -[vlayer] -main-tooltip=vlayer -description-pbr=使用 Vlayer 為玩家電池充電 -description-vi=Vlayer 資訊 -pbr-not-running=vlayer 沒有運行 -display-item-solar=[img=entity/solar-panel] 太陽能板 -display-item-accumulator=[img=entity/accumulator] 蓄電池 -display-current-production=[virtual-signal=signal-P] 現時產能 -display-sustained-production=[virtual-signal=signal-S] 持續產能 -display-max-capacity=[virtual-signal=signal-C] 電池容量 -display-current-capacity=[virtual-signal=signal-E] 電池電量 -display-remaining-surface-area=[virtual-signal=signal-R] 餘下土地 -display-item-solar-tooltip=太陽能板數量 -display-item-accumulator-tooltip=蓄電池數量 -display-current-production-tooltip=現時電力產能 -display-sustained-production-tooltip=持續電力產能 -display-max-capacity-tooltip=電池最大容量 -display-current-capacity-tooltip=現時電池電量 -display-remaining-surface-area-tooltip=餘下土地。 放入堆填來增加 -steel-chest-detect=沒檢測到鋼箱 -steel-chest-empty=鋼箱不是空的 -control-refresh=刷新列表 -control-see=查看介面 -control-build=建造介面 -control-remove=拆除介面 -result-build=建造了 -result-remove=拆除了 -result-interface-location=__1__ 介面在 __2__ -interface-result=__1__ 在 __2__ __3__ __4__ 介面 -result-unable=無法建造 __1__ 介面,因 __2__ -result-multiple=檢測到多個鋼箱 -result-limit=超出最大限制 -result-space=空間不足 -control-type-energy=電力 -control-type-circuit=回路 -control-type-storage-input=放入箱 -control-type-storage-output=提取箱 -power-on-space-research=你要研究 [technology=__1__] 在 __2__ 級 才可使用太空平台 vlayer 功能。 - [toolbar] main-caption=工具箱 main-tooltip=工具箱設定\n選上來設定喜好 diff --git a/exp_legacy/module/locale/zh-TW.cfg b/exp_legacy/module/locale/zh-TW.cfg index 28178f71..c86023f1 100644 --- a/exp_legacy/module/locale/zh-TW.cfg +++ b/exp_legacy/module/locale/zh-TW.cfg @@ -185,45 +185,6 @@ display-fwrsb-tooltip=勢力機械人儲存 display-ffrlm=機械人生命 display-ffrlm-tooltip=勢力戰鬥機械人生命時間 -[vlayer] -main-tooltip=vlayer -description-pbr=使用 Vlayer 為玩家電池充電 -description-vi=Vlayer 資訊 -pbr-not-running=vlayer 沒有運行 -display-item-solar=[img=entity/solar-panel] 太陽能板 -display-item-accumulator=[img=entity/accumulator] 蓄電池 -display-current-production=[virtual-signal=signal-P] 現時產能 -display-sustained-production=[virtual-signal=signal-S] 持續產能 -display-max-capacity=[virtual-signal=signal-C] 電池容量 -display-current-capacity=[virtual-signal=signal-E] 電池電量 -display-remaining-surface-area=[virtual-signal=signal-R] 餘下土地 -display-item-solar-tooltip=太陽能板數量 -display-item-accumulator-tooltip=蓄電池數量 -display-current-production-tooltip=現時電力產能 -display-sustained-production-tooltip=持續電力產能 -display-max-capacity-tooltip=電池最大容量 -display-current-capacity-tooltip=現時電池電量 -display-remaining-surface-area-tooltip=餘下土地。 放入堆填來增加 -steel-chest-detect=沒檢測到鋼箱 -steel-chest-empty=鋼箱不是空的 -control-refresh=刷新列表 -control-see=查看介面 -control-build=建造介面 -control-remove=拆除介面 -result-build=建造了 -result-remove=拆除了 -result-interface-location=__1__ 介面在 __2__ -interface-result=__1__ 在 __2__ __3__ __4__ 介面 -result-unable=無法建造 __1__ 介面,因 __2__ -result-multiple=檢測到多個鋼箱 -result-limit=超出最大限制 -result-space=空間不足 -control-type-energy=電力 -control-type-circuit=回路 -control-type-storage-input=放入箱 -control-type-storage-output=提取箱 -power-on-space-research=你要研究 [technology=__1__] 在 __2__ 級 才可使用太空平台 vlayer 功能。 - [toolbar] main-caption=工具箱 main-tooltip=工具箱設定\n選上來設定喜好 diff --git a/exp_legacy/module/modules/control/vlayer.lua b/exp_legacy/module/modules/control/vlayer.lua deleted file mode 100644 index 72afba6c..00000000 --- a/exp_legacy/module/modules/control/vlayer.lua +++ /dev/null @@ -1,807 +0,0 @@ ---[[-- Control Module - vlayer - - Adds a virtual layer to store power to save space. - @control vlayer - @alias vlayer -]] - -local ExpUtil = require("modules/exp_util") -local Storage = require("modules/exp_util/storage") -local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event -local config = require("modules.exp_legacy.config.vlayer") --- @dep config.vlayer - -local mega = 1000000 - -local vlayer = {} -local vlayer_data = { - entity_interfaces = { - energy = {}, --- @type LuaEntity[] - circuit = {}, --- @type LuaEntity[] - storage_input = {}, --- @type LuaEntity[] - storage_output = {}, --- @type LuaEntity[] - }, - properties = { - total_surface_area = 0, - used_surface_area = 0, - total_production = 0, - production = 0, - discharge = 0, - capacity = 0, - }, - storage = { - items = {}, --- @type table - power_items = {}, --- @type table - energy = 0, - unallocated = {}, --- @type table - }, - surface = table.deep_copy(config.surface), -} - -Storage.register(vlayer_data, function(tbl) - vlayer_data = tbl -end) - -for name, properties in pairs(config.allowed_items) do - properties.modded = false - - if properties.power then - vlayer_data.storage.power_items[name] = { - value = assert(properties.fuel_value) * 1000000, - count = 0, - } - end -end - --- For all modded items, create a config for them -for item_name, properties in pairs(config.modded_items) do - local base_properties = config.allowed_items[properties.base_game_equivalent] - local m = properties.multiplier - - config.allowed_items[item_name] = { - starting_value = properties.starting_value or 0, - required_area = base_properties.required_area or 0, - surface_area = (base_properties.surface_area or 0) * m, - production = (base_properties.production or 0) * m, - capacity = (base_properties.capacity or 0) * m, - modded = true, - } -end - ---- Get all items in storage, do not modify --- @treturn table a dictionary of all items stored in the vlayer -function vlayer.get_items() - return vlayer_data.storage.items -end - ---- Get all unallocated items in storage --- @treturn table a dictionary of all unallocated items stored in the vlayer -function vlayer.get_unallocated_items() - return vlayer_data.storage.unallocated -end - ---- Get all allocated items in storage --- @treturn table a dictionary of all allocated items stored in the vlayer -function vlayer.get_allocated_items() - local r = {} - - for k, v in pairs(vlayer_data.storage.items) do - r[k] = v - - if vlayer_data.storage.unallocated[k] then - r[k] = r[k] - vlayer_data.storage.unallocated[k] - end - end - - return r -end - ---- Get the actual defecit of land -local function get_actual_land_defecit() - local n = vlayer_data.properties.total_surface_area - vlayer_data.properties.used_surface_area - - for k, v in pairs(vlayer.get_unallocated_items()) do - n = n - (config.allowed_items[k].required_area * v) - end - - return n -end - ---- Get interface counts --- @treturn table a dictionary of the vlayer interface counts -function vlayer.get_interface_counts() - local interfaces = vlayer_data.entity_interfaces - - return { - energy = #interfaces.energy, - circuit = #interfaces.circuit, - storage_input = #interfaces.storage_input, - storage_output = #interfaces.storage_output, - } -end - ---- Get interfaces --- @treturn table a dictionary of the vlayer interfaces -function vlayer.get_interfaces() - local interfaces = vlayer_data.entity_interfaces - - return { - energy = interfaces.energy, - circuit = interfaces.circuit, - storage_input = interfaces.storage_input, - storage_output = interfaces.storage_output, - } -end - ---[[ - 25,200 / 420 s - 昼 210秒 ソーラー効率100% - 夕方 84秒 1秒ごとにソーラー発電量が約1.2%ずつ下がり、やがて0%になる - 夜 42秒 ソーラー発電量が0%になる - 朝方 84秒 1秒ごとにソーラー発電量が約1.2%ずつ上がり、やがて100%になる - - (surface.dawn) 0.75 18,900 Day 12,600 210s - 0.00 0 Noon - (surface.dusk) 0.25 6,300 Sunset 5,040 84s - (surface.evening) 0.45 11,340 Night 2,520 42s - (surface.morning) 0.55 13,860 Sunrise 5,040 84s -]] - ---- Get the power multiplier based on the surface time -local function get_production_multiplier() - local mul = vlayer_data.surface.solar_power_multiplier - local surface = vlayer_data.surface - - if surface.always_day then - -- Surface is always day, so full production is used - return mul - end - - if surface.darkness then - -- We are using a real surface, our config does not contain "darkness" - local brightness = 1 - surface.darkness - - if brightness >= surface.min_brightness then - return mul * (brightness - surface.min_brightness) / (1 - surface.min_brightness) - else - return 0 - end - end - - -- Caused by using a set config rather than a surface - local tick = game.tick % surface.ticks_per_day - local daytime = tick / surface.ticks_per_day - surface.daytime = daytime - - if daytime <= surface.dusk then -- Noon to Sunset - return mul - elseif daytime <= surface.evening then -- Sunset to Night - return mul * (1 - ((daytime - surface.dusk) / (surface.evening - surface.dusk))) - elseif daytime <= surface.morning then -- Night to Sunrise - return 0 - elseif daytime <= surface.dawn then -- Sunrise to Morning - return mul * ((surface.daytime - surface.morning) / (surface.dawn - surface.morning)) - else -- Morning to Noon - return mul - end -end - ---- Get the sustained power multiplier, this needs improving -local function get_sustained_multiplier() - local mul = vlayer_data.surface.solar_power_multiplier - local surface = vlayer_data.surface - - if surface.always_day then - -- Surface is always day, so full production is used - return mul - end - - -- For nauvis vanilla: 210s + (1/2 x (84s + 84s)) - local day_duration = 1 - surface.dawn + surface.dusk - local sunset_duration = surface.evening - surface.dusk - local sunrise_duration = surface.dawn - surface.morning - - return mul * (day_duration + (0.5 * (sunset_duration + sunrise_duration))) -end - ---- Internal, Allocate items in the vlayer, this will increase the property values of the vlayer such as production and capacity --- Does not increment item storage, so should not be called before insert_item unless during init --- Does not validate area requirements, so checks must be performed before calling this function --- Accepts negative count for deallocating items --- @tparam string item_name The name of the item to allocate --- @tparam number count The count of the item to allocate -function vlayer.allocate_item(item_name, count) - local item_properties = config.allowed_items[item_name] - assert(item_properties, "Item not allowed in vlayer: " .. tostring(item_name)) - - if item_properties.production then - local nc = item_properties.production * count - vlayer_data.properties.production = vlayer_data.properties.production + nc - vlayer_data.properties.total_production = vlayer_data.properties.total_production + nc - end - - if item_properties.capacity then - vlayer_data.properties.capacity = vlayer_data.properties.capacity + item_properties.capacity * count - end - - if item_properties.discharge then - vlayer_data.properties.discharge = vlayer_data.properties.discharge + item_properties.discharge * count - end - - if item_properties.surface_area then - vlayer_data.properties.total_surface_area = vlayer_data.properties.total_surface_area + item_properties.surface_area * count - end - - if item_properties.required_area and item_properties.required_area > 0 then - vlayer_data.properties.used_surface_area = vlayer_data.properties.used_surface_area + item_properties.required_area * count - end -end - -function vlayer.unable_alloc_item_pwr_calc(item_name, count) - local item_properties = config.allowed_items[item_name] - - if item_properties.production then - vlayer_data.properties.total_production = vlayer_data.properties.total_production + item_properties.production * count - end -end - --- For all allowed items, setup their starting values, default 0 -for item_name, properties in pairs(config.allowed_items) do - vlayer_data.storage.items[item_name] = properties.starting_value or 0 - - if properties.required_area and properties.required_area > 0 then - vlayer_data.storage.unallocated[item_name] = 0 - end - - vlayer.allocate_item(item_name, properties.starting_value) -end - ---- Insert an item into the vlayer, this will increment its count in storage and allocate it if possible --- @tparam string item_name The name of the item to insert --- @tparam number count The count of the item to insert -function vlayer.insert_item(item_name, count) - local item_properties = config.allowed_items[item_name] - assert(item_properties, "Item not allowed in vlayer: " .. tostring(item_name)) - vlayer_data.storage.items[item_name] = vlayer_data.storage.items[item_name] + count - - if not config.unlimited_surface_area and item_properties.required_area and item_properties.required_area > 0 then - -- Calculate how many can be allocated - local surplus_area = vlayer_data.properties.total_surface_area - vlayer_data.properties.used_surface_area - local allocate_count = math.min(count, math.floor(surplus_area / item_properties.required_area)) - - if allocate_count > 0 then - vlayer.allocate_item(item_name, allocate_count) - end - - local unallocated = count - allocate_count - vlayer_data.storage.unallocated[item_name] = vlayer_data.storage.unallocated[item_name] + unallocated - vlayer.unable_alloc_item_pwr_calc(item_name, unallocated) - else - vlayer.allocate_item(item_name, count) - end -end - ---- Remove an item from the vlayer, this will decrement its count in storage and prioritise unallocated items over deallocation --- Can not always fulfil the remove request for items which provide surface area, therefore returns the amount actually removed --- @tparam string item_name The name of the item to remove --- @tparam number count The count of the item to remove --- @treturn number The count of the item actually removed -function vlayer.remove_item(item_name, count) - local item_properties = config.allowed_items[item_name] - assert(item_properties, "Item not allowed in vlayer: " .. tostring(item_name)) - local remove_unallocated = 0 - - if not config.unlimited_surface_area and item_properties.required_area and item_properties.required_area > 0 then - -- Remove from the unallocated storage first - remove_unallocated = math.min(count, vlayer_data.storage.unallocated[item_name]) - - if remove_unallocated > 0 then - vlayer_data.storage.items[item_name] = vlayer_data.storage.items[item_name] - count - vlayer_data.storage.unallocated[item_name] = vlayer_data.storage.unallocated[item_name] - count - end - - -- Check if any more items need to be removed - count = count - remove_unallocated - - if count == 0 then - return remove_unallocated - end - end - - -- Calculate the amount to remove based on items in storage - local remove_count = math.min(count, vlayer_data.storage.items[item_name]) - - if item_properties.surface_area and item_properties.surface_area > 0 then - -- If the item provides surface area then it has additional limitations - local surplus_area = vlayer_data.properties.total_surface_area - vlayer_data.properties.used_surface_area - remove_count = math.min(remove_count, math.floor(surplus_area / item_properties.surface_area)) - - if remove_count <= 0 then - return remove_unallocated - end - end - - -- Remove the item from allocated storage - vlayer_data.storage.items[item_name] = vlayer_data.storage.items[item_name] - remove_count - vlayer.allocate_item(item_name, -remove_count) - - return remove_unallocated + remove_count -end - ---- Create a new storage input interface --- @tparam LuaSurface surface The surface to place the interface onto --- @tparam MapPosition position The position on the surface to place the interface at --- @tparam[opt] LuaPlayer player The player to show as the last user of the interface --- @treturn LuaEntity The entity that was created for the interface -function vlayer.create_input_interface(surface, position, circuit, last_user) - local interface = surface.create_entity{ name = "storage-chest", position = position, force = "neutral" } - interface.storage_filter = { name = "deconstruction-planner", quality = "normal" } - table.insert(vlayer_data.entity_interfaces.storage_input, interface) - - if last_user then - interface.last_user = last_user - end - - if circuit then - for k, _ in pairs(circuit) do - for _, v in pairs(circuit[k]) do - interface.connect_neighbour{ wire = defines.wire_type[k], target_entity = v } - end - end - end - - interface.destructible = false - interface.minable_flag = false - interface.operable = true - - return interface -end - ---- Handle all input interfaces, will take their contents and insert it into the vlayer storage -local function handle_input_interfaces() - for index, interface in pairs(vlayer_data.entity_interfaces.storage_input) do - if not interface.valid then - vlayer_data.entity_interfaces.storage_input[index] = nil - else - local inventory = assert(interface.get_inventory(defines.inventory.chest)) - - for _, v in pairs(inventory.get_contents()) do - if config.allowed_items[v.name] then - --[[ - there are no quality support currently. - so instead, using the stats projection value for higher quality. - ]] - local count_deduct - local count_add - - if prototypes.quality[v.quality].level == 0 then - count_deduct = v.count - count_add = v.count - - elseif prototypes.quality[v.quality].level > 0 and v.count >= 10 then - local batch = math.floor(v.count / 10) - count_deduct = batch * 10 - count_add = batch * (10 + (prototypes.quality[v.quality].level * 3)) - end - - if count_deduct and count_add then - if config.allowed_items[v.name].modded then - local modded_item = assert(config.modded_items[v.name]) - if config.modded_auto_downgrade then - vlayer.insert_item(modded_item.base_game_equivalent, count_add * modded_item.multiplier) - else - vlayer.insert_item(v.name, count_add) - end - else - if vlayer_data.storage.power_items[v.name] then - vlayer_data.storage.power_items[v.name].count = vlayer_data.storage.power_items[v.name].count + count_add - else - vlayer.insert_item(v.name, count_add) - end - end - - inventory.remove{ name = v.name, count = count_deduct, quality = v.quality } - end - end - end - end - end -end - ---- Create a new storage output interface --- @tparam LuaSurface surface The surface to place the interface onto --- @tparam MapPosition position The position on the surface to place the interface at --- @tparam[opt] LuaPlayer player The player to show as the last user of the interface --- @treturn LuaEntity The entity that was created for the interface -function vlayer.create_output_interface(surface, position, circuit, last_user) - local interface = surface.create_entity{ name = "requester-chest", position = position, force = "neutral" } - table.insert(vlayer_data.entity_interfaces.storage_output, interface) - - if last_user then - interface.last_user = last_user - end - - if circuit then - for k, _ in pairs(circuit) do - for _, v in pairs(circuit[k]) do - interface.connect_neighbour{ wire = defines.wire_type[k], target_entity = v } - end - end - end - - interface.destructible = false - interface.minable_flag = false - interface.operable = true - - return interface -end - ---- Handle all output interfaces, will take their requests and remove it from the vlayer storage -local function handle_output_interfaces() - for index, interface in pairs(vlayer_data.entity_interfaces.storage_output) do - if not interface.valid then - vlayer_data.entity_interfaces.storage_output[index] = nil - else - local inventory = assert(interface.get_inventory(defines.inventory.chest)) - local inventory_request_sections = assert(interface.get_logistic_sections()).sections - - for i = 1, #inventory_request_sections do - for _, v in pairs(inventory_request_sections[i].filters) do - if v.value and config.allowed_items[v.value.name] then - local current_amount = inventory.get_item_count(v.value.name) - local request_amount = math.min(assert(v.min) - current_amount, vlayer_data.storage.items[v.value.name]) - - if request_amount > 0 and inventory.can_insert{ name = v.value.name, count = request_amount } then - local removed_item_count = vlayer.remove_item(v.value.name, request_amount) - - if removed_item_count > 0 then - inventory.insert{ name = v.value.name, count = removed_item_count, quality = "normal" } - end - end - end - end - end - end - end -end - ---- Handle the unallocated items because more surface area may have been added -local function handle_unallocated() - -- unallocated cant happen when its unlimited - if config.unlimited_surface_area then - return - end - - -- Get the total unallocated area so items can be allocated in equal amounts - local unallocated_area = 0 - - for item_name, count in pairs(vlayer_data.storage.unallocated) do - local item_properties = config.allowed_items[item_name] - unallocated_area = unallocated_area + item_properties.required_area * count - end - - if unallocated_area == 0 then - return - end - - -- Allocate items in an equal distribution - local surplus_area = vlayer_data.properties.total_surface_area - vlayer_data.properties.used_surface_area - - for item_name, count in pairs(vlayer_data.storage.unallocated) do - local allocation_count = math.min(count, math.floor(count * surplus_area / unallocated_area)) - - if allocation_count > 0 then - vlayer_data.storage.unallocated[item_name] = vlayer_data.storage.unallocated[item_name] - allocation_count - vlayer.allocate_item(item_name, allocation_count) - vlayer.unable_alloc_item_pwr_calc(item_name, -allocation_count) - end - end -end - ---- Get the statistics for the vlayer -function vlayer.get_statistics() - local vdp = vlayer_data.properties.production * mega - local gdm = get_production_multiplier() - local gsm = get_sustained_multiplier() - local gald = get_actual_land_defecit() - - return { - total_surface_area = vlayer_data.properties.total_surface_area, - used_surface_area = vlayer_data.properties.used_surface_area, - remaining_surface_area = gald, - surface_area = vlayer_data.properties.total_surface_area - gald, - production_multiplier = gdm, - energy_max = vdp, - energy_production = vdp * gdm, - energy_total_production = vlayer_data.properties.total_production * gsm * mega, - energy_sustained = vdp * gsm, - energy_capacity = vlayer_data.properties.capacity * mega, - energy_storage = vlayer_data.storage.energy, - } -end - ---- add or reduce vlayer power ---- @param power number ---- @return number -function vlayer.energy_changed(power) - local new_value = vlayer_data.storage.energy + power - vlayer_data.storage.energy = new_value - return new_value -end - ---- Circuit signals used for the statistics -function vlayer.get_circuits() - return { - total_surface_area = "signal-A", - used_surface_area = "signal-U", - remaining_surface_area = "signal-R", - production_multiplier = "signal-M", - energy_production = "signal-P", - energy_sustained = "signal-S", - energy_capacity = "signal-C", - energy_storage = "signal-E", - } -end - -local vlayer_circuits_string = "" - -for key, value in pairs(vlayer.get_circuits()) do - vlayer_circuits_string = vlayer_circuits_string .. string.format("[virtual-signal=%s] = %s\n", value, key:gsub("_", " ")) -end - ---- Create a new circuit interface --- @tparam LuaSurface surface The surface to place the interface onto --- @tparam MapPosition position The position on the surface to place the interface at --- @tparam[opt] LuaPlayer player The player to show as the last user of the interface --- @treturn LuaEntity The entity that was created for the interface -function vlayer.create_circuit_interface(surface, position, circuit, last_user) - local interface = surface.create_entity{ name = "constant-combinator", position = position, force = "neutral" } - table.insert(vlayer_data.entity_interfaces.circuit, interface) - - if last_user then - interface.last_user = last_user - end - - if circuit then - for k, _ in pairs(circuit) do - for _, v in pairs(circuit[k]) do - interface.connect_neighbour{ wire = defines.wire_type[k], target_entity = v } - end - end - end - - interface.destructible = false - interface.minable_flag = false - interface.operable = true - - return interface -end - ---- Handle all circuit interfaces, updating their signals to match the vlayer statistics -local function handle_circuit_interfaces() - local stats = vlayer.get_statistics() - - for index, interface in pairs(vlayer_data.entity_interfaces.circuit) do - if not interface.valid then - vlayer_data.entity_interfaces.circuit[index] = nil - else - local control = interface.get_or_create_control_behavior() --[[@as LuaConstantCombinatorControlBehavior]] - if control.sections_count == 0 then - control.add_section() - end - local circuit_oc = assert(control.sections[1]) - local signal_index = 1 - local circuit = vlayer.get_circuits() - - -- Set the virtual signals based on the vlayer stats - for stat_name, signal_name in pairs(circuit) do - if stat_name:find("energy") then - circuit_oc.set_slot(signal_index, { value = { type = "virtual", name = signal_name, quality = "normal" }, min = math.floor(stats[stat_name] / mega) }) - elseif stat_name == "production_multiplier" then - circuit_oc.set_slot(signal_index, { value = { type = "virtual", name = signal_name, quality = "normal" }, min = math.floor(stats[stat_name] * 10000) }) - else - circuit_oc.set_slot(signal_index, { value = { type = "virtual", name = signal_name, quality = "normal" }, min = math.floor(stats[stat_name]) }) - end - - signal_index = signal_index + 1 - end - - -- Set the item signals based on stored items - for item_name, count in pairs(vlayer_data.storage.items) do - if prototypes.item[item_name] and count > 0 then - local existing_index = circuit_oc.set_slot(signal_index, { value = { type = "item", name = item_name, quality = "normal" }, min = count }) - if existing_index then - circuit_oc.clear_slot(existing_index) - circuit_oc.set_slot(signal_index, { value = { type = "item", name = item_name, quality = "normal" }, min = count }) - end - signal_index = signal_index + 1 - end - end - - -- Clear remaining signals to prevent outdated values being present (caused by count > 0 check) - for clear_index = signal_index, #circuit do - if not circuit_oc.get_slot(clear_index).value then - break -- There are no more signals to clear - end - - circuit_oc.clear_slot(clear_index) - end - - interface.combinator_description = vlayer_circuits_string - end - end -end - ---- Create a new energy interface --- @tparam LuaSurface surface The surface to place the interface onto --- @tparam MapPosition position The position on the surface to place the interface at --- @tparam[opt] LuaPlayer player The player to show as the last user of the interface --- @treturn LuaEntity The entity that was created for the interface, or nil if it could not be created -function vlayer.create_energy_interface(surface, position, last_user) - if not surface.can_place_entity{ name = "electric-energy-interface", position = position } then - return nil - end - - local interface = surface.create_entity{ name = "electric-energy-interface", position = position, force = "neutral" } - table.insert(vlayer_data.entity_interfaces.energy, interface) - - if last_user then - interface.last_user = last_user - end - - interface.destructible = false - interface.minable_flag = false - interface.operable = false - interface.electric_buffer_size = 0 - interface.power_production = 0 - interface.power_usage = 0 - interface.energy = 0 - - return interface -end - ---- Handle all energy interfaces as well as the energy storage -local function handle_energy_interfaces() - -- Add the newly produced power - local production = vlayer_data.properties.production * mega * (config.update_tick_energy / 60) - vlayer_data.storage.energy = vlayer_data.storage.energy + math.floor(production * get_production_multiplier()) - - -- Calculate how much power is present in the network, that is storage + all interfaces - if #vlayer_data.entity_interfaces.energy > 0 then - local available_energy = vlayer_data.storage.energy - - for index, interface in pairs(vlayer_data.entity_interfaces.energy) do - if not interface.valid then - vlayer_data.entity_interfaces.energy[index] = nil - else - available_energy = available_energy + interface.energy - end - end - - -- Distribute the energy between all interfaces - local discharge_rate = 2 * (production + vlayer_data.properties.discharge * mega) / #vlayer_data.entity_interfaces.energy - local fill_to = math.min(discharge_rate, math.floor(available_energy / #vlayer_data.entity_interfaces.energy)) - - for _, interface in pairs(vlayer_data.entity_interfaces.energy) do - interface.electric_buffer_size = math.max(discharge_rate, interface.energy) -- prevent energy loss - local delta = fill_to - interface.energy -- positive means storage to interface - vlayer_data.storage.energy = vlayer_data.storage.energy - delta - interface.energy = interface.energy + delta - end - end - - -- Cap the stored energy to the allowed capacity - if not config.unlimited_capacity and vlayer_data.storage.energy > vlayer_data.properties.capacity * mega then - vlayer_data.storage.energy = vlayer_data.properties.capacity * mega - - -- burn the trash to produce power - elseif vlayer_data.storage.power_items then - for k, v in pairs(vlayer_data.storage.power_items) do - local max_burning = (vlayer_data.properties.capacity * mega / 2) - vlayer_data.storage.energy - - if v.count > 0 and max_burning > 0 then - local to_burn = math.min(v.count, math.floor(max_burning / v.value)) - - vlayer_data.storage.energy = vlayer_data.storage.energy + (to_burn * v.value) - vlayer_data.storage.power_items[k].count = vlayer_data.storage.power_items[k].count - to_burn - end - end - end -end - ---- Remove the entity interface using the given position --- @tparam LuaSurface surface The surface to search for an interface on --- @tparam MapPosition position The position of the item --- @treturn string The type of interface that was removed, or nil if no interface was found --- @treturn MapPosition The position the interface was at, or nil if no interface was found -function vlayer.remove_interface(surface, position) - local entities = surface.find_entities_filtered{ - name = { "storage-chest", "requester-chest", "constant-combinator", "electric-energy-interface" }, - force = "neutral", - position = position, - radius = 2, - limit = 1, - } - - -- Get the details which will be returned - if #entities == 0 then - return nil, nil, nil - end - - local interface = entities[1] - local name = interface.name - local sur = interface.surface - local pos = interface.position - - -- Return the type of interface removed and do some clean up - if name == "storage-chest" then - local inventory = assert(interface.get_inventory(defines.inventory.chest)) - ExpUtil.transfer_inventory_to_surface{ - inventory = inventory, - surface = interface.surface, - name = "iron-chest", - allow_creation = true, - } - table.remove_element(vlayer_data.entity_interfaces.storage_input, interface) - interface.destroy() - - return "storage-input", sur, pos - elseif name == "requester-chest" then - local inventory = assert(interface.get_inventory(defines.inventory.chest)) - ExpUtil.transfer_inventory_to_surface{ - inventory = inventory, - surface = interface.surface, - name = "iron-chest", - allow_creation = true, - } - table.remove_element(vlayer_data.entity_interfaces.storage_output, interface) - interface.destroy() - - return "storage-output", sur, pos - elseif name == "constant-combinator" then - table.remove_element(vlayer_data.entity_interfaces.circuit, interface) - interface.destroy() - - return "circuit", sur, pos - elseif name == "electric-energy-interface" then - vlayer_data.storage.energy = vlayer_data.storage.energy + interface.energy - table.remove_element(vlayer_data.entity_interfaces.energy, interface) - interface.destroy() - - return "energy", sur, pos - end -end - -local function on_surface_event() - if config.mimic_surface then - local surface = game.get_surface(config.mimic_surface) - - if surface then - vlayer_data.surface = surface - return - end - end - - if not vlayer_data.surface.index then - -- Our fake surface data never has an index, we test for this to avoid unneeded copies from the config - vlayer_data.surface = table.deep_copy(config.surface) - end -end - ---- Handle all storage IO and attempt allocation of unallocated items -Event.on_nth_tick(config.update_tick_storage, function(_) - handle_input_interfaces() - handle_output_interfaces() - handle_unallocated() -end) - ---- Handle all energy and circuit updates -Event.on_nth_tick(config.update_tick_energy, function(_) - handle_circuit_interfaces() - handle_energy_interfaces() -end) - -Event.add(defines.events.on_surface_created, on_surface_event) -Event.add(defines.events.on_surface_renamed, on_surface_event) -Event.add(defines.events.on_surface_imported, on_surface_event) -Event.on_init(on_surface_event) -- Default surface always exists, does not trigger on_surface_created - -return vlayer diff --git a/exp_legacy/module/modules/gui/vlayer.lua b/exp_legacy/module/modules/gui/vlayer.lua deleted file mode 100644 index 1060b786..00000000 --- a/exp_legacy/module/modules/gui/vlayer.lua +++ /dev/null @@ -1,547 +0,0 @@ ---[[-- Gui Module - Virtual Layer - - Adds a virtual layer to store power to save space. - @gui Virtual Layer - @alias vlayer_container -]] - -local Gui = require("modules/exp_gui") -local Roles = require("modules/exp_roles") -local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event -local format_number = require("util").format_number --- @dep util -local config = require("modules.exp_legacy.config.vlayer") --- @dep config.vlayer -local vlayer = require("modules.exp_legacy.modules.control.vlayer") - -local Selection = require("modules/exp_util/selection") -local SelectArea = Selection.connect("ExpGui_VLayer") - ---- Align an aabb to the grid by expanding it -local function aabb_align_expand(aabb) - return { - left_top = { x = math.floor(aabb.left_top.x), y = math.floor(aabb.left_top.y) }, - right_bottom = { x = math.ceil(aabb.right_bottom.x), y = math.ceil(aabb.right_bottom.y) }, - } -end - -local vlayer_container --- @type ExpElement -local vlayer_gui_control_type --- @type ExpElement -local vlayer_gui_control_list --- @type ExpElement - -local vlayer_control_type_list = { - [1] = "energy", - [2] = "circuit", - [3] = "storage_input", - [4] = "storage_output", -} - -local function pos_to_gps_string(pos, surface_name) - return "[gps=" .. string.format("%.1f", pos.x) .. "," .. string.format("%.1f", pos.y) .. "," .. surface_name .. "]" -end - -local function format_energy(amount, unit) - if amount < 1 then - return "0 " .. unit - end - - local suffix = "" - local suffix_list = { - ["P"] = 1000000000000000, - ["T"] = 1000000000000, - ["G"] = 1000000000, - ["M"] = 1000000, - ["k"] = 1000, - } - - for letter, limit in pairs(suffix_list) do - if math.abs(amount) >= limit then - amount = string.format("%.1f", amount / limit) - suffix = letter - break - end - end - - local k - local formatted = amount - - while true do - formatted, k = string.gsub(formatted, "^(-?%d+)(%d%d%d)", "%1,%2") - - if (k == 0) then - break - end - end - - return formatted .. " " .. suffix .. unit -end - -local ExpUtil = require("modules/exp_util") ---- When an area is selected to add protection to the area -SelectArea:on_selection(function(event) - log(ExpUtil.format_any(event)) - local area = aabb_align_expand(event.area) - local player = game.players[event.player_index] - - if not player then - return - end - - local container = Gui.get_left_element(vlayer_container, player) - local disp = container.frame["vlayer_st_2"].disp.table - local target = vlayer_control_type_list[disp[vlayer_gui_control_type.name].selected_index] - local entities --- @type LuaEntity[] - - if config.power_on_space and event.surface and event.surface.platform and target == "energy" then - if (player.force --[[@as LuaForce]]).technologies[config.power_on_space_research.name].level >= config.power_on_space_research.level then - entities = event.surface.find_entities_filtered{ area = area, name = "constant-combinator", force = player.force } - else - player.print{ "vlayer.power-on-space-research", config.power_on_space_research.name, config.power_on_space_research.level } - return - end - else - entities = event.surface.find_entities_filtered{ area = area, name = "steel-chest", force = player.force } - end - - if #entities == 0 then - player.print{ "vlayer.steel-chest-detect" } - return - elseif #entities > 1 then - player.print{ "vlayer.result-unable", { "vlayer.control-type-" .. target:gsub("_", "-") }, { "vlayer.result-multiple" } } - return - end - - if not entities[1] then - return - end - - local e = entities[1] - local e_pos = { x = string.format("%.1f", e.position.x), y = string.format("%.1f", e.position.y) } - local e_circ = nil -- e.get_wire_connectors{ or_create = false } - - if e.name and e.name == "steel-chest" and (not assert(e.get_inventory(defines.inventory.chest)).is_empty()) then - player.print{ "vlayer.steel-chest-empty" } - return - end - - if (vlayer.get_interface_counts()[target] >= config.interface_limit[target]) then - player.print{ "vlayer.result-unable", { "vlayer.control-type-" .. target:gsub("_", "-") }, { "vlayer.result-limit" } } - return - end - - e.destroy() - - if target == "energy" then - if not vlayer.create_energy_interface(event.surface, e_pos, player) then - player.print{ "vlayer.result-unable", { "vlayer.control-type-energy" }, { "vlayer.result-space" } } - return - end - elseif target == "circuit" then - vlayer.create_circuit_interface(event.surface, e_pos, e_circ, player) - elseif target == "storage_input" then - vlayer.create_input_interface(event.surface, e_pos, e_circ, player) - elseif target == "storage_output" then - vlayer.create_output_interface(event.surface, e_pos, e_circ, player) - end - - game.print{ "vlayer.interface-result", player.name, pos_to_gps_string(e_pos, event.surface.name), { "vlayer.result-build" }, { "vlayer.control-type-" .. target:gsub("_", "-") } } -end) - ---- Display label for the number of solar panels --- @element vlayer_gui_display_item_solar_name -local vlayer_gui_display_item_solar_name = Gui.define("vlayer_gui_display_item_solar_name") - :draw{ - type = "label", - name = Gui.from_name, - caption = { "vlayer.display-item-solar" }, - style = "heading_2_label", - }:style{ - width = 200, - } - -local vlayer_gui_display_item_solar_count = Gui.define("vlayer_gui_display_item_solar_count") - :draw{ - type = "progressbar", - name = Gui.from_name, - caption = "", - value = 0, - style = "electric_satisfaction_statistics_progressbar", - }:style{ - width = 200, - font = "heading-2", - } - ---- Display label for the number of accumulators --- @element vlayer_gui_display_item_accumulator_name -local vlayer_gui_display_item_accumulator_name = Gui.define("vlayer_gui_display_item_accumulator_name") - :draw{ - type = "label", - name = Gui.from_name, - caption = { "vlayer.display-item-accumulator" }, - style = "heading_2_label", - }:style{ - width = 200, - } - -local vlayer_gui_display_item_accumulator_count = Gui.define("vlayer_gui_display_item_accumulator_count") - :draw{ - type = "progressbar", - name = Gui.from_name, - caption = "", - value = 0, - style = "electric_satisfaction_statistics_progressbar", - }:style{ - width = 200, - font = "heading-2", - } - ---- Display label for the surface area --- @element vlayer_gui_display_signal_surface_area_name -local vlayer_gui_display_signal_surface_area_name = Gui.define("vlayer_gui_display_signal_surface_area_name") - :draw{ - type = "label", - name = Gui.from_name, - caption = { "vlayer.display-remaining-surface-area" }, - tooltip = { "vlayer.display-remaining-surface-area-tooltip" }, - style = "heading_2_label", - }:style{ - width = 200, - } - -local vlayer_gui_display_signal_surface_area_count = Gui.define("vlayer_gui_display_signal_surface_area_count") - :draw{ - type = "progressbar", - name = Gui.from_name, - caption = "", - value = 0, - style = "electric_satisfaction_statistics_progressbar", - }:style{ - width = 200, - font = "heading-2", - } - ---- Display label for the sustained energy production --- @element vlayer_gui_display_signal_sustained_name -local vlayer_gui_display_signal_sustained_name = Gui.define("vlayer_gui_display_signal_sustained_name") - :draw{ - type = "label", - name = Gui.from_name, - caption = { "vlayer.display-sustained-production" }, - tooltip = { "vlayer.display-sustained-production-tooltip" }, - style = "heading_2_label", - }:style{ - width = 200, - } - -local vlayer_gui_display_signal_sustained_count = Gui.define("vlayer_gui_display_signal_sustained_count") - :draw{ - type = "progressbar", - name = Gui.from_name, - caption = "", - value = 0, - style = "electric_satisfaction_statistics_progressbar", - }:style{ - width = 200, - font = "heading-2", - } - ---- Display label for the current energy production --- @element vlayer_gui_display_signal_production_name -local vlayer_gui_display_signal_production_name = Gui.define("vlayer_gui_display_signal_production_name") - :draw{ - type = "label", - name = Gui.from_name, - caption = { "vlayer.display-current-production" }, - tooltip = { "vlayer.display-current-production-tooltip" }, - style = "heading_2_label", - }:style{ - width = 200, - } - -local vlayer_gui_display_signal_production_count = Gui.define("vlayer_gui_display_signal_production_count") - :draw{ - type = "progressbar", - name = Gui.from_name, - caption = "", - value = 0, - style = "electric_satisfaction_statistics_progressbar", - }:style{ - width = 200, - font = "heading-2", - } - ---- Display label for the sustained energy capacity --- @element vlayer_gui_display_signal_capacity_name -local vlayer_gui_display_signal_capacity_name = Gui.define("vlayer_gui_display_signal_capacity_name") - :draw{ - type = "label", - name = Gui.from_name, - caption = { "vlayer.display-current-capacity" }, - tooltip = { "vlayer.display-current-capacity-tooltip" }, - style = "heading_2_label", - }:style{ - width = 200, - } - -local vlayer_gui_display_signal_capacity_count = Gui.define("vlayer_gui_display_signal_capacity_count") - :draw{ - type = "progressbar", - name = Gui.from_name, - caption = "", - value = 0, - style = "electric_satisfaction_statistics_progressbar", - }:style{ - width = 200, - font = "heading-2", - } - ---- A vertical flow containing all the displays labels and their counts --- @element vlayer_display_set -local vlayer_display_set = Gui.define("vlayer_display_set") - :draw(function(_, parent, name) - local vlayer_set = parent.add{ type = "flow", direction = "vertical", name = name } - local disp = Gui.elements.scroll_table(vlayer_set, 400, 2, "disp") - - vlayer_gui_display_item_solar_name(disp) - vlayer_gui_display_item_solar_count(disp) - vlayer_gui_display_item_accumulator_name(disp) - vlayer_gui_display_item_accumulator_count(disp) - vlayer_gui_display_signal_surface_area_name(disp) - vlayer_gui_display_signal_surface_area_count(disp) - vlayer_gui_display_signal_sustained_name(disp) - vlayer_gui_display_signal_sustained_count(disp) - vlayer_gui_display_signal_production_name(disp) - vlayer_gui_display_signal_production_count(disp) - vlayer_gui_display_signal_capacity_name(disp) - vlayer_gui_display_signal_capacity_count(disp) - - return vlayer_set - end) - -local function vlayer_gui_list_refresh(player) - local container = Gui.get_left_element(vlayer_container, player) - local disp = container.frame["vlayer_st_2"].disp.table - local target = disp[vlayer_gui_control_type.name].selected_index - local full_list = {} - - if target then - local interface = vlayer.get_interfaces()[vlayer_control_type_list[target]] - - for i = 1, vlayer.get_interface_counts()[vlayer_control_type_list[target]], 1 do - local entity = assert(interface[i]) - local entity_position = entity.position - table.insert(full_list, i .. " X " .. entity_position.x .. " Y " .. entity_position.y) - end - - disp[vlayer_gui_control_list.name].items = full_list - end -end - ---- A drop down list filter by this type --- @element vlayer_gui_control_type -vlayer_gui_control_type = Gui.define("vlayer_gui_control_type") - :draw{ - type = "drop-down", - name = Gui.from_name, - items = { { "vlayer.control-type-energy" }, { "vlayer.control-type-circuit" }, { "vlayer.control-type-storage-input" }, { "vlayer.control-type-storage-output" } }, - selected_index = 1, - }:style{ - width = 200, - }:on_selection_state_changed(function(def, player, element) - vlayer_gui_list_refresh(player) - end) - ---- A drop down list to see the exact item to remove --- @element vlayer_gui_control_list -vlayer_gui_control_list = Gui.define("vlayer_gui_control_list") - :draw{ - type = "drop-down", - name = Gui.from_name, - }:style{ - width = 200, - } - ---- A button to refresh the remove list --- @element vlayer_gui_control_refresh -local vlayer_gui_control_refresh = Gui.define("vlayer_gui_control_refresh") - :draw{ - type = "button", - name = Gui.from_name, - caption = { "vlayer.control-refresh" }, - }:style{ - width = 200, - }:on_click(function(def, player, element) - vlayer_gui_list_refresh(player) - end) - ---- A button to check if the item is the one wanted to remove --- @element vlayer_gui_control_see -local vlayer_gui_control_see = Gui.define("vlayer_gui_control_see") - :draw{ - type = "button", - name = Gui.from_name, - caption = { "vlayer.control-see" }, - }:style{ - width = 200, - }:on_click(function(def, player, element, event) - local target = assert(element.parent)[vlayer_gui_control_type.name].selected_index - local n = assert(element.parent)[vlayer_gui_control_list.name].selected_index - - if target and vlayer_control_type_list[target] and n > 0 then - local i = vlayer.get_interfaces() - local entity = i[vlayer_control_type_list[target]][n] - if entity and entity.valid then - player.set_controller{ type = defines.controllers.remote, position = entity.position, surface = entity.surface } - player.print{ "vlayer.result-interface-location", { "vlayer.control-type-" .. vlayer_control_type_list[target]:gsub("_", "-") }, pos_to_gps_string(entity.position, entity.surface.name) } - end - end - end) - ---- A button used to build the vlayer interface --- @element vlayer_gui_control_build -local vlayer_gui_control_build = Gui.define("vlayer_gui_control_build") - :draw{ - type = "button", - name = Gui.from_name, - caption = { "vlayer.control-build" }, - }:style{ - width = 200, - }:on_click(function(def, player, element) - if SelectArea:stop(player) then - player.print{ "exp_util.selection_exit", { "vlayer.main-tooltip" } } - else - SelectArea:start(player) - player.print{ "exp_util.selection_enter", { "vlayer.main-tooltip" } } - end - - vlayer_gui_list_refresh(player) - end) - ---- A button used to remove the vlayer interface --- @element vlayer_gui_control_remove -local vlayer_gui_control_remove = Gui.define("vlayer_gui_control_remove") - :draw{ - type = "button", - name = Gui.from_name, - caption = { "vlayer.control-remove" }, - }:style{ - width = 200, - }:on_click(function(def, player, element) - local target = assert(element.parent)[vlayer_gui_control_type.name].selected_index - local n = assert(element.parent)[vlayer_gui_control_list.name].selected_index - - if target and vlayer_control_type_list[target] and n > 0 then - local i = vlayer.get_interfaces() - - if i and i[vlayer_control_type_list[target]] then - local entity = assert(i[vlayer_control_type_list[target]][n]) - local interface_type, interface_surface, interface_position = vlayer.remove_interface(entity.surface, entity.position) - - if interface_type then - game.print{ "vlayer.interface-result", player.name, pos_to_gps_string(interface_position, interface_surface.name), { "vlayer.result-remove" }, { "vlayer.control-type-" .. interface_type } } - end - end - end - - vlayer_gui_list_refresh(player) - end) - ---- A vertical flow containing all the control buttons --- @element vlayer_control_set -local vlayer_control_set = Gui.define("vlayer_control_set") - :draw(function(_, parent, name) - local player = Gui.get_player(parent) - local vlayer_set = parent.add{ type = "flow", direction = "vertical", name = name } - local disp = Gui.elements.scroll_table(vlayer_set, 400, 2, "disp") - - vlayer_gui_control_type(disp) - vlayer_gui_control_list(disp) - vlayer_gui_control_refresh(disp) - vlayer_gui_control_see(disp) - local b = vlayer_gui_control_build(disp) - local r = vlayer_gui_control_remove(disp) - local v = Roles.player_has_permission(player, "exp_scenario.gui.vlayer_edit") - b.visible = v - r.visible = v - - return vlayer_set - end) - ---- The main container for the vlayer gui --- @element vlayer_container -vlayer_container = Gui.define("vlayer_container") - :draw(function(definition, parent) - local container = Gui.elements.container(parent, 400) - - vlayer_display_set(container, "vlayer_st_1") - vlayer_control_set(container, "vlayer_st_2") - - return container.parent - end) - ---- Add the element to the left flow with a toolbar button -Gui.add_left_element(vlayer_container, false) -Gui.toolbar.create_button{ - name = "vlayer_toggle", - left_element = vlayer_container, - sprite = "entity/solar-panel", - tooltip = { "vlayer.main-tooltip" }, - visible = function(player, element) - return Roles.player_has_permission(player, "exp_scenario.gui.vlayer") - end -} - ---- Update the visibly of the buttons based on a players roles -local function role_update_event(event) - local player = game.players[event.player_index] - local visible = Roles.player_has_permission(player, "exp_scenario.gui.vlayer_edit") - local container = Gui.get_left_element(vlayer_container, player) - local disp = container.frame["vlayer_st_2"].disp.table - disp[vlayer_gui_control_build.name].visible = visible - disp[vlayer_gui_control_remove.name].visible = visible -end - -Event.add(Roles.events.on_player_roles_changed, role_update_event) - -Event.on_nth_tick(config.update_tick_gui, function(_) - local stats = vlayer.get_statistics() - local items = vlayer.get_items() - local items_alloc = vlayer.get_allocated_items() - - local vlayer_display = { - [vlayer_gui_display_item_solar_count.name] = { - val = (items_alloc["solar-panel"] / math.max(items["solar-panel"], 1)), - cap = format_number(items_alloc["solar-panel"], false) .. " / " .. format_number(items["solar-panel"], false), - }, - [vlayer_gui_display_item_accumulator_count.name] = { - val = (items_alloc["accumulator"] / math.max(items["accumulator"], 1)), - cap = format_number(items_alloc["accumulator"], false) .. " / " .. format_number(items["accumulator"], false), - }, - [vlayer_gui_display_signal_surface_area_count.name] = { - val = (stats.total_surface_area / math.max(stats.surface_area, 1)), - cap = format_number(stats.remaining_surface_area) - }, - [vlayer_gui_display_signal_sustained_count.name] = { - val = (stats.energy_sustained / math.max(stats.energy_total_production, 1)), - cap = format_energy(stats.energy_sustained, "W") .. " / " .. format_energy(stats.energy_total_production, "W") - }, - [vlayer_gui_display_signal_production_count.name] = { - val = (stats.energy_production / math.max(stats.energy_max, 1)), - cap = format_energy(stats.energy_production, "W") .. " / " .. format_energy(stats.energy_max, "W"), - }, - [vlayer_gui_display_signal_capacity_count.name] = { - val = (stats.energy_storage / math.max(stats.energy_capacity, 1)), - cap = format_energy(stats.energy_storage, "J") .. " / " .. format_energy(stats.energy_capacity, "J"), - }, - } - - for _, player in pairs(game.connected_players) do - local container = Gui.get_left_element(vlayer_container, player) - local disp = container.frame["vlayer_st_1"].disp.table - - for k, v in pairs(vlayer_display) do - disp[k].caption = v.cap - - if v.val then - disp[k].value = v.val - end - end - end -end) diff --git a/exp_scenario/module/commands/vlayer.lua b/exp_scenario/module/commands/vlayer.lua index f4b38e96..8ba856d9 100644 --- a/exp_scenario/module/commands/vlayer.lua +++ b/exp_scenario/module/commands/vlayer.lua @@ -3,7 +3,7 @@ Adds a virtual layer to store power to save space. ]] local Commands = require("modules/exp_commands") -local vlayer = require("modules.exp_legacy.modules.control.vlayer") +local Vlayer = require("modules/exp_scenario/control/vlayer") --- @class ExpCommand_vlayer.commands local commands = {} @@ -14,7 +14,7 @@ commands.vlayer = Commands.new("vlayer-info", { "exp-commands_vlayer.description :register(function(player) local index = 3 local response = { "", "exp-commands_vlayer.title" } --- @type LocalisedString - for title, value in pairs(vlayer.get_circuits()) do + for title, value in pairs(Vlayer.get_circuits()) do response[index] = { "exp-commands_vlayer.result", title, value } index = index + 1 end diff --git a/exp_scenario/module/control.lua b/exp_scenario/module/control.lua index 62c4e943..3d9dbdd4 100644 --- a/exp_scenario/module/control.lua +++ b/exp_scenario/module/control.lua @@ -67,6 +67,7 @@ add(require("modules/exp_scenario/control/roles")) add(require("modules/exp_scenario/control/spawn_area")) add(require("modules/exp_scenario/control/spectate")) add(require("modules/exp_scenario/control/station_auto_name")) +add(require("modules/exp_scenario/control/vlayer")) --- Guis add(require("modules/exp_scenario/gui/autofill")) @@ -83,6 +84,7 @@ add(require("modules/exp_scenario/gui/rocket_info")) add(require("modules/exp_scenario/gui/science_production")) add(require("modules/exp_scenario/gui/surveillance")) add(require("modules/exp_scenario/gui/task_list")) +add(require("modules/exp_scenario/gui/vlayer")) add(require("modules/exp_scenario/gui/warp_list")) add(require("modules/exp_scenario/gui/debug/shim")) add(require("modules/exp_scenario/gui/debug/event_view")) diff --git a/exp_scenario/module/control/vlayer.lua b/exp_scenario/module/control/vlayer.lua new file mode 100644 index 00000000..7d063557 --- /dev/null +++ b/exp_scenario/module/control/vlayer.lua @@ -0,0 +1,673 @@ +--[[-- Control - Virtual Layer +Stores solar panels, accumulators and landfill in a virtual layer which produces power through interface entities +]] + +local ExpUtil = require("modules/exp_util") +local Storage = require("modules/exp_util/storage") +local config = require("modules.exp_legacy.config.vlayer") + +local floor = math.floor +local min = math.min +local max = math.max +local mega = 1000000 + +--- @alias ExpScenario_Vlayer.InterfaceType "energy" | "circuit" | "storage_input" | "storage_output" + +--- @class ExpScenario_Vlayer.Interfaces +--- @field energy LuaEntity[] +--- @field circuit LuaEntity[] +--- @field storage_input LuaEntity[] +--- @field storage_output LuaEntity[] + +--- @class ExpScenario_Vlayer.Properties +--- @field total_surface_area number Tiles provided by landfill +--- @field used_surface_area number Tiles taken by allocated items +--- @field total_production number MW of every solar panel stored, allocated or not +--- @field production number MW of the allocated solar panels +--- @field discharge number MW the allocated accumulators can discharge +--- @field capacity number MJ the allocated accumulators can hold + +--- @class ExpScenario_Vlayer.PowerItem +--- @field value number J released by burning one +--- @field count number + +--- @class ExpScenario_Vlayer.Storage +--- @field items table Every stored item, allocated or not +--- @field unallocated table Items waiting for surface area +--- @field power_items table Fuel burned when the stored energy is low +--- @field energy number J + +--- @class ExpScenario_Vlayer.Statistics +--- @field total_surface_area number +--- @field used_surface_area number +--- @field remaining_surface_area number Surface area left after the unallocated items are counted +--- @field surface_area number +--- @field production_multiplier number +--- @field energy_max number W when the sun is up +--- @field energy_production number W right now +--- @field energy_total_production number W sustained if every stored solar panel was allocated +--- @field energy_sustained number W averaged over a day +--- @field energy_capacity number J +--- @field energy_storage number J + +--- @class ExpScenario_Vlayer.Data +--- @field interfaces ExpScenario_Vlayer.Interfaces +--- @field properties ExpScenario_Vlayer.Properties +--- @field storage ExpScenario_Vlayer.Storage +--- @field surface LuaSurface | table The surface the day cycle is taken from, or a copy of the config settings + +--- @class ExpScenario_Vlayer +local Vlayer = { + --- @package + events = {}, + --- @package + on_nth_tick = {}, +} + +--- The properties of every allowed item, modded items are derived from their base game equivalent +local allowed_items = {} --- @type table +for name, properties in pairs(config.allowed_items) do + allowed_items[name] = properties +end +for name, properties in pairs(config.modded_items) do + local base = config.allowed_items[properties.base_game_equivalent] + local multiplier = properties.multiplier + allowed_items[name] = { + starting_value = properties.starting_value or 0, + required_area = base.required_area or 0, + surface_area = (base.surface_area or 0) * multiplier, + production = (base.production or 0) * multiplier, + capacity = (base.capacity or 0) * multiplier, + } +end + +--- @type ExpScenario_Vlayer.Data +local vlayer_data = { + interfaces = { + energy = {}, + circuit = {}, + storage_input = {}, + storage_output = {}, + }, + properties = { + total_surface_area = 0, + used_surface_area = 0, + total_production = 0, + production = 0, + discharge = 0, + capacity = 0, + }, + storage = { + items = {}, + unallocated = {}, + power_items = {}, + energy = 0, + }, + surface = table.deep_copy(config.surface), +} + +Storage.register(vlayer_data, function(tbl) + vlayer_data = tbl +end) + +--- Interface entities are neutral so players can not mine them, these are the prototypes used for each type +local interface_names = { + energy = "electric-energy-interface", + circuit = "constant-combinator", + storage_input = "storage-chest", + storage_output = "requester-chest", +} --- @type table + +local interface_types = {} --- @type table +for interface_type, name in pairs(interface_names) do + interface_types[name] = interface_type +end + +--- Statistics exposed on circuit interfaces and the signal each is sent as +local circuit_signals = { + total_surface_area = "signal-A", + used_surface_area = "signal-U", + remaining_surface_area = "signal-R", + production_multiplier = "signal-M", + energy_production = "signal-P", + energy_sustained = "signal-S", + energy_capacity = "signal-C", + energy_storage = "signal-E", +} --- @type table + +local circuit_description = {} +for stat_name, signal_name in pairs(circuit_signals) do + circuit_description[#circuit_description + 1] = string.format("[virtual-signal=%s] = %s", signal_name, stat_name:gsub("_", " ")) +end +circuit_description = table.concat(circuit_description, "\n") + +--- Get every stored item keyed by name, allocated or not +--- @return table +function Vlayer.get_items() + return vlayer_data.storage.items +end + +--- Get the items waiting for surface area keyed by name +--- @return table +function Vlayer.get_unallocated_items() + return vlayer_data.storage.unallocated +end + +--- Get the items which contribute to the properties keyed by name +--- @return table +function Vlayer.get_allocated_items() + local allocated = {} + local unallocated = vlayer_data.storage.unallocated + for name, count in pairs(vlayer_data.storage.items) do + allocated[name] = count - (unallocated[name] or 0) + end + return allocated +end + +--- Get the interface entities of each type, do not modify +--- @return ExpScenario_Vlayer.Interfaces +function Vlayer.get_interfaces() + return vlayer_data.interfaces +end + +--- Get the signal each statistic is sent as on circuit interfaces +--- @return table +function Vlayer.get_circuits() + return circuit_signals +end + +--- Surface area left once the unallocated items have taken what they need +--- @return number +local function get_remaining_surface_area() + local properties = vlayer_data.properties + local remaining = properties.total_surface_area - properties.used_surface_area + for name, count in pairs(vlayer_data.storage.unallocated) do + remaining = remaining - allowed_items[name].required_area * count + end + return remaining +end + +--- Fraction of solar production available right now, from the darkness of a real surface or the config day cycle +--- @return number +local function get_production_multiplier() + local surface = vlayer_data.surface + local multiplier = surface.solar_power_multiplier + if surface.always_day then + return multiplier + end + + if surface.darkness then + local brightness = 1 - surface.darkness + if brightness < surface.min_brightness then + return 0 + end + return multiplier * (brightness - surface.min_brightness) / (1 - surface.min_brightness) + end + + local daytime = (game.tick % surface.ticks_per_day) / surface.ticks_per_day + surface.daytime = daytime + if daytime <= surface.dusk then + return multiplier + elseif daytime <= surface.evening then + return multiplier * (1 - (daytime - surface.dusk) / (surface.evening - surface.dusk)) + elseif daytime <= surface.morning then + return 0 + elseif daytime <= surface.dawn then + return multiplier * (daytime - surface.morning) / (surface.dawn - surface.morning) + end + return multiplier +end + +--- Fraction of solar production averaged over a whole day, sunrise and sunset count for half +--- @return number +local function get_sustained_multiplier() + local surface = vlayer_data.surface + local multiplier = surface.solar_power_multiplier + if surface.always_day then + return multiplier + end + + local day_duration = 1 - surface.dawn + surface.dusk + local sunset_duration = surface.evening - surface.dusk + local sunrise_duration = surface.dawn - surface.morning + return multiplier * (day_duration + 0.5 * (sunset_duration + sunrise_duration)) +end + +--- Apply the properties of an item to the layer, a negative count deallocates +--- @param name string +--- @param count number +local function allocate_item(name, count) + local item = allowed_items[name] + local properties = vlayer_data.properties + if item.production then + properties.production = properties.production + item.production * count + properties.total_production = properties.total_production + item.production * count + end + if item.capacity then + properties.capacity = properties.capacity + item.capacity * count + end + if item.discharge then + properties.discharge = properties.discharge + item.discharge * count + end + if item.surface_area then + properties.total_surface_area = properties.total_surface_area + item.surface_area * count + end + if item.required_area > 0 then + properties.used_surface_area = properties.used_surface_area + item.required_area * count + end +end + +--- Count the production of items which are stored but not allocated, so the total shows what more surface area would give +--- @param name string +--- @param count number +local function count_unallocated_production(name, count) + local item = allowed_items[name] + if item.production then + vlayer_data.properties.total_production = vlayer_data.properties.total_production + item.production * count + end +end + +for name, item in pairs(allowed_items) do + vlayer_data.storage.items[name] = item.starting_value + if item.required_area > 0 then + vlayer_data.storage.unallocated[name] = 0 + end + if item.power then + vlayer_data.storage.power_items[name] = { value = assert(item.fuel_value) * mega, count = 0 } + end + allocate_item(name, item.starting_value) +end + +--- Store an item, it is allocated straight away when there is surface area for it +--- @param name string +--- @param count number +function Vlayer.insert_item(name, count) + local item = assert(allowed_items[name], "Item not allowed in the vlayer: " .. name) + local storage = vlayer_data.storage + storage.items[name] = storage.items[name] + count + + if config.unlimited_surface_area or item.required_area == 0 then + allocate_item(name, count) + return + end + + local properties = vlayer_data.properties + local surplus_area = properties.total_surface_area - properties.used_surface_area + local allocated = min(count, floor(surplus_area / item.required_area)) + if allocated > 0 then + allocate_item(name, allocated) + end + + local unallocated = count - allocated + storage.unallocated[name] = storage.unallocated[name] + unallocated + count_unallocated_production(name, unallocated) +end + +--- Take an item out, unallocated items go first and landfill can only go while nothing is using its area +--- @param name string +--- @param count number +--- @return number # The count actually removed +function Vlayer.remove_item(name, count) + local item = assert(allowed_items[name], "Item not allowed in the vlayer: " .. name) + local storage = vlayer_data.storage + local properties = vlayer_data.properties + local removed = 0 + + if not config.unlimited_surface_area and item.required_area > 0 then + removed = min(count, storage.unallocated[name]) + storage.items[name] = storage.items[name] - removed + storage.unallocated[name] = storage.unallocated[name] - removed + count_unallocated_production(name, -removed) + count = count - removed + if count == 0 then + return removed + end + end + + local deallocate = min(count, storage.items[name]) + if item.surface_area and item.surface_area > 0 then + local surplus_area = properties.total_surface_area - properties.used_surface_area + deallocate = min(deallocate, floor(surplus_area / item.surface_area)) + if deallocate <= 0 then + return removed + end + end + + storage.items[name] = storage.items[name] - deallocate + allocate_item(name, -deallocate) + return removed + deallocate +end + +--- Add or take energy, used by other modules which spend the stored energy +--- @param energy number J, negative to take +--- @return number # J stored afterwards +function Vlayer.energy_changed(energy) + local storage = vlayer_data.storage + storage.energy = storage.energy + energy + return storage.energy +end + +--- Get the statistics of the layer +--- @return ExpScenario_Vlayer.Statistics +function Vlayer.get_statistics() + local properties = vlayer_data.properties + local production = properties.production * mega + local production_multiplier = get_production_multiplier() + local sustained_multiplier = get_sustained_multiplier() + local remaining_surface_area = get_remaining_surface_area() + + return { + total_surface_area = properties.total_surface_area, + used_surface_area = properties.used_surface_area, + remaining_surface_area = remaining_surface_area, + surface_area = properties.total_surface_area - remaining_surface_area, + production_multiplier = production_multiplier, + energy_max = production, + energy_production = production * production_multiplier, + energy_total_production = properties.total_production * sustained_multiplier * mega, + energy_sustained = production * sustained_multiplier, + energy_capacity = properties.capacity * mega, + energy_storage = vlayer_data.storage.energy, + } +end + +--- Make an entity into an interface which players can not mine or damage +--- @param interface LuaEntity +--- @param interface_type ExpScenario_Vlayer.InterfaceType +--- @param player LuaPlayer? +local function register_interface(interface, interface_type, player) + local interfaces = vlayer_data.interfaces[interface_type] + interfaces[#interfaces + 1] = interface + interface.last_user = player + interface.destructible = false + interface.minable_flag = false +end + +--- Create an interface which powers the electric network it is placed in +--- @param surface LuaSurface +--- @param position MapPosition +--- @param player LuaPlayer? +--- @return LuaEntity? # Nil when the interface does not fit +function Vlayer.create_energy_interface(surface, position, player) + if not surface.can_place_entity{ name = interface_names.energy, position = position } then + return nil + end + + local interface = assert(surface.create_entity{ name = interface_names.energy, position = position, force = "neutral" }) + register_interface(interface, "energy", player) + interface.operable = false + interface.electric_buffer_size = 0 + interface.power_production = 0 + interface.power_usage = 0 + interface.energy = 0 + return interface +end + +--- Create an interface which outputs the statistics and stored items as signals +--- @param surface LuaSurface +--- @param position MapPosition +--- @param player LuaPlayer? +--- @return LuaEntity +function Vlayer.create_circuit_interface(surface, position, player) + local interface = assert(surface.create_entity{ name = interface_names.circuit, position = position, force = "neutral" }) + register_interface(interface, "circuit", player) + interface.combinator_description = circuit_description + return interface +end + +--- Create an interface which stores every allowed item put into it +--- @param surface LuaSurface +--- @param position MapPosition +--- @param player LuaPlayer? +--- @return LuaEntity +function Vlayer.create_input_interface(surface, position, player) + local interface = assert(surface.create_entity{ name = interface_names.storage_input, position = position, force = "neutral" }) + register_interface(interface, "storage_input", player) + -- A filter nothing can match stops robots filling it with anything + interface.storage_filter = { name = "deconstruction-planner", quality = "normal" } + return interface +end + +--- Create an interface which takes stored items out to fulfil its requests +--- @param surface LuaSurface +--- @param position MapPosition +--- @param player LuaPlayer? +--- @return LuaEntity +function Vlayer.create_output_interface(surface, position, player) + local interface = assert(surface.create_entity{ name = interface_names.storage_output, position = position, force = "neutral" }) + register_interface(interface, "storage_output", player) + return interface +end + +--- Remove an interface, anything it holds is dropped in a chest next to it and stored energy is kept +--- @param interface LuaEntity +--- @return ExpScenario_Vlayer.InterfaceType +function Vlayer.remove_interface(interface) + local interface_type = assert(interface_types[interface.name], "Entity is not a vlayer interface: " .. interface.name) + if interface_type == "storage_input" or interface_type == "storage_output" then + ExpUtil.transfer_inventory_to_surface{ + inventory = assert(interface.get_inventory(defines.inventory.chest)), + surface = interface.surface, + name = "iron-chest", + allow_creation = true, + } + elseif interface_type == "energy" then + vlayer_data.storage.energy = vlayer_data.storage.energy + interface.energy + end + + table.remove_element(vlayer_data.interfaces[interface_type], interface) + interface.destroy() + return interface_type +end + +--- Forget interfaces which no longer exist +--- @param interfaces LuaEntity[] +local function prune_interfaces(interfaces) + for index = #interfaces, 1, -1 do + if not interfaces[index].valid then + table.remove(interfaces, index) + end + end +end + +--- Store the contents of the input interfaces +local function handle_input_interfaces() + local interfaces = vlayer_data.interfaces.storage_input + prune_interfaces(interfaces) + for _, interface in ipairs(interfaces) do + local inventory = assert(interface.get_inventory(defines.inventory.chest)) + for _, item in pairs(inventory.get_contents()) do + local name = item.name + if allowed_items[name] then + -- Quality is not tracked, so higher quality is stored as extra normal items in batches of ten + local quality_level = prototypes.quality[item.quality].level + local removed, stored = 0, 0 + if quality_level == 0 then + removed, stored = item.count, item.count + elseif item.count >= 10 then + local batches = floor(item.count / 10) + removed, stored = batches * 10, batches * (10 + quality_level * 3) + end + + if removed > 0 then + local modded = config.modded_items[name] + if modded and config.modded_auto_downgrade then + Vlayer.insert_item(modded.base_game_equivalent, stored * modded.multiplier) + elseif vlayer_data.storage.power_items[name] then + local power_item = vlayer_data.storage.power_items[name] + power_item.count = power_item.count + stored + else + Vlayer.insert_item(name, stored) + end + inventory.remove{ name = name, count = removed, quality = item.quality } + end + end + end + end +end + +--- Fill the requests of the output interfaces from storage +local function handle_output_interfaces() + local interfaces = vlayer_data.interfaces.storage_output + prune_interfaces(interfaces) + for _, interface in ipairs(interfaces) do + local inventory = assert(interface.get_inventory(defines.inventory.chest)) + for _, section in ipairs(assert(interface.get_logistic_sections()).sections) do + for _, filter in pairs(section.filters) do + local name = filter.value and filter.value.name + if name and allowed_items[name] then + local wanted = min(assert(filter.min) - inventory.get_item_count(name), vlayer_data.storage.items[name]) + if wanted > 0 and inventory.can_insert{ name = name, count = wanted } then + local removed = Vlayer.remove_item(name, wanted) + if removed > 0 then + inventory.insert{ name = name, count = removed, quality = "normal" } + end + end + end + end + end + end +end + +--- Allocate waiting items in proportion to the surface area that has become available +local function handle_unallocated() + if config.unlimited_surface_area then return end + + local unallocated_area = 0 + for name, count in pairs(vlayer_data.storage.unallocated) do + unallocated_area = unallocated_area + allowed_items[name].required_area * count + end + if unallocated_area == 0 then return end + + local properties = vlayer_data.properties + local surplus_area = properties.total_surface_area - properties.used_surface_area + for name, count in pairs(vlayer_data.storage.unallocated) do + local allocated = min(count, floor(count * surplus_area / unallocated_area)) + if allocated > 0 then + vlayer_data.storage.unallocated[name] = count - allocated + allocate_item(name, allocated) + count_unallocated_production(name, -allocated) + end + end +end + +--- Set the signals of the circuit interfaces to the statistics and stored items +local function handle_circuit_interfaces() + local interfaces = vlayer_data.interfaces.circuit + prune_interfaces(interfaces) + if #interfaces == 0 then return end + + local stats = Vlayer.get_statistics() + for _, interface in ipairs(interfaces) do + local control = interface.get_or_create_control_behavior() --[[@as LuaConstantCombinatorControlBehavior]] + if control.sections_count == 0 then + control.add_section() + end + local section = assert(control.sections[1]) + + -- Slots are cleared first because an item can not be set in two slots at once + for index = section.filters_count, 1, -1 do + section.clear_slot(index) + end + + local index = 1 + for stat_name, signal_name in pairs(circuit_signals) do + local value = stats[stat_name] --[[@as number]] + if stat_name:find("energy") then + value = value / mega + elseif stat_name == "production_multiplier" then + value = value * 10000 + end + section.set_slot(index, { value = { type = "virtual", name = signal_name, quality = "normal" }, min = floor(value) }) + index = index + 1 + end + + for name, count in pairs(vlayer_data.storage.items) do + if count > 0 and prototypes.item[name] then + section.set_slot(index, { value = { type = "item", name = name, quality = "normal" }, min = count }) + index = index + 1 + end + end + end +end + +--- Produce energy, share it between the energy interfaces, and burn fuel when storage runs low +local function handle_energy_interfaces() + local storage = vlayer_data.storage + local properties = vlayer_data.properties + local production = properties.production * mega * (config.update_tick_energy / 60) + storage.energy = storage.energy + floor(production * get_production_multiplier()) + + local interfaces = vlayer_data.interfaces.energy + prune_interfaces(interfaces) + if #interfaces > 0 then + local available_energy = storage.energy + for _, interface in ipairs(interfaces) do + available_energy = available_energy + interface.energy + end + + local discharge_rate = 2 * (production + properties.discharge * mega) / #interfaces + local fill_to = min(discharge_rate, floor(available_energy / #interfaces)) + for _, interface in ipairs(interfaces) do + interface.electric_buffer_size = max(discharge_rate, interface.energy) -- A smaller buffer would lose energy + local delta = fill_to - interface.energy + storage.energy = storage.energy - delta + interface.energy = interface.energy + delta + end + end + + local capacity = properties.capacity * mega + if not config.unlimited_capacity and storage.energy > capacity then + storage.energy = capacity + return + end + + for _, power_item in pairs(storage.power_items) do + local max_burn = capacity / 2 - storage.energy + if power_item.count > 0 and max_burn > 0 then + local burn = min(power_item.count, floor(max_burn / power_item.value)) + storage.energy = storage.energy + burn * power_item.value + power_item.count = power_item.count - burn + end + end +end + +--- Take the day cycle from the mimic surface when it exists, otherwise from the config +local function update_surface() + if config.mimic_surface then + local surface = game.get_surface(config.mimic_surface) + if surface then + vlayer_data.surface = surface + return + end + end + + -- The config copy never has an index, so this avoids replacing it every time a surface changes + if not vlayer_data.surface.index then + vlayer_data.surface = table.deep_copy(config.surface) + end +end + +--- Move items between the interfaces and storage +local function update_storage() + handle_input_interfaces() + handle_output_interfaces() + handle_unallocated() +end + +--- Update the signals and energy of the interfaces +local function update_energy() + handle_circuit_interfaces() + handle_energy_interfaces() +end + +local e = defines.events + +Vlayer.on_init = update_surface -- The default surface exists before on_surface_created can fire +Vlayer.events[e.on_surface_created] = update_surface +Vlayer.events[e.on_surface_renamed] = update_surface +Vlayer.events[e.on_surface_imported] = update_surface +Vlayer.on_nth_tick[config.update_tick_storage] = update_storage +Vlayer.on_nth_tick[config.update_tick_energy] = update_energy + +return Vlayer diff --git a/exp_scenario/module/gui/player_bonus.lua b/exp_scenario/module/gui/player_bonus.lua index 13317530..1fadeccc 100644 --- a/exp_scenario/module/gui/player_bonus.lua +++ b/exp_scenario/module/gui/player_bonus.lua @@ -5,7 +5,7 @@ Adds a gui that allows players to apply various bonuses local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") local config = require("modules/exp_legacy/config/bonus") -local vlayer = require("modules/exp_legacy/modules/control/vlayer") +local Vlayer = require("modules/exp_scenario/control/vlayer") local format_number = require("util").format_number --- @class ExpGui_PlayerBonus.elements @@ -486,7 +486,7 @@ end --- Apply periodic bonus to a player --- @param player LuaPlayer local function apply_personal_battery_recharge(player) - local available_energy = vlayer.get_statistics()["energy_storage"] + local available_energy = Vlayer.get_statistics().energy_storage if available_energy <= 0 then return -- No power to give end @@ -508,7 +508,7 @@ local function apply_personal_battery_recharge(player) local energy_to_give = math.min(math.floor(equipment.max_energy - equipment.energy), available_energy, recharge_amount) equipment.energy = equipment.energy + energy_to_give recharge_amount = recharge_amount - energy_to_give - available_energy = vlayer.energy_changed(-energy_to_give) + available_energy = Vlayer.energy_changed(-energy_to_give) end end end diff --git a/exp_scenario/module/gui/vlayer.lua b/exp_scenario/module/gui/vlayer.lua new file mode 100644 index 00000000..b5bea951 --- /dev/null +++ b/exp_scenario/module/gui/vlayer.lua @@ -0,0 +1,531 @@ +--[[-- Gui - Virtual Layer +Shows what the virtual layer holds and lets players build and remove its interfaces +]] + +local Gui = require("modules/exp_gui") +local Roles = require("modules/exp_roles") +local Colors = require("modules/exp_util/include/color") +local config = require("modules.exp_legacy.config.vlayer") +local Vlayer = require("modules/exp_scenario/control/vlayer") +local format_number = require("util").format_number + +local Selection = require("modules/exp_util/selection") +local SelectArea = Selection.connect("ExpGui_Vlayer") + +local floor = math.floor +local ceil = math.ceil +local max = math.max +local format_string = string.format + +--- @class ExpGui_Vlayer.elements +local Elements = {} + +--- Interface types in the order the type dropdown lists them +local interface_types = { "energy", "circuit", "storage_input", "storage_output" } --- @type ExpScenario_Vlayer.InterfaceType[] + +--- Locale key of each interface type +local type_captions = {} --- @type table +for _, interface_type in ipairs(interface_types) do + type_captions[interface_type] = { "exp-gui_vlayer.type-" .. interface_type:gsub("_", "-") } +end + +--- Statistics shown as progress bars, in display order +--- @type { name: string, caption: LocalisedString, tooltip: LocalisedString }[] +local stats = {} +for _, name in ipairs{ "solar", "accumulator", "surface-area", "sustained", "production", "capacity" } do + stats[#stats + 1] = { + name = name, + caption = { "exp-gui_vlayer.caption-stat-" .. name }, + tooltip = { "exp-gui_vlayer.tooltip-stat-" .. name }, + } +end + +local energy_suffixes = { + { 1e15, "P" }, { 1e12, "T" }, { 1e9, "G" }, { 1e6, "M" }, { 1e3, "k" }, +} + +--- Format an amount of energy with an SI prefix, util.format_number uses B for giga +--- @param amount number +--- @param unit string +--- @return string +local function format_energy(amount, unit) + if amount < 1 then + return "0 " .. unit + end + for _, suffix in ipairs(energy_suffixes) do + if amount >= suffix[1] then + return format_string("%.1f %s%s", amount / suffix[1], suffix[2], unit) + end + end + return format_number(floor(amount), false) .. " " .. unit +end + +--- Expand an area to whole tiles +--- @param area BoundingBox +--- @return BoundingBox +local function expand_area(area) + return { + left_top = { x = floor(area.left_top.x), y = floor(area.left_top.y) }, + right_bottom = { x = ceil(area.right_bottom.x), y = ceil(area.right_bottom.y) }, + } +end + +--- Rich text link to a position +--- @param position MapPosition +--- @param surface LuaSurface +--- @return string +local function gps_string(position, surface) + return format_string("[gps=%.1f,%.1f,%s]", position.x, position.y, surface.name) +end + +--- Label naming a statistic +--- @class ExpGui_Vlayer.elements.stat_label: ExpElement +--- @overload fun(parent: LuaGuiElement, caption: LocalisedString, tooltip: LocalisedString): LuaGuiElement +Elements.stat_label = Gui.define("vlayer/stat_label") + :draw{ + type = "label", + caption = Gui.from_argument(1), + tooltip = Gui.from_argument(2), + style = "heading_2_label", + } + :style{ + width = 200, + } --[[@as any]] + +--- Bar showing a statistic as a fraction with the values as its caption +--- @class ExpGui_Vlayer.elements.stat_bar: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.stat_bar = Gui.define("vlayer/stat_bar") + :draw{ + type = "progressbar", + caption = "", + value = 0, + style = "electric_satisfaction_statistics_progressbar", + } + :style{ + width = 200, + font = "heading-2", + } --[[@as any]] + +--- @class ExpGui_Vlayer.elements.stat_bar.display_data +--- @field value number Fraction the bar is filled +--- @field caption string + +--- Refresh a bar +--- @param stat_bar LuaGuiElement +--- @param display_data ExpGui_Vlayer.elements.stat_bar.display_data +function Elements.stat_bar.refresh(stat_bar, display_data) + stat_bar.value = display_data.value + stat_bar.caption = display_data.caption +end + +--- Table of a label and bar for each statistic +--- @class ExpGui_Vlayer.elements.stats_table: ExpElement +--- @field data table> Bars keyed by statistic name +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.stats_table = Gui.define("vlayer/stats_table") + :track_all_elements() + :draw(function(def, parent) + --- @cast def ExpGui_Vlayer.elements.stats_table + local stats_table = Gui.elements.scroll_table(parent, 400, 2, "stats") + local stat_bars = {} + for _, stat in ipairs(stats) do + Elements.stat_label(stats_table, stat.caption, stat.tooltip) + stat_bars[stat.name] = Elements.stat_bar(stats_table) + end + def.data[stats_table] = stat_bars + return stats_table + end) --[[@as any]] + +--- Calculate the display data of every statistic, the same for every player +--- @return table +function Elements.stats_table.calculate_display_data() + local statistics = Vlayer.get_statistics() + local items = Vlayer.get_items() + local allocated = Vlayer.get_allocated_items() + return { + solar = { + value = allocated["solar-panel"] / max(items["solar-panel"], 1), + caption = format_number(allocated["solar-panel"], false) .. " / " .. format_number(items["solar-panel"], false), + }, + accumulator = { + value = allocated["accumulator"] / max(items["accumulator"], 1), + caption = format_number(allocated["accumulator"], false) .. " / " .. format_number(items["accumulator"], false), + }, + ["surface-area"] = { + value = statistics.total_surface_area / max(statistics.surface_area, 1), + caption = format_number(statistics.remaining_surface_area, false), + }, + sustained = { + value = statistics.energy_sustained / max(statistics.energy_total_production, 1), + caption = format_energy(statistics.energy_sustained, "W") .. " / " .. format_energy(statistics.energy_total_production, "W"), + }, + production = { + value = statistics.energy_production / max(statistics.energy_max, 1), + caption = format_energy(statistics.energy_production, "W") .. " / " .. format_energy(statistics.energy_max, "W"), + }, + capacity = { + value = statistics.energy_storage / max(statistics.energy_capacity, 1), + caption = format_energy(statistics.energy_storage, "J") .. " / " .. format_energy(statistics.energy_capacity, "J"), + }, + } +end + +--- Refresh every bar of a table +--- @param stats_table LuaGuiElement +--- @param display_data table +function Elements.stats_table.refresh(stats_table, display_data) + for name, stat_bar in pairs(Elements.stats_table.data[stats_table]) do + Elements.stat_bar.refresh(stat_bar, display_data[name]) + end +end + +--- Refresh the tables of every online player +function Elements.stats_table.refresh_online() + local display_data = Elements.stats_table.calculate_display_data() + for _, stats_table in Elements.stats_table:online_elements() do + Elements.stats_table.refresh(stats_table, display_data) + end +end + +--- @class ExpGui_Vlayer.elements.type_dropdown.element_data +--- @field controls LuaGuiElement + +--- Dropdown which picks the type of interface to build or list +--- @class ExpGui_Vlayer.elements.type_dropdown: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, controls: LuaGuiElement): LuaGuiElement +Elements.type_dropdown = Gui.define("vlayer/type_dropdown") + :draw{ + type = "drop-down", + items = { type_captions.energy, type_captions.circuit, type_captions.storage_input, type_captions.storage_output }, + selected_index = 1, + } + :style{ + width = 200, + } + :element_data{ + controls = Gui.from_argument(1), + } + :on_selection_state_changed(function(def, _, type_dropdown) + --- @cast def ExpGui_Vlayer.elements.type_dropdown + Elements.controls.refresh_interfaces(def.data[type_dropdown].controls) + end) --[[@as any]] + +--- Get the selected interface type +--- @param type_dropdown LuaGuiElement +--- @return ExpScenario_Vlayer.InterfaceType +function Elements.type_dropdown.get_selected(type_dropdown) + return (assert(interface_types[type_dropdown.selected_index])) +end + +--- Dropdown listing the interfaces of the selected type by position +--- @class ExpGui_Vlayer.elements.interface_dropdown: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.interface_dropdown = Gui.define("vlayer/interface_dropdown") + :draw{ + type = "drop-down", + } + :style{ + width = 200, + } --[[@as any]] + +--- Refresh the listed interfaces +--- @param interface_dropdown LuaGuiElement +--- @param interfaces LuaEntity[] +function Elements.interface_dropdown.refresh(interface_dropdown, interfaces) + local items = {} + for index, interface in ipairs(interfaces) do + local position = interface.position + items[index] = format_string("%d X %.1f Y %.1f", index, position.x, position.y) + end + interface_dropdown.items = items +end + +--- Get the selected interface +--- @param interface_dropdown LuaGuiElement +--- @param interfaces LuaEntity[] +--- @return LuaEntity? +function Elements.interface_dropdown.get_selected(interface_dropdown, interfaces) + local interface = interfaces[interface_dropdown.selected_index] + if interface and interface.valid then + return interface + end + return nil +end + +--- @class ExpGui_Vlayer.elements.control_button.element_data +--- @field controls LuaGuiElement + +--- Button which refreshes the listed interfaces +--- @class ExpGui_Vlayer.elements.refresh_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, controls: LuaGuiElement): LuaGuiElement +Elements.refresh_button = Gui.define("vlayer/refresh_button") + :draw{ + type = "button", + caption = { "exp-gui_vlayer.caption-refresh" }, + } + :style{ + width = 200, + } + :element_data{ + controls = Gui.from_argument(1), + } + :on_click(function(def, _, refresh_button) + --- @cast def ExpGui_Vlayer.elements.refresh_button + Elements.controls.refresh_interfaces(def.data[refresh_button].controls) + end) --[[@as any]] + +--- Button which opens the map at the selected interface +--- @class ExpGui_Vlayer.elements.view_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, controls: LuaGuiElement): LuaGuiElement +Elements.view_button = Gui.define("vlayer/view_button") + :draw{ + type = "button", + caption = { "exp-gui_vlayer.caption-view" }, + } + :style{ + width = 200, + } + :element_data{ + controls = Gui.from_argument(1), + } + :on_click(function(def, player, view_button) + --- @cast def ExpGui_Vlayer.elements.view_button + local controls = def.data[view_button].controls + local interface_type, interface = Elements.controls.get_selected_interface(controls) + if not interface then return end + player.set_controller{ type = defines.controllers.remote, position = interface.position, surface = interface.surface } + player.print{ "exp-gui_vlayer.message-located", type_captions[interface_type], gps_string(interface.position, interface.surface) } + end) --[[@as any]] + +--- Button which toggles selecting an area to build an interface in +--- @class ExpGui_Vlayer.elements.build_button: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.build_button = Gui.define("vlayer/build_button") + :track_all_elements() + :draw{ + type = "button", + caption = { "exp-gui_vlayer.caption-build" }, + } + :style{ + width = 200, + } + :on_click(function(_, player) + if SelectArea:stop(player) then + player.print{ "exp_util.selection_exit", { "exp-gui_vlayer.selection-name" } } + else + SelectArea:start(player) + player.print{ "exp_util.selection_enter", { "exp-gui_vlayer.selection-name" } } + end + end) --[[@as any]] + +--- Button which removes the selected interface +--- @class ExpGui_Vlayer.elements.remove_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, controls: LuaGuiElement): LuaGuiElement +Elements.remove_button = Gui.define("vlayer/remove_button") + :track_all_elements() + :draw{ + type = "button", + caption = { "exp-gui_vlayer.caption-remove" }, + } + :style{ + width = 200, + } + :element_data{ + controls = Gui.from_argument(1), + } + :on_click(function(def, player, remove_button) + --- @cast def ExpGui_Vlayer.elements.remove_button + local controls = def.data[remove_button].controls + local _, interface = Elements.controls.get_selected_interface(controls) + if not interface then return end + local position, surface = interface.position, interface.surface + local interface_type = Vlayer.remove_interface(interface) + game.print{ "exp-gui_vlayer.message-removed", player.name, type_captions[interface_type], gps_string(position, surface) } + Elements.controls.refresh_interfaces_online() + end) --[[@as any]] + +--- @class ExpGui_Vlayer.elements.controls.element_data +--- @field type_dropdown LuaGuiElement +--- @field interface_dropdown LuaGuiElement + +--- Table of the dropdowns and buttons used to manage interfaces +--- @class ExpGui_Vlayer.elements.controls: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.controls = Gui.define("vlayer/controls") + :track_all_elements() + :draw(function(def, parent) + --- @cast def ExpGui_Vlayer.elements.controls + local controls = Gui.elements.scroll_table(parent, 400, 2, "controls") + local player = Gui.get_player(parent) + local can_edit = Roles.player_has_permission(player, "exp_scenario.gui.vlayer_edit") + + local type_dropdown = Elements.type_dropdown(controls, controls) + local interface_dropdown = Elements.interface_dropdown(controls) + Elements.refresh_button(controls, controls) + Elements.view_button(controls, controls) + Elements.build_button(controls).visible = can_edit + Elements.remove_button(controls, controls).visible = can_edit + + def.data[controls] = { + type_dropdown = type_dropdown, + interface_dropdown = interface_dropdown, + } + Elements.controls.refresh_interfaces(controls) + return controls + end) --[[@as any]] + +--- Get the selected interface type +--- @param controls LuaGuiElement +--- @return ExpScenario_Vlayer.InterfaceType +function Elements.controls.get_selected_type(controls) + return Elements.type_dropdown.get_selected(Elements.controls.data[controls].type_dropdown) +end + +--- Get the selected interface and its type +--- @param controls LuaGuiElement +--- @return ExpScenario_Vlayer.InterfaceType, LuaEntity? +function Elements.controls.get_selected_interface(controls) + local elements = Elements.controls.data[controls] + local interface_type = Elements.type_dropdown.get_selected(elements.type_dropdown) + local interfaces = Vlayer.get_interfaces()[interface_type] + return interface_type, Elements.interface_dropdown.get_selected(elements.interface_dropdown, interfaces) +end + +--- Refresh the listed interfaces to match the selected type +--- @param controls LuaGuiElement +function Elements.controls.refresh_interfaces(controls) + local elements = Elements.controls.data[controls] + local interface_type = Elements.type_dropdown.get_selected(elements.type_dropdown) + Elements.interface_dropdown.refresh(elements.interface_dropdown, Vlayer.get_interfaces()[interface_type]) +end + +--- Refresh the listed interfaces of every online player, used when an interface is built or removed +function Elements.controls.refresh_interfaces_online() + for _, controls in Elements.controls:online_elements() do + Elements.controls.refresh_interfaces(controls) + end +end + +--- Refresh whether a player can see the edit buttons +--- @param player LuaPlayer +function Elements.controls.refresh_permissions(player) + local can_edit = Roles.player_has_permission(player, "exp_scenario.gui.vlayer_edit") + for _, build_button in Elements.build_button:tracked_elements(player) do + build_button.visible = can_edit + end + for _, remove_button in Elements.remove_button:tracked_elements(player) do + remove_button.visible = can_edit + end +end + +--- Container added to the left gui flow +--- @class ExpGui_Vlayer.elements.container: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.container = Gui.define("vlayer/container") + :draw(function(_, parent) + local container = Gui.elements.container(parent, 400) + local stats_table = Elements.stats_table(container) + Elements.stats_table.refresh(stats_table, Elements.stats_table.calculate_display_data()) + Elements.controls(container) + return Gui.elements.container.get_root_element(container) + end) --[[@as any]] + +--- Add the element to the left flow with a toolbar button +Gui.add_left_element(Elements.container, false) +Gui.toolbar.create_button{ + name = "toggle_vlayer", + left_element = Elements.container, + sprite = "entity/solar-panel", + tooltip = { "exp-gui_vlayer.tooltip-main" }, + visible = function(player, element) + return Roles.player_has_permission(player, "exp_scenario.gui.vlayer") + end +} + +--- Replace the one marker entity in a selected area with an interface of the type the player picked +--- @param event EventData.on_player_selected_area +local function on_area_selected(event) + local player = Gui.get_player(event) + local surface = event.surface + local area = expand_area(event.area) + local force = player.force --[[@as LuaForce]] + + local interface_type = "energy" --- @type ExpScenario_Vlayer.InterfaceType + for _, controls in Elements.controls:tracked_elements(player) do + interface_type = Elements.controls.get_selected_type(controls) + end + + -- Platforms have no chests, so a combinator marks the spot once the research allows it + local marker_name = "steel-chest" + if config.power_on_space and surface.platform and interface_type == "energy" then + local research = config.power_on_space_research + if force.technologies[research.name].level < research.level then + player.print({ "exp-gui_vlayer.message-research", research.name, research.level }, Colors.orange_red) + return + end + marker_name = "constant-combinator" + end + + local markers = surface.find_entities_filtered{ area = area, name = marker_name, force = force } + if #markers == 0 then + player.print({ "exp-gui_vlayer.message-no-chest" }, Colors.orange_red) + return + elseif #markers > 1 then + player.print({ "exp-gui_vlayer.message-multiple", type_captions[interface_type] }, Colors.orange_red) + return + end + + local marker = assert(markers[1]) + if marker.name == "steel-chest" and not assert(marker.get_inventory(defines.inventory.chest)).is_empty() then + player.print({ "exp-gui_vlayer.message-chest-not-empty" }, Colors.orange_red) + return + end + + if #Vlayer.get_interfaces()[interface_type] >= config.interface_limit[interface_type] then + player.print({ "exp-gui_vlayer.message-limit", type_captions[interface_type] }, Colors.orange_red) + return + end + + local position = marker.position + marker.destroy() + + if interface_type == "energy" then + if not Vlayer.create_energy_interface(surface, position, player) then + player.print({ "exp-gui_vlayer.message-no-space" }, Colors.orange_red) + return + end + elseif interface_type == "circuit" then + Vlayer.create_circuit_interface(surface, position, player) + elseif interface_type == "storage_input" then + Vlayer.create_input_interface(surface, position, player) + else + Vlayer.create_output_interface(surface, position, player) + end + + game.print{ "exp-gui_vlayer.message-built", player.name, type_captions[interface_type], gps_string(position, surface) } + Elements.controls.refresh_interfaces_online() +end + +SelectArea:on_selection(on_area_selected) + +--- Refresh the edit buttons as the permissions of a player may have changed +--- @param event EventData.ExpRoles.on_player_roles_changed +local function refresh_player_permissions(event) + Elements.controls.refresh_permissions(Gui.get_player(event)) +end + +return { + elements = Elements, + events = { + [Roles.events.on_player_roles_changed] = refresh_player_permissions, + }, + on_nth_tick = { + [config.update_tick_gui] = Elements.stats_table.refresh_online, + } +} diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index a4884bd1..7499ac2c 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -490,6 +490,39 @@ caption-create-footer=Create task caption-edit-footer=Edit task caption-view-footer=Task details +[exp-gui_vlayer] +tooltip-main=Virtual Layer +selection-name=vlayer +caption-stat-solar=[img=entity/solar-panel] Solar Panels +tooltip-stat-solar=Solar panels in use out of those stored +caption-stat-accumulator=[img=entity/accumulator] Accumulators +tooltip-stat-accumulator=Accumulators in use out of those stored +caption-stat-surface-area=[virtual-signal=signal-R] Remaining Surface +tooltip-stat-surface-area=Remaining surface area, insert landfill to increase it +caption-stat-sustained=[virtual-signal=signal-S] Sustained Production +tooltip-stat-sustained=Power production averaged over a day, out of what every stored solar panel could give +caption-stat-production=[virtual-signal=signal-P] Current Production +tooltip-stat-production=Power production right now, out of the production in full daylight +caption-stat-capacity=[virtual-signal=signal-E] Battery Charge +tooltip-stat-capacity=Stored energy out of the accumulator capacity +type-energy=Energy +type-circuit=Circuit +type-storage-input=Storage Input +type-storage-output=Storage Output +caption-refresh=Refresh List +caption-view=View Interface +caption-build=Build Interface +caption-remove=Remove Interface +message-located=The __1__ interface is at __2__ +message-built=__1__ built a __2__ interface at __3__ +message-removed=__1__ removed a __2__ interface at __3__ +message-no-chest=No steel chest was selected +message-chest-not-empty=The steel chest is not empty +message-multiple=Cannot build a __1__ interface, more than one steel chest was selected +message-limit=Cannot build a __1__ interface, the limit has been reached +message-no-space=Cannot build an energy interface here, there is not enough space +message-research=You need to research [technology=__1__] to level __2__ to use the vlayer on a space platform + [exp-gui_warp-list] caption-main=Warp List [img=info] tooltip-main=Warp List diff --git a/exp_scenario/module/locale/zh-CN.cfg b/exp_scenario/module/locale/zh-CN.cfg index 1c56589d..19d7d004 100644 --- a/exp_scenario/module/locale/zh-CN.cfg +++ b/exp_scenario/module/locale/zh-CN.cfg @@ -517,6 +517,39 @@ tooltip-goto-different-network=該傳送陣未連接, 你可使用電纜連接 tooltip-goto-cooldown=傳送冷卻中, 請等候冷卻 tooltip-goto-disabled=你不在傳送陣, 請移至任意傳送陣 +[exp-gui_vlayer] +tooltip-main=Vlayer 介面 +selection-name=vlayer +caption-stat-solar=[img=entity/solar-panel] 太陽能板 +tooltip-stat-solar=使用中的太陽能板數量 / 儲存的太陽能板數量 +caption-stat-accumulator=[img=entity/accumulator] 蓄電池 +tooltip-stat-accumulator=使用中的蓄電池數量 / 儲存的蓄電池數量 +caption-stat-surface-area=[virtual-signal=signal-R] 餘下土地 +tooltip-stat-surface-area=餘下土地。 放入堆填來增加 +caption-stat-sustained=[virtual-signal=signal-S] 持續產能 +tooltip-stat-sustained=持續電力產能 +caption-stat-production=[virtual-signal=signal-P] 現時產能 +tooltip-stat-production=現時電力產能 +caption-stat-capacity=[virtual-signal=signal-E] 電池電量 +tooltip-stat-capacity=現時電池電量 / 電池最大容量 +type-energy=電力 +type-circuit=回路 +type-storage-input=放入箱 +type-storage-output=提取箱 +caption-refresh=刷新列表 +caption-view=查看介面 +caption-build=建造介面 +caption-remove=拆除介面 +message-located=__1__ 介面在 __2__ +message-built=__1__ 在 __3__ 建造了 __2__ 介面 +message-removed=__1__ 在 __3__ 拆除了 __2__ 介面 +message-no-chest=沒檢測到鋼箱 +message-chest-not-empty=鋼箱不是空的 +message-multiple=無法建造 __1__ 介面,檢測到多個鋼箱 +message-limit=無法建造 __1__ 介面,超出最大限制 +message-no-space=無法在此建造電力介面,空間不足 +message-research=你要研究 [technology=__1__] 在 __2__ 級 才可使用太空平台 vlayer 功能。 + [exp_afk-kick] kick-message=因地圖中沒有活躍玩家,所以所有人都已被請離。 diff --git a/exp_scenario/module/locale/zh-TW.cfg b/exp_scenario/module/locale/zh-TW.cfg index 1c56589d..19d7d004 100644 --- a/exp_scenario/module/locale/zh-TW.cfg +++ b/exp_scenario/module/locale/zh-TW.cfg @@ -517,6 +517,39 @@ tooltip-goto-different-network=該傳送陣未連接, 你可使用電纜連接 tooltip-goto-cooldown=傳送冷卻中, 請等候冷卻 tooltip-goto-disabled=你不在傳送陣, 請移至任意傳送陣 +[exp-gui_vlayer] +tooltip-main=Vlayer 介面 +selection-name=vlayer +caption-stat-solar=[img=entity/solar-panel] 太陽能板 +tooltip-stat-solar=使用中的太陽能板數量 / 儲存的太陽能板數量 +caption-stat-accumulator=[img=entity/accumulator] 蓄電池 +tooltip-stat-accumulator=使用中的蓄電池數量 / 儲存的蓄電池數量 +caption-stat-surface-area=[virtual-signal=signal-R] 餘下土地 +tooltip-stat-surface-area=餘下土地。 放入堆填來增加 +caption-stat-sustained=[virtual-signal=signal-S] 持續產能 +tooltip-stat-sustained=持續電力產能 +caption-stat-production=[virtual-signal=signal-P] 現時產能 +tooltip-stat-production=現時電力產能 +caption-stat-capacity=[virtual-signal=signal-E] 電池電量 +tooltip-stat-capacity=現時電池電量 / 電池最大容量 +type-energy=電力 +type-circuit=回路 +type-storage-input=放入箱 +type-storage-output=提取箱 +caption-refresh=刷新列表 +caption-view=查看介面 +caption-build=建造介面 +caption-remove=拆除介面 +message-located=__1__ 介面在 __2__ +message-built=__1__ 在 __3__ 建造了 __2__ 介面 +message-removed=__1__ 在 __3__ 拆除了 __2__ 介面 +message-no-chest=沒檢測到鋼箱 +message-chest-not-empty=鋼箱不是空的 +message-multiple=無法建造 __1__ 介面,檢測到多個鋼箱 +message-limit=無法建造 __1__ 介面,超出最大限制 +message-no-space=無法在此建造電力介面,空間不足 +message-research=你要研究 [technology=__1__] 在 __2__ 級 才可使用太空平台 vlayer 功能。 + [exp_afk-kick] kick-message=因地圖中沒有活躍玩家,所以所有人都已被請離。 From 53e07aa7611b0fac75daffbdd8895d36ba53f615 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:48:17 +0200 Subject: [PATCH 17/18] Merge pull request #481 from bbassie/fix/upstream-plugin-changes Restore seeding without controller.plugins --- exp_groups/controller.ts | 10 +++++++++- exp_roles/controller.ts | 10 +++++++++- exp_scenario/controller.ts | 15 ++++++--------- exp_scenario/test/controller.test.js | 9 ++++----- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/exp_groups/controller.ts b/exp_groups/controller.ts index a6034433..846fc0fd 100644 --- a/exp_groups/controller.ts +++ b/exp_groups/controller.ts @@ -1,15 +1,23 @@ -import { BaseControllerPlugin } from "@clusterio/controller"; +import { BaseControllerPlugin, type Controller } from "@clusterio/controller"; import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; import * as path from "node:path"; +const loaded = new WeakMap(); + export class ControllerPlugin extends BaseControllerPlugin { groups!: lib.SubscribableDatastore; roleMappings!: lib.SubscribableDatastore; manualAssignments!: lib.SubscribableDatastore; resolvedAssignments!: lib.SubscribableDatastore; + /** The plugin loaded on a controller, for the exp_scenario seed. */ + static get(controller: Controller) { + return loaded.get(controller); + } + async init() { + loaded.set(this.controller, this); const databaseDirectory = this.controller.config.get("controller.database_directory"); this.groups = new lib.SubscribableDatastore( diff --git a/exp_roles/controller.ts b/exp_roles/controller.ts index 96acc167..6d5b46f5 100644 --- a/exp_roles/controller.ts +++ b/exp_roles/controller.ts @@ -1,12 +1,20 @@ -import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller"; +import { BaseControllerPlugin, InstanceRecord, type Controller } from "@clusterio/controller"; import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; import * as path from "node:path"; +const loaded = new WeakMap(); + export class ControllerPlugin extends BaseControllerPlugin { roleMeta!: lib.SubscribableDatastore; + /** The plugin loaded on a controller, for the exp_scenario seed. */ + static get(controller: Controller) { + return loaded.get(controller); + } + async init() { + loaded.set(this.controller, this); const databaseDirectory = this.controller.config.get("controller.database_directory"); this.roleMeta = new lib.SubscribableDatastore( diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index e76d0c8e..cd19bd96 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -1,9 +1,9 @@ import * as lib from "@clusterio/lib"; import { BaseControllerPlugin } from "@clusterio/controller"; -import { RoleMetaRecord, type ControllerPlugin as RolesPlugin } from "@expcluster/roles"; -import { - GroupRecord, GroupPermissions, RoleMappingRecord, type ControllerPlugin as GroupsPlugin, -} from "@expcluster/permission-groups"; +import { RoleMetaRecord } from "@expcluster/roles"; +import { GroupRecord, GroupPermissions, RoleMappingRecord } from "@expcluster/permission-groups"; +import { ControllerPlugin as RolesPlugin } from "@expcluster/roles/dist/node/controller.js"; +import { ControllerPlugin as GroupsPlugin } from "@expcluster/permission-groups/dist/node/controller.js"; import * as messages from "./messages.js"; import { SeedRole, SeedGroup, seedRoles, seedGroups, flattenSeedPermissions } from "./seed.js"; @@ -19,10 +19,8 @@ export class ControllerPlugin extends BaseControllerPlugin { * permissions, groups which already exist by name are reset to the seed. */ async handleSeedRequest() { - throw new lib.RequestError("Seeding is temporarily disabled, please contact the server administrator to seed the roles and groups"); - /* - const rolesPlugin = this.controller.plugins.get("exp_roles") as RolesPlugin | undefined; - const groupsPlugin = this.controller.plugins.get("exp_groups") as GroupsPlugin | undefined; + const rolesPlugin = RolesPlugin.get(this.controller); + const groupsPlugin = GroupsPlugin.get(this.controller); if (!rolesPlugin || !groupsPlugin) { throw new lib.RequestError("Seeding requires the exp_roles and exp_groups plugins"); } @@ -54,7 +52,6 @@ export class ControllerPlugin extends BaseControllerPlugin { this.seedRoleMappings(groupsPlugin, roleIds, groupIds); this.logger.info(`Seeded ${roleIds.size} roles and ${groupIds.size} permission groups`); - */ } /** Find or create the clusterio role for a seed role, returns undefined if it has no role to use. */ diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index d7b96add..097365ab 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -42,7 +42,6 @@ async function startPlugin(t2, { withPlugins = true } = {}) { for (const [name, Plugin] of [["exp_roles", RolesPlugin], ["exp_groups", GroupsPlugin]]) { const plugin = new Plugin({ name }, controller, undefined, logger); await plugin.init(); - controller.plugins.set(name, plugin); } } @@ -65,7 +64,7 @@ t.test("class ControllerPlugin", t2 => { t2.test(".handleSeedRequest() creates the roles and reuses them by name", async t3 => { const { plugin, controller } = await startPlugin(t3); - const rolesPlugin = controller.plugins.get("exp_roles"); + const rolesPlugin = RolesPlugin.get(controller); await plugin.handleSeedRequest(); t3.strictSame(controller.roles.size, seedRoles.length, "every seed role exists"); @@ -80,7 +79,7 @@ t.test("class ControllerPlugin", t2 => { t2.test(".handleSeedRequest() creates the groups and resets them by name", async t3 => { const { plugin, controller } = await startPlugin(t3); - const groupsPlugin = controller.plugins.get("exp_groups"); + const groupsPlugin = GroupsPlugin.get(controller); await plugin.handleSeedRequest(); t3.strictSame(groupsPlugin.groups.size, seedGroups.length, "every seed group exists"); @@ -101,7 +100,7 @@ t.test("class ControllerPlugin", t2 => { t2.test(".handleSeedRequest() maps each role onto its group by rank", async t3 => { const { plugin, controller } = await startPlugin(t3); - const groupsPlugin = controller.plugins.get("exp_groups"); + const groupsPlugin = GroupsPlugin.get(controller); await plugin.handleSeedRequest(); const mapped = seedRoles.filter(role => role.group !== undefined); @@ -123,7 +122,7 @@ t.test("class ControllerPlugin", t2 => { t2.test(".handleSeedRequest() reuses mappings and keeps clear of other mappings", async t3 => { const { plugin, controller } = await startPlugin(t3); - const groupsPlugin = controller.plugins.get("exp_groups"); + const groupsPlugin = GroupsPlugin.get(controller); groupsPlugin.roleMappings.set(new RoleMappingRecord(99, new Set([0, 1]), 5, 2, true)); await plugin.handleSeedRequest(); From 9e9efeff75882a3143f4373917f437c068f01721 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:26:00 +0100 Subject: [PATCH 18/18] Add webui to exp_auto_restart --- exp_auto_restart/index.ts | 2 ++ exp_auto_restart/package.json | 7 +++++-- exp_auto_restart/tsconfig.browser.json | 4 ++++ exp_auto_restart/tsconfig.json | 3 ++- exp_auto_restart/web/index.tsx | 0 exp_auto_restart/webpack.config.cjs | 29 ++++++++++++++++++++++++++ 6 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 exp_auto_restart/tsconfig.browser.json create mode 100644 exp_auto_restart/web/index.tsx create mode 100644 exp_auto_restart/webpack.config.cjs diff --git a/exp_auto_restart/index.ts b/exp_auto_restart/index.ts index 27420411..0d3274cc 100644 --- a/exp_auto_restart/index.ts +++ b/exp_auto_restart/index.ts @@ -47,4 +47,6 @@ export const plugin: lib.PluginDeclaration = { initialValue: false, }, }, + + webEntrypoint: "./web", }; diff --git a/exp_auto_restart/package.json b/exp_auto_restart/package.json index 57df55ac..c848a184 100644 --- a/exp_auto_restart/package.json +++ b/exp_auto_restart/package.json @@ -8,7 +8,7 @@ "type": "module", "main": "dist/node/index.js", "scripts": { - "prepare": "tsc --build", + "prepare": "tsc --build && webpack-cli --env production", "test": "tap --type-strip-only --disable-coverage --allow-empty-coverage test/*.test.js", "coverage": "tap --coverage-report=text test/*.test.js" }, @@ -24,7 +24,10 @@ "@clusterio/lib": "workspace:^", "@types/node": "catalog:", "tap": "^21.1.0", - "typescript": "catalog:" + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" }, "publishConfig": { "access": "public" diff --git a/exp_auto_restart/tsconfig.browser.json b/exp_auto_restart/tsconfig.browser.json new file mode 100644 index 00000000..1ab2704e --- /dev/null +++ b/exp_auto_restart/tsconfig.browser.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.browser.json", + "include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ], +} diff --git a/exp_auto_restart/tsconfig.json b/exp_auto_restart/tsconfig.json index a0473e75..303b8f28 100644 --- a/exp_auto_restart/tsconfig.json +++ b/exp_auto_restart/tsconfig.json @@ -1,6 +1,7 @@ { "files": [], "references": [ - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.browser.json" } ] } diff --git a/exp_auto_restart/web/index.tsx b/exp_auto_restart/web/index.tsx new file mode 100644 index 00000000..e69de29b diff --git a/exp_auto_restart/webpack.config.cjs b/exp_auto_restart/webpack.config.cjs new file mode 100644 index 00000000..58150215 --- /dev/null +++ b/exp_auto_restart/webpack.config.cjs @@ -0,0 +1,29 @@ +"use strict"; +const path = require("path"); +const webpack = require("webpack"); +const { merge } = require("webpack-merge"); + +const common = require("@clusterio/web_ui/webpack.common"); + +module.exports = (env = {}) => merge(common(env), { + context: __dirname, + entry: "./web/index.tsx", + output: { + path: path.resolve(__dirname, "dist", "web"), + }, + plugins: [ + new webpack.container.ModuleFederationPlugin({ + name: "exp_auto_restart", + library: { type: "window", name: "plugin_exp_auto_restart" }, + exposes: { + "./": "./index.ts", + "./package.json": "./package.json", + "./web": "./web/index.tsx", + }, + shared: { + "@clusterio/lib": { import: false }, + "@clusterio/web_ui": { import: false }, + }, + }), + ], +});