Browse Source

Take geographic distance into account for server list ordering (#12790)

sfan5 1 year ago
parent
commit
87051fca26

+ 21 - 0
builtin/mainmenu/common.lua

@@ -45,6 +45,27 @@ local function configure_selected_world_params(idx)
 	end
 end
 
+-- retrieved from https://wondernetwork.com/pings with (hopefully) representative cities
+-- Amsterdam, Auckland, Brasilia, Denver, Lagos, Singapore
+local latency_matrix = {
+	["AF"] = { ["AS"]=258, ["EU"]=100, ["NA"]=218, ["OC"]=432, ["SA"]=308 },
+	["AS"] = { ["EU"]=168, ["NA"]=215, ["OC"]=125, ["SA"]=366 },
+	["EU"] = { ["NA"]=120, ["OC"]=298, ["SA"]=221 },
+	["NA"] = { ["OC"]=202, ["SA"]=168 },
+	["OC"] = { ["SA"]=411 },
+	["SA"] = {}
+}
+function estimate_continent_latency(own, spec)
+	local there = spec.geo_continent
+	if not own or not there then
+		return nil
+	end
+	if own == there then
+		return 0
+	end
+	return latency_matrix[there][own] or latency_matrix[own][there]
+end
+
 function render_serverlist_row(spec)
 	local text = ""
 	if spec.name then

+ 114 - 11
builtin/mainmenu/serverlistmgr.lua

@@ -15,28 +15,101 @@
 --with this program; if not, write to the Free Software Foundation, Inc.,
 --51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
-serverlistmgr = {}
+serverlistmgr = {
+	-- continent code we detected for ourselves
+	my_continent = core.get_once("continent"),
+
+	-- list of locally favorites servers
+	favorites = nil,
+
+	-- list of servers fetched from public list
+	servers = nil,
+}
 
 --------------------------------------------------------------------------------
+-- Efficient data structure for normalizing arbitrary scores attached to objects
+-- e.g. {{"a", 3.14}, {"b", 3.14}, {"c", 20}, {"d", 0}}
+--   -> {["d"] = 0, ["a"] = 0.5, ["b"] = 0.5, ["c"] = 1}
+local Normalizer = {}
+
+function Normalizer:new()
+	local t = {
+		map = {}
+	}
+	setmetatable(t, self)
+	self.__index = self
+	return t
+end
+
+function Normalizer:push(obj, score)
+	if not self.map[score] then
+		self.map[score] = {}
+	end
+	local t = self.map[score]
+	t[#t + 1] = obj
+end
+
+function Normalizer:calc()
+	local list = {}
+	for k, _ in pairs(self.map) do
+		list[#list + 1] = k
+	end
+	table.sort(list)
+	local ret = {}
+	for i, k in ipairs(list) do
+		local score = #list == 1 and 1 or ( (i - 1) / (#list - 1) )
+		for _, obj in ipairs(self.map[k]) do
+			ret[obj] = score
+		end
+	end
+	return ret
+end
+
+--------------------------------------------------------------------------------
+-- how much the pre-sorted server list contributes to the final ranking
+local WEIGHT_SORT = 2
+-- how much the estimated latency contributes to the final ranking
+local WEIGHT_LATENCY = 1
+
 local function order_server_list(list)
-	local res = {}
-	--orders the favorite list after support
-	for i = 1, #list do
-		local fav = list[i]
-		if is_server_protocol_compat(fav.proto_min, fav.proto_max) then
-			res[#res + 1] = fav
+	-- calculate the scores
+	local s1 = Normalizer:new()
+	local s2 = Normalizer:new()
+	for i, fav in ipairs(list) do
+		-- first: the original position
+		s1:push(fav, #list - i)
+		-- second: estimated latency
+		local ping = (fav.ping or 0) * 1000
+		if ping < 400 then
+			-- If ping is over 400ms, assume the server has latency issues
+			-- anyway and don't estimate
+			ping = estimate_continent_latency(serverlistmgr.my_continent, fav) or ping
 		end
+		s2:push(fav, -ping)
 	end
+	s1 = s1:calc()
+	s2 = s2:calc()
+
+	-- make a shallow copy and pre-calculate ordering
+	local res, order = {}, {}
 	for i = 1, #list do
 		local fav = list[i]
-		if not is_server_protocol_compat(fav.proto_min, fav.proto_max) then
-			res[#res + 1] = fav
-		end
+		res[i] = fav
+
+		local n = s1[fav] * WEIGHT_SORT + s2[fav] * WEIGHT_LATENCY
+		order[fav] = n
 	end
+
+	-- now sort the list
+	table.sort(res, function(fav1, fav2)
+		return order[fav1] > order[fav2]
+	end)
+
 	return res
 end
 
 local public_downloading = false
+local geoip_downloading = false
 
 --------------------------------------------------------------------------------
 function serverlistmgr.sync()
@@ -56,6 +129,36 @@ function serverlistmgr.sync()
 		return
 	end
 
+	-- only fetched once per MT instance
+	if not serverlistmgr.my_continent and not geoip_downloading then
+		geoip_downloading = true
+		core.handle_async(
+			function(param)
+				local http = core.get_http_api()
+				local url = core.settings:get("serverlist_url") .. "/geoip"
+
+				local response = http.fetch_sync({ url = url })
+				if not response.succeeded then
+					return
+				end
+
+				local retval = core.parse_json(response.data)
+				return retval and type(retval.continent) == "string" and retval.continent
+			end,
+			nil,
+			function(result)
+				geoip_downloading = false
+				serverlistmgr.my_continent = result
+				core.set_once("continent", result)
+				-- reorder list if we already have it
+				if serverlistmgr.servers then
+					serverlistmgr.servers = order_server_list(serverlistmgr.servers)
+					core.event_handler("Refresh")
+				end
+			end
+		)
+	end
+
 	if public_downloading then
 		return
 	end
@@ -79,7 +182,7 @@ function serverlistmgr.sync()
 		end,
 		nil,
 		function(result)
-			public_downloading = nil
+			public_downloading = false
 			local favs = order_server_list(result)
 			if favs[1] then
 				serverlistmgr.servers = favs

+ 1 - 1
builtin/mainmenu/tests/serverlistmgr_spec.lua

@@ -1,4 +1,4 @@
-_G.core = {}
+_G.core = {get_once = function(_) end}
 _G.vector = {metatable = {}}
 _G.unpack = table.unpack
 _G.serverlistmgr = {}

+ 41 - 0
src/script/lua_api/l_mainmenu.cpp

@@ -39,6 +39,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
 #include "client/renderingengine.h"
 #include "network/networkprotocol.h"
 #include "content/mod_configuration.h"
+#include "threading/mutex_auto_lock.h"
 
 /******************************************************************************/
 std::string ModApiMainMenu::getTextData(lua_State *L, std::string name)
@@ -1007,6 +1008,44 @@ int ModApiMainMenu::l_do_async_callback(lua_State *L)
 	return 1;
 }
 
+/******************************************************************************/
+// this is intentionally a global and not part of MainMenuScripting or such
+namespace {
+	std::unordered_map<std::string, std::string> once_values;
+	std::mutex once_mutex;
+}
+
+int ModApiMainMenu::l_set_once(lua_State *L)
+{
+	std::string key = readParam<std::string>(L, 1);
+	if (lua_isnil(L, 2))
+		return 0;
+	std::string value = readParam<std::string>(L, 2);
+
+	{
+		MutexAutoLock lock(once_mutex);
+		once_values[key] = value;
+	}
+
+	return 0;
+}
+
+int ModApiMainMenu::l_get_once(lua_State *L)
+{
+	std::string key = readParam<std::string>(L, 1);
+
+	{
+		MutexAutoLock lock(once_mutex);
+		auto it = once_values.find(key);
+		if (it == once_values.end())
+			lua_pushnil(L);
+		else
+			lua_pushstring(L, it->second.c_str());
+	}
+
+	return 1;
+}
+
 /******************************************************************************/
 void ModApiMainMenu::Initialize(lua_State *L, int top)
 {
@@ -1054,6 +1093,8 @@ void ModApiMainMenu::Initialize(lua_State *L, int top)
 	API_FCT(open_dir);
 	API_FCT(share_file);
 	API_FCT(do_async_callback);
+	API_FCT(set_once);
+	API_FCT(get_once);
 }
 
 /******************************************************************************/

+ 3 - 0
src/script/lua_api/l_mainmenu.h

@@ -156,6 +156,9 @@ private:
 
 	static int l_share_file(lua_State *L);
 
+	static int l_set_once(lua_State *L);
+
+	static int l_get_once(lua_State *L);
 
 	// async
 	static int l_do_async_callback(lua_State *L);