mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-13 08:04:53 +09:00
Add exp_server_list to replace expcore/external
The controller keeps a record per instance with its name, mod pack, Factorio version, players online, status and address, and the instance plugin pushes the list into lua over rcon. The readme and the connect commands read it through modules/exp_server_list, so expcore/external and its hardcoded server fallback are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b19bbbe5f3
commit
4d136853c9
31 files changed
+1367
-307
No files matched your search
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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.get_current() 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)
|
||||
@@ -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
|
||||
@@ -84,16 +84,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 +107,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 +119,30 @@ 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 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 }
|
||||
local player_count = server and server.player_count or 0
|
||||
button.tooltip = { "exp-gui_readme.servers-connect-" .. status, detail, player_count }
|
||||
|
||||
if status == "Offline" or status == "Current" then
|
||||
button.enabled = false
|
||||
@@ -145,11 +154,6 @@ function Elements.join_server.refresh(button, wrong_version)
|
||||
button.sprite = "utility/shuffle"
|
||||
button.hovered_sprite = "utility/shuffle"
|
||||
|
||||
elseif status == "Password" then
|
||||
button.enabled = true
|
||||
button.sprite = "utility/warning_white"
|
||||
button.hovered_sprite = "utility/warning"
|
||||
|
||||
elseif status == "Modded" then
|
||||
button.enabled = true
|
||||
button.sprite = "utility/downloading_white"
|
||||
@@ -162,21 +166,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 +184,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,
|
||||
reset_time = current.reset_time ~= "" and current.reset_time or "Not Set",
|
||||
}
|
||||
end
|
||||
|
||||
local container = parent.add{ type = "flow", direction = "vertical" }
|
||||
@@ -317,22 +316,11 @@ 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
|
||||
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)
|
||||
end
|
||||
|
||||
local external_links = Elements.title_table(scroll_pane, 235, { "exp-gui_readme.servers-external" }, 2)
|
||||
@@ -707,8 +695,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,
|
||||
}
|
||||
}
|
||||
@@ -350,28 +350,11 @@ 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-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.
|
||||
|
||||
@@ -339,26 +339,9 @@ 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__
|
||||
servers-connect-Password=該服務器需要密碼
|
||||
servers-connect-Modded=該服務器需要下載模組
|
||||
servers-connect-Online=該服務器在線
|
||||
servers-external=外部連結
|
||||
|
||||
@@ -339,26 +339,9 @@ 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__
|
||||
servers-connect-Password=該服務器需要密碼
|
||||
servers-connect-Modded=該服務器需要下載模組
|
||||
servers-connect-Online=該服務器在線
|
||||
servers-external=外部連結
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"exp_util": "*",
|
||||
"exp_roles": "*",
|
||||
"exp_reports": "*",
|
||||
"exp_server_list": "*",
|
||||
"exp_gui": "*",
|
||||
"exp_commands": "*"
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@expcluster/lib_gui": "workspace:^",
|
||||
"@expcluster/permission-groups": "workspace:^",
|
||||
"@expcluster/roles": "workspace:^",
|
||||
"@expcluster/server-list": "workspace:^",
|
||||
"@sinclair/typebox": "catalog:"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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. Nothing is saved, the records are rebuilt from the instances when the controller starts.
|
||||
|
||||
## Config
|
||||
|
||||
All fields are on the instance.
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `exp_server_list.short_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. |
|
||||
| `exp_server_list.welcome` | | Message shown on the welcome tab of the readme. |
|
||||
| `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. |
|
||||
|
||||
## Lua
|
||||
|
||||
```lua
|
||||
local ExpServerList = require("modules/exp_server_list")
|
||||
|
||||
ExpServerList.get_servers() -- Every server that is not hidden, sorted by name
|
||||
ExpServerList.get_server(instance_id)
|
||||
ExpServerList.get_current() -- nil until the controller has sent the list
|
||||
ExpServerList.find_servers("weekly") -- Matches name, short 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
|
||||
|
||||
Controller plugins can read the records with `ControllerPlugin.get(controller).servers`. Web plugins and control connections can subscribe to `ServerUpdatesEvent` with the `exp_server_list.list` permission.
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { Controller, ControllerPluginContext, HostRecord, InstanceRecord } from "@clusterio/controller";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
const loaded = new WeakMap<Controller, ControllerPlugin>();
|
||||
|
||||
/** Instance config fields which change the record */
|
||||
const recordFields = new Set([
|
||||
"instance.name",
|
||||
"instance.assigned_host",
|
||||
"factorio.mod_pack_id",
|
||||
"exp_server_list.short_name",
|
||||
"exp_server_list.description",
|
||||
"exp_server_list.welcome",
|
||||
"exp_server_list.reset_time",
|
||||
"exp_server_list.hidden",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Keeps a record for every instance and broadcasts the ones which change.
|
||||
*
|
||||
* Records are rebuilt from controller state on start, nothing is saved.
|
||||
*/
|
||||
export class ControllerPlugin {
|
||||
/** The plugin loaded on a controller, for other plugins to read the list. */
|
||||
static get(controller: Controller) {
|
||||
return loaded.get(controller);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
public controller: Controller,
|
||||
public logger: lib.Logger,
|
||||
public name: string,
|
||||
public servers: lib.SubscribableDatastore<messages.ServerRecord>,
|
||||
) {}
|
||||
|
||||
static async fromContext(context: ControllerPluginContext) {
|
||||
const controller = context.controller;
|
||||
const plugin = new ControllerPlugin(
|
||||
controller, context.logger, context.plugin.name, new lib.SubscribableDatastore(),
|
||||
);
|
||||
loaded.set(controller, plugin);
|
||||
|
||||
plugin.refreshAll();
|
||||
plugin.servers.on("update", plugin.serversUpdated.bind(plugin));
|
||||
controller.hosts.on("update", plugin.hostsUpdated.bind(plugin));
|
||||
|
||||
controller.subscriptions.handle(messages.ServerUpdatesEvent, plugin.handleServerSubscription.bind(plugin));
|
||||
controller.handle(messages.ServerListRequest, plugin.handleServerListRequest.bind(plugin));
|
||||
|
||||
const hooks = controller.hooks;
|
||||
hooks.instanceStatusChanged.attach(plugin.name, plugin.onInstanceStatusChanged.bind(plugin));
|
||||
hooks.instanceConfigFieldChanged.attach(plugin.name, plugin.onInstanceConfigFieldChanged.bind(plugin));
|
||||
hooks.controllerConfigFieldChanged.attach(plugin.name, plugin.onControllerConfigFieldChanged.bind(plugin));
|
||||
hooks.playerEvent.attach(plugin.name, plugin.onPlayerEvent.bind(plugin));
|
||||
hooks.modPacksUpdated.attach(plugin.name, plugin.onModPacksUpdated.bind(plugin));
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async onInstanceStatusChanged(instance: InstanceRecord) {
|
||||
this.refresh(instance);
|
||||
}
|
||||
|
||||
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string) {
|
||||
if (recordFields.has(field)) {
|
||||
this.refresh(instance);
|
||||
}
|
||||
}
|
||||
|
||||
async onControllerConfigFieldChanged(field: string) {
|
||||
if (field === "controller.default_mod_pack_id") {
|
||||
this.refreshAll();
|
||||
}
|
||||
}
|
||||
|
||||
async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) {
|
||||
if (event.type === "join" || event.type === "leave") {
|
||||
this.refresh(instance);
|
||||
}
|
||||
}
|
||||
|
||||
async onModPacksUpdated() {
|
||||
this.refreshAll();
|
||||
}
|
||||
|
||||
/** The public address of a host arrives after it connects */
|
||||
hostsUpdated(hosts: HostRecord[]) {
|
||||
const hostIds = new Set(hosts.map(host => host.id));
|
||||
for (const instance of this.controller.instances.values()) {
|
||||
const hostId = instance.config.get("instance.assigned_host");
|
||||
if (hostId !== null && hostIds.has(hostId)) {
|
||||
this.refresh(instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshAll() {
|
||||
for (const instance of this.controller.instances.values()) {
|
||||
this.refresh(instance);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild the record of an instance, and store it if anything changed */
|
||||
refresh(instance: InstanceRecord) {
|
||||
const existing = this.servers.get(instance.id);
|
||||
if (instance.isDeleted) {
|
||||
if (existing) {
|
||||
this.servers.delete(existing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const record = this.buildRecord(instance);
|
||||
if (!existing || !existing.sameAs(record)) {
|
||||
this.servers.set(record);
|
||||
}
|
||||
}
|
||||
|
||||
buildRecord(instance: InstanceRecord) {
|
||||
const config = instance.config;
|
||||
const running = instance.status === "running";
|
||||
return new messages.ServerRecord(
|
||||
instance.id,
|
||||
config.get("instance.name"),
|
||||
config.get("exp_server_list.short_name") || config.get("instance.name"),
|
||||
config.get("exp_server_list.description"),
|
||||
config.get("exp_server_list.welcome"),
|
||||
config.get("exp_server_list.reset_time"),
|
||||
config.get("exp_server_list.hidden"),
|
||||
this.modPackName(instance),
|
||||
instance.factorioVersion === "latest" ? "" : instance.factorioVersion ?? "",
|
||||
running ? this.playerCount(instance.id) : 0,
|
||||
instance.status,
|
||||
this.address(instance),
|
||||
);
|
||||
}
|
||||
|
||||
modPackName(instance: InstanceRecord) {
|
||||
const id = instance.config.get("factorio.mod_pack_id")
|
||||
?? this.controller.config.get("controller.default_mod_pack_id");
|
||||
return id !== null && id !== undefined ? this.controller.modPacks.get(id)?.name ?? "" : "";
|
||||
}
|
||||
|
||||
playerCount(instanceId: number) {
|
||||
let count = 0;
|
||||
for (const user of this.controller.users.records.values()) {
|
||||
if (user.instances.has(instanceId)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Same as the address the web ui shows for the instance */
|
||||
address(instance: InstanceRecord) {
|
||||
const hostId = instance.config.get("instance.assigned_host");
|
||||
const publicAddress = hostId !== null ? this.controller.hosts.get(hostId)?.publicAddress : undefined;
|
||||
if (!publicAddress) {
|
||||
return "";
|
||||
}
|
||||
return instance.gamePort === undefined ? publicAddress : `${publicAddress}:${instance.gamePort}`;
|
||||
}
|
||||
|
||||
serversUpdated(updates: messages.ServerRecord[]) {
|
||||
this.controller.subscriptions.broadcast(new messages.ServerUpdatesEvent(updates));
|
||||
}
|
||||
|
||||
async handleServerSubscription(request: lib.SubscriptionRequest) {
|
||||
const updates = [...this.servers.values()].filter(server => server.updatedAtMs > request.lastRequestTimeMs);
|
||||
return updates.length ? new messages.ServerUpdatesEvent(updates) : null;
|
||||
}
|
||||
|
||||
async handleServerListRequest() {
|
||||
return [...this.servers.values()];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function (context: ControllerPluginContext) {
|
||||
await ControllerPlugin.fromContext(context);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
declare module "@clusterio/lib" {
|
||||
export interface InstanceConfigFields {
|
||||
"exp_server_list.short_name": string;
|
||||
"exp_server_list.description": string;
|
||||
"exp_server_list.welcome": string;
|
||||
"exp_server_list.reset_time": string;
|
||||
"exp_server_list.hidden": 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",
|
||||
instanceConfigFields: {
|
||||
"exp_server_list.short_name": {
|
||||
title: "Short 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",
|
||||
type: "string",
|
||||
initialValue: "",
|
||||
},
|
||||
"exp_server_list.welcome": {
|
||||
title: "Welcome",
|
||||
description: "Message shown on the welcome tab of the readme",
|
||||
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,
|
||||
},
|
||||
},
|
||||
|
||||
messages: [
|
||||
messages.ServerUpdatesEvent,
|
||||
messages.ServerListRequest,
|
||||
],
|
||||
|
||||
permissions: [
|
||||
{
|
||||
name: "exp_server_list.list",
|
||||
title: "List servers",
|
||||
description: "View the server list and subscribe to its updates",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Instance, 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. */
|
||||
export class InstancePlugin {
|
||||
/** Rcon is up and the lua side can take updates */
|
||||
started = false;
|
||||
|
||||
private constructor(
|
||||
public instance: Instance,
|
||||
public logger: lib.Logger,
|
||||
public name: string,
|
||||
) {}
|
||||
|
||||
static async fromContext(context: InstancePluginContext) {
|
||||
const instance = context.instance;
|
||||
const plugin = new InstancePlugin(instance, context.logger, context.plugin.name);
|
||||
|
||||
instance.handle(messages.ServerUpdatesEvent, plugin.handleServerUpdatesEvent.bind(plugin));
|
||||
instance.hooks.start.attach(plugin.name, plugin.onStart.bind(plugin));
|
||||
instance.hooks.exit.attach(plugin.name, plugin.onExit.bind(plugin));
|
||||
instance.hooks.controllerConnectionEvent.attach(plugin.name, plugin.onControllerConnectionEvent.bind(plugin));
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
this.started = true;
|
||||
await this.syncServers();
|
||||
}
|
||||
|
||||
async onExit() {
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
/** A new session means the controller restarted and forgot the subscription. */
|
||||
async onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
|
||||
if (event === "connect" && this.started) {
|
||||
await this.syncServers();
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe, then replace the whole list */
|
||||
async syncServers() {
|
||||
await this.instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||
`exp_server_list:${messages.ServerUpdatesEvent.name}`, "subscribe", Date.now(),
|
||||
));
|
||||
const servers = await this.instance.sendTo("controller", new messages.ServerListRequest());
|
||||
await this.luaSendServers(servers, true);
|
||||
}
|
||||
|
||||
async handleServerUpdatesEvent(event: messages.ServerUpdatesEvent) {
|
||||
if (this.started) {
|
||||
await this.luaSendServers(event.updates, false);
|
||||
}
|
||||
}
|
||||
|
||||
async luaSendServers(servers: messages.ServerRecord[], replace: boolean) {
|
||||
const json = JSON.stringify({
|
||||
current_id: this.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 this.instance.sendRcon(
|
||||
`/sc exp_server_list.receive_update(helpers.json_to_table[${level}[${json}]${level}])`, true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function (context: InstancePluginContext) {
|
||||
await InstancePlugin.fromContext(context);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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 shortName: string,
|
||||
public description: string,
|
||||
public welcome: string,
|
||||
public resetTime: string,
|
||||
public hidden: 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(),
|
||||
short_name: Type.String(),
|
||||
description: Type.String(),
|
||||
welcome: Type.String(),
|
||||
reset_time: Type.String(),
|
||||
hidden: 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,
|
||||
short_name: this.shortName,
|
||||
description: this.description,
|
||||
welcome: this.welcome,
|
||||
reset_time: this.resetTime,
|
||||
hidden: this.hidden,
|
||||
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.short_name,
|
||||
json.description,
|
||||
json.welcome,
|
||||
json.reset_time,
|
||||
json.hidden,
|
||||
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.shortName === other.shortName
|
||||
&& this.description === other.description
|
||||
&& this.welcome === other.welcome
|
||||
&& this.resetTime === other.resetTime
|
||||
&& this.hidden === other.hidden
|
||||
&& 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);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
--[[-- 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.
|
||||
|
||||
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 short_name string The name when not set
|
||||
--- @field description string
|
||||
--- @field welcome string
|
||||
--- @field reset_time string
|
||||
--- @field hidden 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)
|
||||
return a.name < b.name
|
||||
end
|
||||
|
||||
--- Get every server that is not hidden, sorted 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
|
||||
|
||||
--- 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, short name or id contains the search, sorted by name
|
||||
--- @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.short_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
|
||||
@@ -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")
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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:^"
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import t from "tap";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Controller, HostRecord, InstanceRecord } from "@clusterio/controller";
|
||||
import entrypoint, { ControllerPlugin } 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: () => {} };
|
||||
|
||||
/** Build the plugin around 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 context = { controller, logger, plugin: pluginDeclaration, metrics: undefined };
|
||||
const plugin = await ControllerPlugin.fromContext(context);
|
||||
return { plugin, controller, broadcasts };
|
||||
}
|
||||
|
||||
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 subscription = since => new lib.SubscriptionRequest("exp_server_list:ServerUpdatesEvent", "subscribe", since);
|
||||
const join = (controller, name, instanceId) => controller.users.getOrCreateUser(name).notifyJoin(instanceId);
|
||||
|
||||
t.test("entrypoint", async t2 => {
|
||||
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 });
|
||||
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}`);
|
||||
}
|
||||
t2.ok(ControllerPlugin.get(controller), "other plugins can find it");
|
||||
});
|
||||
|
||||
t.test("class ControllerPlugin", t2 => {
|
||||
t2.test("builds a record for every instance on start", async t3 => {
|
||||
const { plugin } = await startPlugin(t3, 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,
|
||||
},
|
||||
});
|
||||
addInstance(controller, 11, {
|
||||
status: "stopped",
|
||||
factorioVersion: "latest",
|
||||
fields: { "exp_server_list.short_name": "S11" },
|
||||
});
|
||||
});
|
||||
|
||||
const server = plugin.servers.get(10);
|
||||
t3.equal(server.name, "instance-10");
|
||||
t3.equal(server.shortName, "instance-10", "short name falls back to the name");
|
||||
t3.equal(server.description, "Weekly reset");
|
||||
t3.equal(server.hidden, true);
|
||||
t3.equal(server.factorioVersion, "2.1.14");
|
||||
t3.equal(server.address, "example.com:34197", "public address of the host and the game port");
|
||||
t3.equal(server.status, "running");
|
||||
|
||||
const stopped = plugin.servers.get(11);
|
||||
t3.equal(stopped.shortName, "S11");
|
||||
t3.equal(stopped.factorioVersion, "", "the latest target is not a version");
|
||||
t3.equal(stopped.address, "example.com", "host address alone without a game port");
|
||||
});
|
||||
|
||||
t2.test("uses the mod pack of the instance or the default", async t3 => {
|
||||
const { plugin, controller } = await startPlugin(t3, 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()];
|
||||
t3.equal(plugin.servers.get(10).modPackName, packs[0].name, "the default mod pack");
|
||||
t3.equal(plugin.servers.get(11).modPackName, packs[1].name, "the instance mod pack");
|
||||
t3.equal(plugin.servers.get(10).address, "", "no address while the host has none");
|
||||
});
|
||||
|
||||
t2.test("counts players only while running", async t3 => {
|
||||
const { plugin, controller, broadcasts } = await startPlugin(t3, 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" });
|
||||
t3.equal(plugin.servers.get(10).playerCount, 2);
|
||||
t3.equal(broadcasts.at(-1).updates[0].playerCount, 2, "the change is broadcast");
|
||||
|
||||
instance.status = "stopped";
|
||||
await controller.hooks.instanceStatusChanged.invoke(instance, "running");
|
||||
t3.equal(plugin.servers.get(10).playerCount, 0, "players left online by a crash are not counted");
|
||||
});
|
||||
|
||||
t2.test("follows config, host and status changes", async t3 => {
|
||||
const { plugin, controller, broadcasts } = await startPlugin(t3, c => {
|
||||
addHost(c, 1);
|
||||
addInstance(c, 10, { gamePort: 34197 });
|
||||
});
|
||||
const instance = controller.instances.getMutable(10);
|
||||
|
||||
instance.config.set("instance.name", "Weekly");
|
||||
t3.equal(plugin.servers.get(10).name, "Weekly", "renames are picked up");
|
||||
|
||||
instance.config.set("exp_server_list.welcome", "Hello");
|
||||
t3.equal(plugin.servers.get(10).welcome, "Hello", "plugin fields are picked up");
|
||||
|
||||
addHost(controller, 1, "example.com");
|
||||
t3.equal(plugin.servers.get(10).address, "example.com:34197", "the public address once the host sends it");
|
||||
|
||||
const count = broadcasts.length;
|
||||
addHost(controller, 1, "example.com");
|
||||
t3.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");
|
||||
t3.equal(plugin.servers.get(10), undefined, "deleted instances are removed");
|
||||
t3.equal(broadcasts.at(-1).updates[0].isDeleted, true, "and broadcast as deleted");
|
||||
});
|
||||
|
||||
t2.test(".handleServerSubscription() sends servers updated since the last request", async t3 => {
|
||||
const { plugin } = await startPlugin(t3, controller => {
|
||||
addHost(controller, 1);
|
||||
addInstance(controller, 10);
|
||||
});
|
||||
const all = await plugin.handleServerSubscription(subscription(0));
|
||||
t3.equal(all.updates.length, 1);
|
||||
const none = await plugin.handleServerSubscription(subscription(Date.now() + 1000));
|
||||
t3.equal(none, null);
|
||||
t3.equal((await plugin.handleServerListRequest()).length, 1);
|
||||
});
|
||||
|
||||
t2.end();
|
||||
});
|
||||
|
||||
t.test("ServerRecord round trips through json", async t2 => {
|
||||
const record = new messages.ServerRecord(
|
||||
1, "a", "b", "c", "d", "e", 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())));
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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
|
||||
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, "Base", "2.1.14", 0, "running", "");
|
||||
}
|
||||
|
||||
/** Build a plugin around 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) => {
|
||||
state.sent.push(request);
|
||||
return request instanceof messages.ServerListRequest ? servers : undefined;
|
||||
};
|
||||
|
||||
const context = { plugin: { name: "exp_server_list" }, instance, host: {}, logger };
|
||||
const plugin = await InstancePlugin.fromContext(context);
|
||||
return { plugin, instance, state };
|
||||
}
|
||||
|
||||
/** 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("class InstancePlugin", t2 => {
|
||||
t2.test(".onStart() subscribes and replaces the list in lua", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
await plugin.onStart();
|
||||
|
||||
t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes to updates first");
|
||||
t3.ok(state.sent[1] instanceof messages.ServerListRequest, "then lists the servers");
|
||||
const payload = decodeRcon(state.rcons[0]);
|
||||
t3.equal(payload.current_id, 1, "tells lua which server it is");
|
||||
t3.equal(payload.replace, true);
|
||||
t3.strictSame(payload.servers.map(entry => entry.short_name), ["instance-1", "instance-2"]);
|
||||
});
|
||||
|
||||
t2.test(".handleServerUpdatesEvent() only sends while the game is up", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
const event = new messages.ServerUpdatesEvent([server(2, "renamed")]);
|
||||
|
||||
await plugin.handleServerUpdatesEvent(event);
|
||||
t3.strictSame(state.rcons, [], "nothing is sent before start");
|
||||
|
||||
await plugin.onStart();
|
||||
state.rcons.length = 0;
|
||||
await plugin.handleServerUpdatesEvent(event);
|
||||
const payload = decodeRcon(state.rcons[0]);
|
||||
t3.equal(payload.replace, false, "updates are merged");
|
||||
t3.strictSame(payload.servers.map(entry => entry.name), ["renamed"]);
|
||||
|
||||
await plugin.onExit();
|
||||
state.rcons.length = 0;
|
||||
await plugin.handleServerUpdatesEvent(event);
|
||||
t3.strictSame(state.rcons, [], "nothing is sent after exit");
|
||||
});
|
||||
|
||||
t2.test(".onControllerConnectionEvent() syncs again after the controller restarts", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
await plugin.onControllerConnectionEvent("connect");
|
||||
t3.strictSame(state.sent, [], "nothing happens before start");
|
||||
|
||||
await plugin.onStart();
|
||||
state.sent.length = 0;
|
||||
state.rcons.length = 0;
|
||||
await plugin.onControllerConnectionEvent("resume");
|
||||
t3.strictSame(state.sent, [], "a resumed session keeps its subscription");
|
||||
|
||||
await plugin.onControllerConnectionEvent("connect");
|
||||
t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes again");
|
||||
t3.equal(decodeRcon(state.rcons[0]).replace, true, "and replaces the list");
|
||||
});
|
||||
|
||||
t2.test(".luaSendServers() picks a long bracket the values do not close", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
const name = "a]=]b]==]c";
|
||||
await plugin.luaSendServers([server(3, name)], false);
|
||||
t3.match(state.rcons[0], "json_to_table[===[", "uses a level not found in the json");
|
||||
t3.equal(decodeRcon(state.rcons[0]).servers[0].name, name);
|
||||
});
|
||||
|
||||
t2.end();
|
||||
});
|
||||
@@ -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")));
|
||||
@@ -0,0 +1,105 @@
|
||||
local Suite = ... --- @type Suite<ExpServerList.TestEnv>
|
||||
|
||||
Suite.test("nothing is known before the first update", function(env)
|
||||
Suite.eq(env.ServerList.get_current(), nil, "no current server")
|
||||
Suite.empty(env.ServerList.get_servers(), "no servers")
|
||||
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(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(List.get_server(3).name, "Server 3", "new servers are added")
|
||||
local ids = 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(List.get_server(1).name, "Server 1", "untouched servers stay")
|
||||
Suite.eq(List.get_server(2).player_count, 4, "changed servers are replaced")
|
||||
Suite.eq(List.get_server(3), nil, "deleted servers are removed")
|
||||
local ids = env.events[2].server_ids
|
||||
table.sort(ids)
|
||||
Suite.eq(ids, { 2, 3 }, "the event lists only the given servers")
|
||||
Suite.eq(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(List.get_server(3).name, "Bravo", "but can still be looked up by id")
|
||||
end)
|
||||
|
||||
Suite.test("find_servers() matches name, short 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", short_name = "S1" }),
|
||||
env.server(2, { name = "Modded", short_name = "S2" }),
|
||||
env.server(37, { name = "Event", short_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 short 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()
|
||||
@@ -0,0 +1,63 @@
|
||||
--[[-- 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,
|
||||
short_name = "S" .. id,
|
||||
description = "",
|
||||
welcome = "",
|
||||
reset_time = "",
|
||||
hidden = 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)
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../tsconfig.browser.json",
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.browser.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.node.json",
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
}
|
||||
Whitespace-only changes.
@@ -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 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -10,6 +10,7 @@
|
||||
{ "path": "./exp_reports/" },
|
||||
{ "path": "./exp_roles/" },
|
||||
{ "path": "./exp_scenario/" },
|
||||
{ "path": "./exp_server_list/" },
|
||||
{ "path": "./exp_server_ups/" },
|
||||
{ "path": "./exp_util/" },
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user