Merge pull request #488 from bbassie/feature/server-list

Add exp_server_list to replace expcore/external
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-10-14 22:59:08 +01:00
commit 4fe9148ab7
31 files changed
+1479 -301

No files matched your search

-146
View File
@@ -1,146 +0,0 @@
--[[-- Core Module - External
- A module used to make accessing externally set data easier.
@core External
@alias External
@usage-- Printing all server to chat
local External = require("modules.exp_legacy.expcore.external") --- @dep expcore.external
local message = 'id: %s name: %s version: %s status: %s'
for server_id, server in pairs(External.get_servers()) do
local status = External.get_server_status(server_id)
game.print(message:format(server_id, server.name, server.version, status))
end
]]
local ext --- @type table<string, any>
local var --- @type table<string, any>
local concat = table.concat
local External = {}
--[[-- Checks that local links are valid, will try to add the links if invalid
@treturn boolean If the external data is valid, if false you should not call any other methods from External
@usage-- Check that external data is valid
if not External.valid() then
-- error code here
end
]]
function External.valid()
local stored = storage.ext
if stored == nil then return false end
if ext == stored and var == ext.var then
return var ~= nil
else
ext = stored
var = stored.var
return var ~= nil
end
end
--[[-- Gets a table of all the servers, key is the server id, value is the server details
@treturn table A table containing all the servers, key is the server id, value is the server details
@usage-- Get all servers
local servers = External.get_servers()
]]
function External.get_servers()
assert(ext, "No external data was found, use External.valid() to ensure external data exists.")
return (assert(ext.servers, "No server list was found, please ensure that the external service is running"))
end
--[[-- Gets a table of all the servers filtered by name, key is the server id, value is the server details
@tparam string search The string to search for, names, short_names and ids are checked for this string.
@treturn table A table containing all the servers filtered by name, key is the server id, value is the server details
@usage-- Get all servers with public in the name
local servers = External.get_servers_filtered(public)
]]
function External.get_servers_filtered(search)
assert(ext, "No external data was found, use External.valid() to ensure external data exists.")
local servers = assert(ext.servers, "No server list was found, please ensure that the external service is running")
local found_servers = {}
search = search:lower()
for server_id, server in pairs(servers) do
local str = concat{ server.name, server.short_name, server.id }
if str:lower():find(search, 1, true) then found_servers[server_id] = server end
end
return found_servers
end
--[[-- Gets the details of the current server
@treturn table The details of the current server
@usage-- Get the details of the current server
local server = External.get_current_server()
]]
function External.get_current_server()
assert(ext, "No external data was found, use External.valid() to ensure external data exists.")
local servers = assert(ext.servers, "No server list was found, please ensure that the external service is running")
local server_id = assert(ext.current, "No current id was found, please ensure that the external service is running")
return (assert(servers[server_id], "No details found for server with id: " .. tostring(server_id)))
end
--[[-- Gets the details of the given server
@tparam string server_id The internal server if for the server you want the details of
@treturn table The details of the given server
@usage-- Get the details of the given server
local server = External.get_server_details('eu-01')
]]
function External.get_server_details(server_id)
assert(ext, "No external data was found, use External.valid() to ensure external data exists.")
local servers = assert(ext.servers, "No server list was found, please ensure that the external service is running")
return (assert(servers[server_id], "No details found for server with id: " .. tostring(server_id)))
end
--[[-- Gets the status of the given server
@tparam string server_id The internal server if for the server you want the status of
@tparam boolean raw When true Current will not be returned as status but rather the raw status for the server
@treturn string The status of the given server, one of: Online, Modded, Protected, Current, Offline
@usage-- Get the status of the given server
local status = External.get_server_status('eu-01')
]]
function External.get_server_status(server_id, raw)
assert(var, "No external data was found, use External.valid() to ensure external data exists.")
local servers = assert(var.status, "No server status was found, please ensure that the external service is running")
local current = assert(ext.current, "No current id was found, please ensure that the external service is running")
return not raw and server_id == current and "Current" or assert(servers[server_id], "No status found for server with id: " .. tostring(server_id))
end
--[[-- Connect a player to the given server
@tparam LuaPlayer player The player that you want to request to join a different server
@tparam string server_id The internal id of the server to connect to, can also be any address but this will show Unknown Server
@tparam[opt=false] boolean self_requested If the player requested the join themself, this will hide the message about being asked to switch
@usage-- Request that a player joins a different server
External.request_connection(player, 'eu-01')
@usage-- Request that a player joins a different server, by own request
External.request_connection(player, 'eu-01', true)
]]
function External.request_connection(player, server_id, self_requested)
local server = { address = server_id, name = "Unknown Server", description = "This server is not ran by us, please check the address of the server." }
if ext and ext.servers and ext.servers[server_id] then server = ext.servers[server_id] end
local message = "Please press the connect button below to join."
if not self_requested then message = "You have been asked to switch to a different server.\n" .. message end
player.connect_to_server{
address = server.address,
name = "\n[color=orange][font=heading-1]" .. server.name .. "[/font][/color]\n",
description = server.description .. "\n" .. message,
}
end
--- Module return
return External
+1 -1
View File
@@ -9,4 +9,4 @@ add_static("Gui", require("modules/exp_gui"))
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"))
add_static("ExpServerList", require("modules/exp_server_list"))
+49 -52
View File
@@ -5,50 +5,56 @@ Adds a commands that allows you to request a player move to another server
local Async = require("modules/exp_util/async")
local Commands = require("modules/exp_commands")
local External = require("modules.exp_legacy.expcore.external") --- @dep expcore.external
local request_connection_async = Async.register(External.request_connection)
local ExpServerList = require("modules/exp_server_list")
local request_connection_async = Async.register(ExpServerList.request_connection)
local concat = table.concat
--- Convert a server name into a server id, is not an Commands.InputParser because it does not accept addresses
--- @param server string
--- @return boolean, LocalisedString # True for success
local function get_server_id(server)
local servers = External.get_servers_filtered(server)
local current_server = External.get_current_server()
local current_version = current_server.version
--- Find the one server matching a name, is not an Commands.InputParser because it does not accept addresses
--- @param search string
--- @return boolean, LocalisedString | ExpServerList.Server # True for success
local function find_server(search)
local current_server = ExpServerList.get_current() --- @cast current_server -nil
local found = ExpServerList.find_servers(search)
local server_names_before, server_names = {}, {}
local server_count_before, server_count = 0, 0
for next_server_id, server_details in pairs(servers) do
server_count_before = server_count_before + 1
server_names_before[server_count_before] = server_details.name
if server_details.version == current_version then
server_count = server_count + 1
server_names[server_count] = server_details.name
else
servers[next_server_id] = nil
local names_all, names = {}, {}
local matching = {} --- @type ExpServerList.Server[]
for index, server in ipairs(found) do
names_all[index] = server.name
if server.factorio_version == current_server.factorio_version then
matching[#matching + 1] = server
names[#names + 1] = server.name
end
end
if server_count > 1 then
return false, { "exp-commands_connect.too-many-matching", concat(server_names, ", ") }
elseif server_count == 1 then
local server_id, server_details = next(servers) --- @cast server_details -nil
local status = External.get_server_status(server_id)
if server_id == current_server.id then
return false, { "exp-commands_connect.same-server", server_details.name }
elseif status == "Offline" then
return false, { "exp-commands_connect.offline", server_details.name }
if #matching > 1 then
return false, { "exp-commands_connect.too-many-matching", concat(names, ", ") }
elseif #matching == 1 then
local server = matching[1]
if server.id == current_server.id then
return false, { "exp-commands_connect.same-server", server.name }
elseif not ExpServerList.is_online(server) then
return false, { "exp-commands_connect.offline", server.name }
end
return true, server_id --[[@as string]]
elseif server_count_before > 0 then
return false, { "exp-commands_connect.wrong-version", concat(server_names_before, ", ") }
return true, server
elseif #found > 0 then
return false, { "exp-commands_connect.wrong-version", concat(names_all, ", ") }
else
return false, { "exp-commands_connect.none-matching" }
end
end
--- Resolve the server argument to a server, or leave it as an address
--- @param server string
--- @param is_address boolean?
--- @return boolean, LocalisedString | ExpServerList.Server | string # True for success
local function resolve_server(server, is_address)
if is_address or not ExpServerList.is_synced() then
return true, server
end
return find_server(server)
end
--- Connect to a different server
Commands.new("connect", { "exp-commands_connect.description" })
:argument("server", { "exp-commands_connect.arg-server" }, Commands.types.string)
@@ -57,15 +63,12 @@ Commands.new("connect", { "exp-commands_connect.description" })
:register(function(player, server, is_address)
--- @cast server string
--- @cast is_address boolean?
if not is_address and External.valid() then
local success, result = get_server_id(server)
if not success then
return Commands.status.invalid_input(result)
end
server = result
local success, result = resolve_server(server, is_address)
if not success then
return Commands.status.invalid_input(result)
end
request_connection_async(player, server, true)
request_connection_async(player, result, true)
end)
--- Connect another player to a different server
@@ -78,15 +81,12 @@ Commands.new("connect-player", { "exp-commands_connect.description-player" })
--- @cast other_player LuaPlayer
--- @cast server string
--- @cast is_address boolean?
if not is_address and External.valid() then
local success, result = get_server_id(server)
if not success then
return Commands.status.invalid_input(result)
end
server = result
local success, result = resolve_server(server, is_address)
if not success then
return Commands.status.invalid_input(result)
end
request_connection_async(other_player, server)
request_connection_async(other_player, result)
end)
--- Connect all players to a different server
@@ -97,15 +97,12 @@ Commands.new("connect-all", { "exp-commands_connect.description-all" })
:register(function(player, server, is_address)
--- @cast server string
--- @cast is_address boolean?
if not is_address and External.valid() then
local success, result = get_server_id(server)
if not success then
return Commands.status.invalid_input(result)
end
server = result
local success, result = resolve_server(server, is_address)
if not success then
return Commands.status.invalid_input(result)
end
for _, next_player in pairs(game.connected_players) do
request_connection_async(next_player, server)
request_connection_async(next_player, result)
end
end)
+64 -51
View File
@@ -7,7 +7,7 @@ local Gui = require("modules/exp_gui")
local Roles = require("modules/exp_roles")
local Commands = require("modules/exp_commands")
local PlayerData = require("modules.exp_legacy.expcore.player_data")
local External = require("modules.exp_legacy.expcore.external")
local ExpServerList = require("modules/exp_server_list")
local config_server_detail = require("modules.exp_legacy.config.server_detail") --- @dep config.server_detail
local format_number = require("util").format_number
@@ -16,6 +16,23 @@ local format_time = ExpUtil.format_time_factory_locale{ format = "long", days =
local frame_width = 595
local title_width = 270
local scroll_height = 275
local description_length = 120
--- Cut a string to a number of bytes without splitting a utf8 character
--- @param str string
--- @param length number
--- @return string
local function shorten(str, length)
if #str <= length then
return str
end
local next_byte = str:byte(length + 1)
while length > 0 and next_byte >= 0x80 and next_byte < 0xC0 do
length = length - 1
next_byte = str:byte(length + 1)
end
return str:sub(1, length) .. "..."
end
--- @class ExpGui_Readme.elements
local Elements = {}
@@ -84,16 +101,17 @@ Elements.sub_content = Gui.define("readme/sub_content")
} --[[@as any]]
--- @class ExpGui_Readme.elements.join_server.elements
--- @field server_id string
--- @field server_id number
--- Join server button
--- @class ExpGui_Readme.elements.join_server: ExpElement
--- @field data table<LuaGuiElement, ExpGui_Readme.elements.join_server.elements>
--- @overload fun(parent: LuaGuiElement, server_id: string, wrong_version: string?): LuaGuiElement
--- @overload fun(parent: LuaGuiElement, server: ExpServerList.Server): LuaGuiElement
Elements.join_server = Gui.define("readme/join_server")
:track_all_elements()
:draw(function(def, parent, server_id, wrong_version)
:draw(function(def, parent, server)
--- @cast def ExpGui_Readme.elements.join_server
--- @cast server ExpServerList.Server
local flow = parent.add{
type = "flow",
}
@@ -106,10 +124,10 @@ Elements.join_server = Gui.define("readme/join_server")
}
def.data[button] = {
server_id = server_id,
server_id = server.id,
}
Elements.join_server.refresh(button, wrong_version)
Elements.join_server.refresh(button, server)
return button
end)
:style{
@@ -118,22 +136,33 @@ Elements.join_server = Gui.define("readme/join_server")
}
:on_click(function(def, player, button)
--- @cast def ExpGui_Readme.elements.join_server
local server_id = def.data[button].server_id
External.request_connection(player, server_id, true)
local server = ExpServerList.get_server(def.data[button].server_id)
if server and ExpServerList.is_online(server) then
ExpServerList.request_connection(player, server, true)
end
end) --[[@as any]]
--- Refresh join server button
--- @param button LuaGuiElement
--- @param wrong_version string?
function Elements.join_server.refresh(button, wrong_version)
local server_id = Elements.join_server.data[button].server_id
local status = External.get_server_status(server_id) or "Offline"
if wrong_version then
status = "Version"
--- @param server ExpServerList.Server?
function Elements.join_server.refresh(button, server)
local current = ExpServerList.get_current()
local status, detail = "Online", nil
if not server or not ExpServerList.is_online(server) then
status = "Offline"
elseif current and server.id == current.id then
status = "Current"
elseif current and server.factorio_version ~= current.factorio_version then
status, detail = "Version", server.factorio_version
elseif server.has_password then
status = "Password"
elseif current and server.mod_pack_name ~= current.mod_pack_name then
status, detail = "Modded", server.mod_pack_name
end
button.tooltip = { "exp-gui_readme.servers-connect-" .. status, wrong_version }
-- Factorio drops nil parameters, which would shift the player count into __1__
local player_count = server and server.player_count or 0
button.tooltip = { "exp-gui_readme.servers-connect-" .. status, detail or "", player_count }
if status == "Offline" or status == "Current" then
button.enabled = false
@@ -162,21 +191,11 @@ function Elements.join_server.refresh(button, wrong_version)
end
end
--- Refresh all online join buttons
--- Refresh all join buttons
function Elements.join_server.refresh_all()
if not External.valid() then
return
end
local current_version = External.get_current_server().version
for _, button in Elements.join_server:tracked_elements() do
local server_id = Elements.join_server.data[button].server_id
local server = External.get_servers()[server_id]
if server then
Elements.join_server.refresh(button, current_version ~= server.version and server.version or nil)
end
Elements.join_server.refresh(button, ExpServerList.get_server(server_id))
end
end
@@ -190,12 +209,17 @@ define_tab(
local server_details = {
name = config_server_detail["community_name"] .. " S0 - Local",
welcome = "Failed to load description: disconnected from external api.",
welcome = "Failed to load description: disconnected from the controller.",
reset_time = "Not Set",
}
if External.valid() then
server_details = External.get_current_server()
local current = ExpServerList.get_current()
if current then
server_details = {
name = current.name,
welcome = current.welcome ~= "" and current.welcome or { "exp-gui_readme.welcome-default" },
reset_time = current.reset_time ~= "" and current.reset_time or "Not Set",
}
end
local container = parent.add{ type = "flow", direction = "vertical" }
@@ -206,7 +230,7 @@ define_tab(
local center_flow = top_flow.add{ type = "flow", direction = "vertical" }
center_flow.style.horizontal_align = "center"
Gui.elements.title_label(center_flow, 62, { "exp-gui_readme.welcome-title", server_details.name })
Gui.elements.centered_label(center_flow, 380, { "exp-gui_readme.welcome-title", server_details.name })
Gui.elements.centered_label(center_flow, 380, server_details.welcome)
top_flow.add{ type = "sprite", sprite = "file/modules/exp_scenario/gui/logo.png" }
@@ -317,22 +341,13 @@ define_tab(
local scroll_pane = Elements.title_table_scroll(container)
scroll_pane.style.maximal_height = scroll_height + 20
if External.valid() then
local current_version = External.get_current_server().version
local factorio_servers = Elements.title_table(scroll_pane, 225, { "exp-gui_readme.servers-factorio" }, 3)
for server_id, server in pairs(External.get_servers()) do
Gui.elements.centered_label(factorio_servers, 110, server.short_name)
Gui.elements.centered_label(factorio_servers, 436, server.description)
Elements.join_server(factorio_servers, server_id, current_version ~= server.version and server.version or nil)
end
else
local factorio_servers = Elements.title_table(scroll_pane, 225, { "exp-gui_readme.servers-factorio" }, 2)
for i = 1, 8 do
Gui.elements.centered_label(factorio_servers, 110, { "exp-gui_readme.servers-" .. i })
Gui.elements.centered_label(factorio_servers, 460, { "exp-gui_readme.servers-d" .. i })
end
local factorio_servers = Elements.title_table(scroll_pane, 225, { "exp-gui_readme.servers-factorio" }, 3)
for _, server in ipairs(ExpServerList.get_servers()) do
local description = shorten(server.description, description_length)
local tooltip = description ~= server.description and server.description or nil
Gui.elements.centered_label(factorio_servers, 110, server.display_name)
Gui.elements.centered_label(factorio_servers, 436, description, tooltip)
Elements.join_server(factorio_servers, server)
end
local external_links = Elements.title_table(scroll_pane, 235, { "exp-gui_readme.servers-external" }, 2)
@@ -707,8 +722,6 @@ return {
[e.on_player_created] = open_readme,
[e.on_player_joined_game] = clear_readme,
[e.on_player_respawned] = clear_readme,
[ExpServerList.on_servers_updated] = Elements.join_server.refresh_all,
},
on_nth_tick = {
[60 * 60] = Elements.join_server.refresh_all,
}
}
+4 -19
View File
@@ -320,6 +320,7 @@ main-tooltip=Information
welcome-tab=Welcome
welcome-tooltip=Welcome to __1__
welcome-title=Welcome to __1__
welcome-default=Thanks for playing on our server, we hope you enjoy your stay.
welcome-general=Welcome to __1__; we host many Factorio servers. While you are here, we ask you to follow our rules. You can find these in the tab above. You can also find our custom commands and links to our other servers. This map has been online for __2__.\nPlease note that our servers reset periodically, the next reset is: __3__
welcome-roles=We run a custom role system to help protect the work of others. As a result you may not be able to use your deconstruction planner yet or drop items on the ground. Roles also give you access to some custom features such as adding tasks to our task list or making new warp points.\nYou have been assigned the roles: __1__
welcome-chat=Chatting can be difficult for new players because it’s different than other games! It’s very simple, the button you need to press is the “GRAVE/TILDE” key (which is located under the “ESC key”). If you would like to change the key, go to your Controls tab in options.\nThe setting you need to change is “Toggle chat (and Lua console)” you currently have it set to "__CONTROL__toggle-console__"
@@ -350,28 +351,12 @@ servers-general=This is only one of our Factorio servers, we host many others as
servers-factorio=Factorio Servers
servers-external=External Links
servers-open-in-browser=Open in your browser
servers-1=S1 Public
servers-d1=This is our 48 hour reset server.
servers-2=S2 Off
servers-d2=This server is closed.
servers-3=S3 Weekly
servers-d3=This is our weekly reset server.
servers-4=S4 Monthly
servers-d4=This is our monthly reset server.
servers-5=S5 Modded
servers-d5=This is our modded server, see Discord for details.
servers-6=S6 Donator
servers-d6=This is our donator only server, online when requested.
servers-7=S7 Event
servers-d7=This is our event server, we try to run events at least once per week.
servers-8=S8 T̷-̶s̶-̴:̷
servers-d8=N̵o̴ ̶o̷-̶e̵ ̴k̸n̷-̶w̵s̸ ̴w̷h̷a̶-̶ ̷h̴a̴p̷p̴e̷n̷s̸ ̷o̶n̴ ̷t̶h̴-̶s̶ ̷s̷e̶r̸v̸e̴r̷,̶ ̸i̸t̴ ̷m̶-̸g̴h̶t̷ ̸n̸-̶t̵ ̷e̴v̸e̸n̶t̷ ̵-̷x̴i̵s̶t̸.̸
servers-connect-Offline=Server is currently offline
servers-connect-Current=This is your current server
servers-connect-Version=Server is on a different version: __1__
servers-connect-Password=Server requires a password
servers-connect-Modded=Server requires mods to be downloaded
servers-connect-Online=Server is online
servers-connect-Password=Server requires a password\nPlayers online: __2__
servers-connect-Modded=Server uses the __1__ mod pack, mods may need to be downloaded\nPlayers online: __2__
servers-connect-Online=Server is online\nPlayers online: __2__
backers-tab=Backers
backers-tooltip=People who have helped make our server
backers-general=We would like to thank all our staff and backers who have helped our community grow. Our staff have helped to keep our servers safe from trolls and a fun place to play. Our backers have helped us to cover our running costs and provide a great community for us all to enjoy together.
-16
View File
@@ -339,22 +339,6 @@ servers-tab=伺服器
servers-tooltip=到其他伺服器及網站的連結
servers-general=這是其中一個伺服器。你可以在下方找到其他同樣是由我們運行的伺服器資料。
servers-factorio=異星工廠伺服器
servers-1=S1 公共
servers-d1=這個服務器每兩日重設。
servers-2=S2 -
servers-d2=這個服務器沒在營運。
servers-3=S3 周
servers-d3=這個服務器每週重設。
servers-4=S4 月
servers-d4=這個服務器每月重設。
servers-5=S5 模組
servers-d5=這個服務器運行模組,可在 Discord 中得到更多資訊。
servers-6=S6 Donator
servers-d6=這個服務器為支持者運行,可在 Discord 中得到更多資訊。
servers-7=S7 項目
servers-d7=這個服務器只在有項目期間運行。
servers-8=S8 T̷-̶s̶-̴:̷
servers-d8=N̵o̴ ̶o̷-̶e̵ ̴k̸n̷-̶w̵s̸ ̴w̷h̷a̶-̶ ̷h̴a̴p̷p̴e̷n̷s̸ ̷o̶n̴ ̷t̶h̴-̶s̶ ̷s̷e̶r̸v̸e̴r̷,̶ ̸i̸t̴ ̷m̶-̸g̴h̶t̷ ̸n̸-̶t̵ ̷e̴v̸e̸n̶t̷ ̵-̷x̴i̵s̶t̸。̸
servers-connect-Offline=該服務器目前沒有在運行
servers-connect-Current=你目前所在的伺服器
servers-connect-Version=該服務器運行不同的遊戲版本: __1__
-16
View File
@@ -339,22 +339,6 @@ servers-tab=伺服器
servers-tooltip=到其他伺服器及網站的連結
servers-general=這是其中一個伺服器。你可以在下方找到其他同樣是由我們運行的伺服器資料。
servers-factorio=異星工廠伺服器
servers-1=S1 公共
servers-d1=這個服務器每兩日重設。
servers-2=S2 -
servers-d2=這個服務器沒在營運。
servers-3=S3 周
servers-d3=這個服務器每週重設。
servers-4=S4 月
servers-d4=這個服務器每月重設。
servers-5=S5 模組
servers-d5=這個服務器運行模組,可在 Discord 中得到更多資訊。
servers-6=S6 Donator
servers-d6=這個服務器為支持者運行,可在 Discord 中得到更多資訊。
servers-7=S7 項目
servers-d7=這個服務器只在有項目期間運行。
servers-8=S8 T̷-̶s̶-̴:̷
servers-d8=N̵o̴ ̶o̷-̶e̵ ̴k̸n̷-̶w̵s̸ ̴w̷h̷a̶-̶ ̷h̴a̴p̷p̴e̷n̷s̸ ̷o̶n̴ ̷t̶h̴-̶s̶ ̷s̷e̶r̸v̸e̴r̷,̶ ̸i̸t̴ ̷m̶-̸g̴h̶t̷ ̸n̸-̶t̵ ̷e̴v̸e̸n̶t̷ ̵-̷x̴i̵s̶t̸。̸
servers-connect-Offline=該服務器目前沒有在運行
servers-connect-Current=你目前所在的伺服器
servers-connect-Version=該服務器運行不同的遊戲版本: __1__
+1
View File
@@ -11,6 +11,7 @@
"exp_util": "*",
"exp_roles": "*",
"exp_reports": "*",
"exp_server_list": "*",
"exp_gui": "*",
"exp_commands": "*"
}
+1
View File
@@ -51,6 +51,7 @@
"@expcluster/lib_gui": "workspace:^",
"@expcluster/permission-groups": "workspace:^",
"@expcluster/roles": "workspace:^",
"@expcluster/server-list": "workspace:^",
"@sinclair/typebox": "catalog:"
},
"publishConfig": {
+44
View File
@@ -0,0 +1,44 @@
# ExpGaming - Server List
Shares the details of every instance with every other instance. Replaces `expcore/external` from exp_legacy, which read the same data from a file written by an outside service.
The controller keeps one record per instance with its name, mod pack, Factorio version, players online, status and address. The address is the public address of the host and the game port of the instance, the same one the web UI shows. Whether the server has a game password is sent, the password is not. Nothing is saved, the records are rebuilt from the instances when the controller starts.
## Config
On the controller:
| Field | Default | Description |
|---|---|---|
| `exp_server_list.welcome` | | Message shown on the welcome tab of the readme for instances without their own. |
On the instance:
| Field | Default | Description |
|---|---|---|
| `exp_server_list.display_name` | | Name shown in the server list, the instance name when empty. |
| `exp_server_list.description` | | One line about the server shown in the server list, the description in `factorio.settings` when empty. |
| `exp_server_list.welcome` | | Message shown on the welcome tab of the readme, the controller welcome when empty. |
| `exp_server_list.reset_time` | | When the map next resets, shown on the welcome tab of the readme. |
| `exp_server_list.hidden` | false | Leave this instance out of the server list on other instances. |
| `exp_server_list.always_on_top` | false | List this instance before those without it. |
## Lua
```lua
local ExpServerList = require("modules/exp_server_list")
ExpServerList.get_servers() -- Every server that is not hidden, always on top first then by name
ExpServerList.get_server(instance_id)
ExpServerList.is_synced() -- false until the controller has sent the list
ExpServerList.get_current() -- nil until the controller has sent the list
ExpServerList.find_servers("weekly") -- Matches name, display name or id
ExpServerList.is_online(server)
ExpServerList.request_connection(player, server_or_address, self_requested)
[ExpServerList.on_servers_updated] = function(event) end, -- { server_ids }
```
## Node
`ServerListRequest` returns every record, hidden ones included. Web plugins and control connections can subscribe to `ServerUpdatesEvent` with the `exp_server_list.list` permission.
+153
View File
@@ -0,0 +1,153 @@
import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller";
import * as lib from "@clusterio/lib";
import * as messages from "./messages.js";
/** Instance config fields which change the record */
const recordFields = new Set([
"instance.name",
"instance.assigned_host",
"factorio.mod_pack_id",
"factorio.settings",
"exp_server_list.display_name",
"exp_server_list.description",
"exp_server_list.welcome",
"exp_server_list.reset_time",
"exp_server_list.hidden",
"exp_server_list.always_on_top",
]);
/** Controller config fields which change every record */
const controllerFields = new Set([
"controller.default_mod_pack_id",
"exp_server_list.welcome",
]);
/**
* Keeps a record for every instance and broadcasts the ones which change.
*
* Records are rebuilt from controller state on start, nothing is saved.
*/
export default async function (context: ControllerPluginContext) {
const { controller, plugin } = context;
const servers = new lib.SubscribableDatastore<messages.ServerRecord>();
function modPackName(instance: InstanceRecord) {
const id = instance.config.get("factorio.mod_pack_id")
?? controller.config.get("controller.default_mod_pack_id");
return id !== null && id !== undefined ? controller.modPacks.get(id)?.name ?? "" : "";
}
function playerCount(instanceId: number) {
let count = 0;
for (const user of controller.users.records.values()) {
if (user.instances.has(instanceId)) {
count += 1;
}
}
return count;
}
/** Same as the address the web ui shows for the instance */
function address(instance: InstanceRecord) {
const hostId = instance.config.get("instance.assigned_host");
const publicAddress = hostId !== null ? controller.hosts.get(hostId)?.publicAddress : undefined;
if (!publicAddress) {
return "";
}
return instance.gamePort === undefined ? publicAddress : `${publicAddress}:${instance.gamePort}`;
}
function buildRecord(instance: InstanceRecord) {
const config = instance.config;
const settings = config.get("factorio.settings");
const settingString = (name: string) => (typeof settings[name] === "string" ? settings[name] : "");
return new messages.ServerRecord(
instance.id,
config.get("instance.name"),
config.get("exp_server_list.display_name") || config.get("instance.name"),
config.get("exp_server_list.description") || settingString("description"),
config.get("exp_server_list.welcome") || controller.config.get("exp_server_list.welcome"),
config.get("exp_server_list.reset_time"),
config.get("exp_server_list.hidden"),
config.get("exp_server_list.always_on_top"),
settingString("game_password") !== "",
modPackName(instance),
instance.factorioVersion === "latest" ? "" : instance.factorioVersion ?? "",
instance.status === "running" ? playerCount(instance.id) : 0,
instance.status,
address(instance),
);
}
/** Rebuild the records of the instances, and store the ones which changed in one update */
function refresh(instances: Iterable<InstanceRecord>) {
const changed: messages.ServerRecord[] = [];
const deleted: messages.ServerRecord[] = [];
for (const instance of instances) {
const existing = servers.get(instance.id);
if (instance.isDeleted) {
if (existing) {
deleted.push(existing);
}
continue;
}
const record = buildRecord(instance);
if (!existing || !existing.sameAs(record)) {
changed.push(record);
}
}
if (changed.length) {
servers.setMany(changed);
}
if (deleted.length) {
servers.deleteMany(deleted);
}
}
const refreshAll = () => refresh(controller.instances.values());
refreshAll();
servers.on("update", updates => {
controller.subscriptions.broadcast(new messages.ServerUpdatesEvent(updates));
});
// The public address of a host arrives after it connects
controller.hosts.on("update", hosts => {
const hostIds = new Set(hosts.map(host => host.id));
refresh([...controller.instances.values()].filter(instance => {
const hostId = instance.config.get("instance.assigned_host");
return hostId !== null && hostIds.has(hostId);
}));
});
controller.subscriptions.handle(messages.ServerUpdatesEvent, async request => {
const updates = [...servers.values()].filter(server => server.updatedAtMs > request.lastRequestTimeMs);
return updates.length ? new messages.ServerUpdatesEvent(updates) : null;
});
controller.handle(messages.ServerListRequest, async () => [...servers.values()]);
const hooks = controller.hooks;
hooks.instanceStatusChanged.attach(plugin.name, async instance => {
refresh([instance]);
});
hooks.instanceConfigFieldChanged.attach(plugin.name, async (instance, field) => {
if (recordFields.has(field)) {
refresh([instance]);
}
});
hooks.controllerConfigFieldChanged.attach(plugin.name, async field => {
if (controllerFields.has(field)) {
refreshAll();
}
});
hooks.playerEvent.attach(plugin.name, async (instance, event) => {
if (event.type === "join" || event.type === "leave") {
refresh([instance]);
}
});
hooks.modPacksUpdated.attach(plugin.name, async () => {
refreshAll();
});
}
+89
View File
@@ -0,0 +1,89 @@
import * as lib from "@clusterio/lib";
import * as messages from "./messages.js";
declare module "@clusterio/lib" {
export interface ControllerConfigFields {
"exp_server_list.welcome": string;
}
export interface InstanceConfigFields {
"exp_server_list.display_name": string;
"exp_server_list.description": string;
"exp_server_list.welcome": string;
"exp_server_list.reset_time": string;
"exp_server_list.hidden": boolean;
"exp_server_list.always_on_top": boolean;
}
export interface Permissions {
"exp_server_list.list": never;
}
}
export const plugin: lib.PluginDeclaration = {
name: "exp_server_list",
title: "ExpGaming - Server List",
description: "Clusterio plugin sharing the details of every instance with the others",
controllerEntrypoint: "./dist/node/controller.js",
instanceEntrypoint: "./dist/node/instance.js",
controllerConfigFields: {
"exp_server_list.welcome": {
title: "Welcome",
description: "Message shown on the welcome tab of the readme for instances without their own",
type: "string",
initialValue: "",
},
},
instanceConfigFields: {
"exp_server_list.display_name": {
title: "Display Name",
description: "Name shown in the server list, the instance name when empty",
type: "string",
initialValue: "",
},
"exp_server_list.description": {
title: "Description",
description: "One line about the server shown in the server list, the server description when empty",
type: "string",
initialValue: "",
},
"exp_server_list.welcome": {
title: "Welcome",
description: "Message shown on the welcome tab of the readme, the controller welcome when empty",
type: "string",
initialValue: "",
},
"exp_server_list.reset_time": {
title: "Reset Time",
description: "When the map next resets, shown on the welcome tab of the readme",
type: "string",
initialValue: "",
},
"exp_server_list.hidden": {
title: "Hidden",
description: "Leave this instance out of the server list on other instances",
type: "boolean",
initialValue: false,
},
"exp_server_list.always_on_top": {
title: "Always On Top",
description: "List this instance before those without it",
type: "boolean",
initialValue: false,
},
},
messages: [
messages.ServerUpdatesEvent,
messages.ServerListRequest,
],
permissions: [
{
name: "exp_server_list.list",
title: "List servers",
description: "View the server list and subscribe to its updates",
},
],
};
+53
View File
@@ -0,0 +1,53 @@
import type { InstancePluginContext } from "@clusterio/host";
import * as lib from "@clusterio/lib";
import * as messages from "./messages.js";
/**
* Sends the server list to the lua side, see module/control.lua.
*
* The subscription is made by the start hook once rcon is up, so updates
* only arrive while the game can take them.
*/
export default async function (context: InstancePluginContext) {
const { instance, plugin } = context;
async function luaSendServers(servers: messages.ServerRecord[], replace: boolean) {
const json = JSON.stringify({
current_id: instance.id,
replace,
servers: servers.map(server => server.toJSON()),
});
// Pick a long bracket the json does not contain
let level = "=";
while (json.includes(`]${level}]`)) {
level += "=";
}
await instance.sendRcon(
`/sc exp_server_list.receive_update(helpers.json_to_table[${level}[${json}]${level}])`, true,
);
}
/** Subscribe, then replace the whole list */
async function syncServers() {
await instance.sendTo("controller", new lib.SubscriptionRequest(
`exp_server_list:${messages.ServerUpdatesEvent.name}`, "subscribe", Date.now(),
));
const servers = await instance.sendTo("controller", new messages.ServerListRequest());
await luaSendServers(servers, true);
}
instance.handle(messages.ServerUpdatesEvent, async event => {
if (instance.status === "starting" || instance.status === "running") {
await luaSendServers(event.updates, false);
}
});
instance.hooks.start.attach(plugin.name, syncServers);
// A new session means the controller restarted and forgot the subscription
instance.hooks.controllerConnectionEvent.attach(plugin.name, async event => {
if (event === "connect" && instance.status === "running") {
await syncServers();
}
});
}
+158
View File
@@ -0,0 +1,158 @@
import * as lib from "@clusterio/lib";
import { Type, Static } from "@sinclair/typebox";
/**
* What the rest of the cluster knows about one instance.
*
* Built by the controller from the instance, its host and its mod pack, so
* it is never stored. The id is the instance id.
*/
export class ServerRecord {
constructor(
public id: number,
public name: string,
public displayName: string,
public description: string,
public welcome: string,
public resetTime: string,
public hidden: boolean,
/** Sorted before the servers which are not */
public alwaysOnTop: boolean,
/** The password itself is never sent */
public hasPassword: boolean,
/** Empty when the instance has no mod pack */
public modPackName: string,
/** Version the server last ran, empty if it has never started */
public factorioVersion: string,
public playerCount: number,
public status: lib.InstanceStatus,
/** Empty when the host has no public address */
public address: string,
public updatedAtMs: number = 0,
public isDeleted: boolean = false,
) {}
static jsonSchema = Type.Object({
id: Type.Integer(),
name: Type.String(),
display_name: Type.String(),
description: Type.String(),
welcome: Type.String(),
reset_time: Type.String(),
hidden: Type.Boolean(),
always_on_top: Type.Boolean(),
has_password: Type.Boolean(),
mod_pack_name: Type.String(),
factorio_version: Type.String(),
player_count: Type.Integer(),
status: lib.InstanceStatus,
address: Type.String(),
updated_at_ms: Type.Optional(Type.Number()),
is_deleted: Type.Optional(Type.Boolean()),
});
toJSON() {
const json: Static<typeof ServerRecord.jsonSchema> = {
id: this.id,
name: this.name,
display_name: this.displayName,
description: this.description,
welcome: this.welcome,
reset_time: this.resetTime,
hidden: this.hidden,
always_on_top: this.alwaysOnTop,
has_password: this.hasPassword,
mod_pack_name: this.modPackName,
factorio_version: this.factorioVersion,
player_count: this.playerCount,
status: this.status,
address: this.address,
};
if (this.updatedAtMs) {
json.updated_at_ms = this.updatedAtMs;
}
if (this.isDeleted) {
json.is_deleted = true;
}
return json;
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.id,
json.name,
json.display_name,
json.description,
json.welcome,
json.reset_time,
json.hidden,
json.always_on_top,
json.has_password,
json.mod_pack_name,
json.factorio_version,
json.player_count,
json.status,
json.address,
json.updated_at_ms ?? 0,
json.is_deleted ?? false,
);
}
/** True when every field other than updatedAtMs matches */
sameAs(other: ServerRecord) {
return this.id === other.id
&& this.name === other.name
&& this.displayName === other.displayName
&& this.description === other.description
&& this.welcome === other.welcome
&& this.resetTime === other.resetTime
&& this.hidden === other.hidden
&& this.alwaysOnTop === other.alwaysOnTop
&& this.hasPassword === other.hasPassword
&& this.modPackName === other.modPackName
&& this.factorioVersion === other.factorioVersion
&& this.playerCount === other.playerCount
&& this.status === other.status
&& this.address === other.address
&& this.isDeleted === other.isDeleted;
}
}
export class ServerUpdatesEvent {
declare ["constructor"]: typeof ServerUpdatesEvent;
static plugin = "exp_server_list" as const;
static type = "event" as const;
static src = "controller" as const;
static dst = ["control", "instance"] as const;
static permission = "exp_server_list.list" as const;
constructor(
public updates: ServerRecord[],
) {}
static jsonSchema = Type.Object({
updates: Type.Array(ServerRecord.jsonSchema),
});
toJSON() {
return { updates: this.updates.map(server => server.toJSON()) };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.updates.map(server => ServerRecord.fromJSON(server)));
}
}
/** List every instance, hidden ones included. */
export class ServerListRequest {
declare ["constructor"]: typeof ServerListRequest;
static plugin = "exp_server_list" as const;
static type = "request" as const;
static src = ["control", "instance"] as const;
static dst = "controller" as const;
static permission = "exp_server_list.list" as const;
static Response = lib.jsonArray(ServerRecord);
}
+172
View File
@@ -0,0 +1,172 @@
--[[-- ExpServerList
The details of every instance in the cluster, kept up to date by the controller.
The instance plugin sends the whole list when the server starts and each
changed server after that. Hidden servers are kept but left out of
get_servers and find_servers. Servers which are always on top come first.
local ExpServerList = require("modules/exp_server_list")
for _, server in ipairs(ExpServerList.get_servers()) do
game.print(server.name .. " " .. server.player_count)
end
[ExpServerList.on_servers_updated] = function(event) end, -- { server_ids }
]]
local Storage = require("modules/exp_util/storage")
--- @class ExpServerList.Server
--- @field id number The instance id
--- @field name string
--- @field display_name string The name when not set
--- @field description string The server description when not set
--- @field welcome string The controller welcome when not set, can still be empty
--- @field reset_time string
--- @field hidden boolean
--- @field always_on_top boolean
--- @field has_password boolean
--- @field mod_pack_name string
--- @field factorio_version string Empty if it has never started
--- @field player_count number
--- @field status string The clusterio instance status, "running" once players can join
--- @field address string Empty when the host has no public address
--- @class ExpServerList.Update
--- @field current_id number
--- @field replace boolean True when servers is the whole list
--- @field servers (ExpServerList.Server | { id: number, is_deleted: true })[]
--- @class ExpServerList
local ExpServerList = {
--- Raised after servers were added, changed or removed
--- @type EventData.ExpServerList.on_servers_updated
on_servers_updated = script.generate_event_name(),
}
--- @class EventData.ExpServerList.on_servers_updated : EventData
--- @field server_ids number[] Removed servers included
local servers = {} --- @type table<number, ExpServerList.Server>
local current = { id = nil } --- @type { id: number? }
Storage.register({ servers, current }, function(tbl)
servers = tbl[1]
current = tbl[2]
end)
--- @param a ExpServerList.Server
--- @param b ExpServerList.Server
local function by_name(a, b)
if a.always_on_top ~= b.always_on_top then
return a.always_on_top
end
return a.name < b.name
end
--- Get every server that is not hidden, those always on top first then by name
--- @return ExpServerList.Server[]
function ExpServerList.get_servers()
local rtn, count = {}, 0
for _, server in pairs(servers) do
if not server.hidden then
count = count + 1
rtn[count] = server
end
end
table.sort(rtn, by_name)
return rtn
end
--- Get a server by instance id, hidden ones included
--- @param server_id number
--- @return ExpServerList.Server?
function ExpServerList.get_server(server_id)
return servers[server_id]
end
--- True once the controller has sent the list
--- @return boolean
function ExpServerList.is_synced()
return current.id ~= nil
end
--- Get this server, nil until the controller has sent the list
--- @return ExpServerList.Server?
function ExpServerList.get_current()
return current.id and servers[current.id]
end
--- Get the servers that are not hidden whose name, display name or id contains the search, sorted like get_servers
--- @param search string
--- @return ExpServerList.Server[]
function ExpServerList.find_servers(search)
search = search:lower()
local rtn, count = {}, 0
for _, server in ipairs(ExpServerList.get_servers()) do
local str = server.name .. " " .. server.display_name .. " " .. server.id
if str:lower():find(search, 1, true) then
count = count + 1
rtn[count] = server
end
end
return rtn
end
--- True when players can join the server
--- @param server ExpServerList.Server
--- @return boolean
function ExpServerList.is_online(server)
return server.status == "running" and server.address ~= ""
end
--- Ask a player to join a different server
--- @param player LuaPlayer
--- @param server ExpServerList.Server | string A server, or the address of one outside the cluster
--- @param self_requested boolean? Hides the message about being asked to switch
function ExpServerList.request_connection(player, server, self_requested)
local message = self_requested and "exp-server-list.connect-description" or "exp-server-list.connect-description-asked"
if type(server) == "string" then
player.connect_to_server{
address = server,
name = { "exp-server-list.connect-name", { "exp-server-list.unknown-name" } },
description = { message, { "exp-server-list.unknown-description" } },
}
else
player.connect_to_server{
address = server.address,
name = { "exp-server-list.connect-name", server.name },
description = { message, server.description },
}
end
end
--- Called by the instance plugin over rcon
--- @param payload ExpServerList.Update
function ExpServerList.receive_update(payload)
current.id = payload.current_id
local changed = {} --- @type table<number, true>
if payload.replace then
for server_id in pairs(servers) do
changed[server_id] = true
servers[server_id] = nil
end
end
for _, server in ipairs(payload.servers) do
changed[server.id] = true
if server.is_deleted then
servers[server.id] = nil
else
servers[server.id] = server --[[@as ExpServerList.Server]]
end
end
local server_ids, count = {}, 0
for server_id in pairs(changed) do
count = count + 1
server_ids[count] = server_id
end
script.raise_event(ExpServerList.on_servers_updated, { server_ids = server_ids })
end
return ExpServerList
+4
View File
@@ -0,0 +1,4 @@
--- @diagnostic disable: global-in-non-module
-- Access using `/sc exp_server_list.foo()`
exp_server_list = require("modules/exp_server_list/control")
+6
View File
@@ -0,0 +1,6 @@
[exp-server-list]
connect-name=\n[color=orange][font=heading-1]__1__[/font][/color]\n
connect-description=__1__\nPlease press the connect button below to join.
connect-description-asked=__1__\nYou have been asked to switch to a different server.\nPlease press the connect button below to join.
unknown-name=Unknown Server
unknown-description=This server is not run by us, please check the address of the server.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "exp_server_list",
"load": [
],
"require": [
"globals.lua"
],
"dependencies": {
"clusterio": "*",
"exp_util": "*"
}
}
@@ -0,0 +1,2 @@
-- Access the exports from other modules using require("modules/exp_server_list")
return require("modules/exp_server_list/control")
+56
View File
@@ -0,0 +1,56 @@
{
"name": "@expcluster/server-list",
"version": "7.0.1",
"description": "Clusterio plugin sharing the details of every instance with the others.",
"author": "Cooldude2606 <https://github.com/Cooldude2606>",
"license": "MIT",
"repository": "explosivegaming/ExpCluster",
"type": "module",
"main": "dist/node/index.js",
"scripts": {
"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"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@clusterio/controller": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^"
},
"peerDependenciesMeta": {
"@clusterio/controller": {
"optional": true
},
"@clusterio/host": {
"optional": true
}
},
"devDependencies": {
"@clusterio/controller": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@types/node": "catalog:",
"fengari": "^0.1.5",
"tap": "^21.1.0",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
},
"dependencies": {
"@sinclair/typebox": "catalog:",
"@expcluster/lib_util": "workspace:^"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"clusterio",
"clusterio-plugin",
"factorio"
]
}
+225
View File
@@ -0,0 +1,225 @@
import t from "tap";
import * as lib from "@clusterio/lib";
import { Controller, HostRecord, InstanceRecord } from "@clusterio/controller";
import entrypoint from "../dist/node/controller.js";
import { plugin as pluginDeclaration } from "../dist/node/index.js";
import * as messages from "../dist/node/messages.js";
// The plugin's config fields must be defined before an InstanceConfig can set them
lib.addPluginConfigFields([pluginDeclaration]);
for (const Message of pluginDeclaration.messages) {
lib.Link.register(Message);
}
const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
/** Run the plugin on a real controller, which is side effect free while not started. */
async function startPlugin(t2, setup = () => {}) {
const controllerConfig = new lib.ControllerConfig("controller", {
"controller.database_directory": t2.testdir(),
});
const controller = new Controller(logger, [], controllerConfig);
setup(controller);
const broadcasts = [];
controller.subscriptions.broadcast = event => {
if (event instanceof messages.ServerUpdatesEvent) {
broadcasts.push(event);
}
};
const handlers = new Map();
controller.handle = (Class, handler) => handlers.set(Class, handler);
controller.subscriptions.handle = (Class, handler) => handlers.set(Class, handler);
await entrypoint({ controller, logger, plugin: pluginDeclaration, metrics: undefined });
const list = async () => handlers.get(messages.ServerListRequest)(new messages.ServerListRequest());
return {
controller,
broadcasts,
list,
/** The record of one instance */
get: async id => (await list()).find(server => server.id === id),
subscribe: since => handlers.get(messages.ServerUpdatesEvent)(
new lib.SubscriptionRequest("exp_server_list:ServerUpdatesEvent", "subscribe", since),
),
};
}
function addHost(controller, id, publicAddress = "") {
controller.hosts.set(new HostRecord(id, `host-${id}`, "2.0.0", new Map(), true, "", publicAddress));
}
function addInstance(controller, id, { hostId = 1, status = "running", gamePort, factorioVersion, 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, gamePort, factorioVersion);
controller.instances.records.set(record);
controller.instances.addInstanceHooks(record);
return record;
}
const join = (controller, name, instanceId) => controller.users.getOrCreateUser(name).notifyJoin(instanceId);
t.test("entrypoint attaches to the controller hooks", async t2 => {
const { controller } = await startPlugin(t2);
const hooks = [
"instanceStatusChanged",
"instanceConfigFieldChanged",
"controllerConfigFieldChanged",
"playerEvent",
"modPacksUpdated",
];
for (const hook of hooks) {
t2.ok([...controller.hooks[hook].attached].includes("exp_server_list"), `attached to ${hook}`);
}
});
t.test("builds a record for every instance on start", async t2 => {
const { get } = await startPlugin(t2, controller => {
addHost(controller, 1, "example.com");
addInstance(controller, 10, {
gamePort: 34197,
factorioVersion: "2.1.14",
fields: {
"exp_server_list.description": "Weekly reset",
"exp_server_list.hidden": true,
"exp_server_list.always_on_top": true,
"factorio.settings": { description: "From the settings", game_password: "secret" },
},
});
addInstance(controller, 11, {
status: "stopped",
factorioVersion: "latest",
fields: {
"exp_server_list.display_name": "S11",
"factorio.settings": { description: "From the settings", game_password: "" },
},
});
});
const server = await get(10);
t2.equal(server.name, "instance-10");
t2.equal(server.displayName, "instance-10", "display name falls back to the name");
t2.equal(server.description, "Weekly reset", "own description wins over the settings");
t2.equal(server.hidden, true);
t2.equal(server.alwaysOnTop, true);
t2.equal(server.hasPassword, true);
t2.notMatch(JSON.stringify(server.toJSON()), "secret", "the password is not sent");
t2.equal(server.factorioVersion, "2.1.14");
t2.equal(server.address, "example.com:34197", "public address of the host and the game port");
t2.equal(server.status, "running");
const stopped = await get(11);
t2.equal(stopped.displayName, "S11");
t2.equal(stopped.description, "From the settings", "description falls back to the settings");
t2.equal(stopped.hasPassword, false, "an empty password is no password");
t2.equal(stopped.factorioVersion, "", "the latest target is not a version");
t2.equal(stopped.address, "example.com", "host address alone without a game port");
});
t.test("uses the mod pack of the instance or the default", async t2 => {
const { get, controller } = await startPlugin(t2, c => {
addHost(c, 1);
c.modPacks.setMany(lib.ModPack.defaultModPacks.map(pack => lib.ModPack.fromJSON(pack.toJSON())));
const packs = [...c.modPacks.values()];
c.config.set("controller.default_mod_pack_id", packs[0].id);
addInstance(c, 10);
addInstance(c, 11, { fields: { "factorio.mod_pack_id": packs[1].id } });
});
const packs = [...controller.modPacks.values()];
t2.equal((await get(10)).modPackName, packs[0].name, "the default mod pack");
t2.equal((await get(11)).modPackName, packs[1].name, "the instance mod pack");
t2.equal((await get(10)).address, "", "no address while the host has none");
});
t.test("uses the controller welcome when the instance has none", async t2 => {
const { get, controller, broadcasts } = await startPlugin(t2, c => {
addHost(c, 1);
addInstance(c, 10);
addInstance(c, 11, { fields: { "exp_server_list.welcome": "Own welcome" } });
});
t2.equal((await get(10)).welcome, "", "empty when neither is set");
controller.config.set("exp_server_list.welcome", "Cluster welcome");
await controller.hooks.controllerConfigFieldChanged.invoke("exp_server_list.welcome", "");
t2.equal((await get(10)).welcome, "Cluster welcome");
t2.equal((await get(11)).welcome, "Own welcome", "the instance welcome wins");
t2.strictSame(broadcasts.at(-1).updates.map(server => server.id), [10], "only changed records are sent");
});
t.test("counts players only while running", async t2 => {
const { get, controller, broadcasts } = await startPlugin(t2, c => {
addHost(c, 1);
addInstance(c, 10);
});
const instance = controller.instances.get(10);
join(controller, "alice", 10);
join(controller, "bob", 10);
await controller.hooks.playerEvent.invoke(instance, { type: "join", name: "bob" });
t2.equal((await get(10)).playerCount, 2);
t2.equal(broadcasts.at(-1).updates[0].playerCount, 2, "the change is broadcast");
instance.status = "stopped";
await controller.hooks.instanceStatusChanged.invoke(instance, "running");
t2.equal((await get(10)).playerCount, 0, "players left online by a crash are not counted");
});
t.test("follows config, host and status changes", async t2 => {
const { get, controller, broadcasts } = await startPlugin(t2, c => {
addHost(c, 1);
addHost(c, 2);
addInstance(c, 10, { gamePort: 34197 });
addInstance(c, 11, { gamePort: 34198 });
addInstance(c, 12, { hostId: 2 });
});
const instance = controller.instances.getMutable(10);
instance.config.set("instance.name", "Weekly");
t2.equal((await get(10)).name, "Weekly", "renames are picked up");
instance.config.set("exp_server_list.welcome", "Hello");
t2.equal((await get(10)).welcome, "Hello", "plugin fields are picked up");
instance.config.set("factorio.settings", { game_password: "secret" });
t2.equal((await get(10)).hasPassword, true, "server settings are picked up");
addHost(controller, 1, "example.com");
t2.equal((await get(10)).address, "example.com:34197", "the public address once the host sends it");
t2.strictSame(
broadcasts.at(-1).updates.map(server => server.id), [10, 11], "the instances on that host in one update",
);
const count = broadcasts.length;
addHost(controller, 1, "example.com");
t2.equal(broadcasts.length, count, "nothing is broadcast when nothing changed");
// What deleteInstance does once the host has deleted it
controller.instances.records.delete(instance);
await controller.hooks.instanceStatusChanged.invoke(instance, "running");
t2.equal(await get(10), undefined, "deleted instances are removed");
t2.equal(broadcasts.at(-1).updates[0].isDeleted, true, "and broadcast as deleted");
});
t.test("subscriptions get the servers updated since the last request", async t2 => {
const { subscribe, list } = await startPlugin(t2, controller => {
addHost(controller, 1);
addInstance(controller, 10);
});
t2.equal((await subscribe(0)).updates.length, 1);
t2.equal(await subscribe(Date.now() + 1000), null);
t2.equal((await list()).length, 1);
});
t.test("ServerRecord round trips through json", async t2 => {
const record = new messages.ServerRecord(
1, "a", "b", "c", "d", "e", true, true, true, "Base", "2.1.14", 3, "running", "localhost:1", 5, true,
);
t2.same(messages.ServerRecord.fromJSON(record.toJSON()), record);
t2.ok(record.sameAs(messages.ServerRecord.fromJSON(record.toJSON())));
});
+139
View File
@@ -0,0 +1,139 @@
import t from "tap";
import * as lib from "@clusterio/lib";
import { Instance } from "@clusterio/host";
import entrypoint 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
for (const Message of pluginDeclaration.messages) {
lib.Link.register(Message);
}
const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
class TestConnector extends lib.BaseConnector {
constructor() {
super(lib.Address.fromShorthand({ instanceId: 1 }), lib.Address.fromShorthand({ hostId: 1 }));
this.valid = true;
this.connected = true;
this.hasSession = true;
}
send() {}
}
function server(id, name = `instance-${id}`) {
return new messages.ServerRecord(
id, name, name, "", "", "", false, false, false, "Base", "2.1.14", 0, "running", "",
);
}
/** Run the plugin on a real instance with spies on what leaves it. */
async function startPlugin(t2, servers = [server(1), server(2)]) {
const instanceConfig = new lib.InstanceConfig("host");
instanceConfig.set("instance.id", 1);
instanceConfig.set("instance.name", "test");
const instance = new Instance(
{ assignGamePort: () => 1 }, new TestConnector(), t2.testdir(), "factorioDir", instanceConfig
);
const state = { sent: [], rcons: [] };
instance.server = {
handle: () => {},
sendRcon: async command => {
state.rcons.push(command);
return "";
},
};
instance.sendTo = async (dst, request) => {
if (request instanceof lib.InstanceStatusChangedEvent) {
return undefined;
}
state.sent.push(request);
return request instanceof messages.ServerListRequest ? servers : undefined;
};
const handlers = new Map();
instance.handle = (Class, handler) => handlers.set(Class, handler);
await entrypoint({ plugin: { name: "exp_server_list" }, instance, host: {}, logger });
return {
instance,
state,
/** What the host does once rcon is up */
start: async () => {
instance.notifyStatus("starting");
await instance.hooks.start.invoke();
instance.notifyStatus("running");
},
update: event => handlers.get(messages.ServerUpdatesEvent)(event),
connection: event => instance.hooks.controllerConnectionEvent.invoke(event),
clear: () => {
state.sent.length = 0;
state.rcons.length = 0;
},
};
}
/** The payload of a recorded rcon command. */
function decodeRcon(command) {
const match = command.match(/^\/sc exp_server_list\.receive_update\(helpers\.json_to_table\[(=+)\[(.*)\]\1\]\)$/s);
return JSON.parse(match[2]);
}
t.test("start subscribes and replaces the list in lua", async t2 => {
const { start, state } = await startPlugin(t2);
await start();
t2.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes to updates first");
t2.ok(state.sent[1] instanceof messages.ServerListRequest, "then lists the servers");
const payload = decodeRcon(state.rcons[0]);
t2.equal(payload.current_id, 1, "tells lua which server it is");
t2.equal(payload.replace, true);
t2.strictSame(payload.servers.map(entry => entry.display_name), ["instance-1", "instance-2"]);
});
t.test("updates are only sent while the game is up", async t2 => {
const { instance, start, update, clear, state } = await startPlugin(t2);
const event = new messages.ServerUpdatesEvent([server(2, "renamed")]);
await update(event);
t2.strictSame(state.rcons, [], "nothing is sent before start");
await start();
clear();
await update(event);
const payload = decodeRcon(state.rcons[0]);
t2.equal(payload.replace, false, "updates are merged");
t2.strictSame(payload.servers.map(entry => entry.name), ["renamed"]);
instance.notifyStatus("stopped");
clear();
await update(event);
t2.strictSame(state.rcons, [], "nothing is sent after the game stopped");
});
t.test("syncs again after the controller restarts", async t2 => {
const { start, connection, clear, state } = await startPlugin(t2);
await connection("connect");
t2.strictSame(state.sent, [], "nothing happens before start");
await start();
clear();
await connection("resume");
t2.strictSame(state.sent, [], "a resumed session keeps its subscription");
await connection("connect");
t2.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes again");
t2.equal(decodeRcon(state.rcons[0]).replace, true, "and replaces the list");
});
t.test("picks a long bracket the values do not close", async t2 => {
const name = "a]=]b]==]c";
const { start, state } = await startPlugin(t2, [server(3, name)]);
await start();
t2.match(state.rcons[0], "json_to_table[===[", "uses a level not found in the json");
t2.equal(decodeRcon(state.rcons[0]).servers[0].name, name);
});
+7
View File
@@ -0,0 +1,7 @@
import path from "node:path";
import t from "tap";
import { reportLuaTests } from "../../test/lua/runner.js";
const envFile = path.join(import.meta.dirname, "module", "env.lua");
t.test("control.lua", t2 => reportLuaTests(t2, envFile, path.join(import.meta.dirname, "module", "control.lua")));
+129
View File
@@ -0,0 +1,129 @@
local Suite = ... --- @type Suite<ExpServerList.TestEnv>
Suite.test("nothing is known before the first update", function(env)
Suite.eq(env.ServerList.is_synced(), false, "not synced")
Suite.eq(env.ServerList.get_current(), nil, "no current server")
Suite.empty(env.ServerList.get_servers(), "no servers")
end)
Suite.test("is_synced() is true once the list arrives, even without this server in it", function(env)
local List = env.ServerList
List.receive_update{ current_id = 5, replace = true, servers = { env.server(1) } }
Suite.eq(List.is_synced(), true, "synced")
Suite.eq(List.get_current(), nil, "this server is not in the list")
end)
Suite.test("receive_update() with replace swaps the whole list", function(env)
local List = env.ServerList
List.receive_update{ current_id = 1, replace = true, servers = { env.server(1), env.server(2) } }
List.receive_update{ current_id = 1, replace = true, servers = { env.server(1), env.server(3) } }
Suite.eq(assert(List.get_current()).id, 1, "the current server is set")
Suite.eq(List.get_server(2), nil, "servers missing from the new list are removed")
Suite.eq(assert(List.get_server(3)).name, "Server 3", "new servers are added")
local ids = assert(env.events[2]).server_ids
table.sort(ids)
Suite.eq(ids, { 1, 2, 3 }, "the event lists removed and added servers")
end)
Suite.test("receive_update() without replace changes only the given servers", function(env)
local List = env.ServerList
List.receive_update{ current_id = 1, replace = true, servers = { env.server(1), env.server(2), env.server(3) } }
List.receive_update{ current_id = 1, replace = false, servers = {
env.server(2, { player_count = 4 }),
{ id = 3, is_deleted = true },
} }
Suite.eq(assert(List.get_server(1)).name, "Server 1", "untouched servers stay")
Suite.eq(assert(List.get_server(2)).player_count, 4, "changed servers are replaced")
Suite.eq(List.get_server(3), nil, "deleted servers are removed")
local ids = assert(env.events[2]).server_ids
table.sort(ids)
Suite.eq(ids, { 2, 3 }, "the event lists only the given servers")
Suite.eq(assert(env.events[2]).name, List.on_servers_updated, "on_servers_updated is raised")
end)
Suite.test("get_servers() leaves out hidden servers and sorts by name", function(env)
local List = env.ServerList
List.receive_update{ current_id = 1, replace = true, servers = {
env.server(1, { name = "Charlie" }),
env.server(2, { name = "Alpha" }),
env.server(3, { name = "Bravo", hidden = true }),
} }
local names = {}
for index, server in ipairs(List.get_servers()) do
names[index] = server.name
end
Suite.eq(names, { "Alpha", "Charlie" }, "hidden servers are not listed")
Suite.eq(assert(List.get_server(3)).name, "Bravo", "but can still be looked up by id")
end)
Suite.test("get_servers() puts servers always on top first", function(env)
local List = env.ServerList
List.receive_update{ current_id = 1, replace = true, servers = {
env.server(1, { name = "Alpha" }),
env.server(2, { name = "Delta", always_on_top = true }),
env.server(3, { name = "Bravo" }),
env.server(4, { name = "Charlie", always_on_top = true }),
} }
local names = {}
for index, server in ipairs(List.get_servers()) do
names[index] = server.name
end
Suite.eq(names, { "Charlie", "Delta", "Alpha", "Bravo" }, "on top by name, then the rest by name")
end)
Suite.test("find_servers() matches name, display name and id ignoring case", function(env)
local List = env.ServerList
List.receive_update{ current_id = 1, replace = true, servers = {
env.server(1, { name = "Public Weekly", display_name = "S1" }),
env.server(2, { name = "Modded", display_name = "S2" }),
env.server(37, { name = "Event", display_name = "Event" }),
} }
local function ids(search)
local rtn = {}
for index, server in ipairs(List.find_servers(search)) do
rtn[index] = server.id
end
return rtn
end
Suite.eq(ids("weekly"), { 1 }, "by name")
Suite.eq(ids("s2"), { 2 }, "by display name")
Suite.eq(ids("37"), { 37 }, "by id")
Suite.eq(ids("nothing"), {}, "no match")
end)
Suite.test("is_online() needs a running server with an address", function(env)
local List = env.ServerList
Suite.eq(List.is_online(env.server(1)), true, "running with an address")
Suite.eq(List.is_online(env.server(1, { status = "stopped" })), false, "stopped")
Suite.eq(List.is_online(env.server(1, { address = "" })), false, "no address")
end)
Suite.test("request_connection() connects to a server or an address", function(env)
local List = env.ServerList
local alice = env.add_player{ name = "alice" }
List.request_connection(alice, env.server(2, { description = "Weekly reset" }), true)
List.request_connection(alice, "example.com:34197")
Suite.eq(env.connections, {
{
to = "alice",
address = "localhost:34198",
name = { "exp-server-list.connect-name", "Server 2" },
description = { "exp-server-list.connect-description", "Weekly reset" },
},
{
to = "alice",
address = "example.com:34197",
name = { "exp-server-list.connect-name", { "exp-server-list.unknown-name" } },
description = { "exp-server-list.connect-description-asked", { "exp-server-list.unknown-description" } },
},
}, "players asked by someone else are told so")
end)
return Suite.run()
+65
View File
@@ -0,0 +1,65 @@
--[[-- Test environment for module/control.lua
Extends the shared stubs with a fresh copy of the server list module, a stub
of exp_util storage, and a helper to build the servers the instance plugin
sends.
Run as a chunk by test/lua/runner.js, receiving the shared folder. The suite
returned here becomes `...` in each test file.
]]
local shared_root = ... --- @type string
local source = assert(debug.getinfo(1, "S")).source:gsub("\\", "/")
local plugin_root = assert(source:match("^@(.*)/test/module/env%.lua$"))
local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type Framework
--- @class ExpServerList.TestEnv : Stubs
--- @field ServerList ExpServerList A fresh copy of the server list module
--- @field server fun(id: number, fields: table?): ExpServerList.Server A server as the controller sends it
--- @field connections table[] Calls to connect_to_server, with the player name as `to`
return Framework.suite(function(env)
--- @cast env ExpServerList.TestEnv
env.extend_requires{
["modules/exp_util/storage"] = {
register = function() end,
},
}
env.ServerList = assert(loadfile(plugin_root .. "/module/control.lua"))() --- @type ExpServerList
function env.server(id, fields)
local server = {
id = id,
name = "Server " .. id,
display_name = "S" .. id,
description = "",
welcome = "",
reset_time = "",
hidden = false,
always_on_top = false,
has_password = false,
mod_pack_name = "Base",
factorio_version = "2.1.14",
player_count = 0,
status = "running",
address = "localhost:" .. (34196 + id),
}
for key, value in pairs(fields or {}) do
server[key] = value
end
return server
end
env.connections = {}
local add_player = env.add_player
function env.add_player(params)
local player = add_player(params)
rawset(player, "connect_to_server", function(opts)
opts.to = player.name
env.connections[#env.connections + 1] = opts
end)
return player
end
return env
end)
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.browser.json",
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.browser.json" }
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.node.json",
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
}
View File
Whitespace-only changes.
+28
View File
@@ -0,0 +1,28 @@
"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_server_list",
library: { type: "window", name: "plugin_exp_server_list" },
exposes: {
"./": "./index.ts",
"./package.json": "./package.json",
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
},
}),
],
});
+1
View File
@@ -10,6 +10,7 @@
{ "path": "./exp_reports/" },
{ "path": "./exp_roles/" },
{ "path": "./exp_scenario/" },
{ "path": "./exp_server_list/" },
{ "path": "./exp_server_ups/" },
{ "path": "./exp_util/" },
],