Index: binaries/data/config/default.cfg
===================================================================
--- binaries/data/config/default.cfg
+++ binaries/data/config/default.cfg
@@ -156,6 +156,11 @@
lobby = "Alt+L" ; Show the multiplayer lobby in a dialog window.
structree = "Alt+Shift+T" ; Show structure tree
civinfo = "Alt+Shift+H" ; Show civilization info
+hostgame = "H" ; Open the dialog to host a match
+joingame = "J" ; Join the selected lobby match or open the dialog to enter an IP address to join
+networkstats = "N" ; Open the dialog that shows the network statistics of the connected clients
+previousPage = "P" ; Presses the "return" button, which leads one back to the previous menu
+ ; This is different from the "close dialog" hotkey which doesn't change the root page but only closes a child page
; > CLIPBOARD CONTROLS
copy = "Ctrl+C" ; Copy to clipboard
@@ -453,6 +458,10 @@
observerlimit = 8 ; Prevent further observer joins in running games if this limit is reached
gamestarttimeout = 60000 ; Don't disconnect clients timing out in the loading screen and rejoin process before exceeding this timeout.
+[network.geolite2]
+enabled = true; ; Whether or not to load the local GeoLite2 database, if it exists.
+directory = "geolite2/" ; directory that contains the GeoLite2 databse. Obtainable at https://dev.maxmind.com/geoip/geoip2/
+
[overlay]
fps = "false" ; Show frames per second in top right corner
realtime = "false" ; Show current system time in top right corner
Index: binaries/data/mods/public/gui/common/NetworkDialogManager.js
===================================================================
--- /dev/null
+++ binaries/data/mods/public/gui/common/NetworkDialogManager.js
@@ -0,0 +1,28 @@
+function NetworkDialogManager()
+{
+ this.guiPage = undefined;
+}
+
+NetworkDialogManager.prototype.open = function()
+{
+ this.guiPage = Engine.PushGuiPage("page_networkreport.xml", {
+ "gameAttributes": g_GameAttributes,
+ "playerAssignments": g_PlayerAssignments,
+ "callback": "closePageHack" // TODO: should use D1684
+ });
+};
+
+function closePageHack()
+{
+ if (typeof g_NetworkDialogManager != "undefined")
+ g_NetworkDialogManager.guiPage = undefined;
+}
+
+NetworkDialogManager.prototype.refresh = function()
+{
+ if (this.guiPage)
+ this.guiPage.updatePage({
+ "gameAttributes": g_GameAttributes,
+ "playerAssignments": g_PlayerAssignments,
+ });
+};
Index: binaries/data/mods/public/gui/common/color.js
===================================================================
--- binaries/data/mods/public/gui/common/color.js
+++ binaries/data/mods/public/gui/common/color.js
@@ -75,6 +75,15 @@
}
/**
+ * @param {Number} efficiency - between 0 and 1
+ * @returns {String} GUI color representing efficiency, 1 yields green, 0.5 yellow and 0 red.
+ */
+function efficiencyToColor(efficiency)
+{
+ return hslToRgb(Math.min(1, Math.max(0, efficiency)) / 3, 1, 0.5).join(" ");
+}
+
+/**
* Convert color value from RGB to HSL space.
*
* @see {@link https://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion}
Index: binaries/data/mods/public/gui/common/network.js
===================================================================
--- binaries/data/mods/public/gui/common/network.js
+++ binaries/data/mods/public/gui/common/network.js
@@ -1,40 +1,22 @@
/**
- * Number of milliseconds to display network warnings.
+ * Latency at which the game performance becomes impaired.
*/
-var g_NetworkWarningTimeout = 3000;
+const inefficientRTT = Engine.GetTurnLength();
/**
- * Currently displayed network warnings. At most one message per user.
+ * Ratio at which the game performance becomes impaired.
*/
-var g_NetworkWarnings = {};
+const inefficientPacketLoss = 0.25;
/**
- * Message-types to be displayed.
+ * Number of milliseconds to display network warnings.
*/
-var g_NetworkWarningTexts = {
+var g_NetworkWarningTimeout = 3000;
- "server-timeout": (msg, username) =>
- sprintf(translate("Losing connection to server (%(seconds)ss)"), {
- "seconds": Math.ceil(msg.lastReceivedTime / 1000)
- }),
-
- "client-timeout": (msg, username) =>
- sprintf(translate("%(player)s losing connection (%(seconds)ss)"), {
- "player": username,
- "seconds": Math.ceil(msg.lastReceivedTime / 1000)
- }),
-
- "server-latency": (msg, username) =>
- sprintf(translate("Bad connection to server (%(milliseconds)sms)"), {
- "milliseconds": msg.meanRTT
- }),
-
- "client-latency": (msg, username) =>
- sprintf(translate("Bad connection to %(player)s (%(milliseconds)sms)"), {
- "player": username,
- "milliseconds": msg.meanRTT
- })
-};
+/**
+ * Currently displayed network reports. At most one message per user.
+ */
+var g_NetworkWarnings = {};
var g_NetworkCommands = {
"/kick": argument => kickPlayer(argument, false),
@@ -81,6 +63,42 @@
}
/**
+ * @param {bool} isLocal - Whether this is a remote client or our connection.
+ * @param {Object} performance - The performance data of a remote client passed by the NetClient.
+ * @returns the String
+ */
+function getNetworkWarningString(isLocal, performance)
+{
+ if (performance.lastReceivedTime > 3000)
+ return isLocal ?
+ translate("Losing connection to server (%(seconds)ss)") :
+ translate("%(player)s losing connection (%(seconds)ss)");
+
+ if (performance.meanRTT > inefficientRTT)
+ return isLocal ?
+ translate("Bad connection to server (%(milliseconds)sms)") :
+ translate("Bad connection to %(player)s (%(milliseconds)sms)");
+
+ if (performance.packetLoss > inefficientPacketLoss)
+ return isLocal ?
+ translate("Bad connection to server (%(packetLossRatio)s%% packet loss)") :
+ translate("Bad connection to %(player)s (%(packetLossRatio)s%% packet loss)");
+
+ return "";
+}
+
+function getNetworkWarningText(string, performance, username)
+{
+ return sprintf(string, {
+ "player": username,
+ "seconds": Math.ceil(performance.lastReceivedTime / 1000),
+ "milliseconds": performance.meanRTT,
+ "packetLossRatio": (performance.packetLoss * 100).toFixed(1)
+ })
+}
+
+
+/**
* Show the disconnect reason in a message box.
*
* @param {number} reason
@@ -110,7 +128,7 @@
function kickPlayer(username, ban)
{
- if (g_IsController)
+ if (Engine.HasNetServer())
Engine.KickPlayer(username, ban);
else
kickError();
@@ -118,7 +136,7 @@
function kickObservers(ban)
{
- if (!g_IsController)
+ if (!Engine.HasNetServer())
{
kickError();
return;
@@ -185,26 +203,28 @@
}
/**
- * Remember this warning for a few seconds.
- * Overwrite previous warnings for this user.
- *
- * @param msg - GUI message sent by NetServer or NetClient
+ * Creates a report for each network client with a critically bad network connection to be displayed for some duration.
*/
-function addNetworkWarning(msg)
+function pollNetworkWarnings()
{
- if (!g_NetworkWarningTexts[msg.warntype])
- {
- warn("Unknown network warning type received: " + uneval(msg));
+ if (Engine.ConfigDB_GetValue("user", "overlay.netwarnings") != "true")
return;
- }
- if (Engine.ConfigDB_GetValue("user", "overlay.netwarnings") != "true")
+ let clientPerformance = Engine.GetNetworkClientPerformance();
+ if (!clientPerformance)
return;
- g_NetworkWarnings[msg.guid || "server"] = {
- "added": Date.now(),
- "msg": msg
- };
+ for (let guid in clientPerformance)
+ {
+ let string = getNetworkWarningString(guid == Engine.GetPlayerGUID(), clientPerformance[guid]);
+
+ if (string)
+ g_NetworkWarnings[guid] = {
+ "added": Date.now(),
+ "string": string,
+ "performance": clientPerformance[guid]
+ };
+ }
}
/**
@@ -213,14 +233,19 @@
*/
function getNetworkWarnings()
{
+ if (Engine.ConfigDB_GetValue("user", "overlay.netwarnings") != "true")
+ return {
+ "messages": [],
+ "maxTextWidth": 0
+ };
+
// Remove outdated messages
for (let guid in g_NetworkWarnings)
- if (Date.now() > g_NetworkWarnings[guid].added + g_NetworkWarningTimeout ||
- guid != "server" && !g_PlayerAssignments[guid])
+ if (Date.now() > g_NetworkWarnings[guid].added + g_NetworkWarningTimeout || !g_PlayerAssignments[guid])
delete g_NetworkWarnings[guid];
// Show local messages first
- let guids = Object.keys(g_NetworkWarnings).sort(guid => guid != "server");
+ let guids = Object.keys(g_NetworkWarnings).sort(guid => guid != Engine.GetPlayerGUID());
let font = Engine.GetGUIObjectByName("gameStateNotifications").font;
@@ -229,14 +254,11 @@
for (let guid of guids)
{
- let msg = g_NetworkWarnings[guid].msg;
-
// Add formatted text
- messages.push(g_NetworkWarningTexts[msg.warntype](msg, colorizePlayernameByGUID(guid)));
+ messages.push(getNetworkWarningText(g_NetworkWarnings[guid].string, g_NetworkWarnings[guid].performance, colorizePlayernameByGUID(guid)));
// Add width of unformatted text
- let username = guid != "server" && g_PlayerAssignments[guid].name;
- let textWidth = Engine.GetTextWidth(font, g_NetworkWarningTexts[msg.warntype](msg, username));
+ let textWidth = Engine.GetTextWidth(font, getNetworkWarningText(g_NetworkWarnings[guid].string, g_NetworkWarnings[guid].performance, g_PlayerAssignments[guid].name));
maxTextWidth = Math.max(textWidth, maxTextWidth);
}
Index: binaries/data/mods/public/gui/gamesetup/gamesetup.js
===================================================================
--- binaries/data/mods/public/gui/gamesetup/gamesetup.js
+++ binaries/data/mods/public/gui/gamesetup/gamesetup.js
@@ -12,6 +12,8 @@
var g_GameSpeeds = getGameSpeedChoices(false);
+var g_NetworkDialogManager = new NetworkDialogManager();
+
/**
* Offer users to select playable civs only.
* Load unselectable civs as they could appear in scenario maps.
@@ -140,7 +142,6 @@
*/
var g_NetMessageTypes = {
"netstatus": msg => handleNetStatusMessage(msg),
- "netwarn": msg => addNetworkWarning(msg),
"gamesetup": msg => handleGamesetupMessage(msg),
"players": msg => handlePlayerAssignmentMessage(msg),
"ready": msg => handleReadyMessage(msg),
@@ -1063,6 +1064,13 @@
},
"hidden": () => !Engine.HasXmppClient()
},
+ "networkButton": {
+ "onPress": () => function() {
+ if (g_IsNetworked)
+ g_NetworkDialogManager.open();
+ },
+ "hidden": () => !g_IsNetworked
+ },
"spTips": {
"hidden": () => {
let settingsPanel = Engine.GetGUIObjectByName("settingsPanel");
@@ -1405,7 +1413,7 @@
let settingTabButtons = Engine.GetGUIObjectByName("settingTabButtons");
let settingTabButtonsSize = settingTabButtons.size;
settingTabButtonsSize.bottom = settingTabButtonsSize.top + g_SettingsTabsGUI.length * (g_TabButtonHeight + g_TabButtonDist);
- settingTabButtonsSize.right = g_MiscControls.lobbyButton.hidden() ?
+ settingTabButtonsSize.right = (g_MiscControls.lobbyButton.hidden() && g_MiscControls.networkButton.hidden()) ?
settingTabButtonsSize.right :
Engine.GetGUIObjectByName("lobbyButton").size.left - g_LobbyButtonSpacing;
settingTabButtons.size = settingTabButtonsSize;
@@ -1572,6 +1580,8 @@
updateGUIObjects();
+ g_NetworkDialogManager.refresh();
+
hideLoadingWindow();
}
@@ -1605,6 +1615,8 @@
sendRegisterGameStanzaImmediate();
else
sendRegisterGameStanza();
+
+ g_NetworkDialogManager.refresh();
}
function onClientJoin(newGUID, newAssignments)
@@ -1996,6 +2008,7 @@
handleNetMessages();
updateTimers();
+ pollNetworkWarnings();
let now = Date.now();
let tickLength = now - g_LastTickTime;
Index: binaries/data/mods/public/gui/gamesetup/gamesetup.xml
===================================================================
--- binaries/data/mods/public/gui/gamesetup/gamesetup.xml
+++ binaries/data/mods/public/gui/gamesetup/gamesetup.xml
@@ -202,6 +202,20 @@
Show the multiplayer lobby in a dialog window.
+
+
+
+
-
+
Join Game
joinButton();
-
+
Host Game
hostGame();
Index: binaries/data/mods/public/gui/networkreport/ClientList.js
===================================================================
--- /dev/null
+++ binaries/data/mods/public/gui/networkreport/ClientList.js
@@ -0,0 +1,168 @@
+function ClientList(guiObjectName)
+{
+ this.geoLite2 = new GeoLite2Cache();
+ this.clientList = Engine.GetGUIObjectByName(guiObjectName);
+ this.updateInterval = 1000;
+ this.lastUpdate = 0;
+ this.countryFlags = new CountryFlags(14, 14, true);
+ this.selectedGUID = undefined;
+}
+
+// TODO: missing hotload
+ClientList.prototype.OnTick = function(gameAttributes, playerAssignments)
+{
+ let now = Date.now();
+ if (now <= this.lastUpdate + this.updateInterval)
+ return false;
+
+ this.UpdateList(gameAttributes, playerAssignments);
+ this.lastUpdate = now;
+ return true;
+};
+
+ClientList.prototype.UpdateList = function(gameAttributes, playerAssignments)
+{
+ let clientPerformance = Engine.GetNetworkClientPerformance();
+ if (!clientPerformance)
+ return;
+
+ let guids = Object.keys(clientPerformance).filter(guid => !!playerAssignments[guid]).sort((guid1, guid2) =>
+ this.clientList.selected_column_order *
+ this.GetListEntryOrder(playerAssignments)[this.clientList.selected_column](guid1, guid2, clientPerformance));
+
+ // TODO: It would be nicer and safer to exchange the entire table at a time
+ let clientListEntries = prepareForDropdown(guids.map(guid => this.GetListEntry(gameAttributes, playerAssignments, guid, clientPerformance[guid])));
+
+ let selectedGUID = this.SelectedGUID();
+
+ for (let column in clientListEntries)
+ //if (("list_" + column) in clientList) TODO: this shouldn't make it crash
+ if (column != "Default")
+ this.clientList["list_" + column] = clientListEntries[column];
+ this.clientList.list = guids;
+ this.clientList.selected = this.clientList.list.indexOf(selectedGUID);
+};
+
+ClientList.prototype.SelectedGUID = function()
+{
+ return this.clientList.list[this.clientList.selected] || undefined;
+};
+
+/**
+ * Notice that the "this" keyword refers to a different object depending
+ */
+ClientList.prototype.GetListEntry = function(gameAttributes, playerAssignments, guid, clientPerformance)
+{
+ // TODO: this scope should not exist, but "this" references are difficult
+ return {
+ "name":
+ setStringTags(playerAssignments[guid].name, {
+ "color": (() => {
+ let playerID = playerAssignments[guid].player - 1;
+ return gameAttributes.settings.PlayerData[playerID] ? rgbToGuiColor(gameAttributes.settings.PlayerData[playerID].Color) : "white";
+ })()
+ }),
+
+ "status":
+ getNetworkWarningText(
+ getNetworkWarningString(guid == Engine.GetPlayerGUID(), clientPerformance),
+ clientPerformance,
+ playerAssignments[guid].name) || translate("Ok"),
+ "ipAddress": Engine.GetClientIPAddress(guid),
+
+ "hostname": Engine.LookupClientHostname(guid),
+
+ "isp": (() => {
+ let geoLite2 = this.geoLite2.GetByGUID(guid);
+ return geoLite2 && geoLite2.autonomousSystemOrganization || translateWithContext("unknown internet service provider", "?");
+ })(),
+
+ "location": (() => {
+ let geoLite2 = this.geoLite2.GetByGUID(guid);
+
+ if (!geoLite2)
+ return translateWithContext("unknown country", "?");
+
+ // TODO: Test for icon existence and use different string if it doesn't exist
+ // TODO: icon for satellite (no country / global) ISP and anonymous / VPN
+ return sprintf(
+ geoLite2.cityName ?
+ translate("%(icon)s %(continent)s/%(country)s/%(city)s (±%(accuracyRadius)skm)") :
+ translate("%(icon)s %(continent)s/%(country)s"),
+ {
+ "icon": iconTag(this.countryFlags.GetIconName(geoLite2.countryCode)),
+ "continent": geoLite2.continentName,
+ "country": geoLite2.countryName,
+ "city": geoLite2.cityName || "",
+ "accuracyRadius": geoLite2.accuracyRadius || ""
+ });
+ })(),
+
+ "time": (() => {
+ // TODO: Get time in timezone
+ let geoLite2 = this.geoLite2.GetByGUID(guid);
+ return geoLite2 && geoLite2.timeZone || "";
+ })(),
+
+ "meanRTT": (() => {
+ let lastReceivedTime = clientPerformance.lastReceivedTime > 3000 ? clientPerformance.lastReceivedTime : 0;
+ let meanRTT = Math.max(clientPerformance.meanRTT, lastReceivedTime);
+ return meanRTT == 0 ?
+ translateWithContext("unknown mean roundtriptime", "?") :
+ coloredText(
+ sprintf(translateWithContext("network latency", "%(milliseconds)sms"), {
+ "milliseconds": meanRTT
+ }),
+ efficiencyToColor(1 - meanRTT / inefficientRTT));
+ })(),
+
+ "packetLoss":
+ // TODO: 0 can also mean perfect connection, for example localhost.
+ clientPerformance.packetLoss == 0 ?
+ translateWithContext("unknown packet loss ratio", "?") :
+ coloredText(
+ sprintf(translateWithContext("network packet loss", "%(packetLossRatio)s%%"), {
+ "packetLossRatio": (clientPerformance.packetLoss * 100).toFixed(1)
+ }),
+ efficiencyToColor(1 - clientPerformance.packetLoss / inefficientPacketLoss))
+ };
+};
+
+ClientList.prototype.GetListEntryOrder = function(playerAssignments)
+{
+ return {
+ "name": (guid1, guid2) =>
+ playerAssignments[guid1].name.localeCompare(
+ playerAssignments[guid2].name),
+
+ "status": (guid1, guid2, clientPerformance) =>
+ getNetworkWarningString(guid1 == Engine.GetPlayerGUID(), clientPerformance[guid1]).localeCompare(
+ getNetworkWarningString(guid2 == Engine.GetPlayerGUID(), clientPerformance[guid2])),
+
+ "ipAddress": (guid1, guid2) =>
+ Engine.IPv4ToNumber(Engine.GetClientIPAddress(guid1)) - Engine.IPv4ToNumber(Engine.GetClientIPAddress(guid2)),
+
+ "hostname": (guid1, guid2) =>
+ Engine.LookupClientHostname(guid1).localeCompare(Engine.LookupClientHostname(guid2)),
+
+ "location": (guid1, guid2) => {
+ let getSortKey = geolite2 => geolite2 ? geolite2.continentName + "/" + geolite2.countryCode + "/" + (geolite2.cityCode || "") : "";
+ return getSortKey(this.geoLite2.GetByGUID(guid1)).localeCompare(getSortKey(this.geoLite2.GetByGUID(guid2)));
+ },
+
+ "isp": (guid1, guid2) =>
+ (this.geoLite2.GetByGUID(guid1) ? this.geoLite2.GetByGUID(guid1).autonomousSystemOrganization : "").localeCompare(
+ (this.geoLite2.GetByGUID(guid2) ? this.geoLite2.GetByGUID(guid2).autonomousSystemOrganization : "")),
+
+ "time": (guid1, guid2) =>
+ 0, // TODO
+
+ "meanRTT": (guid1, guid2, clientPerformance) =>
+ clientPerformance[guid1].meanRTT -
+ clientPerformance[guid2].meanRTT,
+
+ "packetLoss": (guid1, guid2, clientPerformance) =>
+ clientPerformance[guid1].packetLoss -
+ clientPerformance[guid2].packetLoss
+ };
+};
Index: binaries/data/mods/public/gui/networkreport/CountryFlags.js
===================================================================
--- /dev/null
+++ binaries/data/mods/public/gui/networkreport/CountryFlags.js
@@ -0,0 +1,18 @@
+function CountryFlags(width = 16, height = 16, replaceExisting = true)
+{
+ let directory = "global/icon/flags/";
+
+ // TODO: displace tag?
+
+ for (let countryID of listFiles("art/textures/ui/" + directory, ".png", false))
+ Engine.AddIcon(
+ this.GetIconName(countryID),
+ "stretched:" + directory + countryID + ".png",
+ width + " " + height,
+ replaceExisting);
+}
+
+CountryFlags.prototype.GetIconName = function(countryCode)
+{
+ return "icon_country_" + countryCode.toLowerCase();
+};
Index: binaries/data/mods/public/gui/networkreport/GeoLite2.js
===================================================================
--- /dev/null
+++ binaries/data/mods/public/gui/networkreport/GeoLite2.js
@@ -0,0 +1,23 @@
+function GeoLite2Cache()
+{
+ this.geoLite2 = {};
+}
+
+GeoLite2Cache.prototype.GetByIP = function(ipAddress)
+{
+ // Also cache undefined return value
+ if (!(ipAddress in this.geoLite2))
+ {
+ this.geoLite2[ipAddress] = Engine.GetGeoLite2(ipAddress);
+
+ if (this.geoLite2[ipAddress])
+ deepfreeze(this.geoLite2[ipAddress]);
+ }
+
+ return this.geoLite2[ipAddress];
+};
+
+GeoLite2Cache.prototype.GetByGUID = function(guid)
+{
+ return this.GetByIP(Engine.GetClientIPAddress(guid));
+};
Index: binaries/data/mods/public/gui/networkreport/NetworkDialog.js
===================================================================
--- /dev/null
+++ binaries/data/mods/public/gui/networkreport/NetworkDialog.js
@@ -0,0 +1,100 @@
+// TODO: remember selected columns
+// TODO: sort players first
+// TODO: this should show whether the client is rejoining at the moment
+// TODO: display host
+// TODO: allow giving host controls to clients
+// TODO: allow muting clients
+// TODO: display if clients are too slow with the simulation
+
+function NetworkDialog(gameAttributes, playerAssignments)
+{
+ this.playerAssignments = playerAssignments;
+ this.gameAttributes = gameAttributes;
+ this.isController = Engine.HasNetServer();
+ this.clientList = new ClientList("clientList");
+
+ this.UpdateGUIObjects();
+}
+
+NetworkDialog.prototype.GetHotloadData = function()
+{
+ return {
+ "gameAttributes": this.gameAttributes,
+ "playerAssignments": this.playerAssignments
+ };
+};
+
+NetworkDialog.prototype.UpdateGameData = function(data)
+{
+ this.gameAttributes = data.gameAttributes;
+ this.playerAssignments = data.playerAssignments;
+
+ this.UpdateGUIObjects();
+};
+
+NetworkDialog.prototype.UpdateGUIObjects = function()
+{
+ this.clientList.UpdateList(this.gameAttributes, this.playerAssignments);
+ this.UpdateGUIProperties();
+};
+
+NetworkDialog.prototype.UpdateGUIProperties = function()
+{
+ let guiProperties = this.GetGUIProperties();
+ for (let objectName in guiProperties)
+ for (let propertyName in guiProperties[objectName])
+ Engine.GetGUIObjectByName(objectName)[propertyName] = guiProperties[objectName][propertyName];
+};
+
+NetworkDialog.prototype.GetGUIProperties = function()
+{
+ return {
+ "networkreport": {
+ "onTick": () => {
+ pollNetworkWarnings();
+ }
+ },
+ "clientList": {
+ "onSelectionColumnChange": () => {
+ this.UpdateGUIObjects();
+ },
+ "onSelectionChange": () => {
+ // onSelectionChange may not call UpdateList, otherwise infinite loop
+ this.UpdateGUIProperties();
+ },
+ // TODO: Support skip confirmation hotkey
+ "onMouseLeftDoubleClickItem": () => {
+ if (this.isController)
+ Engine.GetGUIObjectByName("kickButton").onPress();
+ },
+ "onTick": () => {
+ if (this.clientList.OnTick(this.gameAttributes, this.playerAssignments))
+ this.UpdateGUIProperties();
+ }
+ },
+ "kickButton": {
+ "caption": translate("Kick"),
+ "tooltip": translate("Disconnect this player immediately."),
+ "hidden": !this.isController,
+ "enabled": this.clientList.SelectedGUID() && this.clientList.SelectedGUID() != Engine.GetPlayerGUID(),
+ "onPress": () => {
+ kickPlayer(this.playerAssignments[this.clientList.SelectedGUID()].name, false);
+ }
+ },
+ "banButton": {
+ "caption": translate("Ban"),
+ "tooltip": translate("Disconnect this player immediately and deny any request to rejoin."),
+ "hidden": !this.isController,
+ "enabled": this.clientList.SelectedGUID() && this.clientList.SelectedGUID() != Engine.GetPlayerGUID(),
+ "onPress": () => {
+ kickPlayer(this.playerAssignments[this.clientList.SelectedGUID()].name, true);
+ }
+ },
+ "closeButton": {
+ "caption": translate("Close"),
+ "onPress": () => {
+ Engine.PopGuiPageCB();
+ }
+ }
+ };
+};
Index: binaries/data/mods/public/gui/networkreport/networkreport.js
===================================================================
--- /dev/null
+++ binaries/data/mods/public/gui/networkreport/networkreport.js
@@ -0,0 +1,19 @@
+var g_NetworkDialog;
+
+// TODO: page_foo.xml instead of hardcoded global function names, it would be nicer to hardcode one JSClass/prototype name
+function init(data, hotloadData)
+{
+ g_NetworkDialog = new NetworkDialog(
+ hotloadData ? hotloadData.gameAttributes : data.gameAttributes,
+ hotloadData ? hotloadData.playerAssignments : data.playerAssignments);
+}
+
+function getHotloadData()
+{
+ return g_NetworkDialog.GetHotloadData();
+}
+
+function updatePage(data)
+{
+ g_NetworkDialog.UpdateGameData(data);
+}
Index: binaries/data/mods/public/gui/networkreport/networkreport.xml
===================================================================
--- /dev/null
+++ binaries/data/mods/public/gui/networkreport/networkreport.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+ Network
+
+
+
+
+ Name
+
+
+
+ Status
+
+
+
+ IP Address
+
+
+
+ Hostname
+
+
+
+ Internet Provider
+
+
+
+ Location
+
+
+
+ Time
+
+
+
+ Ping
+
+
+
+ Packet Loss
+
+
+
+
+
+
+
+
+
+
Index: binaries/data/mods/public/gui/options/options.json
===================================================================
--- binaries/data/mods/public/gui/options/options.json
+++ binaries/data/mods/public/gui/options/options.json
@@ -373,6 +373,19 @@
]
},
{
+ "label": "Network",
+ "tooltip": "Set options for multiplayer matches.",
+ "options":
+ [
+ {
+ "type": "boolean",
+ "label": "GeoLite2",
+ "tooltip": "Enable geolocation lookup of connected clients.",
+ "config": "network.geolite2.enabled"
+ }
+ ]
+ },
+ {
"label": "Lobby",
"tooltip": "These settings only affect the multiplayer.",
"options":
Index: binaries/data/mods/public/gui/page_networkreport.xml
===================================================================
--- /dev/null
+++ binaries/data/mods/public/gui/page_networkreport.xml
@@ -0,0 +1,13 @@
+
+
+ common/modern/setup.xml
+ common/modern/styles.xml
+ common/modern/sprites.xml
+
+ common/setup.xml
+ common/sprites.xml
+ common/styles.xml
+
+ networkreport/networkreport.xml
+ common/global.xml
+
Index: binaries/data/mods/public/gui/pregame/mainmenu.xml
===================================================================
--- binaries/data/mods/public/gui/pregame/mainmenu.xml
+++ binaries/data/mods/public/gui/pregame/mainmenu.xml
@@ -227,6 +227,7 @@
style="StoneButtonFancy"
size="0 0 100% 28"
tooltip_style="pgToolTip"
+ hotkey="joingame"
>
Join Game
Joining an existing multiplayer game.
@@ -241,6 +242,7 @@
style="StoneButtonFancy"
size="0 32 100% 60"
tooltip_style="pgToolTip"
+ hotkey="hostgame"
>
Host Game
Host a multiplayer game.\n\nRequires UDP port 20595 to be open.
Index: binaries/data/mods/public/gui/session/menu.js
===================================================================
--- binaries/data/mods/public/gui/session/menu.js
+++ binaries/data/mods/public/gui/session/menu.js
@@ -2,7 +2,7 @@
var MARGIN = 4;
// Includes the main menu button
-const NUM_BUTTONS = 10;
+const NUM_BUTTONS = 11;
// Regular menu buttons
var BUTTON_HEIGHT = 32;
@@ -74,6 +74,8 @@
// Redefined every time someone makes a tribute (so we can save some data in a closure). Called in input.js handleInputBeforeGui.
var g_FlushTributing = function() {};
+var g_NetworkDialogManager = new NetworkDialogManager();
+
function initMenu()
{
Engine.GetGUIObjectByName("menu").size = "100%-164 " + MENU_TOP + " 100% " + MENU_BOTTOM;
Index: binaries/data/mods/public/gui/session/menu.xml
===================================================================
--- binaries/data/mods/public/gui/session/menu.xml
+++ binaries/data/mods/public/gui/session/menu.xml
@@ -99,11 +99,22 @@
resignMenuButton();
+
+
+ Network
+ g_NetworkDialogManager.open();
+
+
Exit
Index: binaries/data/mods/public/gui/session/messages.js
===================================================================
--- binaries/data/mods/public/gui/session/messages.js
+++ binaries/data/mods/public/gui/session/messages.js
@@ -50,9 +50,6 @@
"netstatus": msg => {
handleNetStatusMessage(msg);
},
- "netwarn": msg => {
- addNetworkWarning(msg);
- },
"out-of-sync": msg => {
onNetworkOutOfSync(msg);
},
@@ -757,6 +754,8 @@
updateGUIObjects();
updateChatAddressees();
sendLobbyPlayerlistUpdate();
+
+ g_NetworkDialogManager.refresh();
}
function onClientJoin(guid)
Index: binaries/data/mods/public/gui/session/session.js
===================================================================
--- binaries/data/mods/public/gui/session/session.js
+++ binaries/data/mods/public/gui/session/session.js
@@ -802,6 +802,7 @@
g_LastTickTime = now;
handleNetMessages();
+ pollNetworkWarnings();
updateCursorAndTooltip();
Index: source/gui/scripting/ScriptFunctions.cpp
===================================================================
--- source/gui/scripting/ScriptFunctions.cpp
+++ source/gui/scripting/ScriptFunctions.cpp
@@ -25,6 +25,7 @@
#include "gui/scripting/JSInterface_GUITypes.h"
#include "i18n/scripting/JSInterface_L10n.h"
#include "lobby/scripting/JSInterface_Lobby.h"
+#include "network/scripting/JSInterface_GeoLite2.h"
#include "network/scripting/JSInterface_Network.h"
#include "ps/scripting/JSInterface_ConfigDB.h"
#include "ps/scripting/JSInterface_Console.h"
@@ -58,6 +59,7 @@
JSI_GUIManager::RegisterScriptFunctions(scriptInterface);
JSI_Game::RegisterScriptFunctions(scriptInterface);
JSI_GameView::RegisterScriptFunctions(scriptInterface);
+ JSI_GeoLite2::RegisterScriptFunctions(scriptInterface);
JSI_L10n::RegisterScriptFunctions(scriptInterface);
JSI_Lobby::RegisterScriptFunctions(scriptInterface);
JSI_Main::RegisterScriptFunctions(scriptInterface);
Index: source/i18n/scripting/JSInterface_L10n.cpp
===================================================================
--- source/i18n/scripting/JSInterface_L10n.cpp
+++ source/i18n/scripting/JSInterface_L10n.cpp
@@ -24,6 +24,8 @@
#include "ps/Profile.h"
#include "scriptinterface/ScriptInterface.h"
+// TODO: country-code to language name
+
// Returns a translation of the specified English string into the current language.
std::wstring JSI_L10n::Translate(ScriptInterface::CxPrivate* UNUSED(pCxPrivate), const std::wstring& sourceString)
{
Index: source/network/GeoLite2.h
===================================================================
--- /dev/null
+++ source/network/GeoLite2.h
@@ -0,0 +1,127 @@
+/* Copyright (C) 2019 Wildfire Games.
+ * This file is part of 0 A.D.
+ *
+ * 0 A.D. is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * 0 A.D. is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with 0 A.D. If not, see .
+ */
+
+#ifndef GEOLITE2_H
+#define GEOLITE2_H
+
+#include "lib/file/vfs/vfs_path.h"
+#include "scriptinterface/ScriptInterface.h"
+
+#include
+
+/**
+ * Identifies an IPv4 range as a parsed CIDR, i.e. an IPv4 in hostbyte order + Number of bits of the subnet mask.
+ */
+// TODO: Consider using u64 conversion to save space :/
+using IPv4SubnetKeyType = std::pair;
+
+/**
+ * This class provides caching of the GeoLite2 database and query results using the systems inet.h.
+ * It uses caching to prevent reoccuring slow lookup times.
+ *
+ * GeoLite2 files:
+ * The here supported GeoLite2 datasets are a GeoLite2-*Blocks*.csv and a GeoLite2-*Location.csv file.
+ * The Blocks file maps from IPv4 or IPv6 subnet to the location ID and some properties about the ISP.
+ * The Location file maps from a location ID to some properties about the location.
+ * Both the Country and the City level are supported.
+ */
+class GeoLite2
+{
+public:
+ GeoLite2(const std::string& IETFLanguageTag);
+ ~GeoLite2();
+
+ /**
+ * Returns whether the user requested a class instance.
+ */
+ static bool IsEnabled();
+
+ /**
+ * Loads both the Blocks and the Locations file of the given IPv4.
+ */
+ JS::Value GetIPv4Data(const ScriptInterface& scriptInterface, u32 ipAddress);
+
+private:
+
+ // Loads the user configured VFS directory from which the csv files will be loaded.
+ void LoadPath();
+
+ // Proxy calling LoadBlocksIPv4 and LoadLocations.
+ bool LoadContent(const std::string& content);
+
+ // Loads and parses the GeoLite2 Blocks csv file.
+ bool LoadBlocks(const std::string& content);
+
+ // Parses Country data of a City or Country Blocks file.
+ void ParseCountryBlocksLine(const std::string& subnet, const IPv4SubnetKeyType& subnetKey, const std::vector& values);
+ void ParseCityBlocksLine(const std::string& subnet, const IPv4SubnetKeyType& subnetKey, const std::vector& values);
+ void ParseASNBlocksLine(const std::string& subnet, const IPv4SubnetKeyType& subnetKey, const std::vector& values);
+
+ // Loads and parses the GeoLite2 Blocks csv file.
+ bool LoadLocations(const std::string& content);
+
+ // Loads a csv file and parses it as a vector of strings excluding the first line.
+ bool LoadCSVFile(const VfsPath& filePath, const std::string& expectedHeader, std::function&)>& lineRead);
+
+ bool ParseGeonameID(const std::string& geoNameID, u32& geonameIDNum);
+ void ParseCityLocationsLine(const std::string& geonameID, const u32& geonameIDNum, const std::vector& values);
+ void ParseCountryLocationsLine(const std::string& geonameID, const u32& geonameIDNum, const std::vector& values);
+
+ /**
+ * The directory that the user configured to load.
+ */
+ VfsPath m_Path;
+
+ /**
+ * This is the IETF code of language that should be loaded, for example "en", or "pt-BR" for brazilian portuguese.
+ */
+ std::string m_IETFLanguageTag;
+
+ /**
+ * Stores the header of the csv files of every content type. Used as an integrity test.
+ */
+ static std::map m_BlocksHeader;
+ static std::map m_LocationsHeader;
+
+ // This discards a lot of less relevant data, because storing all strings of a City file would consume about 2GB.
+
+ // Country Blocks data
+ std::map m_Blocks_IPv4_GeoID;
+ std::set m_Blocks_IPv4_Anonymous;
+ std::set m_Blocks_IPv4_Satellite;
+
+ // City Blocks data
+ std::map> m_Blocks_IPv4_GeoCoordinates;
+
+ // ASN Blocks data
+ std::map m_Blocks_IPv4_AutonomousSystemNumber;
+ std::map m_Blocks_IPv4_AutonomousSystemOrganization;
+
+ /**
+ * Maps from geoname ID to Locations data.
+ */
+ std::map m_CountryLocations_CountryCodes;
+ std::map> m_CountryLocations_CountryData;
+ std::map> m_CityLocations;
+};
+
+/**
+ * Sneaky global.
+ */
+extern GeoLite2* g_GeoLite2;
+
+#endif // GEOLITE2_H
Index: source/network/GeoLite2.cpp
===================================================================
--- /dev/null
+++ source/network/GeoLite2.cpp
@@ -0,0 +1,450 @@
+/* Copyright (C) 2019 Wildfire Games.
+ * This file is part of 0 A.D.
+ *
+ * 0 A.D. is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * 0 A.D. is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with 0 A.D. If not, see .
+ */
+
+#include "precompiled.h"
+
+#include "GeoLite2.h"
+
+#include "lib/file/vfs/vfs_path.h"
+#include "network/IPTools.h"
+#include "ps/ConfigDB.h"
+#include "ps/Filesystem.h"
+#include "ps/CLogger.h"
+#include "scriptinterface/ScriptInterface.h"
+
+#include
+#include
+#include
+
+// TODO: Support IPv6
+// TODO: implement a thread to load, the City file consumes 14 seconds to load!
+
+GeoLite2* g_GeoLite2 = nullptr;
+
+GeoLite2::GeoLite2(const std::string& IETFLanguageTag)
+: m_IETFLanguageTag(IETFLanguageTag)
+{
+ LoadPath();
+
+ if (!LoadContent("City") && !LoadContent("Country"))
+ LOGERROR("Could not load GeoLite2 city nor country data!");
+
+ LoadBlocks("ASN");
+}
+
+GeoLite2::~GeoLite2()
+{
+}
+
+bool GeoLite2::IsEnabled()
+{
+ bool enabled = true;
+ CFG_GET_VAL("network.geolite2.enabled", enabled);
+ return enabled;
+}
+
+void GeoLite2::LoadPath()
+{
+ std::string path;
+ CFG_GET_VAL("network.geolite2.directory", path);
+ m_Path = path;
+}
+
+bool GeoLite2::LoadContent(const std::string& content)
+{
+ return LoadBlocks(content) && LoadLocations(content);
+}
+
+std::map GeoLite2::m_BlocksHeader = {
+ { "Country", "network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider" },
+ { "City", "network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius" },
+ { "ASN", "network,autonomous_system_number,autonomous_system_organization" }
+};
+
+std::map GeoLite2::m_LocationsHeader = {
+ { "Country", "geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,is_in_european_union" },
+ { "City", "geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union" }
+};
+
+/**
+ * Load
+ * GeoLite2-City-Blocks-IPv4.csv or
+ * GeoLite2-Country-Blocks-IPv4.csv or
+ * GeoLite2-ASN-Blocks-IPv4.csv.
+ *
+ * The City filesize can exceed 150MB. Storing it as a string vector can consume 2GB+!
+ * Therefore the data must be stored as numbers and bools where possible.
+ *
+ * Example Country:
+ * network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider
+ * 92.222.251.176/28,3017382,3017382,,0,0
+ *
+ * Example City:
+ * network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius
+ * 38.88.98.0/23,6075357,6252001,,0,0,L5J,43.5102,-79.6296,500
+ *
+ * Example ASM:
+ * network,autonomous_system_number,autonomous_system_organization
+ * 88.198.0.0/16,24940,"Hetzner Online GmbH"
+ */
+bool GeoLite2::LoadBlocks(const std::string& content)
+{
+ VfsPath filePath(m_Path / ("GeoLite2-" + content + "-Blocks-IPv4.csv"));
+
+ std::function& values)> readLine = [this, content](const std::vector& values)
+ {
+ if (values.size() < 1)
+ return;
+
+ // Parse subnet
+ const std::string& subnet = values.at(0);
+ u32 subnetAddress;
+ u8 subnetMaskBits;
+ if (!IPTools::ParseSubnet(subnet, subnetAddress, subnetMaskBits))
+ {
+ LOGERROR("GeoLite2: Could not parse Subnet %s\n", subnet.c_str());
+ return;
+ }
+ IPv4SubnetKeyType subnetKey = { subnetAddress, subnetMaskBits };
+
+ // Parse content
+ if (content == "Country")
+ ParseCountryBlocksLine(subnet, subnetKey, values);
+ else if (content == "City")
+ ParseCityBlocksLine(subnet, subnetKey, values);
+ else if (content == "ASN")
+ ParseASNBlocksLine(subnet, subnetKey, values);
+ };
+
+ return LoadCSVFile(filePath, m_BlocksHeader[content], readLine);
+}
+
+/**
+ * This function is also used to parse the first columns of the City Blocks file.
+ */
+void GeoLite2::ParseCountryBlocksLine(const std::string& UNUSED(subnet), const IPv4SubnetKeyType& subnetKey, const std::vector& values)
+{
+ if (values.size() < 6)
+ return;
+
+ const std::string& geonameID = values.at(1);
+ const std::string& registeredGeonameID = values.at(2);
+ const std::string& representedGeonameID = values.at(3);
+ const bool isAnonymous = values.at(4) == "1";
+ const bool isSatellite = values.at(5) == "1";
+
+ if (geonameID.empty())
+ return;
+
+ // Parse geonameID
+ const u32 geonameIDNum = static_cast(std::stoul(
+ geonameID.length() ? geonameID :
+ representedGeonameID.length() ? representedGeonameID :
+ registeredGeonameID));
+
+ // Store the data
+ m_Blocks_IPv4_GeoID[subnetKey] = geonameIDNum;
+
+ if (isSatellite)
+ m_Blocks_IPv4_Satellite.insert(subnetKey);
+
+ if (isAnonymous)
+ m_Blocks_IPv4_Anonymous.insert(subnetKey);
+}
+
+void GeoLite2::ParseCityBlocksLine(const std::string& subnet, const IPv4SubnetKeyType& subnetKey, const std::vector& values)
+{
+ ParseCountryBlocksLine(subnet, subnetKey, values);
+
+ // This can happen if the accuracy radius is empty
+ if (values.size() < 10)
+ return;
+
+ //const std::string& postal_code = values.at(6);
+ const std::string& latitude = values.at(7);
+ const std::string& longitude = values.at(8);
+ const std::string& accuracy_radius = values.at(9);
+
+ try
+ {
+ m_Blocks_IPv4_GeoCoordinates[subnetKey] = {
+ std::stof(latitude),
+ std::stof(longitude),
+ static_cast(std::stoul(accuracy_radius))
+ };
+ }
+ catch (...)
+ {
+ LOGERROR("Could not parse City Block data of %s", subnet.c_str());
+ }
+}
+
+void GeoLite2::ParseASNBlocksLine(const std::string& UNUSED(subnet), const IPv4SubnetKeyType& subnetKey, const std::vector& values)
+{
+ if (values.size() < 3)
+ return;
+
+ const std::string& autonomousSystemNumber = values.at(1);
+ const std::string& autonomousSystemOrganization = values.at(2);
+
+ const u32 autonomousSystemNumberNum = static_cast(std::stoul(autonomousSystemNumber));
+
+ m_Blocks_IPv4_AutonomousSystemNumber[subnetKey] = autonomousSystemNumberNum;
+ m_Blocks_IPv4_AutonomousSystemOrganization[autonomousSystemNumberNum] = autonomousSystemOrganization;
+}
+
+/**
+ * Load
+ * GeoLite2-City-Locations-en.csv or
+ * GeoLite2-Country-Locations-en.csv or...
+ *
+ * The filesize can exceed 10MB.
+ *
+ * Example Country:
+ * geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,is_in_european_union
+ * 2264397,en,EU,Europe,PT,Portugal,1
+ *
+ * Example City:
+ * geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union
+ * 11696023,en,NA,"North America",CA,Canada,QC,Quebec,,,Sainte-Claire,,America/Toronto,0
+ */
+bool GeoLite2::LoadLocations(const std::string& content)
+{
+ // TODO: I suppose the language tag should be an argument provided elsewhere
+ std::vector IETFLanguageTags = { m_IETFLanguageTag, "en" };
+
+ for (const std::string& IETFLanguageTag : IETFLanguageTags)
+ {
+ VfsPath filePath(m_Path / ("GeoLite2-" + content + "-Locations-" + IETFLanguageTag + ".csv"));
+
+ std::function& values)> readLine = [this, content](const std::vector& values)
+ {
+ if (values.size() < 1)
+ return;
+
+ const std::string& geonameID = values.at(0);
+ const u32 geonameIDNum = static_cast(std::stoul(geonameID));
+
+ if (content == "City")
+ ParseCityLocationsLine(geonameID, geonameIDNum, values);
+ else if (content == "Country")
+ ParseCountryLocationsLine(geonameID, geonameIDNum, values);
+ };
+
+ if (LoadCSVFile(filePath, m_LocationsHeader[content], readLine))
+ return true;
+ }
+
+ return false;
+}
+
+void GeoLite2::ParseCountryLocationsLine(const std::string& UNUSED(geonameID), const u32& geonameIDNum, const std::vector& values)
+{
+ if (values.size() < 7)
+ return;
+
+ // Notice ICU could be used for country and continent names, but not for city names, so it would be inconsistent to do so.
+
+ // const std::string& localeCode = values.at(1);
+ // const std::string& continentCode = values.at(2);
+ const std::string& continentName = values.at(3);
+ const std::string& countryCode = values.at(4);
+ const std::string& countryName = values.at(5);
+ //const bool isInEuropeanUnion = values.at(6) == "1";
+
+ if (m_CountryLocations_CountryCodes.count(geonameIDNum) == 0)
+ {
+ m_CountryLocations_CountryCodes[geonameIDNum] = countryCode;
+ m_CountryLocations_CountryData[countryCode] = { continentName, countryName };
+ }
+}
+
+void GeoLite2::ParseCityLocationsLine(const std::string& geonameID, const u32& geonameIDNum, const std::vector& values)
+{
+ if (values.size() < 14)
+ return;
+
+ ParseCountryLocationsLine(geonameID, geonameIDNum, values);
+
+ //const std::string& subdivision1Code = values.at(6);
+ //const std::string& subdivision1Name = values.at(7);
+ //const std::string& subdivision2Code = values.at(8);
+ //const std::string& subdivision2Name = values.at(9);
+ const std::string& cityName = values.at(10);
+ //const std::string& metroCode = values.at(11);
+ const std::string& timeZone = values.at(12);
+ //const bool isInEuropeanUnion = values.at(13) == "1";
+
+ // TODO: This duplicates a bit per city entry!
+ m_CityLocations[geonameIDNum] = { cityName, timeZone };
+}
+
+/**
+ * Loads the given GeoLite2 csv file as a map from the first value to the rest of the values.
+ */
+bool GeoLite2::LoadCSVFile(const VfsPath& filePath, const std::string& expectedHeader, std::function&)>& lineRead)
+{
+ CVFSFile file;
+ // TODO: VfsFileExists needed?
+ if (!VfsFileExists(filePath) || file.Load(g_VFS, filePath) != PSRETURN_OK)
+ return false;
+
+ debug_printf("Loading %s", filePath.string8().c_str());
+ std::time_t started = std::time(nullptr);
+
+ std::stringstream sstream(file.DecodeUTF8());
+
+ // Read header
+ std::string header;
+ std::getline(sstream, header);
+
+ if (header != expectedHeader)
+ {
+ debug_printf("\n");
+ LOGERROR("Unexpected GeoLite2 csv header!");
+ }
+
+ // Notice that:
+ // 1. The comma separated values can be encapsulated in quotes if the word contains a comma
+ // 2. Quotes in comma separated values are escaped with a quote character, for example the city Kup""yans'k:
+ // 703656,en,EU,Europe,UA,Ukraine,63,"Kharkivs'ka Oblast'",,,"Kup""yans'k",,Europe/Kiev,0
+ std::string line;
+ while (std::getline(sstream, line))
+ {
+ std::vector values;
+ {
+ std::stringstream valuesStream(line);
+ while (!valuesStream.eof())
+ {
+ const bool quoted = valuesStream.peek() == '"';
+ if (quoted)
+ {
+ valuesStream.ignore(1);
+
+ std::string value;
+ std::string word;
+ while(std::getline(valuesStream, word, '"'))
+ {
+ value += word;
+
+ if (valuesStream.peek() == '"')
+ // Remove quote escape character
+ valuesStream.ignore(1);
+ else
+ break;
+
+ }
+ values.push_back(value);
+ }
+ else
+ {
+ std::string value;
+ if (std::getline(valuesStream, value, ','))
+ values.push_back(value);
+ }
+
+ if (quoted)
+ {
+ char comma = 0;
+ valuesStream >> comma;
+ if (comma != ',' && comma != 0)
+ {
+ debug_printf("\n");
+ LOGERROR("CSV: Unterminated quoted comma-separated value in line %s!", line.c_str());
+ }
+ }
+ }
+ }
+ lineRead(values);
+ }
+
+ debug_printf(", took %lds.\n", std::time(nullptr) - started);
+ return true;
+}
+
+/**
+ * Returns the data of the given IP address from both Blocks and Location files.
+ */
+JS::Value GeoLite2::GetIPv4Data(const ScriptInterface& scriptInterface, u32 ipAddress)
+{
+ JSContext* cx = scriptInterface.GetContext();
+ JSAutoRequest rq(cx);
+ JS::RootedValue returnValue(cx, JS::ObjectValue(*JS_NewPlainObject(cx)));
+
+ bool foundBlock = false;
+ for (const std::pair& block : m_Blocks_IPv4_GeoID)
+ {
+ const IPv4SubnetKeyType& subnetKey = block.first;
+ const u32& geonameIDNum = block.second;
+
+ if (!IPTools::IsIpV4PartOfSubnet(ipAddress, subnetKey.first, subnetKey.second))
+ continue;
+
+ // Blocks data
+ scriptInterface.SetProperty(returnValue, "isAnonymous", m_Blocks_IPv4_Anonymous.count(subnetKey) != 0);
+ scriptInterface.SetProperty(returnValue, "isSatellite", m_Blocks_IPv4_Satellite.count(subnetKey) != 0);
+
+ if (m_Blocks_IPv4_GeoCoordinates.count(subnetKey) != 0)
+ {
+ scriptInterface.SetProperty(returnValue, "longitude", std::get<0>(m_Blocks_IPv4_GeoCoordinates.at(subnetKey)));
+ scriptInterface.SetProperty(returnValue, "latitude", std::get<1>(m_Blocks_IPv4_GeoCoordinates.at(subnetKey)));
+ scriptInterface.SetProperty(returnValue, "accuracyRadius", std::get<2>(m_Blocks_IPv4_GeoCoordinates.at(subnetKey)));
+ }
+
+ // Locations data
+ if (m_CountryLocations_CountryCodes.count(geonameIDNum) != 0)
+ {
+ const std::string& countryCode = m_CountryLocations_CountryCodes.at(geonameIDNum);
+ const std::pair& countryData = m_CountryLocations_CountryData.at(countryCode);
+
+ scriptInterface.SetProperty(returnValue, "countryCode", wstring_from_utf8(countryCode));
+ scriptInterface.SetProperty(returnValue, "continentName", wstring_from_utf8(countryData.first));
+ scriptInterface.SetProperty(returnValue, "countryName", wstring_from_utf8(countryData.second));
+ }
+
+ if (m_CityLocations.count(geonameIDNum) != 0)
+ {
+ scriptInterface.SetProperty(returnValue, "cityName", wstring_from_utf8(m_CityLocations.at(geonameIDNum).at(0)));
+ scriptInterface.SetProperty(returnValue, "timeZone", wstring_from_utf8(m_CityLocations.at(geonameIDNum).at(1)));
+ }
+
+ foundBlock = true;
+ break;
+ }
+
+ if (!foundBlock)
+ {
+ return JS::UndefinedValue();
+ }
+
+ // ASN data
+ // Notice the different csv files use different subnets
+ for (const std::pair& autonomousSystem : m_Blocks_IPv4_AutonomousSystemNumber)
+ {
+ const IPv4SubnetKeyType& subnetKey = autonomousSystem.first;
+ const u32& autonomousSystemNumberNum = autonomousSystem.second;
+
+ if (IPTools::IsIpV4PartOfSubnet(ipAddress, subnetKey.first, subnetKey.second) ||
+ m_Blocks_IPv4_AutonomousSystemNumber.count(subnetKey) == 0)
+ {
+ scriptInterface.SetProperty(returnValue, "autonomousSystemOrganization", m_Blocks_IPv4_AutonomousSystemOrganization.at(autonomousSystemNumberNum));
+ break;
+ }
+ }
+
+ return returnValue;
+}
Index: source/network/IPTools.h
===================================================================
--- /dev/null
+++ source/network/IPTools.h
@@ -0,0 +1,31 @@
+/* Copyright (C) 2019 Wildfire Games.
+ * This file is part of 0 A.D.
+ *
+ * 0 A.D. is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * 0 A.D. is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with 0 A.D. If not, see .
+ */
+
+#ifndef IPTOOLS_H
+#define IPTOOLS_H
+
+#include
+
+namespace IPTools
+{
+ bool ParseIPv4Address(const std::string& ipAddress, u32& ipAddressNum);
+ bool ParseSubnet(const std::string& subnetString, u32& subnetAddress, u8& subnetMaskBits);
+
+ bool IsIpV4PartOfSubnet(u32 ipAddress, u32 subnetAddress, int subnetMaskBits);
+}
+
+#endif // IPTOOLS_H
Index: source/network/IPTools.cpp
===================================================================
--- /dev/null
+++ source/network/IPTools.cpp
@@ -0,0 +1,67 @@
+/* Copyright (C) 2019 Wildfire Games.
+ * This file is part of 0 A.D.
+ *
+ * 0 A.D. is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * 0 A.D. is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with 0 A.D. If not, see .
+ */
+
+#include "precompiled.h"
+
+#include "IPTools.h"
+
+#include
+#include
+
+// TODO: implement banmasks
+
+/**
+ * Parses an IPv4 address, such as "223.252.161.128" to an u32 representation in hostbyte order (useful for bitmask matching).
+ */
+bool IPTools::ParseIPv4Address(const std::string& ipAddress, u32& ipAddressNum)
+{
+ // Returns in network byte order, big endianness
+ // TODO: This doesn't exist on all platforms
+ if (inet_pton(AF_INET, ipAddress.c_str(), &ipAddressNum) != 1)
+ return false;
+
+ // Convert to host byte order (little endianness)
+ ipAddressNum = ntohl(ipAddressNum);
+ return true;
+}
+
+/**
+ * Parses CIDR notation, for example "223.252.161.128/25".
+ */
+bool IPTools::ParseSubnet(const std::string& subnet, u32& subnetAddress, u8& subnetMaskBits)
+{
+ std::istringstream subnetStream(subnet);
+ std::string subnetAddressString;
+ std::getline(subnetStream, subnetAddressString, '/');
+
+ if (!ParseIPv4Address(subnetAddressString, subnetAddress))
+ return false;
+
+ std::string subnetMaskBitsString;
+ std::getline(subnetStream, subnetMaskBitsString);
+ subnetMaskBits = static_cast(std::stoi(subnetMaskBitsString));
+
+ return true;
+}
+
+/**
+ * All values in hostbyte order (little endianness)
+ */
+bool IPTools::IsIpV4PartOfSubnet(u32 ipAddress, u32 subnetAddress, int subnetMaskBits)
+{
+ return ((0xFFFFFFFF << (32 - subnetMaskBits)) & ipAddress) == subnetAddress;
+}
Index: source/network/NetClient.h
===================================================================
--- source/network/NetClient.h
+++ source/network/NetClient.h
@@ -149,6 +149,8 @@
*/
void GuiPoll(JS::MutableHandleValue);
+ JS::Value GetClientPerformance();
+
/**
* Add a message to the queue, to be read by GuiPoll.
* The script value must be in the GetScriptInterface() JS context.
@@ -243,7 +245,6 @@
static bool OnJoinSyncEndCommandBatch(void* context, CFsmEvent* event);
static bool OnRejoined(void* context, CFsmEvent* event);
static bool OnKicked(void* context, CFsmEvent* event);
- static bool OnClientTimeout(void* context, CFsmEvent* event);
static bool OnClientPerformance(void* context, CFsmEvent* event);
static bool OnClientsLoading(void* context, CFsmEvent* event);
static bool OnClientPaused(void* context, CFsmEvent* event);
@@ -284,6 +285,9 @@
/// Latest copy of player assignments heard from the server
PlayerAssignmentMap m_PlayerAssignments;
+ // Latest copy of roundtrip time and lost packets
+ std::map> m_ClientPerformance;
+
/// Globally unique identifier to distinguish users beyond the lifetime of a single network session
CStr m_GUID;
Index: source/network/NetClient.cpp
===================================================================
--- source/network/NetClient.cpp
+++ source/network/NetClient.cpp
@@ -36,6 +36,8 @@
#include "scriptinterface/ScriptInterface.h"
#include "simulation2/Simulation2.h"
+#include