diff --git a/binaries/data/mods/public/gui/lobby/LeaderboardPage/LeaderboardList.js b/binaries/data/mods/public/gui/lobby/LeaderboardPage/LeaderboardList.js
new file mode 100644
index 0000000000..ddc1804115
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LeaderboardPage/LeaderboardList.js
@@ -0,0 +1,64 @@
+/**
+ * This is class manages the content of the leaderboardlist, i.e. the list of highest rated players.
+ */
+class LeaderboardList
+{
+ constructor(xmppMessages)
+ {
+ this.selectionChangeHandlers = new Set();
+
+ this.leaderboardBox = Engine.GetGUIObjectByName("leaderboardBox");
+ this.leaderboardBox.onSelectionChange = this.onSelectionChange.bind(this);
+
+ let rebuild = this.rebuild.bind(this);
+ xmppMessages.registerXmppMessageHandler("game", "leaderboard", rebuild);
+ xmppMessages.registerXmppMessageHandler("system", "disconnected", rebuild);
+
+ this.rebuild();
+ }
+
+ registerSelectionChangeHandler(handler)
+ {
+ this.selectionChangeHandlers.add(handler);
+ }
+
+ onSelectionChange()
+ {
+ let playerName = this.selectedPlayer();
+ for (let handler of this.selectionChangeHandlers)
+ handler(playerName);
+ }
+
+ selectedPlayer()
+ {
+ return this.leaderboardBox.list[this.leaderboardBox.selected] || undefined;
+ }
+
+ /**
+ * Update the leaderboard from data cached in C++.
+ */
+ rebuild()
+ {
+ // TODO: Display placeholder if the data is not available
+ let boardList = Engine.GetBoardList().sort(
+ (a, b) => b.rating - a.rating);
+
+ let list_name = [];
+ let list_rank = [];
+ let list_rating = [];
+
+ boardList.forEach((entry, i) => {
+ list_name.push(escapeText(entry.name));
+ list_rating.push(entry.rating);
+ list_rank.push(i + 1);
+ });
+
+ this.leaderboardBox.list_name = list_name;
+ this.leaderboardBox.list_rating = list_rating;
+ this.leaderboardBox.list_rank = list_rank;
+ this.leaderboardBox.list = list_name;
+
+ if (this.leaderboardBox.selected >= this.leaderboardBox.list.length)
+ this.leaderboardBox.selected = -1;
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LeaderboardPage/LeaderboardPage.js b/binaries/data/mods/public/gui/lobby/LeaderboardPage/LeaderboardPage.js
new file mode 100644
index 0000000000..95ccd93e4a
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LeaderboardPage/LeaderboardPage.js
@@ -0,0 +1,52 @@
+/**
+ * The leaderboard page allows the player to view the highest rated players and update that list.
+ */
+class LeaderboardPage
+{
+ constructor(xmppMessages)
+ {
+ this.openPageHandlers = new Set();
+ this.closePageHandlers = new Set();
+
+ this.leaderboardList = new LeaderboardList(xmppMessages);
+
+ this.leaderboardPage = Engine.GetGUIObjectByName("leaderboardPage");
+
+ Engine.GetGUIObjectByName("leaderboardUpdateButton").onPress = this.onPressUpdate.bind(this);
+ Engine.GetGUIObjectByName("leaderboardPageBack").onPress = this.onPressClose.bind(this);
+ }
+
+ registerOpenPageHandler(handler)
+ {
+ this.openPageHandlers.add(handler);
+ }
+
+ registerClosePageHandler(handler)
+ {
+ this.closePageHandlers.add(handler);
+ }
+
+ openPage()
+ {
+ this.leaderboardPage.hidden = false;
+ Engine.SetGlobalHotkey("cancel", this.onPressClose.bind(this));
+ Engine.SendGetBoardList();
+
+ let playerName = this.leaderboardList.selectedPlayer();
+ for (let handler of this.openPageHandlers)
+ handler(playerName);
+ }
+
+ onPressUpdate()
+ {
+ Engine.SendGetBoardList();
+ }
+
+ onPressClose()
+ {
+ this.leaderboardPage.hidden = true;
+
+ for (let handler of this.closePageHandlers)
+ handler();
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LeaderboardPage/LeaderboardPage.xml b/binaries/data/mods/public/gui/lobby/LeaderboardPage/LeaderboardPage.xml
new file mode 100644
index 0000000000..613ec15843
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LeaderboardPage/LeaderboardPage.xml
@@ -0,0 +1,33 @@
+
+
diff --git a/binaries/data/mods/public/gui/lobby/Lobby.js b/binaries/data/mods/public/gui/lobby/Lobby.js
new file mode 100644
index 0000000000..716dc1ea4e
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/Lobby.js
@@ -0,0 +1,25 @@
+/**
+ * This class owns the page handlers.
+ */
+class Lobby
+{
+ constructor(dialog)
+ {
+ this.xmppMessages = new XmppMessages();
+
+ this.profilePage = new ProfilePage(this.xmppMessages);
+ this.leaderboardPage = new LeaderboardPage(this.xmppMessages);
+ this.lobbyPage = new LobbyPage(dialog, this.xmppMessages, this.leaderboardPage, this.profilePage);
+
+ this.xmppMessages.processHistoricMessages();
+
+ if (Engine.LobbyGetPlayerPresence(g_Nickname) != "available")
+ Engine.LobbySetPlayerPresence("available");
+
+ if (!dialog)
+ {
+ initMusic();
+ global.music.setState(global.music.states.MENU);
+ }
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/AnnouncementHandler.js b/binaries/data/mods/public/gui/lobby/LobbyPage/AnnouncementHandler.js
new file mode 100644
index 0000000000..d43b92b0b7
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/AnnouncementHandler.js
@@ -0,0 +1,20 @@
+/**
+ * This class informs clients of the server if an announcement had been broadcasted.
+ */
+class AnnouncementHandler
+{
+ constructor(xmppMessages)
+ {
+ xmppMessages.registerXmppMessageHandler("chat", "private-message", this.onPrivateMessage.bind(this));
+ }
+
+ onPrivateMessage(message)
+ {
+ // Announcements and the Message of the Day are sent by the server directly
+ if (!message.from)
+ messageBox(
+ 400, 250,
+ message.text.trim(),
+ translate("Notice"));
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/BuddyButton.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/BuddyButton.js
new file mode 100644
index 0000000000..25de3579a3
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/BuddyButton.js
@@ -0,0 +1,70 @@
+/**
+ * This class manages the button that enables the player to add or remove buddies.
+ */
+class BuddyButton
+{
+ constructor(xmppMessages)
+ {
+ this.buddyChangedHandlers = new Set();
+ this.playerName = undefined;
+
+ this.toggleBuddyButton = Engine.GetGUIObjectByName("toggleBuddyButton");
+ this.toggleBuddyButton.onPress = this.onPress.bind(this);
+
+ let rebuild = this.rebuild.bind(this);
+ xmppMessages.registerXmppMessageHandler("system", "connected", rebuild);
+ xmppMessages.registerXmppMessageHandler("system", "disconnected", rebuild);
+
+ this.rebuild();
+ }
+
+ registerBuddyChangeHandler(handler)
+ {
+ this.buddyChangedHandlers.add(handler);
+ }
+
+ onPlayerSelectionChange(playerName)
+ {
+ this.playerName = playerName;
+ this.rebuild();
+ }
+
+ rebuild()
+ {
+ this.toggleBuddyButton.caption =
+ g_Buddies.indexOf(this.playerName) != -1 ?
+ this.UnmarkString :
+ this.MarkString;
+
+ this.toggleBuddyButton.enabled = Engine.IsXmppClientConnected() && !!this.playerName && this.playerName != g_Nickname;
+ }
+
+ /**
+ * Toggle the buddy state of the selected player.
+ */
+ onPress()
+ {
+ if (!this.playerName || this.playerName == g_Nickname || this.playerName.indexOf(g_BuddyListDelimiter) != -1)
+ return;
+
+ let index = g_Buddies.indexOf(this.playerName);
+ if (index != -1)
+ g_Buddies.splice(index, 1);
+ else
+ g_Buddies.push(this.playerName);
+
+ Engine.ConfigDB_CreateAndWriteValueToFile(
+ "user",
+ "lobby.buddies",
+ g_Buddies.filter(nick => nick).join(g_BuddyListDelimiter) || g_BuddyListDelimiter,
+ "config/user.cfg");
+
+ this.rebuild();
+
+ for (let handler of this.buddyChangedHandlers)
+ handler();
+ }
+}
+
+BuddyButton.prototype.MarkString = translate("Mark as Buddy");
+BuddyButton.prototype.UnmarkString = translate("Unmark as Buddy");
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/HostButton.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/HostButton.js
new file mode 100644
index 0000000000..658d6f92eb
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/HostButton.js
@@ -0,0 +1,32 @@
+/**
+ * This class manages the button that enables the player to configure the start a new hosted multiplayer match.
+ */
+class HostButton
+{
+ constructor(dialog, xmppMessages)
+ {
+ this.hostButton = Engine.GetGUIObjectByName("hostButton");
+ this.hostButton.onPress = this.onPress.bind(this);
+ this.hostButton.caption = translate("Host Game");
+ this.hostButton.hidden = dialog;
+
+ let onConnectionStatusChange = this.onConnectionStatusChange.bind(this);
+ xmppMessages.registerXmppMessageHandler("system", "connected", onConnectionStatusChange);
+ xmppMessages.registerXmppMessageHandler("system", "disconnected", onConnectionStatusChange);
+ this.onConnectionStatusChange();
+ }
+
+ onConnectionStatusChange()
+ {
+ this.hostButton.enabled = Engine.IsXmppClientConnected();
+ }
+
+ onPress()
+ {
+ Engine.PushGuiPage("page_gamesetup_mp.xml", {
+ "multiplayerGameType": "host",
+ "name": g_Nickname,
+ "rating": Engine.LobbyGetPlayerRating(g_Nickname)
+ });
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/JoinButton.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/JoinButton.js
new file mode 100644
index 0000000000..0ba094638a
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/JoinButton.js
@@ -0,0 +1,130 @@
+/**
+ * This class manages the button that enables the player to join a lobby game hosted by a remote player.
+ */
+class JoinButton
+{
+ constructor(dialog, gameList)
+ {
+ this.gameList = gameList;
+
+ this.joinButton = Engine.GetGUIObjectByName("joinButton");
+ this.joinButton.caption = this.Caption;
+ this.joinButton.hidden = dialog;
+ if (!dialog)
+ this.joinButton.onPress = this.onPress.bind(this);
+
+ gameList.gamesBox.onMouseLeftDoubleClickItem = this.onPress.bind(this);
+ gameList.registerSelectionChangeHandler(this.onSelectedGameChange.bind(this, dialog));
+ }
+
+ onSelectedGameChange(dialog, selectedGame)
+ {
+ this.joinButton.hidden = dialog || !selectedGame;
+ }
+
+ /**
+ * Immediately rejoin and join gamesetups. Otherwise confirm late-observer join attempt.
+ */
+ onPress()
+ {
+ let game = this.gameList.selectedGame();
+ if (!game)
+ return;
+
+ let rating = this.getRejoinRating(game);
+ let playername = rating ? g_Nickname + " (" + rating + ")" : g_Nickname;
+
+ if (!game.isCompatible)
+ messageBox(
+ 400, 200,
+ translate("Your active mods do not match the mods of this game.") + "\n\n" +
+ comparedModsString(game.mods, Engine.GetEngineInfo().mods) + "\n\n" +
+ translate("Do you want to switch to the mod selection page?"),
+ translate("Incompatible mods"),
+ [translate("No"), translate("Yes")],
+ [null, this.openModSelectionPage.bind(this)]
+ );
+ else if (game.stanza.state == "init" || game.players.some(player => player.Name == playername))
+ this.joinSelectedGame();
+ else
+ messageBox(
+ 400, 200,
+ translate("The game has already started. Do you want to join as observer?"),
+ translate("Confirmation"),
+ [translate("No"), translate("Yes")],
+ [null, this.joinSelectedGame.bind(this)]);
+ }
+
+ /**
+ * Attempt to join the selected game without asking for confirmation.
+ */
+ joinSelectedGame()
+ {
+ if (this.joinButton.hidden)
+ return;
+
+ let game = this.gameList.selectedGame();
+ if (!game)
+ return;
+
+ let ip;
+ let port;
+ let stanza = game.stanza;
+ if (stanza.stunIP)
+ {
+ ip = stanza.stunIP;
+ port = stanza.stunPort;
+ }
+ else
+ {
+ ip = stanza.ip;
+ port = stanza.port;
+ }
+
+ if (ip.split('.').length != 4)
+ {
+ messageBox(
+ 400, 250,
+ sprintf(
+ translate("This game's address '%(ip)s' does not appear to be valid."),
+ { "ip": escapeText(stanza.ip) }),
+ translate("Error"));
+ return;
+ }
+
+ Engine.PushGuiPage("page_gamesetup_mp.xml", {
+ "multiplayerGameType": "join",
+ "ip": ip,
+ "port": port,
+ "name": g_Nickname,
+ "rating": this.getRejoinRating(stanza),
+ "useSTUN": !!stanza.stunIP,
+ "hostJID": stanza.hostUsername + "@" + Engine.ConfigDB_GetValue("user", "lobby.server") + "/0ad"
+ });
+ }
+
+ openModSelectionPage()
+ {
+ Engine.StopXmppClient();
+ Engine.SwitchGuiPage("page_modmod.xml", {
+ "cancelbutton": true
+ });
+ }
+
+ /**
+ * Rejoin games with the original playername, even if the rating changed meanwhile.
+ */
+ getRejoinRating(game)
+ {
+ for (let player of game.players)
+ {
+ let playerNickRating = splitRatingFromNick(player.Name);
+ if (playerNickRating.nick == g_Nickname)
+ return playerNickRating.rating;
+ }
+ return Engine.LobbyGetPlayerRating(g_Nickname);
+ }
+}
+
+// Translation: Join the game currently selected in the list.
+JoinButton.prototype.Caption = translate("Join Game");
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/LeaderboardButton.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/LeaderboardButton.js
new file mode 100644
index 0000000000..b5aebd273b
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/LeaderboardButton.js
@@ -0,0 +1,22 @@
+/**
+ * This class deals with the button that opens the leaderboard page.
+ */
+class LeaderboardButton
+{
+ constructor(xmppMessages, leaderboardPage)
+ {
+ this.leaderboardButton = Engine.GetGUIObjectByName("leaderboardButton");
+ this.leaderboardButton.caption = translate("Leaderboard");
+ this.leaderboardButton.onPress = leaderboardPage.openPage.bind(leaderboardPage);
+
+ let onConnectionStatusChange = this.onConnectionStatusChange.bind(this);
+ xmppMessages.registerXmppMessageHandler("system", "connected", onConnectionStatusChange);
+ xmppMessages.registerXmppMessageHandler("system", "disconnected", onConnectionStatusChange);
+ this.onConnectionStatusChange();
+ }
+
+ onConnectionStatusChange()
+ {
+ this.leaderboardButton.enabled = Engine.IsXmppClientConnected();
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/ProfileButton.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/ProfileButton.js
new file mode 100644
index 0000000000..1a191f0ab5
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/ProfileButton.js
@@ -0,0 +1,22 @@
+/**
+ * This class deals with the button that opens the profile view page.
+ */
+class ProfileButton
+{
+ constructor(xmppMessages, profilePage)
+ {
+ this.profileButton = Engine.GetGUIObjectByName("profileButton");
+ this.profileButton.caption = translate("Player Profile Lookup");
+ this.profileButton.onPress = profilePage.openPage.bind(profilePage, false);
+
+ let onConnectionStatusChange = this.onConnectionStatusChange.bind(this);
+ xmppMessages.registerXmppMessageHandler("system", "connected", onConnectionStatusChange);
+ xmppMessages.registerXmppMessageHandler("system", "disconnected", onConnectionStatusChange);
+ this.onConnectionStatusChange();
+ }
+
+ onConnectionStatusChange()
+ {
+ this.profileButton.enabled = Engine.IsXmppClientConnected();
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/QuitButton.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/QuitButton.js
new file mode 100644
index 0000000000..f635158580
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Buttons/QuitButton.js
@@ -0,0 +1,40 @@
+/**
+ * This class manages the button that allows the player to close the lobby page.
+ */
+class QuitButton
+{
+ constructor(dialog, leaderboardPage, profilePage)
+ {
+ let closeDialog = this.closeDialog.bind(this);
+ let returnToMainMenu = this.returnToMainMenu.bind(this);
+ let onPress = dialog ? closeDialog : returnToMainMenu;
+
+ let leaveButton = Engine.GetGUIObjectByName("leaveButton");
+ leaveButton.onPress = onPress;
+ leaveButton.caption = dialog ?
+ translateWithContext("previous page", "Back") :
+ translateWithContext("previous page", "Main Menu");
+
+ if (dialog)
+ {
+ Engine.SetGlobalHotkey("lobby", onPress);
+ Engine.SetGlobalHotkey("cancel", onPress);
+
+ let cancelHotkey = Engine.SetGlobalHotkey.bind(Engine, "cancel", onPress);
+ leaderboardPage.registerClosePageHandler(cancelHotkey);
+ profilePage.registerClosePageHandler(cancelHotkey);
+ }
+ }
+
+ closeDialog()
+ {
+ Engine.LobbySetPlayerPresence("playing");
+ Engine.PopGuiPage();
+ }
+
+ returnToMainMenu()
+ {
+ Engine.StopXmppClient();
+ Engine.SwitchGuiPage("page_pregame.xml");
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatCommandHandler.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatCommandHandler.js
new file mode 100644
index 0000000000..e7a21406c7
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatCommandHandler.js
@@ -0,0 +1,166 @@
+/**
+ * The purpose of this class is to test if a given textual input of the current player
+ * is not a chat message to be sent but a command to be performed locally or on the
+ * server, and if so perform it.
+ */
+class ChatCommandHandler
+{
+ constructor(chatMessagesPanel, systemMessageFormat)
+ {
+ this.chatMessagesPanel = chatMessagesPanel;
+ this.systemMessageFormat = systemMessageFormat;
+ }
+
+ /**
+ * @returns {boolean} true if the input was successfully parsed as a chat command.
+ */
+ handleChatCommand(text)
+ {
+ if (!text.startsWith('/'))
+ return false;
+
+ let index = text.indexOf(" ");
+ let command = text.substr(1, index == -1 ? undefined : index - 1);
+ let args = index == -1 ? "" : text.substr(index + 1);
+
+ let commandObj = this.ChatCommands[command] || undefined;
+ if (!commandObj)
+ {
+ this.chatMessagesPanel.addText(
+ Date.now() / 1000,
+ this.systemMessageFormat.format(
+ sprintf(translate("The command '%(cmd)s' is not supported."), {
+ "cmd": setStringTags(escapeText(command), this.ChatCommandTags)
+ })));
+ this.chatMessagesPanel.flushMessages();
+ return true;
+ }
+
+ if (commandObj.moderatorOnly && Engine.LobbyGetPlayerRole(g_Nickname) != "moderator")
+ {
+ this.chatMessagesPanel.addText(
+ Date.now() / 1000,
+ this.systemMessageFormat.format(
+ sprintf(translate("The command '%(cmd)s' is restricted to moderators."), {
+ "cmd": setStringTags(escapeText(command), this.ChatCommandTags)
+ })));
+ this.chatMessagesPanel.flushMessages();
+ return true;
+ }
+
+ let handler = commandObj && commandObj.handler || undefined;
+ if (!handler)
+ return false;
+
+ return handler.call(this, args);
+ }
+
+ argumentCount(commandName, args)
+ {
+ if (args.trim())
+ return false;
+
+ this.chatMessagesPanel.addText(
+ Date.now() / 1000,
+ this.systemMessageFormat.format(
+ sprintf(translate("The command '%(cmd)s' requires at least one argument."), {
+ "cmd": setStringTags(commandName, this.ChatCommandTags)
+ })));
+ this.chatMessagesPanel.flushMessages();
+ return true;
+ }
+}
+
+/**
+ * Color to highlight chat commands in the explanation.
+ */
+ChatCommandHandler.prototype.ChatCommandTags = {
+ "color": "200 200 255"
+};
+
+/**
+ * Commands that can be entered by clients via chat input.
+ * A handler returns true if the user input should be sent as a chat message.
+ */
+ChatCommandHandler.prototype.ChatCommands = {
+ "away": {
+ "description": translate("Set your state to 'Away'."),
+ "handler": function(args) {
+ Engine.LobbySetPlayerPresence("away");
+ return true;
+ }
+ },
+ "back": {
+ "description": translate("Set your state to 'Online'."),
+ "handler": function(args) {
+ Engine.LobbySetPlayerPresence("available");
+ return true;
+ }
+ },
+ "kick": {
+ "description": translate("Kick a specified user from the lobby. Usage: /kick nick reason"),
+ "handler": function(args) {
+ let index = args.indexOf(" ");
+ if (index == -1)
+ Engine.LobbyKick(args, "");
+ else
+ Engine.LobbyKick(args.substr(0, index), args.substr(index + 1));
+ return true;
+ },
+ "moderatorOnly": true
+ },
+ "ban": {
+ "description": translate("Ban a specified user from the lobby. Usage: /ban nick reason"),
+ "handler": function(args) {
+ let index = args.indexOf(" ");
+ if (index == -1)
+ Engine.LobbyBan(args, "");
+ else
+ Engine.LobbyBan(args.substr(0, index), args.substr(index + 1));
+ return true;
+ },
+ "moderatorOnly": true
+ },
+ "help": {
+ "description": translate("Show this help."),
+ "handler": function(args) {
+ let isModerator = Engine.LobbyGetPlayerRole(g_Nickname) == "moderator";
+ let txt = translate("Chat commands:");
+ for (let command in this.ChatCommands)
+ if (!this.ChatCommands[command].moderatorOnly || isModerator)
+ // Translation: Chat command help format
+ txt += "\n" + sprintf(translate("%(command)s - %(description)s"), {
+ "command": setStringTags(command, this.ChatCommandTags),
+ "description": this.ChatCommands[command].description
+ });
+
+ this.chatMessagesPanel.addText(
+ Date.now() / 1000,
+ this.systemMessageFormat.format(txt));
+
+ this.chatMessagesPanel.flushMessages();
+ return true;
+ }
+ },
+ "me": {
+ "description": translate("Send a chat message about yourself. Example: /me goes swimming."),
+ "handler": function(args) {
+ // Translation: Chat command
+ return this.argumentCount(translate("/me"), args);
+ }
+ },
+ "say": {
+ "description": translate("Send text as a chat message (even if it starts with slash). Example: /say /help is a great command."),
+ "handler": function(args) {
+ // Translation: Chat command
+ return this.argumentCount(translate("/say"), args);
+ }
+ },
+ "clear": {
+ "description": translate("Clear all chat scrollback."),
+ "handler": function(args) {
+ this.chatMessagesPanel.clearChatMessages();
+ return true;
+ }
+ }
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatInputPanel.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatInputPanel.js
new file mode 100644
index 0000000000..310e632478
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatInputPanel.js
@@ -0,0 +1,52 @@
+/**
+ * The purpose of this class is to process the chat input of the local player and
+ * either submit the input as chat or perform it as a local or remote command.
+ */
+class ChatInputPanel
+{
+ constructor(xmppMessages, chatMessagesPanel, systemMessageFormat)
+ {
+ this.chatCommandHandler = new ChatCommandHandler(chatMessagesPanel, systemMessageFormat);
+
+ this.chatSubmit = Engine.GetGUIObjectByName("chatSubmit");
+ this.chatSubmit.onPress = this.submitChatInput.bind(this);
+
+ this.chatInput = Engine.GetGUIObjectByName("chatInput");
+ this.chatInput.onPress = this.submitChatInput.bind(this);
+ this.chatInput.onTab = this.autocomplete.bind(this);
+ this.chatInput.tooltip = colorizeAutocompleteHotkey();
+
+ let update = this.update.bind(this);
+ xmppMessages.registerXmppMessageHandler("system", "connected", update);
+ xmppMessages.registerXmppMessageHandler("system", "disconnected", update);
+ xmppMessages.registerXmppMessageHandler("chat", "role", update);
+
+ this.update();
+ }
+
+ update()
+ {
+ let hidden = !Engine.IsXmppClientConnected() || Engine.LobbyGetPlayerRole(g_Nickname) == "visitor";
+ this.chatInput.hidden = hidden;
+ this.chatSubmit.hidden = hidden;
+ }
+
+ submitChatInput()
+ {
+ let text = this.chatInput.caption;
+ if (!text.length)
+ return;
+
+ if (!this.chatCommandHandler.handleChatCommand(text))
+ Engine.LobbySendMessage(text);
+
+ this.chatInput.caption = "";
+ }
+
+ autocomplete()
+ {
+ autoCompleteText(
+ this.chatInput,
+ Engine.GetPlayerList().map(player => player.name));
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessageEvents.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessageEvents.js
new file mode 100644
index 0000000000..ea1f9808b5
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessageEvents.js
@@ -0,0 +1,26 @@
+/**
+ * This is the class (and only class) that formats textual messages submitted by chat participants.
+ */
+ChatMessageEvents.PlayerChat = class
+{
+ constructor(xmppMessages, chatMessagesPanel)
+ {
+ this.chatMessagesPanel = chatMessagesPanel;
+ this.chatMessageFormat = new ChatMessageFormat();
+ xmppMessages.registerXmppMessageHandler("chat", "room-message", this.onRoomMessage.bind(this));
+ xmppMessages.registerXmppMessageHandler("chat", "private-message", this.onPrivateMessage.bind(this));
+ }
+
+ onRoomMessage(message)
+ {
+ this.chatMessagesPanel.addText(message.time, this.chatMessageFormat.format(message));
+ }
+
+ onPrivateMessage(message)
+ {
+ // We intend to not support private messages between users
+ if (!message.from || Engine.LobbyGetPlayerRole(message.from) == "moderator")
+ // some XMPP clients send trailing whitespace
+ this.chatMessagesPanel.addText(message.time, this.chatMessageFormat.format(message));
+ }
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessageFormat.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessageFormat.js
new file mode 100644
index 0000000000..3eb1a7fee0
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessageFormat.js
@@ -0,0 +1,69 @@
+/**
+ * Formats a chat message sent by a player (i.e. not a chat notification),
+ * accounting for chat format commands such as /me or /say and private messages.
+ *
+ * Plays an acoustic notification if the playername was mentioned
+ */
+class ChatMessageFormat
+{
+ constructor()
+ {
+ this.chatMessageFormatMe = new ChatMessageFormatMe();
+ this.chatMessageFormatSay = new ChatMessageFormatSay();
+ this.chatMessagePrivateWrapper = new ChatMessagePrivateWrapper();
+ }
+
+ /**
+ * Message properties: from, text, historic, optionally private
+ */
+ format(message)
+ {
+ let text = escapeText(message.text);
+ if (g_Nickname != message.from)
+ {
+ // Highlight nicknames, assume they do not contain escapaped characters
+ text = text.replace(g_Nickname, PlayerColor.ColorPlayerName(g_Nickname));
+
+ // Notify local player
+ if (!message.historic && text.toLowerCase().indexOf(g_Nickname.toLowerCase()) != -1)
+ soundNotification("nick");
+ }
+
+ let sender = PlayerColor.ColorPlayerName(message.from, undefined, Engine.LobbyGetPlayerRole(message.from));
+
+ // Handle chat format commands
+ let formattedMessage;
+ let index = text.indexOf(" ");
+ if (text.startsWith("/") && index != -1)
+ {
+ let command = text.substr(1, index - 1);
+ let commandText = text.substr(index + 1);
+
+ switch (command)
+ {
+ case "me":
+ {
+ formattedMessage = this.chatMessageFormatMe.format(sender, commandText);
+ break;
+ }
+ case "say":
+ {
+ formattedMessage = this.chatMessageFormatSay.format(sender, commandText);
+ break;
+ }
+ default:
+ {
+ formattedMessage = this.chatMessageFormatSay.format(sender, text);
+ break;
+ }
+ }
+ }
+ else
+ formattedMessage = this.chatMessageFormatSay.format(sender, text);
+
+ if (message.level == "private-message")
+ formattedMessage = this.chatMessagePrivateWrapper.format(formattedMessage);
+
+ return formattedMessage;
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessageFormatMe.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessageFormatMe.js
new file mode 100644
index 0000000000..abb7c1db6e
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessageFormatMe.js
@@ -0,0 +1,31 @@
+/**
+ * This class formats a chat message that was sent using the /me format command.
+ * For example "/me goes away".
+ */
+class ChatMessageFormatMe
+{
+ constructor()
+ {
+ this.args = {};
+ }
+
+ /**
+ * Sender is formatted, escapeText is the responsibility of the caller.
+ */
+ format(sender, text)
+ {
+ this.args.sender = setStringTags(sender, this.SenderTags);
+ this.args.message = text;
+ return sprintf(this.Format, this.args);
+ }
+}
+
+// Translation: Chat message issued using the ‘/me’ command.
+ChatMessageFormatMe.prototype.Format = translate("* %(sender)s %(message)s");
+
+/**
+ * Used for highlighting the sender of chat messages.
+ */
+ChatMessageFormatMe.prototype.SenderTags = {
+ "font": "sans-bold-13"
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessageFormatSay.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessageFormatSay.js
new file mode 100644
index 0000000000..0f67924833
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessageFormatSay.js
@@ -0,0 +1,38 @@
+/**
+ * This class formats a chat message that was not formatted with any commands.
+ * The nickname and the message content will be assumed to be player input, thus escaped,
+ * meaning that one cannot use colorized messages here.
+ */
+class ChatMessageFormatSay
+{
+ constructor()
+ {
+ this.senderArgs = {};
+ this.messageArgs = {};
+ }
+
+ /**
+ * Sender is formatted, escapeText is the responsibility of the caller.
+ */
+ format(sender, text)
+ {
+ this.senderArgs.sender = sender;
+ this.messageArgs.message = text;
+ this.messageArgs.sender = setStringTags(
+ sprintf(this.ChatSenderFormat, this.senderArgs),
+ this.SenderTags);
+
+ return sprintf(this.ChatMessageFormat, this.messageArgs);
+ }
+}
+
+ChatMessageFormatSay.prototype.ChatSenderFormat = translate("<%(sender)s>");
+
+ChatMessageFormatSay.prototype.ChatMessageFormat = translate("%(sender)s %(message)s");
+
+/**
+ * Used for highlighting the sender of chat messages.
+ */
+ChatMessageFormatSay.prototype.SenderTags = {
+ "font": "sans-bold-13"
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessagePrivateWrapper.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessagePrivateWrapper.js
new file mode 100644
index 0000000000..6a4fca9432
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessages/ChatMessagePrivateWrapper.js
@@ -0,0 +1,32 @@
+/**
+ * This class adds an indication that the chat message was a private message to the given text.
+ */
+class ChatMessagePrivateWrapper
+{
+ constructor()
+ {
+ this.args = {
+ "private": setStringTags(this.PrivateFormat, this.PrivateMessageTags)
+ };
+ }
+
+ /**
+ * Text is formatted, escapeText is the responsibility of the caller.
+ */
+ format(text)
+ {
+ this.args.message = text;
+ return sprintf(this.PrivateMessageFormat, this.args);
+ }
+}
+
+ChatMessagePrivateWrapper.prototype.PrivateFormat = translate("Private");
+
+ChatMessagePrivateWrapper.prototype.PrivateMessageFormat = translate("(%(private)s) %(message)s");
+
+/**
+ * Color for private messages in the chat.
+ */
+ChatMessagePrivateWrapper.prototype.PrivateMessageTags = {
+ "color": "0 150 0"
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessagesPanel.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessagesPanel.js
new file mode 100644
index 0000000000..30e51373c5
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatMessagesPanel.js
@@ -0,0 +1,52 @@
+/**
+ * This class stores and displays the chat history since the login and
+ * displays timestamps if enabled.
+ */
+class ChatMessagesPanel
+{
+ constructor(xmppMessages)
+ {
+ this.xmppMessages = xmppMessages;
+
+ this.chatText = Engine.GetGUIObjectByName("chatText");
+ this.chatHistory = "";
+
+ if (Engine.ConfigDB_GetValue("user", "chat.timestamp") == "true")
+ this.timestampWrapper = new TimestampWrapper();
+
+ this.hasUpdate = false;
+ this.flushEvent = this.flushMessages.bind(this);
+ }
+
+ addText(timestamp, text)
+ {
+ if (this.timestampWrapper)
+ text = this.timestampWrapper.format(timestamp, text);
+
+ this.chatHistory += this.chatHistory ? "\n" + text : text;
+
+ if (!this.hasUpdate)
+ {
+ this.hasUpdate = true;
+ // Most xmpp messages are not chat messages, hence
+ // only subscribe the event handler when relevant to improve performance.
+ this.xmppMessages.registerMessageBatchProcessedHandler(this.flushEvent);
+ }
+ }
+
+ flushMessages()
+ {
+ if (this.hasUpdate)
+ {
+ this.chatText.caption = this.chatHistory;
+ this.hasUpdate = false;
+ this.xmppMessages.unregisterMessageBatchProcessedHandler(this.flushEvent);
+ }
+ }
+
+ clearChatMessages()
+ {
+ this.chatHistory = "";
+ this.chatText.caption = "";
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatPanel.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatPanel.js
new file mode 100644
index 0000000000..638084b493
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatPanel.js
@@ -0,0 +1,26 @@
+/**
+ * Properties of this prototype are classes that subscribe to one or more events and
+ * construct a formatted chat message to be displayed on that event.
+ *
+ * Important: Apply escapeText on player provided input to avoid players breaking the game for everybody.
+ */
+class ChatMessageEvents
+{
+}
+
+class ChatPanel
+{
+ constructor(xmppMessages)
+ {
+ this.systemMessageFormat = new SystemMessageFormat();
+ this.statusMessageFormat = new StatusMessageFormat();
+
+ this.chatMessagesPanel = new ChatMessagesPanel(xmppMessages);
+ this.chatInputPanel = new ChatInputPanel(xmppMessages, this.chatMessagesPanel, this.systemMessageFormat);
+
+ this.chatMessageEvents = {};
+ for (let name in ChatMessageEvents)
+ this.chatMessageEvents[name] = new ChatMessageEvents[name](
+ xmppMessages, this.chatMessagesPanel, this.statusMessageFormat, this.systemMessageFormat);
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatPanel.xml b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatPanel.xml
new file mode 100644
index 0000000000..bb781c1df6
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/ChatPanel.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+ Send
+
+
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/StatusMessages/StatusMessageEvents.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/StatusMessages/StatusMessageEvents.js
new file mode 100644
index 0000000000..e7acc9a6bf
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/StatusMessages/StatusMessageEvents.js
@@ -0,0 +1,153 @@
+/**
+ * @file The classes in this file trigger notifications about occurrences in the multi-user
+ * chat room that are not chat messages, nor SystemMessages.
+ */
+
+ChatMessageEvents.ClientEvents = class
+{
+ constructor(xmppMessages, chatMessagesPanel, statusMessageFormat)
+ {
+ this.chatMessagesPanel = chatMessagesPanel;
+ this.statusMessageFormat = statusMessageFormat;
+ this.kickStrings = new KickStrings();
+ this.nickArgs = {};
+
+ xmppMessages.registerXmppMessageHandler("chat", "join", this.onClientJoin.bind(this));
+ xmppMessages.registerXmppMessageHandler("chat", "leave", this.onClientLeave.bind(this));
+ xmppMessages.registerXmppMessageHandler("chat", "kicked", this.onClientKicked.bind(this, false));
+ xmppMessages.registerXmppMessageHandler("chat", "banned", this.onClientKicked.bind(this, true));
+ }
+
+ onClientJoin(message)
+ {
+ this.nickArgs.nick = escapeText(message.nick);
+ this.chatMessagesPanel.addText(
+ message.time,
+ this.statusMessageFormat.format(sprintf(this.FormatJoin, this.nickArgs)));
+ }
+
+ onClientLeave(message)
+ {
+ this.nickArgs.nick = escapeText(message.nick);
+ this.chatMessagesPanel.addText(
+ message.time,
+ this.statusMessageFormat.format(sprintf(this.FormatLeave, this.nickArgs)));
+ }
+
+ onClientKicked(banned, message)
+ {
+ // If the local player had been kicked, that is logged more vividly than a neutral status message
+ if (message.nick != g_Nickname)
+ this.chatMessagesPanel.addText(
+ message.time,
+ this.statusMessageFormat.format(this.kickStrings.get(banned, message)));
+ }
+};
+
+ChatMessageEvents.ClientEvents.prototype.FormatJoin = translate("%(nick)s has joined.");
+ChatMessageEvents.ClientEvents.prototype.FormatLeave = translate("%(nick)s has left.");
+
+ChatMessageEvents.Nick = class
+{
+ constructor(xmppMessages, chatMessagesPanel, statusMessageFormat)
+ {
+ this.chatMessagesPanel = chatMessagesPanel;
+ this.statusMessageFormat = statusMessageFormat;
+ this.args = {};
+ xmppMessages.registerXmppMessageHandler("chat", "nick", this.onNickChange.bind(this));
+ }
+
+ onNickChange(message)
+ {
+ this.args.oldnick = escapeText(message.oldnick);
+ this.args.newnick = escapeText(message.newnick);
+ this.chatMessagesPanel.addText(
+ message.time,
+ this.statusMessageFormat.format(sprintf(this.Format, this.args)));
+ }
+};
+
+ChatMessageEvents.Nick.prototype.Format = translate("%(oldnick)s is now known as %(newnick)s.");
+
+ChatMessageEvents.Role = class
+{
+ constructor(xmppMessages, chatMessagesPanel, statusMessageFormat)
+ {
+ this.chatMessagesPanel = chatMessagesPanel;
+ this.statusMessageFormat = statusMessageFormat;
+ this.args = {};
+ xmppMessages.registerXmppMessageHandler("chat", "role", this.onRoleChange.bind(this));
+ }
+
+ onRoleChange(message)
+ {
+ let roleType = this.RoleStrings.find(type =>
+ type.newrole == message.newrole &&
+ (!type.oldrole || type.oldrole == message.oldrole));
+
+ let txt;
+ if (message.nick == g_Nickname)
+ txt = roleType.you;
+ else
+ {
+ this.args.nick = escapeText(message.nick);
+ txt = sprintf(roleType.nick, this.args);
+ }
+
+ this.chatMessagesPanel.addText(
+ message.time,
+ this.statusMessageFormat.format(txt));
+ }
+};
+
+ChatMessageEvents.Role.prototype.RoleStrings =
+[
+ {
+ "newrole": "visitor",
+ "you": translate("You have been muted."),
+ "nick": translate("%(nick)s has been muted.")
+ },
+ {
+ "newrole": "moderator",
+ "you": translate("You are now a moderator."),
+ "nick": translate("%(nick)s is now a moderator.")
+ },
+ {
+ "newrole": "participant",
+ "oldrole": "visitor",
+ "you": translate("You have been unmuted."),
+ "nick": translate("%(nick)s has been unmuted.")
+ },
+ {
+ "newrole": "participant",
+ "oldrole": "moderator",
+ "you": translate("You are not a moderator anymore."),
+ "nick": translate("%(nick)s is not a moderator anymore.")
+ }
+];
+
+ChatMessageEvents.Subject = class
+{
+ constructor(xmppMessages, chatMessagesPanel, statusMessageFormat)
+ {
+ this.chatMessagesPanel = chatMessagesPanel;
+ this.statusMessageFormat = statusMessageFormat;
+ this.args = {};
+ xmppMessages.registerXmppMessageHandler("chat", "subject", this.onSubjectChange.bind(this));
+ }
+
+ onSubjectChange(message)
+ {
+ this.args.nick = escapeText(message.nick);
+ let subject = message.subject.trim();
+ this.chatMessagesPanel.addText(
+ message.time,
+ this.statusMessageFormat.format(
+ subject ?
+ sprintf(this.FormatChange, this.args) + "\n" + subject :
+ sprintf(this.FormatDelete, this.args)));
+ }
+};
+
+ChatMessageEvents.Subject.prototype.FormatChange = translate("%(nick)s changed the lobby subject to:");
+ChatMessageEvents.Subject.prototype.FormatDelete = translate("%(nick)s deleted the lobby subject.");
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/StatusMessages/StatusMessageFormat.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/StatusMessages/StatusMessageFormat.js
new file mode 100644
index 0000000000..5451f34308
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/StatusMessages/StatusMessageFormat.js
@@ -0,0 +1,29 @@
+/**
+ * Status messages are textual event notifications triggered by multi-user chat room actions.
+ */
+class StatusMessageFormat
+{
+ constructor()
+ {
+ this.args = {};
+ }
+
+ /**
+ * escapeText is the responsibility of the caller.
+ */
+ format(text)
+ {
+ this.args.message = text;
+ return setStringTags(
+ sprintf(this.MessageFormat, this.args),
+ this.MessageTags);
+ }
+}
+
+StatusMessageFormat.prototype.MessageFormat =
+ // Translation: Chat status message
+ translate("== %(message)s");
+
+StatusMessageFormat.prototype.MessageTags = {
+ "font": "sans-bold-13"
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/SystemMessages/SystemMessageEvents.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/SystemMessages/SystemMessageEvents.js
new file mode 100644
index 0000000000..d19ada6a17
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/SystemMessages/SystemMessageEvents.js
@@ -0,0 +1,56 @@
+/**
+ * System messages are highlighted chat notifications that concern the current player.
+ */
+ChatMessageEvents.System = class
+{
+ constructor(xmppMessages, chatMessagesPanel, statusMessageFormat, systemMessageFormat)
+ {
+ this.chatMessagesPanel = chatMessagesPanel;
+ this.systemMessageFormat = systemMessageFormat;
+ this.kickStrings = new KickStrings();
+
+ xmppMessages.registerXmppMessageHandler("system", "connected", this.onConnected.bind(this));
+ xmppMessages.registerXmppMessageHandler("system", "disconnected", this.onDisconnected.bind(this));
+ xmppMessages.registerXmppMessageHandler("system", "error", this.onSystemError.bind(this));
+ xmppMessages.registerXmppMessageHandler("chat", "kicked", this.onClientKicked.bind(this, false));
+ xmppMessages.registerXmppMessageHandler("chat", "banned", this.onClientKicked.bind(this, true));
+ }
+
+ // TODO: XmppClient StanzaErrorServiceUnavailable is thrown if the ratings bot is not serving
+ // This should be caught more transparently than an unrelatable "Service unavailable" system error chat message
+ onSystemError(message)
+ {
+ this.chatMessagesPanel.addText(
+ message.time,
+ this.systemMessageFormat.format(
+ escapeText(message.text)));
+ }
+
+ onConnected(message)
+ {
+ this.chatMessagesPanel.addText(
+ message.time,
+ this.systemMessageFormat.format(this.ConnectedCaption));
+ }
+
+ onDisconnected(message)
+ {
+ this.chatMessagesPanel.addText(
+ message.time,
+ this.systemMessageFormat.format(
+ this.DisconnectedCaption + " " +
+ escapeText(message.reason + " " + message.certificate_status)));
+ }
+
+ onClientKicked(banned, message)
+ {
+ if (message.nick == g_Nickname)
+ this.chatMessagesPanel.addText(
+ message.time,
+ this.systemMessageFormat.format(
+ this.kickStrings.get(banned, message)));
+ }
+};
+
+ChatMessageEvents.System.prototype.ConnectedCaption = translate("Connected.");
+ChatMessageEvents.System.prototype.DisconnectedCaption = translate("Disconnected.");
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/SystemMessages/SystemMessageFormat.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/SystemMessages/SystemMessageFormat.js
new file mode 100644
index 0000000000..3a26f6763d
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/SystemMessages/SystemMessageFormat.js
@@ -0,0 +1,36 @@
+/**
+ * Status messages are textual event notifications triggered by local events.
+ * The messages may be colorized, hence the caller needs to apply escapeText on player input.
+ */
+class SystemMessageFormat
+{
+ constructor()
+ {
+ this.args = {
+ "system": setStringTags(this.System, this.SystemTags)
+ };
+ }
+
+ format(text)
+ {
+ this.args.message = text;
+ return setStringTags(
+ sprintf(this.MessageFormat, this.args),
+ this.MessageTags);
+ }
+}
+
+SystemMessageFormat.prototype.System =
+ // Translation: Caption for system notifications shown in the chat panel
+ translate("System:");
+
+SystemMessageFormat.prototype.SystemTags = {
+ "color": "150 0 0"
+};
+
+SystemMessageFormat.prototype.MessageFormat =
+ translate("=== %(system)s %(message)s");
+
+SystemMessageFormat.prototype.MessageTags = {
+ "font": "sans-bold-13"
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/TimestampWrapper.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/TimestampWrapper.js
new file mode 100644
index 0000000000..5f04c2a541
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Chat/TimestampWrapper.js
@@ -0,0 +1,33 @@
+/**
+ * This class wraps a string with a timestamp dating to when the message was sent.
+ */
+class TimestampWrapper
+{
+ constructor()
+ {
+ this.timeArgs = {};
+ this.timestampArgs = {};
+ }
+
+ format(timestamp, text)
+ {
+ this.timeArgs.time =
+ Engine.FormatMillisecondsIntoDateStringLocal(timestamp * 1000, this.TimeFormat);
+
+ this.timestampArgs.time = sprintf(this.TimestampFormat, this.timeArgs);
+ this.timestampArgs.message = text;
+
+ return sprintf(this.TimestampedMessageFormat, this.timestampArgs);
+ }
+}
+
+// Translation: Chat message format when there is a time prefix.
+TimestampWrapper.prototype.TimestampedMessageFormat = translate("%(time)s %(message)s");
+
+// Translation: Time prefix as shown in the multiplayer lobby (when you enable it in the options page).
+TimestampWrapper.prototype.TimestampFormat = translate("\\[%(time)s]");
+
+// Translation: Time as shown in the multiplayer lobby (when you enable it in the options page).
+// For a list of symbols that you can use, see:
+// https://sites.google.com/site/icuprojectuserguide/formatparse/datetime?pli=1#TOC-Date-Field-Symbol-Table
+TimestampWrapper.prototype.TimeFormat = translate("HH:mm");
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/ConnectionHandler.js b/binaries/data/mods/public/gui/lobby/LobbyPage/ConnectionHandler.js
new file mode 100644
index 0000000000..1905afbff5
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/ConnectionHandler.js
@@ -0,0 +1,74 @@
+/**
+ * This class will ask the player to rejoin the lobby after having been disconnected.
+ */
+class ConnectionHandler
+{
+ constructor(xmppMessages)
+ {
+ // Whether the current player has been kicked or banned
+ this.kicked = false;
+
+ // Avoid stacking of multiple dialog boxes
+ this.askingReconnect = false;
+
+ xmppMessages.registerXmppMessageHandler("chat", "leave", this.onClientLeave.bind(this));
+ xmppMessages.registerXmppMessageHandler("chat", "kicked", this.onClientKicked.bind(this, false));
+ xmppMessages.registerXmppMessageHandler("system", "disconnected", this.askReconnect.bind(this));
+ xmppMessages.registerXmppMessageHandler("chat", "nick", this.onNickChange.bind(this));
+ }
+
+ onNickChange(message)
+ {
+ if (message.oldnick == g_Nickname)
+ g_Nickname = message.newnick;
+ }
+
+ onClientLeave(message)
+ {
+ if (message.nick == g_Nickname)
+ Engine.DisconnectXmppClient();
+ }
+
+ onClientKicked(banned, message)
+ {
+ if (message.nick != g_Nickname)
+ return;
+
+ this.kicked = true;
+
+ // The current player has been kicked from the room, not from the server
+ Engine.DisconnectXmppClient();
+
+ messageBox(
+ 400, 250,
+ new KickStrings().get(banned, message),
+ banned ? translate("BANNED") : translate("KICKED"));
+ }
+
+ askReconnect()
+ {
+ if (this.kicked)
+ return;
+
+ // Ignore stacked disconnect messages
+ if (Engine.IsXmppClientConnected() || this.askingReconnect)
+ return;
+
+ this.askingReconnect = true;
+
+ messageBox(
+ 400, 200,
+ translate("You have been disconnected from the lobby. Do you want to reconnect?"),
+ translate("Confirmation"),
+ [translate("No"), translate("Yes")],
+ [
+ () => {
+ this.askingReconnect = false;
+ },
+ () => {
+ this.askingReconnect = false;
+ Engine.ConnectXmppClient();
+ }
+ ]);
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Game.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Game.js
new file mode 100644
index 0000000000..390723632a
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Game.js
@@ -0,0 +1,338 @@
+/**
+ * This class represents a multiplayer match hosted by a player in the lobby.
+ * Having this represented as a class allows to leverage significant performance
+ * gains by caching computed, escaped, translated strings and sorting keys.
+ *
+ * Additionally class representation allows implementation of events such as
+ * a new match being hosted, a match having ended, or a buddy having joined a match.
+ *
+ * Ensure that escapeText is applied to player controlled data for display.
+ *
+ * Users of the properties of this class:
+ * GameList, GameDetails, MapFilters, JoinButton, any user of GameList.selectedGame()
+ */
+class Game
+{
+ constructor()
+ {
+ // Stanza data, object with exclusively string values
+ // Used to compare which part of the stanza data changed,
+ // perform partial updates and trigger event notifications.
+ this.stanza = {};
+ for (let name of this.StanzaKeys)
+ this.stanza[name] = "";
+
+ // This will be displayed in the GameList and GameDetails
+ // Important: Player input must be processed with escapeText
+ this.displayData = {
+ "tags": {}
+ };
+
+ // Cache the values used for sorting
+ this.sortValues = {
+ "state": "",
+ "compatibility": "",
+ "hasBuddyString": ""
+ };
+
+ // Array of objects, result of stringifiedTeamListToPlayerData
+ this.players = [];
+
+ // Whether the current player has the same mods launched as the host of this game
+ this.isCompatible = undefined;
+
+ // Used to display which mods are missing if the player attempts a join
+ this.mods = undefined;
+
+ // Used by the rating column and rating filer
+ this.gameRating = undefined;
+
+ // 'Persistent temporary' sprintf arguments object to avoid repeated object construction
+ this.playerCountArgs = {};
+ }
+
+ /**
+ * Called from GameList to ensure call order.
+ */
+ onBuddyChange()
+ {
+ this.updatePlayers(this.stanza);
+ }
+
+ /**
+ * This function computes values that will either certainly or
+ * most likely be used later (i.e. by filtering, sorting and gamelist display).
+ *
+ * The performance benefit arises from the fact that for a new gamelist stanza
+ * many if not most games and game properties did not change.
+ */
+ update(newStanza, sortKey)
+ {
+ let oldStanza = this.stanza;
+ let displayData = this.displayData;
+ let sortValues = this.sortValues;
+
+ if (oldStanza.name != newStanza.name)
+ {
+ Engine.ProfileStart("gameName");
+ sortValues.gameName = newStanza.name.toLowerCase();
+ this.updateGameName(newStanza);
+ Engine.ProfileStop();
+ }
+
+ if (oldStanza.state != newStanza.state)
+ {
+ Engine.ProfileStart("gameState");
+ this.updateGameTags(newStanza);
+ sortValues.state = this.GameStatusOrder.indexOf(newStanza.state);
+ Engine.ProfileStop();
+ }
+
+ if (oldStanza.niceMapName != newStanza.niceMapName)
+ {
+ Engine.ProfileStart("niceMapName");
+ displayData.mapName = escapeText(translateMapTitle(newStanza.niceMapName));
+ Engine.ProfileStop();
+ }
+
+ if (oldStanza.mapName != newStanza.mapName)
+ {
+ Engine.ProfileStart("mapName");
+ sortValues.mapName = displayData.mapName;
+ Engine.ProfileStop();
+ }
+
+ if (oldStanza.mapType != newStanza.mapType)
+ {
+ Engine.ProfileStart("mapType");
+ displayData.mapType = g_MapTypes.Title[g_MapTypes.Name.indexOf(newStanza.mapType)] || "";
+ sortValues.mapType = newStanza.mapType;
+ Engine.ProfileStop();
+ }
+
+ if (oldStanza.mapSize != newStanza.mapSize)
+ {
+ Engine.ProfileStart("mapSize");
+ displayData.mapSize = translateMapSize(newStanza.mapSize);
+ sortValues.mapSize = newStanza.mapSize;
+ Engine.ProfileStop();
+ }
+
+ let playersChanged = oldStanza.players != newStanza.players;
+ if (playersChanged)
+ {
+ Engine.ProfileStart("playerData");
+ this.updatePlayers(newStanza);
+ Engine.ProfileStop();
+ }
+
+ if (oldStanza.nbp != newStanza.nbp ||
+ oldStanza.maxnbp != newStanza.maxnbp ||
+ playersChanged)
+ {
+ Engine.ProfileStart("playerCount");
+ displayData.playerCount = this.getTranslatedPlayerCount(newStanza);
+ sortValues.maxnbp = newStanza.maxnbp;
+ Engine.ProfileStop();
+ }
+
+ if (oldStanza.mods != newStanza.mods)
+ {
+ Engine.ProfileStart("mods");
+ this.updateMods(newStanza);
+ Engine.ProfileStop();
+ }
+
+ this.stanza = newStanza;
+ this.sortValue = this.sortValues[sortKey];
+ }
+
+ updatePlayers(newStanza)
+ {
+ let players;
+ {
+ Engine.ProfileStart("stringifiedTeamListToPlayerData");
+ players = stringifiedTeamListToPlayerData(newStanza.players);
+ this.players = players;
+ Engine.ProfileStop();
+ }
+
+ {
+ Engine.ProfileStart("parsePlayers");
+ let observerCount = 0;
+ let hasBuddies = 0;
+
+ let playerRatingTotal = 0;
+ for (let player of players)
+ {
+ let playerNickRating = splitRatingFromNick(player.Name);
+
+ if (player.Team == "observer")
+ ++observerCount;
+ else
+ playerRatingTotal += playerNickRating.rating || g_DefaultLobbyRating;
+
+ // Sort games with playing buddies above games with spectating buddies
+ if (hasBuddies < 2 && g_Buddies.indexOf(playerNickRating.nick) != -1)
+ hasBuddies = player.Team == "observer" ? 1 : 2;
+ }
+
+ this.observerCount = observerCount;
+ this.hasBuddies = hasBuddies;
+
+ let displayData = this.displayData;
+ let sortValues = this.sortValues;
+ displayData.buddy = this.hasBuddies ? setStringTags(g_BuddySymbol, displayData.tags) : "";
+ sortValues.hasBuddyString = String(hasBuddies);
+ sortValues.buddy = sortValues.hasBuddyString + sortValues.gameName;
+
+ let playerCount = players.length - observerCount;
+ let gameRating =
+ playerCount ?
+ Math.round(playerRatingTotal / playerCount) :
+ g_DefaultLobbyRating;
+ this.gameRating = gameRating;
+ sortValues.gameRating = gameRating;
+ Engine.ProfileStop();
+ }
+ }
+
+ updateMods(newStanza)
+ {
+ {
+ Engine.ProfileStart("JSON.parse");
+ try
+ {
+ this.mods = JSON.parse(newStanza.mods);
+ }
+ catch (e)
+ {
+ this.mods = [];
+ }
+ Engine.ProfileStop();
+ }
+
+ {
+ Engine.ProfileStart("hasSameMods");
+ let isCompatible = this.mods && hasSameMods(this.mods, Engine.GetEngineInfo().mods);
+ if (this.isCompatible != isCompatible)
+ {
+ this.isCompatible = isCompatible;
+ this.updateGameTags(newStanza);
+ this.sortValues.compatibility = String(isCompatible);
+ }
+ Engine.ProfileStop();
+ }
+ }
+
+ updateGameTags(newStanza)
+ {
+ let displayData = this.displayData;
+ displayData.tags = this.isCompatible ? this.StateTags[newStanza.state] : this.IncompatibleTags;
+ displayData.buddy = this.hasBuddies ? setStringTags(g_BuddySymbol, displayData.tags) : "";
+ this.updateGameName(newStanza);
+ }
+
+ updateGameName(newStanza)
+ {
+ let displayData = this.displayData;
+ displayData.gameName = setStringTags(escapeText(newStanza.name), displayData.tags);
+
+ let sortValues = this.sortValues;
+ sortValues.gameName = sortValues.compatibility + sortValues.state + sortValues.gameName;
+ sortValues.buddy = sortValues.hasBuddyString + sortValues.gameName;
+ }
+
+ getTranslatedPlayerCount(newStanza)
+ {
+ let playerCountArgs = this.playerCountArgs;
+ playerCountArgs.current = setStringTags(escapeText(newStanza.nbp), this.PlayerCountTags.CurrentPlayers);
+ playerCountArgs.max = setStringTags(escapeText(newStanza.maxnbp), this.PlayerCountTags.MaxPlayers);
+
+ let txt;
+ if (this.observerCount)
+ {
+ playerCountArgs.observercount = setStringTags(this.observerCount, this.PlayerCountTags.Observers);
+ txt = this.PlayerCountObservers;
+ }
+ else
+ txt = this.PlayerCountNoObservers;
+
+ return sprintf(txt, playerCountArgs);
+ }
+}
+
+/**
+ * These are all keys that occur in a gamelist stanza sent by XPartaMupp.
+ */
+Game.prototype.StanzaKeys = [
+ "name",
+ "ip",
+ "port",
+ "stunIP",
+ "stunPort",
+ "hostUsername",
+ "state",
+ "nbp",
+ "maxnbp",
+ "players",
+ "mapName",
+ "niceMapName",
+ "mapSize",
+ "mapType",
+ "victoryConditions",
+ "startTime",
+ "mods"
+];
+
+/**
+ * Initial sorting order of the gamelist.
+ */
+Game.prototype.GameStatusOrder = [
+ "init",
+ "waiting",
+ "running"
+];
+
+// Translation: The number of players and observers in this game
+Game.prototype.PlayerCountObservers = translate("%(current)s/%(max)s +%(observercount)s");
+
+// Translation: The number of players in this game
+Game.prototype.PlayerCountNoObservers = translate("%(current)s/%(max)s");
+
+/**
+ * Compatible games will be listed in these colors.
+ */
+Game.prototype.StateTags = {
+ "init": {
+ "color": "0 219 0"
+ },
+ "waiting": {
+ "color": "255 127 0"
+ },
+ "running": {
+ "color": "219 0 0"
+ }
+};
+
+/**
+ * Games that require different mods than the ones launched by the current player are grayed out.
+ */
+Game.prototype.IncompatibleTags = {
+ "color": "gray"
+};
+
+/**
+ * Color for the player count number in the games list.
+ */
+Game.prototype.PlayerCountTags = {
+ "CurrentPlayers": {
+ "color": "0 160 160"
+ },
+ "MaxPlayers": {
+ "color": "0 160 160"
+ },
+ "Observers": {
+ "color": "0 128 128"
+ }
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/GameDetails.js b/binaries/data/mods/public/gui/lobby/LobbyPage/GameDetails.js
new file mode 100644
index 0000000000..a8d6737c34
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/GameDetails.js
@@ -0,0 +1,141 @@
+/**
+ * The purpose of this class is to display information about the selected game.
+ */
+class GameDetails
+{
+ constructor(dialog, gameList)
+ {
+ this.playernameArgs = {};
+ this.playerCountArgs = {};
+ this.gameStartArgs = {};
+
+ this.lastGame = {};
+
+ this.gameDetails = Engine.GetGUIObjectByName("gameDetails");
+
+ this.sgMapName = Engine.GetGUIObjectByName("sgMapName");
+ this.sgGame = Engine.GetGUIObjectByName("sgGame");
+ this.sgPlayersNames = Engine.GetGUIObjectByName("sgPlayersNames");
+ this.sgMapSize = Engine.GetGUIObjectByName("sgMapSize");
+ this.sgMapPreview = Engine.GetGUIObjectByName("sgMapPreview");
+ this.sgMapDescription = Engine.GetGUIObjectByName("sgMapDescription");
+
+ gameList.registerSelectionChangeHandler(this.onGameListSelectionChange.bind(this));
+
+ this.resize(dialog);
+ }
+
+ resize(dialog)
+ {
+ let bottom = Engine.GetGUIObjectByName(dialog ? "leaveButton" : "joinButton").size.top - 5;
+ let size = this.gameDetails.size;
+ size.bottom = bottom;
+ this.gameDetails.size = size;
+ }
+
+ /**
+ * Populate the game info area with information on the current game selection.
+ */
+ onGameListSelectionChange(game)
+ {
+ this.gameDetails.hidden = !game;
+ if (!game)
+ return;
+
+ Engine.ProfileStart("GameDetails");
+
+ let stanza = game.stanza;
+ if (stanza.mapType != this.lastGame.mapType || stanza.mapName != this.lastGame.mapName)
+ {
+ let mapData = getMapDescriptionAndPreview(stanza.mapType, stanza.mapName);
+ this.sgMapPreview.sprite = getMapPreviewImage(mapData.preview);
+ this.mapDescription = mapData.description;
+ }
+
+ let displayData = game.displayData;
+ this.sgMapName.caption = displayData.mapName;
+
+ {
+ let txt;
+ if (game.isCompatible)
+ txt =
+ setStringTags(this.VictoryConditionsFormat, this.CaptionTags) + " " +
+ (stanza.victoryConditions ?
+ stanza.victoryConditions.split(",").map(translateVictoryCondition).join(this.Comma) :
+ translateWithContext("victory condition", "Endless Game"));
+ else
+ txt =
+ setStringTags(this.ModsFormat, this.CaptionTags) + " " +
+ escapeText(modsToString(game.mods, Engine.GetEngineInfo().mods));
+
+ txt +=
+ "\n" + setStringTags(this.MapTypeFormat, this.CaptionTags) + " " + displayData.mapType +
+ "\n" + setStringTags(this.MapSizeFormat, this.CaptionTags) + " " + displayData.mapSize +
+ "\n" + setStringTags(this.MapDescriptionFormat, this.CaptionTags) + " " + this.mapDescription;
+
+ this.sgMapDescription.caption = txt;
+ }
+
+ {
+ let txt = escapeText(stanza.name);
+
+ this.playernameArgs.playername = escapeText(stanza.hostUsername);
+ txt += "\n" + sprintf(this.HostFormat, this.playernameArgs);
+
+ this.playerCountArgs.current = escapeText(stanza.nbp);
+ this.playerCountArgs.total = escapeText(stanza.maxnbp);
+ txt += "\n" + sprintf(this.PlayerCountFormat, this.playerCountArgs);
+
+ if (stanza.startTime)
+ {
+ this.gameStartArgs.time = Engine.FormatMillisecondsIntoDateStringLocal(+stanza.startTime * 1000, this.TimeFormat);
+ txt += "\n" + sprintf(this.GameStartFormat, this.gameStartArgs);
+ }
+
+ this.sgGame.caption = txt;
+ }
+
+ {
+ let textHeight = this.sgGame.getTextSize().height;
+
+ let sgGameSize = this.sgGame.size;
+ sgGameSize.bottom = textHeight;
+ this.sgGame.size = sgGameSize;
+
+ let sgPlayersNamesSize = this.sgPlayersNames.size;
+ sgPlayersNamesSize.top = textHeight + 5;
+ this.sgPlayersNames.size = sgPlayersNamesSize;
+ }
+
+ this.sgPlayersNames.caption = formatPlayerInfo(game.players);
+
+ this.lastGame = game;
+ Engine.ProfileStop();
+ }
+}
+
+GameDetails.prototype.HostFormat = translate("Host: %(playername)s");
+
+GameDetails.prototype.PlayerCountFormat = translate("Players: %(current)s/%(total)s");
+
+GameDetails.prototype.VictoryConditionsFormat = translate("Victory Conditions:");
+
+// Translation: Comma used to concatenate victory conditions
+GameDetails.prototype.Comma = translate(", ");
+
+GameDetails.prototype.ModsFormat = translate("Mods:");
+
+// Translation: %(time)s is the hour and minute here.
+GameDetails.prototype.GameStartFormat = translate("Game started at %(time)s");
+
+GameDetails.prototype.TimeFormat = translate("HH:mm");
+
+GameDetails.prototype.MapTypeFormat = translate("Map Type:");
+
+GameDetails.prototype.MapSizeFormat = translate("Map Size:");
+
+GameDetails.prototype.MapDescriptionFormat = translate("Map Description:");
+
+GameDetails.prototype.CaptionTags = {
+ "font": "sans-bold-14"
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/GameDetails.xml b/binaries/data/mods/public/gui/lobby/LobbyPage/GameDetails.xml
new file mode 100644
index 0000000000..43092058cd
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/GameDetails.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/GameList.js b/binaries/data/mods/public/gui/lobby/LobbyPage/GameList.js
new file mode 100644
index 0000000000..29df38ef45
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/GameList.js
@@ -0,0 +1,221 @@
+/**
+ * Each property of this class handles one specific map filter and is defined in external files.
+ */
+class GameListFilters
+{
+}
+
+/**
+ * This class displays the list of multiplayer matches that are currently being set up or running,
+ * filtered and sorted depending on player selection.
+ */
+class GameList
+{
+ constructor(xmppMessages, buddyButton)
+ {
+ // Array of Game class instances, where the keys are ip+port strings, used for quick lookups
+ this.games = {};
+
+ // Array of Game class instances sorted by display order
+ this.gameList = [];
+
+ this.selectionChangeHandlers = new Set();
+
+ this.gamesBox = Engine.GetGUIObjectByName("gamesBox");
+ this.gamesBox.onSelectionChange = this.onSelectionChange.bind(this);
+ this.gamesBox.onSelectionColumnChange = this.onFilterChange.bind(this);
+ let ratingColumn = Engine.ConfigDB_GetValue("user", "lobby.columns.gamerating") == "true";
+ this.gamesBox.hidden_mapType = ratingColumn;
+ this.gamesBox.hidden_gameRating = !ratingColumn;
+
+ // Avoid repeated array construction
+ this.list_buddy = [];
+ this.list_gameName = [];
+ this.list_mapName = [];
+ this.list_mapSize = [];
+ this.list_mapType = [];
+ this.list_maxnbp = [];
+ this.list_gameRating = [];
+ this.list = [];
+
+ this.filters = [];
+ for (let name in GameListFilters)
+ this.filters.push(new GameListFilters[name](this.onFilterChange.bind(this)));
+
+ xmppMessages.registerXmppMessageHandler("game", "gamelist", this.rebuildGameList.bind(this));
+ xmppMessages.registerXmppMessageHandler("system", "disconnected", this.rebuildGameList.bind(this));
+
+ buddyButton.registerBuddyChangeHandler(this.onBuddyChange.bind(this));
+
+ this.rebuildGameList();
+ }
+
+ registerSelectionChangeHandler(handler)
+ {
+ this.selectionChangeHandlers.add(handler);
+ }
+
+ onFilterChange()
+ {
+ this.rebuildGameList();
+ }
+
+ onBuddyChange()
+ {
+ for (let name in this.games)
+ this.games[name].onBuddyChange();
+
+ this.rebuildGameList();
+ }
+
+ onSelectionChange()
+ {
+ let game = this.selectedGame();
+ for (let handler of this.selectionChangeHandlers)
+ handler(game);
+ }
+
+ selectedGame()
+ {
+ return this.gameList[this.gamesBox.selected] || undefined;
+ }
+
+ rebuildGameList()
+ {
+ Engine.ProfileStart("rebuildGameList");
+
+ Engine.ProfileStart("getGameList");
+ let selectedGame = this.selectedGame();
+ let gameListData = Engine.GetGameList();
+ Engine.ProfileStop();
+
+ {
+ Engine.ProfileStart("updateGames");
+ let selectedColumn = this.gamesBox.selected_column;
+ let newGames = {};
+ for (let stanza of gameListData)
+ {
+ let game = this.games[stanza.ip] || undefined;
+ let exists = !!game;
+ if (!exists)
+ game = new Game();
+
+ game.update(stanza, selectedColumn);
+ newGames[stanza.ip] = game;
+ }
+ this.games = newGames;
+ Engine.ProfileStop();
+ }
+
+ {
+ Engine.ProfileStart("filterGameList");
+ this.gameList.length = 0;
+ for (let ip in this.games)
+ {
+ let game = this.games[ip];
+ if (this.filters.every(filter => filter.filter(game)))
+ this.gameList.push(game);
+ }
+ Engine.ProfileStop();
+ }
+
+ {
+ Engine.ProfileStart("sortGameList");
+ let sortOrder = this.gamesBox.selected_column_order;
+ this.gameList.sort((game1, game2) => {
+ if (game1.sortValue < game2.sortValue) return -sortOrder;
+ if (game1.sortValue > game2.sortValue) return +sortOrder;
+ return 0;
+ });
+ Engine.ProfileStop();
+ }
+
+ let selectedGameIndex = -1;
+
+ {
+ Engine.ProfileStart("setupGameList");
+ let length = this.gameList.length;
+ this.list_buddy.length = length;
+ this.list_gameName.length = length;
+ this.list_mapName.length = length;
+ this.list_mapSize.length = length;
+ this.list_mapType.length = length;
+ this.list_maxnbp.length = length;
+ this.list_gameRating.length = length;
+ this.list.length = length;
+
+ this.gameList.forEach((game, i) => {
+
+ let displayData = game.displayData;
+ this.list_buddy[i] = displayData.buddy;
+ this.list_gameName[i] = displayData.gameName;
+ this.list_mapName[i] = displayData.mapName;
+ this.list_mapSize[i] = displayData.mapSize;
+ this.list_mapType[i] = displayData.mapType;
+ this.list_maxnbp[i] = displayData.playerCount;
+ this.list_gameRating[i] = game.gameRating;
+ this.list[i] = "";
+
+ if (selectedGame && game.stanza.ip == selectedGame.stanza.ip && game.stanza.port == selectedGame.stanza.port)
+ selectedGameIndex = i;
+ });
+ Engine.ProfileStop();
+ }
+
+ {
+ Engine.ProfileStart("copyToGUI");
+ let gamesBox = this.gamesBox;
+ gamesBox.list_buddy = this.list_buddy;
+ gamesBox.list_gameName = this.list_gameName;
+ gamesBox.list_mapName = this.list_mapName;
+ gamesBox.list_mapSize = this.list_mapSize;
+ gamesBox.list_mapType = this.list_mapType;
+ gamesBox.list_maxnbp = this.list_maxnbp;
+ gamesBox.list_gameRating = this.list_gameRating;
+
+ // Change these last, otherwise crash
+ gamesBox.list = this.list;
+ gamesBox.list_data = this.list;
+ gamesBox.auto_scroll = false;
+ Engine.ProfileStop();
+
+ gamesBox.selected = selectedGameIndex;
+ }
+
+ Engine.ProfileStop();
+ }
+
+ /**
+ * Select the game where the selected player is currently playing, observing or offline.
+ * Selects in that order to account for players that occur in multiple games.
+ */
+ selectGameFromPlayername(playerName)
+ {
+ if (!playerName)
+ return;
+
+ let foundAsObserver = false;
+
+ for (let i = 0; i < this.gameList.length; ++i)
+ for (let player of this.gameList[i].players)
+ {
+ if (playerName != splitRatingFromNick(player.Name).nick)
+ continue;
+
+ this.gamesBox.auto_scroll = true;
+ if (player.Team == "observer")
+ {
+ foundAsObserver = true;
+ this.gamesBox.selected = i;
+ }
+ else if (!player.Offline)
+ {
+ this.gamesBox.selected = i;
+ return;
+ }
+
+ if (!foundAsObserver)
+ this.gamesBox.selected = i;
+ }
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/GameList.xml b/binaries/data/mods/public/gui/lobby/LobbyPage/GameList.xml
new file mode 100644
index 0000000000..0d1a14fa99
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/GameList.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ Name
+
+
+ Map Name
+
+
+ Size
+
+
+ Type
+
+
+ Players
+
+
+ Rating
+
+
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters.xml b/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters.xml
new file mode 100644
index 0000000000..5b1d5ce82c
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters.xml
@@ -0,0 +1,37 @@
+
+
+
+
+ Show only open games
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/MapSize.js b/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/MapSize.js
new file mode 100644
index 0000000000..ecc5e5c6e4
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/MapSize.js
@@ -0,0 +1,25 @@
+GameListFilters.MapSize = class
+{
+ constructor(onFilterChange)
+ {
+ this.selected = 0;
+ this.onFilterChange = onFilterChange;
+
+ this.mapSizeFilter = Engine.GetGUIObjectByName("mapSizeFilter");
+ this.mapSizeFilter.list = [translateWithContext("map size", "Any"), ...g_MapSizes.Name];
+ this.mapSizeFilter.list_data = ["", ...g_MapSizes.Tiles];
+ this.mapSizeFilter.selected = 0;
+ this.mapSizeFilter.onSelectionChange = this.onSelectionChange.bind(this);
+ }
+
+ onSelectionChange()
+ {
+ this.selected = this.mapSizeFilter.list_data[this.mapSizeFilter.selected];
+ this.onFilterChange();
+ }
+
+ filter(game)
+ {
+ return !this.selected || game.stanza.mapSize == this.selected;
+ }
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/MapType.js b/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/MapType.js
new file mode 100644
index 0000000000..107d22703d
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/MapType.js
@@ -0,0 +1,25 @@
+GameListFilters.MapType = class
+{
+ constructor(onFilterChange)
+ {
+ this.selected = "";
+ this.onFilterChange = onFilterChange;
+
+ this.mapTypeFilter = Engine.GetGUIObjectByName("mapTypeFilter");
+ this.mapTypeFilter.list = [translateWithContext("map", "Any")].concat(g_MapTypes.Title);
+ this.mapTypeFilter.list_data = [""].concat(g_MapTypes.Name);
+ this.mapTypeFilter.selected = g_MapTypes.Default;
+ this.mapTypeFilter.onSelectionChange = this.onSelectionChange.bind(this);
+ }
+
+ onSelectionChange()
+ {
+ this.selected = this.mapTypeFilter.list_data[this.mapTypeFilter.selected];
+ this.onFilterChange();
+ }
+
+ filter(game)
+ {
+ return !this.selected || game.stanza.mapType == this.selected;
+ }
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/OpenGame.js b/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/OpenGame.js
new file mode 100644
index 0000000000..3004276db6
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/OpenGame.js
@@ -0,0 +1,24 @@
+GameListFilters.OpenGame = class
+{
+ constructor(onFilterChange)
+ {
+ this.checked = false;
+ this.onFilterChange = onFilterChange;
+
+ this.filterOpenGames = Engine.GetGUIObjectByName("filterOpenGames");
+ this.filterOpenGames.checked = false;
+ this.filterOpenGames.onPress = this.onPress.bind(this);
+ }
+
+ onPress()
+ {
+ this.checked = this.filterOpenGames.checked;
+ this.onFilterChange();
+ }
+
+ filter(game)
+ {
+ let stanza = game.stanza;
+ return !this.checked || stanza.state == "init" && stanza.nbp < stanza.maxnbp;
+ }
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/PlayerCount.js b/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/PlayerCount.js
new file mode 100644
index 0000000000..3cc6d3920d
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/PlayerCount.js
@@ -0,0 +1,26 @@
+GameListFilters.PlayerCount = class
+{
+ constructor(onFilterChange)
+ {
+ this.selected = "";
+ this.onFilterChange = onFilterChange;
+
+ let playersArray = Array(g_MaxPlayers).fill(0).map((v, i) => i + 1); // 1, 2, ... MaxPlayers
+ this.playersNumberFilter = Engine.GetGUIObjectByName("playersNumberFilter");
+ this.playersNumberFilter.list = [translateWithContext("player number", "Any")].concat(playersArray);
+ this.playersNumberFilter.list_data = [""].concat(playersArray);
+ this.playersNumberFilter.selected = 0;
+ this.playersNumberFilter.onSelectionChange = this.onSelectionChange.bind(this);
+ }
+
+ onSelectionChange()
+ {
+ this.selected = this.playersNumberFilter.list_data[this.playersNumberFilter.selected];
+ this.onFilterChange();
+ }
+
+ filter(game)
+ {
+ return !this.selected || game.stanza.maxnbp == this.selected;
+ }
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/Rating.js b/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/Rating.js
new file mode 100644
index 0000000000..c730a5720a
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/GameListFilters/Rating.js
@@ -0,0 +1,75 @@
+GameListFilters.Rating = class
+{
+ constructor(onFilterChange)
+ {
+ this.enabled = undefined;
+ this.onFilterChange = onFilterChange;
+ this.filter = () => true;
+
+ this.gameRatingFilter = Engine.GetGUIObjectByName("gameRatingFilter");
+ this.gameRatingFilter.list = [
+ translateWithContext("map", "Any"),
+ ...this.RatingFilters
+ ];
+ this.gameRatingFilter.list_data = [
+ "",
+ ...this.RatingFilters.map(r =>
+ sprintf(
+ r[0] == ">" ?
+ translateWithContext("gamelist filter", "> %(rating)s") :
+ translateWithContext("gamelist filter", "< %(rating)s"),
+ {
+ "rating": r.substr(1)
+ }))
+ ];
+
+ this.gameRatingFilter.selected = 0;
+ this.gameRatingFilter.onSelectionChange = this.onSelectionChange.bind(this);
+
+ this.setEnabled(Engine.ConfigDB_GetValue("user", "lobby.columns.gamerating") == "true");
+ }
+
+ setEnabled(enabled)
+ {
+ this.enabled = enabled;
+ this.gameRatingFilter.hidden = !this.enabled;
+ if (!enabled)
+ return;
+
+ // TODO: COList should expose the precise column width
+ // Hide element to compensate width
+ let mapTypeFilter = Engine.GetGUIObjectByName("mapTypeFilter");
+ mapTypeFilter.hidden = enabled;
+ let playersNumberFilter = Engine.GetGUIObjectByName("playersNumberFilter");
+ let mapTypeFilterSize = mapTypeFilter.size;
+ let size = playersNumberFilter.size;
+ size.rleft = mapTypeFilterSize.rleft;
+ size.rright = this.gameRatingFilter.size.rleft;
+ playersNumberFilter.size = size;
+ }
+
+ onSelectionChange()
+ {
+ let selectedType = this.gameRatingFilter.list_data[this.gameRatingFilter.selected];
+ let selectedRating = +selectedType.substr(1);
+
+ this.filter =
+ (!this.enabled || !selectedType) ?
+ () => true :
+ selectedType.startsWith(">") ?
+ game => game.gameRating >= selectedRating :
+ game => game.gameRating < selectedRating;
+
+ this.onFilterChange();
+ }
+};
+
+GameListFilters.Rating.prototype.RatingFilters = [
+ ">1500",
+ ">1400",
+ ">1300",
+ ">1200",
+ "<1200",
+ "<1100",
+ "<1000"
+];
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/KickStrings.js b/binaries/data/mods/public/gui/lobby/LobbyPage/KickStrings.js
new file mode 100644
index 0000000000..6f0c6ef9d6
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/KickStrings.js
@@ -0,0 +1,49 @@
+/**
+ * This class provides translated kick event notification strings,
+ * consumed by chat and notification message box.
+ */
+class KickStrings
+{
+ constructor()
+ {
+ this.nickArgs = {};
+ this.reasonArgs = {};
+ }
+
+ get(banned, message)
+ {
+ let level = banned ? 1 : 0;
+ let me = message.nick == g_Nickname;
+
+ let txt;
+ if (me)
+ txt = this.Strings.Local[level];
+ else
+ {
+ this.nickArgs.nick = escapeText(message.nick);
+ txt = sprintf(this.Strings.Remote[level], this.nickArgs);
+ }
+
+ if (message.reason)
+ {
+ this.reasonArgs.reason = escapeText(message.reason);
+ txt += " " + sprintf(this.Reason, this.reasonArgs);
+ }
+
+ return txt;
+ }
+}
+
+KickStrings.prototype.Strings = {
+ "Local": [
+ translate("You have been kicked from the lobby!"),
+ translate("You have been banned from the lobby!")
+ ],
+ "Remote": [
+ translate("%(nick)s has been kicked from the lobby."),
+ translate("%(nick)s has been banned from the lobby.")
+ ]
+};
+
+KickStrings.prototype.Reason =
+ translateWithContext("lobby kick", "Reason: %(reason)s");
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/LobbyPage.js b/binaries/data/mods/public/gui/lobby/LobbyPage/LobbyPage.js
new file mode 100644
index 0000000000..59b878c25f
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/LobbyPage.js
@@ -0,0 +1,73 @@
+/**
+ * This class stores the handlers for all GUI objects in the lobby page,
+ * (excluding other pages in the same context such as leaderboard and profile page).
+ */
+class LobbyPage
+{
+ constructor(dialog, xmppMessages, leaderboardPage, profilePage)
+ {
+ Engine.ProfileStart("Create LobbyPage");
+ let buddyButton = new BuddyButton(xmppMessages);
+ let gameList = new GameList(xmppMessages, buddyButton);
+ let playerList = new PlayerList(xmppMessages, buddyButton, gameList);
+
+ this.lobbyPage = {
+ "buttons": {
+ "buddyButton": buddyButton,
+ "hostButton": new HostButton(dialog, xmppMessages),
+ "joinButton": new JoinButton(dialog, gameList),
+ "leaderboardButton": new LeaderboardButton(xmppMessages, leaderboardPage),
+ "profileButton": new ProfileButton(xmppMessages, profilePage),
+ "quitButton": new QuitButton(dialog, leaderboardPage, profilePage)
+ },
+ "panels": {
+ "chatPanel": new ChatPanel(xmppMessages),
+ "gameDetails": new GameDetails(dialog, gameList),
+ "gameList": gameList,
+ "playerList": playerList,
+ "profilePanel": new ProfilePanel(xmppMessages, playerList, leaderboardPage),
+ "subject": new Subject(dialog, xmppMessages, gameList)
+ },
+ "eventHandlers": {
+ "announcementHandler": new AnnouncementHandler(xmppMessages),
+ "connectionHandler": new ConnectionHandler(xmppMessages),
+ }
+ };
+
+ if (dialog)
+ this.setDialogStyle();
+ Engine.ProfileStop();
+ }
+
+ setDialogStyle()
+ {
+ {
+ let lobbyPage = Engine.GetGUIObjectByName("lobbyPage");
+ lobbyPage.sprite = "ModernDialog";
+
+ let size = lobbyPage.size;
+ size.left = this.WindowMargin;
+ size.top = this.WindowMargin;
+ size.right = -this.WindowMargin;
+ size.bottom = -this.WindowMargin;
+ lobbyPage.size = size;
+ }
+
+ {
+ let lobbyPageTitle = Engine.GetGUIObjectByName("lobbyPageTitle");
+ let size = lobbyPageTitle.size;
+ size.top -= this.WindowMargin / 2;
+ size.bottom -= this.WindowMargin / 2;
+ lobbyPageTitle.size = size;
+ }
+
+ {
+ let lobbyPanels = Engine.GetGUIObjectByName("lobbyPanels");
+ let size = lobbyPanels.size;
+ size.top -= this.WindowMargin / 2;
+ lobbyPanels.size = size;
+ }
+ }
+}
+
+LobbyPage.prototype.WindowMargin = 40;
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/LobbyPage.xml b/binaries/data/mods/public/gui/lobby/LobbyPage/LobbyPage.xml
new file mode 100644
index 0000000000..5aa9d659b5
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/LobbyPage.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+ Multiplayer Lobby
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/PlayerColor.js b/binaries/data/mods/public/gui/lobby/LobbyPage/PlayerColor.js
new file mode 100644
index 0000000000..650741d9a0
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/PlayerColor.js
@@ -0,0 +1,53 @@
+/**
+ * The purpose of this class is to determine a color per playername and to apply that color,
+ * escape reserved characters and add a moderator prefix when displaying playernames.
+ */
+class PlayerColor
+{
+}
+
+/**
+ * Generate a (mostly) unique color for this player based on their name.
+ * @see https://stackoverflow.com/questions/3426404/create-a-hexadecimal-colour-based-on-a-string-with-jquery-javascript
+ */
+PlayerColor.GetPlayerColor = function(playername)
+{
+ // Generate a probably-unique hash for the player name and use that to create a color.
+ let hash = 0;
+ for (let i in playername)
+ hash = playername.charCodeAt(i) + ((hash << 5) - hash);
+
+ // First create the color in RGB then HSL, clamp the lightness so it's not too dark to read, and then convert back to RGB to display.
+ // The reason for this roundabout method is this algorithm can generate values from 0 to 255 for RGB but only 0 to 100 for HSL; this gives
+ // us much more variety if we generate in RGB. Unfortunately, enforcing that RGB values are a certain lightness is very difficult, so
+ // we convert to HSL to do the computation. Since our GUI code only displays RGB colors, we have to convert back.
+ let [h, s, l] = rgbToHsl(hash >> 24 & 0xFF, hash >> 16 & 0xFF, hash >> 8 & 0xFF);
+ return hslToRgb(h, s, Math.max(0.7, l)).join(" ");
+};
+
+/**
+ * Colorizes the given nickname with a color unique and deterministic for that player.
+ */
+PlayerColor.ColorPlayerName = function(playername, rating, role)
+{
+ let name = rating ?
+ sprintf(translate("%(nick)s (%(rating)s)"), {
+ "nick": playername,
+ "rating": rating
+ }) :
+ playername;
+
+ if (role == "moderator")
+ name = PlayerColor.ModeratorPrefix + name;
+
+ return coloredText(escapeText(name), PlayerColor.GetPlayerColor(playername));
+};
+
+/**
+ * A symbol which is prepended to the nickname of moderators.
+ */
+PlayerColor.ModeratorPrefix = "@";
+
+
+// TODO: Remove global required by formatPlayerInfo
+var getPlayerColor = PlayerColor.GetPlayerColor;
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/PlayerList.js b/binaries/data/mods/public/gui/lobby/LobbyPage/PlayerList.js
new file mode 100644
index 0000000000..2fb6eb6792
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/PlayerList.js
@@ -0,0 +1,207 @@
+/**
+ * This class is concerned with displaying players who are online and
+ * triggering handlers when selecting or doubleclicking on a player.
+ */
+class PlayerList
+{
+ constructor(xmppMessages, buddyButton, gameList)
+ {
+ this.gameList = gameList;
+ this.selectedPlayer = undefined;
+ this.statusOrder = Object.keys(this.PlayerStatuses);
+
+ // Avoid repeated array construction for performance
+ this.buddyStatusList = [];
+ this.playerList = [];
+ this.presenceList = [];
+ this.nickList = [];
+ this.ratingList = [];
+
+ this.selectionChangeHandlers = new Set();
+ this.mouseLeftDoubleClickItemHandlers = new Set();
+
+ this.playersBox = Engine.GetGUIObjectByName("playersBox");
+ this.playersBox.onSelectionChange = this.onPlayerListSelection.bind(this);
+ this.playersBox.onSelectionColumnChange = this.rebuildPlayerList.bind(this);
+ this.playersBox.onMouseLeftClickItem = this.onMouseLeftClickItem.bind(this);
+ this.playersBox.onMouseLeftDoubleClickItem = this.onMouseLeftDoubleClickItem.bind(this);
+
+ buddyButton.registerBuddyChangeHandler(this.onBuddyChange.bind(this));
+ xmppMessages.registerPlayerListUpdateHandler(this.rebuildPlayerList.bind(this));
+ this.registerSelectionChangeHandler(buddyButton.onPlayerSelectionChange.bind(buddyButton));
+ this.registerMouseLeftDoubleClickItemHandler(buddyButton.onPress.bind(buddyButton));
+
+ this.rebuildPlayerList();
+ }
+
+ registerSelectionChangeHandler(handler)
+ {
+ this.selectionChangeHandlers.add(handler);
+ }
+
+ registerMouseLeftDoubleClickItemHandler(handler)
+ {
+ this.mouseLeftDoubleClickItemHandlers.add(handler);
+ }
+
+ onBuddyChange()
+ {
+ this.rebuildPlayerList();
+ }
+
+ onMouseLeftDoubleClickItem()
+ {
+ for (let handler of this.mouseLeftDoubleClickItemHandlers)
+ handler();
+ }
+
+ onMouseLeftClickItem()
+ {
+ // In case of clicking on the same player again
+ this.gameList.selectGameFromPlayername(this.selectedPlayer);
+ }
+
+
+ onPlayerListSelection()
+ {
+ if (this.playersBox.selected == this.playersBox.list.indexOf(this.selectedPlayer))
+ return;
+
+ this.selectedPlayer = this.playersBox.list[this.playersBox.selected];
+
+ this.gameList.selectGameFromPlayername(this.selectedPlayer);
+
+ for (let handler of this.selectionChangeHandlers)
+ handler(this.selectedPlayer);
+ }
+
+ parsePlayer(sortKey, player)
+ {
+ player.isBuddy = g_Buddies.indexOf(player.name) != -1;
+
+ switch (sortKey)
+ {
+ case 'buddy':
+ player.sortValue = (player.isBuddy ? 1 : 2) + this.statusOrder.indexOf(player.presence) + player.name.toLowerCase();
+ break;
+ case 'rating':
+ player.sortValue = +player.rating;
+ break;
+ case 'status':
+ player.sortValue = this.statusOrder.indexOf(player.presence) + player.name.toLowerCase();
+ break;
+ case 'name':
+ default:
+ player.sortValue = player.name.toLowerCase();
+ break;
+ }
+ }
+
+ /**
+ * Do a full update of the player listing, including ratings from cached C++ information.
+ * Important: This should only be performed once if
+ * there have been multiple messages received changing this list.
+ */
+ rebuildPlayerList()
+ {
+ Engine.ProfileStart("rebuildPlaersList");
+
+ Engine.ProfileStart("getPlayerList");
+ let playerList = Engine.GetPlayerList();
+ Engine.ProfileStop();
+
+ Engine.ProfileStart("parsePlayers");
+ playerList.forEach(this.parsePlayer.bind(this, this.playersBox.selected_column));
+ Engine.ProfileStop();
+
+ Engine.ProfileStart("sortPlayers");
+ playerList.sort(this.sortPlayers.bind(this, this.playersBox.selected_column_order));
+ Engine.ProfileStop();
+
+ Engine.ProfileStart("prepareList");
+ let length = playerList.length;
+ this.buddyStatusList.length = length;
+ this.playerList.length = length;
+ this.presenceList.length = length;
+ this.nickList.length = length;
+ this.ratingList.length = length;
+
+ playerList.forEach((player, i) => {
+ // TODO: COList.cpp columns should support horizontal center align
+ let rating = player.rating ? (" " + player.rating).substr(-5) : " -";
+
+ let presence = this.PlayerStatuses[player.presence] ? player.presence : "unknown";
+ if (presence == "unknown")
+ warn("Unknown presence:" + player.presence);
+
+ let statusTags = this.PlayerStatuses[presence].tags;
+ this.buddyStatusList[i] = player.isBuddy ? setStringTags(g_BuddySymbol, statusTags) : "";
+ this.playerList[i] = PlayerColor.ColorPlayerName(player.name, "", player.role);
+ this.presenceList[i] = setStringTags(this.PlayerStatuses[presence].status, statusTags);
+ this.ratingList[i] = setStringTags(rating, statusTags);
+ this.nickList[i] = escapeText(player.name);
+ });
+ Engine.ProfileStop();
+
+ Engine.ProfileStart("copyToGUI");
+ this.playersBox.list_buddy = this.buddyStatusList;
+ this.playersBox.list_name = this.playerList;
+ this.playersBox.list_status = this.presenceList;
+ this.playersBox.list_rating = this.ratingList;
+ this.playersBox.list = this.nickList;
+ Engine.ProfileStop();
+
+ Engine.ProfileStart("selectionChange");
+ this.playersBox.selected = this.playersBox.list.indexOf(this.selectedPlayer);
+ Engine.ProfileStop();
+
+ Engine.ProfileStop();
+ }
+
+ sortPlayers(sortOrder, player1, player2)
+ {
+ if (player1.sortValue < player2.sortValue)
+ return -sortOrder;
+
+ if (player1.sortValue > player2.sortValue)
+ return +sortOrder;
+
+ return 0;
+ }
+}
+
+/**
+ * The playerlist will be assembled using these values.
+ */
+PlayerList.prototype.PlayerStatuses = {
+ "available": {
+ "status": translate("Online"),
+ "tags": {
+ "color": "0 219 0"
+ }
+ },
+ "away": {
+ "status": translate("Away"),
+ "tags": {
+ "color": "229 76 13"
+ }
+ },
+ "playing": {
+ "status": translate("Busy"),
+ "tags": {
+ "color": "200 0 0"
+ }
+ },
+ "offline": {
+ "status": translate("Offline"),
+ "tags": {
+ "color": "0 0 0"
+ }
+ },
+ "unknown": {
+ "status": translateWithContext("lobby presence", "Unknown"),
+ "tags": {
+ "color": "178 178 178"
+ }
+ }
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/PlayerList.xml b/binaries/data/mods/public/gui/lobby/LobbyPage/PlayerList.xml
new file mode 100644
index 0000000000..d5f553e1b6
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/PlayerList.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ Status
+
+
+ Name
+
+
+ Rating
+
+
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/ProfilePanel.js b/binaries/data/mods/public/gui/lobby/LobbyPage/ProfilePanel.js
new file mode 100644
index 0000000000..335b4df6f8
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/ProfilePanel.js
@@ -0,0 +1,125 @@
+/**
+ * This class fetches and displays player profile data,
+ * where the player had been selected in the playerlist or leaderboard.
+ */
+class ProfilePanel
+{
+ constructor(xmppMessages, playerList, leaderboardPage)
+ {
+ // Playerlist or leaderboard selection
+ this.requestedPlayer = undefined;
+
+ // Playerlist selection
+ this.selectedPlayer = undefined;
+
+ this.roleText = Engine.GetGUIObjectByName("roleText");
+ this.ratioText = Engine.GetGUIObjectByName("ratioText");
+ this.lossesText = Engine.GetGUIObjectByName("lossesText");
+ this.winsText = Engine.GetGUIObjectByName("winsText");
+ this.totalGamesText = Engine.GetGUIObjectByName("totalGamesText");
+ this.highestRatingText = Engine.GetGUIObjectByName("highestRatingText");
+ this.rankText = Engine.GetGUIObjectByName("rankText");
+ this.fade = Engine.GetGUIObjectByName("fade");
+ this.playernameText = Engine.GetGUIObjectByName("playernameText");
+ this.profileArea = Engine.GetGUIObjectByName("profileArea");
+
+ xmppMessages.registerXmppMessageHandler("game", "profile", this.onProfile.bind(this));
+ xmppMessages.registerXmppMessageHandler("chat", "role", this.onRoleChange.bind(this));
+
+ playerList.registerSelectionChangeHandler(this.onPlayerListSelection.bind(this));
+
+ leaderboardPage.registerOpenPageHandler(this.onLeaderboardOpenPage.bind(this));
+ leaderboardPage.registerClosePageHandler(this.onLeaderboardClosePage.bind(this));
+ leaderboardPage.leaderboardList.registerSelectionChangeHandler(this.onLeaderboardSelectionChange.bind(this));
+ }
+
+ onPlayerListSelection(playerName)
+ {
+ this.selectedPlayer = playerName;
+ this.requestProfile(playerName);
+ }
+
+ onRoleChange(message)
+ {
+ if (message.nick == this.requestedPlayer)
+ this.updatePlayerRoleText(this.requestedPlayer);
+ }
+
+ onLeaderboardOpenPage(playerName)
+ {
+ this.requestProfile(playerName);
+ }
+
+ onLeaderboardSelectionChange(playerName)
+ {
+ this.requestProfile(playerName);
+ }
+
+ onLeaderboardClosePage()
+ {
+ this.requestProfile(this.selectedPlayer);
+ }
+
+ updatePlayerRoleText(playerName)
+ {
+ this.roleText.caption = this.RoleNames[Engine.LobbyGetPlayerRole(playerName) || "participant"];
+ }
+
+ requestProfile(playerName)
+ {
+ this.profileArea.hidden = !playerName && !this.playernameText.caption;
+ this.requestedPlayer = playerName;
+ if (!playerName)
+ return;
+
+ this.playernameText.caption = playerName;
+ this.updatePlayerRoleText(playerName);
+
+ this.rankText.caption = this.NotAvailable;
+ this.highestRatingText.caption = this.NotAvailable;
+ this.totalGamesText.caption = this.NotAvailable;
+ this.winsText.caption = this.NotAvailable;
+ this.lossesText.caption = this.NotAvailable;
+ this.ratioText.caption = this.NotAvailable;
+
+ Engine.SendGetProfile(playerName);
+ }
+
+ onProfile()
+ {
+ let attributes = Engine.GetProfile()[0];
+ if (attributes.rating == "-2" || attributes.player != this.requestedPlayer)
+ return;
+
+ this.playernameText.caption = attributes.player;
+ this.updatePlayerRoleText(attributes.player);
+
+ this.rankText.caption = attributes.rank;
+ this.highestRatingText.caption = attributes.highestRating;
+ this.totalGamesText.caption = attributes.totalGamesPlayed;
+ this.winsText.caption = attributes.wins;
+ this.lossesText.caption = attributes.losses;
+ this.ratioText.caption = ProfilePanel.FormatWinRate(attributes);
+ }
+}
+
+ProfilePanel.prototype.NotAvailable = translate("N/A");
+
+/**
+ * These role names correspond to the names constructed by the XmppClient.
+ */
+ProfilePanel.prototype.RoleNames = {
+ "moderator": translate("Moderator"),
+ "participant": translate("Player"),
+ "visitor": translate("Muted Player")
+};
+
+ProfilePanel.FormatWinRate = function(attr)
+{
+ if (!attr.totalGamesPlayed)
+ return translateWithContext("Used for an undefined winning rate", "-");
+
+ return sprintf(translate("%(percentage)s%%"), {
+ "percentage": (attr.wins / attr.totalGamesPlayed * 100).toFixed(2)
+ });
+};
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/ProfilePanel.xml b/binaries/data/mods/public/gui/lobby/LobbyPage/ProfilePanel.xml
new file mode 100644
index 0000000000..a1b16624b7
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/ProfilePanel.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+ Current Rank:
+
+
+
+ Highest Rating:
+
+
+
+ Total Games:
+
+
+
+ Wins:
+
+
+
+ Losses:
+
+
+
+ Win Rate:
+
+
+
+
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Subject.js b/binaries/data/mods/public/gui/lobby/LobbyPage/Subject.js
new file mode 100644
index 0000000000..35a0b80513
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Subject.js
@@ -0,0 +1,45 @@
+/**
+ * The purpose of this class is to display the room subject in a panel and to
+ * update it when a new one was received.
+ */
+// TODO: It should be easily possible to view the subject after a game has been selected
+class Subject
+{
+ constructor(dialog, xmppMessages, gameList)
+ {
+ this.subjectPanel = Engine.GetGUIObjectByName("subjectPanel");
+ this.subjectText = Engine.GetGUIObjectByName("subjectText");
+ this.subjectBox = Engine.GetGUIObjectByName("subjectBox");
+ this.logoTop = Engine.GetGUIObjectByName("logoTop");
+ this.logoCenter = Engine.GetGUIObjectByName("logoCenter");
+
+ this.updateSubject(Engine.LobbyGetRoomSubject());
+
+ xmppMessages.registerXmppMessageHandler("chat", "subject", this.onSubject.bind(this));
+ gameList.registerSelectionChangeHandler(this.onGameListSelectionChange.bind(this));
+
+ let bottom = Engine.GetGUIObjectByName(dialog ? "leaveButton" : "hostButton").size.top - 5;
+ let size = this.subjectPanel.size;
+ size.bottom = bottom;
+ this.subjectPanel.size = size;
+ }
+
+ onGameListSelectionChange(game)
+ {
+ this.subjectPanel.hidden = !!game;
+ }
+
+ onSubject(message)
+ {
+ this.updateSubject(message.subject);
+ }
+
+ updateSubject(subject)
+ {
+ subject = subject.trim();
+ this.subjectBox.hidden = !subject;
+ this.subjectText.caption = subject;
+ this.logoTop.hidden = !subject;
+ this.logoCenter.hidden = !!subject;
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/LobbyPage/Subject.xml b/binaries/data/mods/public/gui/lobby/LobbyPage/Subject.xml
new file mode 100644
index 0000000000..4bd7217e37
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/LobbyPage/Subject.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/binaries/data/mods/public/gui/lobby/ProfilePage/ProfilePage.js b/binaries/data/mods/public/gui/lobby/ProfilePage/ProfilePage.js
new file mode 100644
index 0000000000..d8acf9f24e
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/ProfilePage/ProfilePage.js
@@ -0,0 +1,84 @@
+/**
+ * The profile page enables the player to lookup statistics of an arbitrary player.
+ */
+class ProfilePage
+{
+ constructor(xmppMessages)
+ {
+ this.requestedPlayer = undefined;
+ this.closePageHandlers = new Set();
+
+ this.profilePage = Engine.GetGUIObjectByName("profilePage");
+
+ this.fetchInput = Engine.GetGUIObjectByName("fetchInput");
+ this.fetchInput.onPress = this.onPressLookup.bind(this);
+
+ Engine.GetGUIObjectByName("viewProfileButton").onPress = this.onPressLookup.bind(this);
+ Engine.GetGUIObjectByName("profileBackButton").onPress = this.onPressClose.bind(this, true);
+
+ this.profilePlayernameText = Engine.GetGUIObjectByName("profilePlayernameText");
+ this.profileRankText = Engine.GetGUIObjectByName("profileRankText");
+ this.profileHighestRatingText = Engine.GetGUIObjectByName("profileHighestRatingText");
+ this.profileTotalGamesText = Engine.GetGUIObjectByName("profileTotalGamesText");
+ this.profileWinsText = Engine.GetGUIObjectByName("profileWinsText");
+ this.profileLossesText = Engine.GetGUIObjectByName("profileLossesText");
+ this.profileRatioText = Engine.GetGUIObjectByName("profileRatioText");
+ this.profileErrorText = Engine.GetGUIObjectByName("profileErrorText");
+ this.profileWindowArea = Engine.GetGUIObjectByName("profileWindowArea");
+
+ xmppMessages.registerXmppMessageHandler("game", "profile", this.onProfile.bind(this));
+ }
+
+ registerClosePageHandler(handler)
+ {
+ this.closePageHandlers.add(handler);
+ }
+
+ openPage()
+ {
+ this.profilePage.hidden = false;
+ Engine.SetGlobalHotkey("cancel", this.onPressClose.bind(this));
+ }
+
+ onPressLookup()
+ {
+ this.requestedPlayer = this.fetchInput.caption;
+ Engine.SendGetProfile(this.requestedPlayer);
+ }
+
+ onPressClose()
+ {
+ this.profilePage.hidden = true;
+
+ for (let handler of this.closePageHandlers)
+ handler();
+ }
+
+ onProfile()
+ {
+ let attributes = Engine.GetProfile()[0];
+ if (this.profilePage.hidden || this.requestedPlayer != attributes.player)
+ return;
+
+ let profileFound = attributes.rating != "-2";
+ this.profileWindowArea.hidden = !profileFound;
+ this.profileErrorText.hidden = profileFound;
+
+ if (!profileFound)
+ {
+ this.profileErrorText.caption =
+ sprintf(translate("Player \"%(nick)s\" not found."), {
+ "nick": escapeText(attributes.player)
+ });
+ return;
+ }
+
+ this.profilePlayernameText.caption = escapeText(attributes.player);
+ this.profileRankText.caption = attributes.rank;
+ this.profileHighestRatingText.caption = attributes.highestRating;
+ this.profileTotalGamesText.caption = attributes.totalGamesPlayed;
+ this.profileWinsText.caption = attributes.wins;
+ this.profileLossesText.caption = attributes.losses;
+ this.profileRatioText.caption = ProfilePanel.FormatWinRate(attributes);
+ }
+}
diff --git a/binaries/data/mods/public/gui/lobby/ProfilePage/ProfilePage.xml b/binaries/data/mods/public/gui/lobby/ProfilePage/ProfilePage.xml
new file mode 100644
index 0000000000..2cfb0a6c37
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/ProfilePage/ProfilePage.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+ Player Profile Lookup
+
+
+
+ Enter playername:
+
+
+
+
+
+ View Profile
+
+
+
+
+
+
+
+ Current Rank:
+
+
+
+ Highest Rating:
+
+
+
+ Total Games:
+
+
+
+ Wins:
+
+
+
+ Losses:
+
+
+
+ Win Rate:
+
+
+
+
+ Please enter a player name.
+
+
+
+
+
+ Back
+
+
+
+
diff --git a/binaries/data/mods/public/gui/lobby/XmppMessages.js b/binaries/data/mods/public/gui/lobby/XmppMessages.js
new file mode 100644
index 0000000000..b8fdfe8578
--- /dev/null
+++ b/binaries/data/mods/public/gui/lobby/XmppMessages.js
@@ -0,0 +1,115 @@
+/**
+ * This class stores and triggers the event handlers for the GUI messages constructed by the XmppClient.
+ */
+class XmppMessages
+{
+ constructor()
+ {
+ this.xmppMessageHandlers = {};
+ for (let type in this.MessageTypes)
+ {
+ this.xmppMessageHandlers[type] = {};
+ for (let level of this.MessageTypes[type])
+ this.xmppMessageHandlers[type][level] = new Set();
+ }
+
+ this.messageBatchProcessedHandlers = new Set();
+ this.playerListUpdateHandlers = new Set();
+
+ Engine.GetGUIObjectByName("lobbyPage").onTick = this.onTick.bind(this);
+ }
+
+ registerXmppMessageHandler(type, level, handler)
+ {
+ this.xmppMessageHandlers[type][level].add(handler);
+ }
+
+ unregisterXmppMessageHandler(type, level, handler)
+ {
+ this.xmppMessageHandlers[type][level].delete(handler);
+ }
+
+ registerMessageBatchProcessedHandler(handler)
+ {
+ this.messageBatchProcessedHandlers.add(handler);
+ }
+
+ unregisterMessageBatchProcessedHandler(handler)
+ {
+ this.messageBatchProcessedHandlers.delete(handler);
+ }
+
+ registerPlayerListUpdateHandler(handler)
+ {
+ this.playerListUpdateHandlers.add(handler);
+ }
+
+ unregisterPlayerListUpdateHandler(handler)
+ {
+ this.playerListUpdateHandlers.delete(handler);
+ }
+
+ onTick()
+ {
+ this.handleMessages(Engine.LobbyGuiPollNewMessages);
+
+ if (Engine.LobbyGuiPollHasPlayerListUpdate())
+ for (let handler of this.playerListUpdateHandlers)
+ handler();
+ }
+
+ processHistoricMessages()
+ {
+ this.handleMessages(Engine.LobbyGuiPollHistoricMessages);
+ }
+
+ handleMessages(getMessages)
+ {
+ let messages = getMessages();
+ if (!messages)
+ return;
+
+ for (let message of messages)
+ {
+ if (!this.xmppMessageHandlers[message.type])
+ error("Unrecognized message type: " + message.type);
+ else if (!this.xmppMessageHandlers[message.type][message.level])
+ error("Unrecognized message level: " + message.level);
+ else
+ for (let handler of this.xmppMessageHandlers[message.type][message.level])
+ handler(message);
+ }
+
+ for (let handler of this.messageBatchProcessedHandlers)
+ handler();
+ }
+}
+
+/**
+ * Processing of notifications sent by XmppClient.cpp.
+ */
+XmppMessages.prototype.MessageTypes = {
+ "system": [
+ "registered",
+ "connected",
+ "disconnected",
+ "error"
+ ],
+ "chat": [
+ "subject",
+ "join",
+ "leave",
+ "role",
+ "nick",
+ "kicked",
+ "banned",
+ "room-message",
+ "private-message"
+ ],
+ "game": [
+ "gamelist",
+ "profile",
+ "leaderboard",
+ "ratinglist"
+ ]
+};
diff --git a/binaries/data/mods/public/gui/lobby/lobby.js b/binaries/data/mods/public/gui/lobby/lobby.js
index ffea3840ad..009bbb73bb 100644
--- a/binaries/data/mods/public/gui/lobby/lobby.js
+++ b/binaries/data/mods/public/gui/lobby/lobby.js
@@ -1,3 +1,8 @@
+/**
+ * Used for gameselection details.
+ */
+const g_VictoryConditions = g_Settings && g_Settings.VictoryConditions;
+
/**
* Used for the gamelist-filtering.
*/
@@ -14,1581 +19,22 @@ const g_MapTypes = prepareForDropdown(g_Settings && g_Settings.MapTypes);
const g_CivData = loadCivData(false, false);
/**
- * A symbol which is prepended to the username of moderators.
+ * Current nickname.
*/
-var g_ModeratorPrefix = "@";
+var g_Nickname = Engine.LobbyGetNick();
/**
- * Current username. Cannot contain whitespace.
+ * This class organizes all components of this GUI page.
*/
-const g_Username = Engine.LobbyGetNick();
-
-/**
- * Lobby server address to construct host JID.
- */
-const g_LobbyServer = Engine.ConfigDB_GetValue("user", "lobby.server");
-
-/**
- * Current games will be listed in these colors.
- */
-var g_GameColors = {
- "init": "0 219 0",
- "waiting": "255 127 0",
- "running": "219 0 0",
- "incompatible": "gray"
-};
-
-/**
- * Initial sorting order of the gamelist.
- */
-var g_GameStatusOrder = ["init", "waiting", "running", "incompatible"];
-
-/**
- * The playerlist will be assembled using these values.
- */
-var g_PlayerStatuses = {
- "available": { "color": "0 219 0", "status": translate("Online") },
- "away": { "color": "229 76 13", "status": translate("Away") },
- "playing": { "color": "200 0 0", "status": translate("Busy") },
- "offline": { "color": "0 0 0", "status": translate("Offline") },
- "unknown": { "color": "178 178 178", "status": translateWithContext("lobby presence", "Unknown") }
-};
-
-var g_RoleNames = {
- "moderator": translate("Moderator"),
- "participant": translate("Player"),
- "visitor": translate("Muted Player")
-};
-
-/**
- * Color for error messages in the chat.
- */
-var g_SystemColor = "150 0 0";
-
-/**
- * Color for private messages in the chat.
- */
-var g_PrivateMessageColor = "0 150 0";
-
-/**
- * Used for highlighting the sender of chat messages.
- */
-var g_SenderFont = "sans-bold-13";
-
-/**
- * Color to highlight chat commands in the explanation.
- */
-var g_ChatCommandColor = "200 200 255";
-
-/**
- * Color for the player count number in the games list.
- */
-var g_PlayerCountTags = {
- "CurrentPlayers": { "color": "0 160 160" },
- "MaxPlayers": { "color": "0 160 160" },
- "Observers": { "color": "0 128 128" }
-};
-
-/**
- * Indicates if the lobby is opened as a dialog or window.
- */
-var g_Dialog = false;
-
-/**
- * All chat messages received since init (i.e. after lobby join and after returning from a game).
- */
-var g_ChatMessages = [];
-
-/**
- * Rating of the current user.
- * Contains the number or an empty string in case the user has no rating.
- */
-var g_UserRating = "";
-
-/**
- * All games currently running.
- */
-var g_GameList = [];
-
-/**
- * Used to restore the selection after updating the playerlist.
- */
-var g_SelectedPlayer = "";
-
-/**
- * Used to restore the selection after updating the gamelist.
- */
-var g_SelectedGameIP = "";
-
-/**
- * Used to restore the selection after updating the gamelist.
- */
-var g_SelectedGamePort = "";
-
-/**
- * Whether the current user has been kicked or banned.
- */
-var g_Kicked = false;
-
-/**
- * Whether the player was already asked to reconnect to the lobby.
- * Ensures that no more than one message box is opened at a time.
- */
-var g_AskedReconnect = false;
-
-/**
- * Processing of notifications sent by XmppClient.cpp.
- *
- * @returns true if the playerlist GUI must be updated.
- */
-var g_NetMessageTypes = {
- "system": {
- // Three cases are handled in prelobby.js
- "registered": msg => {
- },
- "connected": msg => {
-
- g_AskedReconnect = false;
- updateConnectedState();
- },
- "disconnected": msg => {
-
- updateGameList();
- updateLeaderboard();
- updateConnectedState();
-
- if (!g_Kicked)
- {
- addChatMessage({
- "from": "system",
- "time": msg.time,
- "text": translate("Disconnected.") + " " + msg.reason + msg.certificate_status
- });
- reconnectMessageBox();
- }
- },
- "error": msg => {
- addChatMessage({
- "from": "system",
- "time": msg.time,
- "text": msg.text
- });
- }
- },
- "chat": {
- "subject": msg => {
- updateSubject(msg.subject);
-
- if (msg.nick)
- addChatMessage({
- "text": "/special " + sprintf(translate("%(nick)s changed the lobby subject to %(subject)s"), {
- "nick": msg.nick,
- "subject": msg.subject
- }),
- "time": msg.time,
- "isSpecial": true
- });
- },
- "join": msg => {
- addChatMessage({
- "text": "/special " + sprintf(translate("%(nick)s has joined."), {
- "nick": msg.nick
- }),
- "time": msg.time,
- "isSpecial": true
- });
- },
- "leave": msg => {
- addChatMessage({
- "text": "/special " + sprintf(translate("%(nick)s has left."), {
- "nick": msg.nick
- }),
- "time": msg.time,
- "isSpecial": true
- });
-
- if (msg.nick == g_Username)
- Engine.DisconnectXmppClient();
- },
- "role": msg => {
- Engine.GetGUIObjectByName("chatInput").hidden = Engine.LobbyGetPlayerRole(g_Username) == "visitor";
-
- let me = g_Username == msg.nick;
- let txt =
- msg.newrole == "visitor" ?
- me ?
- translate("You have been muted.") :
- translate("%(nick)s has been muted.") :
- msg.newrole == "moderator" ?
- me ?
- translate("You are now a moderator.") :
- translate("%(nick)s is now a moderator.") :
- msg.oldrole == "visitor" ?
- me ?
- translate("You have been unmuted.") :
- translate("%(nick)s has been unmuted.") :
- me ?
- translate("You are not a moderator anymore.") :
- translate("%(nick)s is not a moderator anymore.");
-
- addChatMessage({
- "text": "/special " + sprintf(txt, { "nick": msg.nick }),
- "time": msg.time,
- "isSpecial": true
- });
-
- if (g_SelectedPlayer == msg.nick)
- updateUserRoleText(g_SelectedPlayer);
- },
- "nick": msg => {
- addChatMessage({
- "text": "/special " + sprintf(translate("%(oldnick)s is now known as %(newnick)s."), {
- "oldnick": msg.oldnick,
- "newnick": msg.newnick
- }),
- "time": msg.time,
- "isSpecial": true
- });
- },
- "kicked": msg => {
- handleKick(false, msg.nick, msg.reason, msg.time, msg.historic);
- },
- "banned": msg => {
- handleKick(true, msg.nick, msg.reason, msg.time, msg.historic);
- },
- "room-message": msg => {
- addChatMessage({
- "from": escapeText(msg.from),
- "text": escapeText(msg.text),
- "time": msg.time,
- "historic": msg.historic
- });
- },
- "private-message": msg => {
- // Announcements and the Message of the Day are sent by the server directly
- if (!msg.from)
- messageBox(
- 400, 250,
- msg.text.trim(),
- translate("Notice"));
-
- // We intend to not support private messages between users
- if (!msg.from || Engine.LobbyGetPlayerRole(msg.from) == "moderator")
- // some XMPP clients send trailing whitespace
- addChatMessage({
- "from": escapeText(msg.from || "system"),
- "text": escapeText(msg.text.trim()),
- "time": msg.time,
- "historic": msg.historic,
- "private": true
- });
- }
- },
- "game": {
- "gamelist": msg => {
- updateGameList();
- },
- "profile": msg => {
- updateProfile();
- },
- "leaderboard": msg => {
- updateLeaderboard();
- },
- "ratinglist": msg => {
- }
- }
-};
-
-/**
- * Commands that can be entered by clients via chat input.
- * A handler returns true if the user input should be sent as a chat message.
- */
-var g_ChatCommands = {
- "away": {
- "description": translate("Set your state to 'Away'."),
- "handler": args => {
- Engine.LobbySetPlayerPresence("away");
- return false;
- }
- },
- "back": {
- "description": translate("Set your state to 'Online'."),
- "handler": args => {
- Engine.LobbySetPlayerPresence("available");
- return false;
- }
- },
- "kick": {
- "description": translate("Kick a specified user from the lobby. Usage: /kick nick reason"),
- "handler": args => {
- Engine.LobbyKick(args[0] || "", args[1] || "");
- return false;
- },
- "moderatorOnly": true
- },
- "ban": {
- "description": translate("Ban a specified user from the lobby. Usage: /ban nick reason"),
- "handler": args => {
- Engine.LobbyBan(args[0] || "", args[1] || "");
- return false;
- },
- "moderatorOnly": true
- },
- "help": {
- "description": translate("Show this help."),
- "handler": args => {
- let isModerator = Engine.LobbyGetPlayerRole(g_Username) == "moderator";
- let text = translate("Chat commands:");
- for (let command in g_ChatCommands)
- if (!g_ChatCommands[command].moderatorOnly || isModerator)
- // Translation: Chat command help format
- text += "\n" + sprintf(translate("%(command)s - %(description)s"), {
- "command": coloredText(command, g_ChatCommandColor),
- "description": g_ChatCommands[command].description
- });
-
- addChatMessage({
- "from": "system",
- "text": text
- });
- return false;
- }
- },
- "me": {
- "description": translate("Send a chat message about yourself. Example: /me goes swimming."),
- "handler": args => true
- },
- "say": {
- "description": translate("Send text as a chat message (even if it starts with slash). Example: /say /help is a great command."),
- "handler": args => true
- },
- "clear": {
- "description": translate("Clear all chat scrollback."),
- "handler": args => {
- clearChatMessages();
- return false;
- }
- },
- "quit": {
- "description": translate("Return to the main menu."),
- "handler": args => {
- leaveLobby();
- return false;
- }
- }
-};
+var g_Lobby;
/**
* Called after the XmppConnection succeeded and when returning from a game.
- *
- * @param {Object} attribs
*/
function init(attribs)
{
- g_Dialog = attribs && attribs.dialog;
-
- if (!g_Settings)
- {
- leaveLobby();
- return;
- }
-
- initMusic();
- global.music.setState(global.music.states.MENU);
-
- initDialogStyle();
- initGameFilters();
- updateConnectedState();
-
- Engine.LobbySetPlayerPresence("available");
-
- updatePlayerList();
- updateSubject(Engine.LobbyGetRoomSubject());
- updateLobbyColumns();
-
- updateToggleBuddy();
- Engine.GetGUIObjectByName("chatInput").tooltip = colorizeAutocompleteHotkey();
-
- // Get all messages since the login
- for (let msg of Engine.LobbyGuiPollHistoricMessages())
- g_NetMessageTypes[msg.type][msg.level](msg);
-
- if (!Engine.IsXmppClientConnected())
- reconnectMessageBox();
-}
-
-function reconnectMessageBox()
-{
- if (g_AskedReconnect)
- return;
-
- g_AskedReconnect = true;
-
- messageBox(
- 400, 200,
- translate("You have been disconnected from the lobby. Do you want to reconnect?"),
- translate("Confirmation"),
- [translate("No"), translate("Yes")],
- [null, Engine.ConnectXmppClient]);
-}
-
-/**
- * Set style of GUI elements and the window style.
- */
-function initDialogStyle()
-{
- let lobbyWindow = Engine.GetGUIObjectByName("lobbyWindow");
- lobbyWindow.sprite = g_Dialog ? "ModernDialog" : "ModernWindow";
- lobbyWindow.size = g_Dialog ? "42 42 100%-42 100%-42" : "0 0 100% 100%";
- Engine.GetGUIObjectByName("lobbyWindowTitle").size = g_Dialog ? "50%-128 -16 50%+128 16" : "50%-128 4 50%+128 36";
-
- Engine.GetGUIObjectByName("leaveButton").caption = g_Dialog ?
- translateWithContext("previous page", "Back") :
- translateWithContext("previous page", "Main Menu");
-
- Engine.GetGUIObjectByName("hostButton").hidden = g_Dialog;
- Engine.GetGUIObjectByName("joinGameButton").hidden = g_Dialog;
- Engine.GetGUIObjectByName("gameInfoEmpty").size = "0 0 100% 100%-24" + (g_Dialog ? "" : "-30");
- Engine.GetGUIObjectByName("gameInfo").size = "0 0 100% 100%-24" + (g_Dialog ? "" : "-60");
-
- Engine.GetGUIObjectByName("middlePanel").size = "20%+5 " + (g_Dialog ? "18" : "40") + " 100%-255 100%-20";
- Engine.GetGUIObjectByName("rightPanel").size = "100%-250 " + (g_Dialog ? "18" : "40") + " 100%-20 100%-20";
- Engine.GetGUIObjectByName("leftPanel").size = "20 " + (g_Dialog ? "18" : "40") + " 20% 100%-315";
-
- if (g_Dialog)
- {
- Engine.GetGUIObjectByName("lobbyDialogToggle").onPress = leaveLobby;
- Engine.GetGUIObjectByName("cancelDialog").onPress = leaveLobby;
- }
-}
-
-/**
- * Set style of GUI elements according to the connection state of the lobby.
- */
-function updateConnectedState()
-{
- Engine.GetGUIObjectByName("chatInput").hidden = !Engine.IsXmppClientConnected();
-
- for (let button of ["host", "leaderboard", "userprofile", "toggleBuddy"])
- Engine.GetGUIObjectByName(button + "Button").enabled = Engine.IsXmppClientConnected();
-}
-
-function updateLobbyColumns()
-{
- let gameRating = Engine.ConfigDB_GetValue("user", "lobby.columns.gamerating") == "true";
-
- // Only show the selected columns
- let gamesBox = Engine.GetGUIObjectByName("gamesBox");
- gamesBox.hidden_mapType = gameRating;
- gamesBox.hidden_gameRating = !gameRating;
-
- // Only show the filters of selected columns
- let mapTypeFilter = Engine.GetGUIObjectByName("mapTypeFilter");
- mapTypeFilter.hidden = gameRating;
- let gameRatingFilter = Engine.GetGUIObjectByName("gameRatingFilter");
- gameRatingFilter.hidden = !gameRating;
-
- // Keep filters right above the according column
- let playersNumberFilter = Engine.GetGUIObjectByName("playersNumberFilter");
- let size = playersNumberFilter.size;
- size.rleft = gameRating ? 74 : 90;
- size.rright = gameRating ? 84 : 100;
- playersNumberFilter.size = size;
-}
-
-function leaveLobby()
-{
- if (g_Dialog)
- {
- Engine.LobbySetPlayerPresence("playing");
- Engine.PopGuiPage();
- }
+ if (g_Settings)
+ g_Lobby = new Lobby(attribs && attribs.dialog);
else
- {
- Engine.StopXmppClient();
- Engine.SwitchGuiPage("page_pregame.xml");
- }
-}
-
-function initGameFilters()
-{
- let mapSizeFilter = Engine.GetGUIObjectByName("mapSizeFilter");
- mapSizeFilter.list = [translateWithContext("map size", "Any")].concat(g_MapSizes.Name);
- mapSizeFilter.list_data = [""].concat(g_MapSizes.Tiles);
-
- let playersArray = Array(g_MaxPlayers).fill(0).map((v, i) => i + 1); // 1, 2, ... MaxPlayers
- let playersNumberFilter = Engine.GetGUIObjectByName("playersNumberFilter");
- playersNumberFilter.list = [translateWithContext("player number", "Any")].concat(playersArray);
- playersNumberFilter.list_data = [""].concat(playersArray);
-
- let mapTypeFilter = Engine.GetGUIObjectByName("mapTypeFilter");
- mapTypeFilter.list = [translateWithContext("map", "Any")].concat(g_MapTypes.Title);
- mapTypeFilter.list_data = [""].concat(g_MapTypes.Name);
-
- let gameRatingOptions = [">1500", ">1400", ">1300", ">1200", "<1200", "<1100", "<1000"];
- gameRatingOptions = prepareForDropdown(gameRatingOptions.map(r => ({
- "value": r,
- "label": sprintf(
- r[0] == ">" ?
- translateWithContext("gamelist filter", "> %(rating)s") :
- translateWithContext("gamelist filter", "< %(rating)s"),
- { "rating": r.substr(1) })
- })));
-
- let gameRatingFilter = Engine.GetGUIObjectByName("gameRatingFilter");
- gameRatingFilter.list = [translateWithContext("map", "Any")].concat(gameRatingOptions.label);
- gameRatingFilter.list_data = [""].concat(gameRatingOptions.value);
-
- resetFilters();
-}
-
-function resetFilters()
-{
- Engine.GetGUIObjectByName("mapSizeFilter").selected = 0;
- Engine.GetGUIObjectByName("playersNumberFilter").selected = 0;
- Engine.GetGUIObjectByName("mapTypeFilter").selected = g_MapTypes.Default;
- Engine.GetGUIObjectByName("gameRatingFilter").selected = 0;
- Engine.GetGUIObjectByName("filterOpenGames").checked = false;
-
- applyFilters();
-}
-
-function applyFilters()
-{
- updateGameList();
- updateGameSelection();
-}
-
-/**
- * Filter a game based on the status of the filter dropdowns.
- *
- * @param {Object} game
- * @returns {boolean} - True if game should not be displayed.
- */
-function filterGame(game)
-{
- let mapSizeFilter = Engine.GetGUIObjectByName("mapSizeFilter");
- let playersNumberFilter = Engine.GetGUIObjectByName("playersNumberFilter");
- let mapTypeFilter = Engine.GetGUIObjectByName("mapTypeFilter");
- let gameRatingFilter = Engine.GetGUIObjectByName("gameRatingFilter");
- let filterOpenGames = Engine.GetGUIObjectByName("filterOpenGames");
-
- // We assume index 0 means display all for any given filter.
- if (mapSizeFilter.selected != 0 &&
- game.mapSize != mapSizeFilter.list_data[mapSizeFilter.selected])
- return true;
-
- if (playersNumberFilter.selected != 0 &&
- game.maxnbp != playersNumberFilter.list_data[playersNumberFilter.selected])
- return true;
-
- if (mapTypeFilter.selected != 0 &&
- game.mapType != mapTypeFilter.list_data[mapTypeFilter.selected])
- return true;
-
- if (filterOpenGames.checked && (game.nbp >= game.maxnbp || game.state != "init"))
- return true;
-
- if (gameRatingFilter.selected > 0)
- {
- let selected = gameRatingFilter.list_data[gameRatingFilter.selected];
- if (selected.startsWith(">") && +selected.substr(1) >= game.gameRating ||
- selected.startsWith("<") && +selected.substr(1) <= game.gameRating)
- return true;
- }
-
- return false;
-}
-
-function handleKick(banned, nick, reason, time, historic)
-{
- let kickString = nick == g_Username ?
- banned ?
- translate("You have been banned from the lobby!") :
- translate("You have been kicked from the lobby!") :
- banned ?
- translate("%(nick)s has been banned from the lobby.") :
- translate("%(nick)s has been kicked from the lobby.");
-
- if (reason)
- reason = sprintf(translateWithContext("lobby kick", "Reason: %(reason)s"), {
- "reason": reason
- });
-
- if (nick != g_Username)
- {
- addChatMessage({
- "text": "/special " + sprintf(kickString, { "nick": nick }) + " " + reason,
- "time": time,
- "historic": historic,
- "isSpecial": true
- });
- return;
- }
-
- addChatMessage({
- "from": "system",
- "time": time,
- "text": kickString + " " + reason,
- });
-
- g_Kicked = true;
-
- Engine.DisconnectXmppClient();
-
- messageBox(
- 400, 250,
- kickString + "\n" + reason,
- banned ? translate("BANNED") : translate("KICKED")
- );
-}
-
-/**
- * Update the subject GUI object.
- */
-function updateSubject(newSubject)
-{
- Engine.GetGUIObjectByName("subject").caption = newSubject;
-
- // If the subject is only whitespace, hide it and reposition the logo.
- let subjectBox = Engine.GetGUIObjectByName("subjectBox");
- subjectBox.hidden = !newSubject.trim();
-
- let logo = Engine.GetGUIObjectByName("logo");
- if (subjectBox.hidden)
- logo.size = "50%-110 50%-50 50%+110 50%+50";
- else
- logo.size = "50%-110 40 50%+110 140";
-}
-
-/**
- * Update the caption of the toggle buddy button.
- */
-function updateToggleBuddy()
-{
- let playerList = Engine.GetGUIObjectByName("playersBox");
- let playerName = playerList.list[playerList.selected];
-
- let toggleBuddyButton = Engine.GetGUIObjectByName("toggleBuddyButton");
- toggleBuddyButton.caption = g_Buddies.indexOf(playerName) != -1 ? translate("Unmark as Buddy") : translate("Mark as Buddy");
- toggleBuddyButton.enabled = !!playerName && playerName != g_Username;
-}
-
-/**
- * Do a full update of the player listing, including ratings from cached C++ information.
- */
-function updatePlayerList()
-{
- let playersBox = Engine.GetGUIObjectByName("playersBox");
- let sortBy = playersBox.selected_column || "name";
- let sortOrder = playersBox.selected_column_order || 1;
-
- let buddyStatusList = [];
- let playerList = [];
- let presenceList = [];
- let nickList = [];
- let ratingList = [];
-
- let cleanPlayerList = Engine.GetPlayerList().map(player => {
- player.isBuddy = g_Buddies.indexOf(player.name) != -1;
- return player;
- }).sort((a, b) => {
- let sortA, sortB;
- let statusOrder = Object.keys(g_PlayerStatuses);
- let statusA = statusOrder.indexOf(a.presence) + a.name.toLowerCase();
- let statusB = statusOrder.indexOf(b.presence) + b.name.toLowerCase();
-
- switch (sortBy)
- {
- case 'buddy':
- sortA = (a.isBuddy ? 1 : 2) + statusA;
- sortB = (b.isBuddy ? 1 : 2) + statusB;
- break;
- case 'rating':
- sortA = +a.rating;
- sortB = +b.rating;
- break;
- case 'status':
- sortA = statusA;
- sortB = statusB;
- break;
- case 'name':
- default:
- sortA = a.name.toLowerCase();
- sortB = b.name.toLowerCase();
- break;
- }
- if (sortA < sortB) return -sortOrder;
- if (sortA > sortB) return +sortOrder;
- return 0;
- });
-
- // Colorize list entries
- for (let player of cleanPlayerList)
- {
- if (player.rating && player.name == g_Username)
- g_UserRating = player.rating;
- let rating = player.rating ? (" " + player.rating).substr(-5) : " -";
-
- let presence = g_PlayerStatuses[player.presence] ? player.presence : "unknown";
- if (presence == "unknown")
- warn("Unknown presence:" + player.presence);
-
- let statusColor = g_PlayerStatuses[presence].color;
- buddyStatusList.push(player.isBuddy ? coloredText(g_BuddySymbol, statusColor) : "");
- playerList.push(colorPlayerName((player.role == "moderator" ? g_ModeratorPrefix : "") + player.name));
- presenceList.push(coloredText(g_PlayerStatuses[presence].status, statusColor));
- ratingList.push(coloredText(rating, statusColor));
- nickList.push(player.name);
- }
-
- playersBox.list_buddy = buddyStatusList;
- playersBox.list_name = playerList;
- playersBox.list_status = presenceList;
- playersBox.list_rating = ratingList;
- playersBox.list = nickList;
-
- playersBox.selected = playersBox.list.indexOf(g_SelectedPlayer);
-}
-
-/**
-* Toggle buddy state for a player in playerlist within the user config
-*/
-function toggleBuddy()
-{
- let playerList = Engine.GetGUIObjectByName("playersBox");
- let name = playerList.list[playerList.selected];
-
- if (!name || name == g_Username || name.indexOf(g_BuddyListDelimiter) != -1)
- return;
-
- let index = g_Buddies.indexOf(name);
- if (index != -1)
- g_Buddies.splice(index, 1);
- else
- g_Buddies.push(name);
-
- updateToggleBuddy();
-
- Engine.ConfigDB_CreateAndWriteValueToFile("user", "lobby.buddies", g_Buddies.filter(nick => nick).join(g_BuddyListDelimiter) || g_BuddyListDelimiter, "config/user.cfg");
-
- updatePlayerList();
- updateGameList();
-}
-
-/**
- * Select the game where the selected player is currently playing, observing or offline.
- * Selects in that order to account for players that occur in multiple games.
- */
-function selectGameFromPlayername()
-{
- if (!g_SelectedPlayer)
- return;
-
- let gameList = Engine.GetGUIObjectByName("gamesBox");
- let foundAsObserver = false;
-
- for (let i = 0; i < g_GameList.length; ++i)
- for (let player of stringifiedTeamListToPlayerData(g_GameList[i].players))
- {
- if (g_SelectedPlayer != splitRatingFromNick(player.Name).nick)
- continue;
-
- gameList.auto_scroll = true;
- if (player.Team == "observer")
- {
- foundAsObserver = true;
- gameList.selected = i;
- }
- else if (!player.Offline)
- {
- gameList.selected = i;
- return;
- }
- else if (!foundAsObserver)
- gameList.selected = i;
- }
-}
-
-function onPlayerListSelection()
-{
- let playerList = Engine.GetGUIObjectByName("playersBox");
- if (playerList.selected == playerList.list.indexOf(g_SelectedPlayer))
- return;
-
- g_SelectedPlayer = playerList.list[playerList.selected];
- lookupSelectedUserProfile("playersBox");
- updateToggleBuddy();
- selectGameFromPlayername();
-}
-
-function setLeaderboardVisibility(visible)
-{
- if (visible)
- Engine.SendGetBoardList();
-
- lookupSelectedUserProfile(visible ? "leaderboardBox" : "playersBox");
- Engine.GetGUIObjectByName("leaderboard").hidden = !visible;
- Engine.GetGUIObjectByName("fade").hidden = !visible;
-}
-
-function setUserProfileVisibility(visible)
-{
- Engine.GetGUIObjectByName("profileFetch").hidden = !visible;
- Engine.GetGUIObjectByName("fade").hidden = !visible;
-}
-
-/**
- * Display the profile of the player in the user profile window.
- */
-function lookupUserProfile()
-{
- Engine.SendGetProfile(Engine.GetGUIObjectByName("fetchInput").caption);
-}
-
-/**
- * Display the profile of the selected player in the main window.
- * Displays N/A for all stats until updateProfile is called when the stats
- * are actually received from the bot.
- */
-function lookupSelectedUserProfile(guiObjectName)
-{
- let playerList = Engine.GetGUIObjectByName(guiObjectName);
- let playerName = playerList.list[playerList.selected];
-
- Engine.GetGUIObjectByName("profileArea").hidden = !playerName && !Engine.GetGUIObjectByName("usernameText").caption;
- if (!playerName)
- return;
-
- Engine.SendGetProfile(playerName);
-
- Engine.GetGUIObjectByName("usernameText").caption = playerName;
- Engine.GetGUIObjectByName("rankText").caption = translate("N/A");
- Engine.GetGUIObjectByName("highestRatingText").caption = translate("N/A");
- Engine.GetGUIObjectByName("totalGamesText").caption = translate("N/A");
- Engine.GetGUIObjectByName("winsText").caption = translate("N/A");
- Engine.GetGUIObjectByName("lossesText").caption = translate("N/A");
- Engine.GetGUIObjectByName("ratioText").caption = translate("N/A");
-
- updateUserRoleText(playerName);
-}
-
-function updateUserRoleText(playerName)
-{
- Engine.GetGUIObjectByName("roleText").caption = g_RoleNames[Engine.LobbyGetPlayerRole(playerName) || "participant"];
-}
-
-/**
- * Update the profile of the selected player with data from the bot.
- */
-function updateProfile()
-{
- let attributes = Engine.GetProfile()[0];
-
- let user = colorPlayerName(attributes.player, attributes.rating);
-
- if (!Engine.GetGUIObjectByName("profileFetch").hidden)
- {
- let profileFound = attributes.rating != "-2";
- Engine.GetGUIObjectByName("profileWindowArea").hidden = !profileFound;
- Engine.GetGUIObjectByName("profileErrorText").hidden = profileFound;
-
- if (!profileFound)
- {
- Engine.GetGUIObjectByName("profileErrorText").caption = sprintf(
- translate("Player \"%(nick)s\" not found."),
- { "nick": attributes.player }
- );
- return;
- }
-
- Engine.GetGUIObjectByName("profileUsernameText").caption = user;
- Engine.GetGUIObjectByName("profileRankText").caption = attributes.rank;
- Engine.GetGUIObjectByName("profileHighestRatingText").caption = attributes.highestRating;
- Engine.GetGUIObjectByName("profileTotalGamesText").caption = attributes.totalGamesPlayed;
- Engine.GetGUIObjectByName("profileWinsText").caption = attributes.wins;
- Engine.GetGUIObjectByName("profileLossesText").caption = attributes.losses;
- Engine.GetGUIObjectByName("profileRatioText").caption = formatWinRate(attributes);
- return;
- }
-
- let playerList;
- if (!Engine.GetGUIObjectByName("leaderboard").hidden)
- playerList = Engine.GetGUIObjectByName("leaderboardBox");
- else
- playerList = Engine.GetGUIObjectByName("playersBox");
-
- if (attributes.rating == "-2")
- return;
-
- // Make sure the stats we have received coincide with the selected player.
- if (attributes.player != playerList.list[playerList.selected])
- return;
-
- Engine.GetGUIObjectByName("usernameText").caption = user;
- Engine.GetGUIObjectByName("rankText").caption = attributes.rank;
- Engine.GetGUIObjectByName("highestRatingText").caption = attributes.highestRating;
- Engine.GetGUIObjectByName("totalGamesText").caption = attributes.totalGamesPlayed;
- Engine.GetGUIObjectByName("winsText").caption = attributes.wins;
- Engine.GetGUIObjectByName("lossesText").caption = attributes.losses;
- Engine.GetGUIObjectByName("ratioText").caption = formatWinRate(attributes);
-}
-
-/**
- * Update the leaderboard from data cached in C++.
- */
-function updateLeaderboard()
-{
- let leaderboard = Engine.GetGUIObjectByName("leaderboardBox");
- let boardList = Engine.GetBoardList().sort((a, b) => b.rating - a.rating);
-
- let list = [];
- let list_name = [];
- let list_rank = [];
- let list_rating = [];
-
- for (let i in boardList)
- {
- list_name.push(boardList[i].name);
- list_rating.push(boardList[i].rating);
- list_rank.push(+i + 1);
- list.push(boardList[i].name);
- }
-
- leaderboard.list_name = list_name;
- leaderboard.list_rating = list_rating;
- leaderboard.list_rank = list_rank;
- leaderboard.list = list;
-
- if (leaderboard.selected >= leaderboard.list.length)
- leaderboard.selected = -1;
-}
-
-/**
- * Update the game listing from data cached in C++.
- */
-function updateGameList()
-{
- let gamesBox = Engine.GetGUIObjectByName("gamesBox");
- let sortBy = gamesBox.selected_column;
- let sortOrder = gamesBox.selected_column_order;
-
- if (gamesBox.selected > -1)
- {
- g_SelectedGameIP = g_GameList[gamesBox.selected].ip;
- g_SelectedGamePort = g_GameList[gamesBox.selected].port;
- }
-
- g_GameList = Engine.GetGameList().map(game => {
-
- game.hasBuddies = 0;
- game.observerCount = 0;
-
- // Compute average rating of participating players
- let playerRatings = [];
-
- for (let player of stringifiedTeamListToPlayerData(game.players))
- {
- let playerNickRating = splitRatingFromNick(player.Name);
-
- if (player.Team != "observer")
- playerRatings.push(playerNickRating.rating || g_DefaultLobbyRating);
- else
- ++game.observerCount;
-
- // Sort games with playing buddies above games with spectating buddies
- if (game.hasBuddies < 2 && g_Buddies.indexOf(playerNickRating.nick) != -1)
- game.hasBuddies = player.Team == "observer" ? 1 : 2;
- }
-
- game.gameRating =
- playerRatings.length ?
- Math.round(playerRatings.reduce((sum, current) => sum + current) / playerRatings.length) :
- g_DefaultLobbyRating;
-
- try
- {
- game.mods = JSON.parse(game.mods);
- }
- catch (e)
- {
- game.mods = [];
- }
-
- if (!hasSameMods(game.mods, Engine.GetEngineInfo().mods))
- game.state = "incompatible";
-
- return game;
- }).filter(game => !filterGame(game)).sort((a, b) => {
- let sortA, sortB;
- switch (sortBy)
- {
- case 'name':
- sortA = g_GameStatusOrder.indexOf(a.state) + a.name.toLowerCase();
- sortB = g_GameStatusOrder.indexOf(b.state) + b.name.toLowerCase();
- break;
- case 'gameRating':
- case 'mapSize':
- case 'mapType':
- sortA = a[sortBy];
- sortB = b[sortBy];
- break;
- case 'buddy':
- sortA = String(b.hasBuddies) + g_GameStatusOrder.indexOf(a.state) + a.name.toLowerCase();
- sortB = String(a.hasBuddies) + g_GameStatusOrder.indexOf(b.state) + b.name.toLowerCase();
- break;
- case 'mapName':
- sortA = translate(a.niceMapName);
- sortB = translate(b.niceMapName);
- break;
- case 'nPlayers':
- sortA = a.maxnbp;
- sortB = b.maxnbp;
- break;
- }
- if (sortA < sortB) return -sortOrder;
- if (sortA > sortB) return +sortOrder;
- return 0;
- });
-
- let list_buddy = [];
- let list_name = [];
- let list_mapName = [];
- let list_mapSize = [];
- let list_mapType = [];
- let list_nPlayers = [];
- let list_gameRating = [];
- let list = [];
- let list_data = [];
- let selectedGameIndex = -1;
-
- for (let i in g_GameList)
- {
- let game = g_GameList[i];
- let gameName = escapeText(game.name);
- let mapTypeIdx = g_MapTypes.Name.indexOf(game.mapType);
-
- if (game.ip == g_SelectedGameIP && game.port == g_SelectedGamePort)
- selectedGameIndex = +i;
-
- list_buddy.push(game.hasBuddies ? coloredText(g_BuddySymbol, g_GameColors[game.state]) : "");
- list_name.push(coloredText(gameName, g_GameColors[game.state]));
- list_mapName.push(translateMapTitle(game.niceMapName));
- list_mapSize.push(translateMapSize(game.mapSize));
- list_mapType.push(g_MapTypes.Title[mapTypeIdx] || "");
- list_nPlayers.push(
- sprintf(
- game.observerCount ?
- // Translation: The number of players and observers in this game
- translate("%(current)s/%(max)s +%(observercount)s") :
- // Translation: The number of players in this game
- translate("%(current)s/%(max)s"),
- {
- "current": setStringTags(game.nbp, g_PlayerCountTags.CurrentPlayers),
- "max": setStringTags(game.maxnbp, g_PlayerCountTags.MaxPlayers),
- "observercount": setStringTags(game.observerCount, g_PlayerCountTags.Observers)
- }));
- list_gameRating.push(game.gameRating);
- list.push(gameName);
- list_data.push(i);
- }
-
- gamesBox.list_buddy = list_buddy;
- gamesBox.list_name = list_name;
- gamesBox.list_mapName = list_mapName;
- gamesBox.list_mapSize = list_mapSize;
- gamesBox.list_mapType = list_mapType;
- gamesBox.list_nPlayers = list_nPlayers;
- gamesBox.list_gameRating = list_gameRating;
-
- // Change these last, otherwise crash
- gamesBox.list = list;
- gamesBox.list_data = list_data;
-
- gamesBox.auto_scroll = false;
- gamesBox.selected = selectedGameIndex;
-
- updateGameSelection();
-}
-
-/**
- * Populate the game info area with information on the current game selection.
- */
-function updateGameSelection()
-{
- let game = selectedGame();
-
- Engine.GetGUIObjectByName("gameInfo").hidden = !game;
- Engine.GetGUIObjectByName("joinGameButton").hidden = g_Dialog || !game;
- Engine.GetGUIObjectByName("gameInfoEmpty").hidden = !!game;
-
- if (!game)
- return;
-
- Engine.GetGUIObjectByName("sgMapName").caption = translateMapTitle(game.niceMapName);
-
- let sgGameStartTime = Engine.GetGUIObjectByName("sgGameStartTime");
- let sgNbPlayers = Engine.GetGUIObjectByName("sgNbPlayers");
- let sgPlayersNames = Engine.GetGUIObjectByName("sgPlayersNames");
-
- let playersNamesSize = sgPlayersNames.size;
- playersNamesSize.top = game.startTime ? sgGameStartTime.size.bottom : sgNbPlayers.size.bottom;
- playersNamesSize.rtop = game.startTime ? sgGameStartTime.size.rbottom : sgNbPlayers.size.rbottom;
- sgPlayersNames.size = playersNamesSize;
-
- sgGameStartTime.hidden = !game.startTime;
- if (game.startTime)
- sgGameStartTime.caption = sprintf(
- // Translation: %(time)s is the hour and minute here.
- translate("Game started at %(time)s"), {
- "time": Engine.FormatMillisecondsIntoDateStringLocal(+game.startTime * 1000, translate("HH:mm"))
- });
-
- sgNbPlayers.caption = sprintf(
- translate("Players: %(current)s/%(total)s"), {
- "current": game.nbp,
- "total": game.maxnbp
- });
-
- sgPlayersNames.caption = formatPlayerInfo(stringifiedTeamListToPlayerData(game.players));
- Engine.GetGUIObjectByName("sgMapSize").caption = translateMapSize(game.mapSize);
-
- let mapTypeIdx = g_MapTypes.Name.indexOf(game.mapType);
- Engine.GetGUIObjectByName("sgMapType").caption = g_MapTypes.Title[mapTypeIdx] || "";
-
- let mapData = getMapDescriptionAndPreview(game.mapType, game.mapName);
- Engine.GetGUIObjectByName("sgMapPreview").sprite = getMapPreviewImage(mapData.preview);
- Engine.GetGUIObjectByName("sgMapDescription").caption = mapData.description;
-}
-
-function selectedGame()
-{
- let gamesBox = Engine.GetGUIObjectByName("gamesBox");
- if (gamesBox.selected < 0)
- return undefined;
-
- return g_GameList[gamesBox.list_data[gamesBox.selected]];
-}
-
-/**
- * Immediately rejoin and join gamesetups. Otherwise confirm late-observer join attempt.
- */
-function joinButton()
-{
- let game = selectedGame();
- if (!game || g_Dialog)
- return;
-
- let rating = getRejoinRating(game);
- let username = rating ? g_Username + " (" + rating + ")" : g_Username;
-
- if (game.state == "incompatible")
- messageBox(
- 400, 200,
- translate("Your active mods do not match the mods of this game.") + "\n\n" +
- comparedModsString(game.mods, Engine.GetEngineInfo().mods) + "\n\n" +
- translate("Do you want to switch to the mod selection page?"),
- translate("Incompatible mods"),
- [translate("No"), translate("Yes")],
- [
- null,
- () => {
- Engine.StopXmppClient();
- Engine.SwitchGuiPage("page_modmod.xml", {
- "cancelbutton": true
- });
- }
- ]
- );
- else if (game.state == "init" || stringifiedTeamListToPlayerData(game.players).some(player => player.Name == username))
- joinSelectedGame();
- else
- messageBox(
- 400, 200,
- translate("The game has already started. Do you want to join as observer?"),
- translate("Confirmation"),
- [translate("No"), translate("Yes")],
- [null, joinSelectedGame]
- );
-}
-
-/**
- * Attempt to join the selected game without asking for confirmation.
- */
-function joinSelectedGame()
-{
- let game = selectedGame();
- if (!game)
- return;
-
- let ip;
- let port;
- if (game.stunIP)
- {
- ip = game.stunIP;
- port = game.stunPort;
- }
- else
- {
- ip = game.ip;
- port = game.port;
- }
-
- if (ip.split('.').length != 4)
- {
- addChatMessage({
- "from": "system",
- "text": sprintf(
- translate("This game's address '%(ip)s' does not appear to be valid."),
- { "ip": game.ip }
- )
- });
- return;
- }
-
- Engine.PushGuiPage("page_gamesetup_mp.xml", {
- "multiplayerGameType": "join",
- "ip": ip,
- "port": port,
- "name": g_Username,
- "rating": getRejoinRating(game),
- "useSTUN": !!game.stunIP,
- "hostJID": game.hostUsername + "@" + g_LobbyServer + "/0ad"
- });
-}
-
-/**
- * Rejoin games with the original playername, even if the rating changed meanwhile.
- */
-function getRejoinRating(game)
-{
- for (let player of stringifiedTeamListToPlayerData(game.players))
- {
- let playerNickRating = splitRatingFromNick(player.Name);
- if (playerNickRating.nick == g_Username)
- return playerNickRating.rating;
- }
- return g_UserRating;
-}
-
-/**
- * Open the dialog box to enter the game name.
- */
-function hostGame()
-{
- Engine.PushGuiPage("page_gamesetup_mp.xml", {
- "multiplayerGameType": "host",
- "name": g_Username,
- "rating": g_UserRating
- });
-}
-
-/**
- * Processes GUI messages sent by the XmppClient.
- */
-function onTick()
-{
- updateTimers();
-
- let updateList = false;
-
- while (true)
- {
- let msg = Engine.LobbyGuiPollNewMessage();
- if (!msg)
- break;
-
- if (!g_NetMessageTypes[msg.type])
- {
- warn("Unrecognised message type: " + msg.type);
- continue;
- }
- if (!g_NetMessageTypes[msg.type][msg.level])
- {
- warn("Unrecognised message level: " + msg.level);
- continue;
- }
-
- g_NetMessageTypes[msg.type][msg.level](msg);
- }
-
- if (Engine.LobbyGuiPollHasPlayerListUpdate())
- updatePlayerList();
-}
-
-/**
- * Executes a lobby command or sends GUI input directly as chat.
- */
-function submitChatInput()
-{
- let input = Engine.GetGUIObjectByName("chatInput");
- let text = input.caption;
-
- if (!text.length)
- return;
-
- if (handleChatCommand(text))
- Engine.LobbySendMessage(text);
-
- input.caption = "";
-}
-
-/**
- * Handle all '/' commands.
- *
- * @param {string} text - Text to be checked for commands.
- * @returns {boolean} true if the text should be sent via chat.
- */
-function handleChatCommand(text)
-{
- if (text[0] != '/')
- return true;
-
- let [cmd, args] = ircSplit(text);
- args = ircSplit("/" + args);
-
- if (!g_ChatCommands[cmd])
- {
- addChatMessage({
- "from": "system",
- "text": sprintf(
- translate("The command '%(cmd)s' is not supported."), {
- "cmd": coloredText(cmd, g_ChatCommandColor)
- })
- });
- return false;
- }
-
- if (g_ChatCommands[cmd].moderatorOnly && Engine.LobbyGetPlayerRole(g_Username) != "moderator")
- {
- addChatMessage({
- "from": "system",
- "text": sprintf(
- translate("The command '%(cmd)s' is restricted to moderators."), {
- "cmd": coloredText(cmd, g_ChatCommandColor)
- })
- });
- return false;
- }
-
- return g_ChatCommands[cmd].handler(args);
-}
-
-/**
- * Process and if appropriate, display a formatted message.
- *
- * @param {Object} msg - The message to be processed.
- */
-function addChatMessage(msg)
-{
- if (msg.from)
- {
- if (Engine.LobbyGetPlayerRole(msg.from) == "moderator")
- msg.from = g_ModeratorPrefix + msg.from;
-
- // Highlight local user's nick
- if (g_Username != msg.from)
- {
- msg.text = msg.text.replace(g_Username, colorPlayerName(g_Username));
-
- if (!msg.historic && msg.text.toLowerCase().indexOf(g_Username.toLowerCase()) != -1)
- soundNotification("nick");
- }
- }
-
- let formatted = ircFormat(msg);
- if (!formatted)
- return;
-
- g_ChatMessages.push(formatted);
- Engine.GetGUIObjectByName("chatText").caption = g_ChatMessages.join("\n");
-}
-
-function clearChatMessages()
-{
- g_ChatMessages.length = 0;
- Engine.GetGUIObjectByName("chatText").caption = "";
-}
-
-/**
- * Splits given input into command and argument.
- */
-function ircSplit(string)
-{
- let idx = string.indexOf(' ');
-
- if (idx != -1)
- return [string.substr(1, idx - 1), string.substr(idx + 1)];
-
- return [string.substr(1), ""];
-}
-
-/**
- * Format text in an IRC-like way.
- *
- * @param {Object} msg - Received chat message.
- * @returns {string} - Formatted text.
- */
-function ircFormat(msg)
-{
- let formattedMessage = "";
- let coloredFrom = msg.from && colorPlayerName(msg.from);
-
- // Handle commands allowed past handleChatCommand.
- if (msg.text && msg.text[0] == '/')
- {
- let [command, message] = ircSplit(msg.text);
- switch (command)
- {
- case "me":
- {
- // Translation: IRC message prefix when the sender uses the /me command.
- let senderString = sprintf(translate("* %(sender)s"), {
- "sender": coloredFrom
- });
-
- // Translation: IRC message issued using the ‘/me’ command.
- formattedMessage = sprintf(translate("%(sender)s %(action)s"), {
- "sender": senderFont(senderString),
- "action": message
- });
- break;
- }
- case "say":
- {
- // Translation: IRC message prefix.
- let senderString = sprintf(translate("<%(sender)s>"), {
- "sender": coloredFrom
- });
-
- // Translation: IRC message.
- formattedMessage = sprintf(translate("%(sender)s %(message)s"), {
- "sender": senderFont(senderString),
- "message": message
- });
- break;
- }
- case "special":
- {
- if (msg.isSpecial)
- // Translation: IRC system message.
- formattedMessage = senderFont(sprintf(translate("== %(message)s"), {
- "message": message
- }));
- else
- {
- // Translation: IRC message prefix.
- let senderString = sprintf(translate("<%(sender)s>"), {
- "sender": coloredFrom
- });
-
- // Translation: IRC message.
- formattedMessage = sprintf(translate("%(sender)s %(message)s"), {
- "sender": senderFont(senderString),
- "message": message
- });
- }
- break;
- }
- default:
- return "";
- }
- }
- else
- {
- let senderString;
-
- // Translation: IRC message prefix.
- if (msg.private)
- senderString = sprintf(translateWithContext("lobby private message", "(%(private)s) <%(sender)s>"), {
- "private": coloredText(translate("Private"), g_PrivateMessageColor),
- "sender": coloredFrom
- });
- else
- senderString = sprintf(translate("<%(sender)s>"), {
- "sender": coloredFrom
- });
-
- // Translation: IRC message.
- formattedMessage = sprintf(translate("%(sender)s %(message)s"), {
- "sender": senderFont(senderString),
- "message": msg.text
- });
- }
-
- // Add chat message timestamp
- if (Engine.ConfigDB_GetValue("user", "chat.timestamp") != "true")
- return formattedMessage;
-
- // Translation: Time as shown in the multiplayer lobby (when you enable it in the options page).
- // For a list of symbols that you can use, see:
- // https://sites.google.com/site/icuprojectuserguide/formatparse/datetime?pli=1#TOC-Date-Field-Symbol-Table
- let timeString = Engine.FormatMillisecondsIntoDateStringLocal(msg.time ? msg.time * 1000 : Date.now(), translate("HH:mm"));
-
- // Translation: Time prefix as shown in the multiplayer lobby (when you enable it in the options page).
- let timePrefixString = sprintf(translate("\\[%(time)s]"), {
- "time": timeString
- });
-
- // Translation: IRC message format when there is a time prefix.
- return sprintf(translate("%(time)s %(message)s"), {
- "time": timePrefixString,
- "message": formattedMessage
- });
-}
-
-/**
- * Generate a (mostly) unique color for this player based on their name.
- * @see https://stackoverflow.com/questions/3426404/create-a-hexadecimal-colour-based-on-a-string-with-jquery-javascript
- * @param {string} playername
- */
-function getPlayerColor(playername)
-{
- if (playername == "system")
- return g_SystemColor;
-
- // Generate a probably-unique hash for the player name and use that to create a color.
- let hash = 0;
- for (let i in playername)
- hash = playername.charCodeAt(i) + ((hash << 5) - hash);
-
- // First create the color in RGB then HSL, clamp the lightness so it's not too dark to read, and then convert back to RGB to display.
- // The reason for this roundabout method is this algorithm can generate values from 0 to 255 for RGB but only 0 to 100 for HSL; this gives
- // us much more variety if we generate in RGB. Unfortunately, enforcing that RGB values are a certain lightness is very difficult, so
- // we convert to HSL to do the computation. Since our GUI code only displays RGB colors, we have to convert back.
- let [h, s, l] = rgbToHsl(hash >> 24 & 0xFF, hash >> 16 & 0xFF, hash >> 8 & 0xFF);
- return hslToRgb(h, s, Math.max(0.7, l)).join(" ");
-}
-
-/**
- * Returns the given playername wrapped in an appropriate color-tag.
- *
- * @param {string} playername
- * @param {string} rating
- */
-function colorPlayerName(playername, rating)
-{
- return coloredText(
- (rating ? sprintf(
- translate("%(nick)s (%(rating)s)"), {
- "nick": playername,
- "rating": rating
- }) : playername
- ),
- getPlayerColor(playername.replace(g_ModeratorPrefix, "")));
-}
-
-function senderFont(text)
-{
- return '[font="' + g_SenderFont + '"]' + text + "[/font]";
-}
-
-function formatWinRate(attr)
-{
- if (!attr.totalGamesPlayed)
- return translateWithContext("Used for an undefined winning rate", "-");
-
- return sprintf(translate("%(percentage)s%%"), {
- "percentage": (attr.wins / attr.totalGamesPlayed * 100).toFixed(2)
- });
+ error("Could not load settings");
}
diff --git a/binaries/data/mods/public/gui/lobby/lobby.xml b/binaries/data/mods/public/gui/lobby/lobby.xml
index 200d36116a..c860526aef 100644
--- a/binaries/data/mods/public/gui/lobby/lobby.xml
+++ b/binaries/data/mods/public/gui/lobby/lobby.xml
@@ -4,17 +4,21 @@
+
+
+
+
+
+
+
+
+
-
+
-
-
-
- Multiplayer Lobby
-
-
-
+
+
+
-
diff --git a/binaries/data/mods/public/gui/lobby/lobby_panels.xml b/binaries/data/mods/public/gui/lobby/lobby_panels.xml
deleted file mode 100644
index 2ffe427dff..0000000000
--- a/binaries/data/mods/public/gui/lobby/lobby_panels.xml
+++ /dev/null
@@ -1,366 +0,0 @@
-
-
-
-
-
- onTick();
-
-
-
-
-
-
-
-
-
- Status
-
-
- Name
-
-
- Rating
-
-
- onPlayerListSelection();
-
-
- updatePlayerList();
-
-
- // In case of clicking on the same player again
- selectGameFromPlayername();
-
- toggleBuddy();
-
-
-
-
-
-
-
-
-
- Current Rank:
-
-
-
- Highest Rating:
-
-
-
- Total Games:
-
-
-
- Wins:
-
-
-
- Losses:
-
-
-
- Win Rate:
-
-
-
-
-
-
-
-
-
- toggleBuddy();
-
-
-
- Leaderboard
-
- setLeaderboardVisibility(true);
-
-
-
- User Profile Lookup
-
- setUserProfileVisibility(true);
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Map Type:
-
-
-
-
-
-
-
-
-
-
-
- Map Size:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Join Game
-
- joinButton();
-
-
-
- Host Game
-
- hostGame();
-
-
-
-
- leaveLobby();
-
-
-
-
-
-
- updateGameSelection();
- applyFilters();
- joinButton();
-
-
- Name
-
-
- Map Name
-
-
- Size
-
-
- Type
-
-
- Players
-
-
- Rating
-
-
-
-
-
-
- applyFilters();
-
-
-
- applyFilters();
-
-
-
- applyFilters();
-
-
-
- applyFilters();
-
-
-
- Show only open games
-
-
-
- applyFilters();
-
-
-
-
-
-
-
- submitChatInput();
-
- autoCompleteText(this, Engine.GetPlayerList().map(player => player.name));
-
-
-
- Send
- submitChatInput();
-
-
-
-
-
-
-
-
-
-
- Leaderboard
-
-
-
- Rank
-
-
- Name
-
-
- Rating
-
-
- lookupSelectedUserProfile(this.name);
-
-
-
- Back
-
- setLeaderboardVisibility(false);
-
-
-
- Update
- Engine.SendGetBoardList();
-
-
-
-
-
-
-
- User Profile Lookup
-
-
- Enter username:
-
-
- lookupUserProfile();
-
-
- View Profile
- lookupUserProfile();
-
-
-
-
-
-
- Current Rank:
-
-
-
- Highest Rating:
-
-
-
- Total Games:
-
-
-
- Wins:
-
-
-
- Losses:
-
-
-
- Win Rate:
-
-
-
-
- Please enter a player name.
-
-
-
-
- Back
-
- setUserProfileVisibility(false);
-
-
-
-
-
-
-
-
-
diff --git a/binaries/data/mods/public/gui/page_lobby.xml b/binaries/data/mods/public/gui/page_lobby.xml
index 8daf9cda47..c982ec630f 100644
--- a/binaries/data/mods/public/gui/page_lobby.xml
+++ b/binaries/data/mods/public/gui/page_lobby.xml
@@ -4,9 +4,11 @@
common/modern/styles.xml
common/modern/sprites.xml
- common/global.xml
common/styles.xml
common/sprites.xml
lobby/lobby.xml
+
+ common/global.xml
+
diff --git a/binaries/data/mods/public/gui/prelobby/common/feedback/feedback.js b/binaries/data/mods/public/gui/prelobby/common/feedback/feedback.js
index b13d052bdd..e49ce1436a 100644
--- a/binaries/data/mods/public/gui/prelobby/common/feedback/feedback.js
+++ b/binaries/data/mods/public/gui/prelobby/common/feedback/feedback.js
@@ -10,19 +10,20 @@ var g_LobbyMessages = {
}
};
+/**
+ * Other message types (such as gamelists) may be received in case of the current player being logged in and
+ * logging in in a second program instance with the same account name.
+ * Therefore messages without handlers are ignored without reporting them here.
+ */
function onTick()
{
- while (true)
- {
- let message = Engine.LobbyGuiPollNewMessage();
- if (!message)
- break;
+ let messages = Engine.LobbyGuiPollNewMessages();
+ if (!messages)
+ return;
+ for (let message of messages)
if (message.type == "system" && message.level)
g_LobbyMessages[message.level](message);
- else
- warn("Unknown prelobby message: " + uneval(message));
- }
}
function setFeedback(feedbackText)
diff --git a/source/lobby/IXmppClient.h b/source/lobby/IXmppClient.h
index cc364e6d95..a3785f1cad 100644
--- a/source/lobby/IXmppClient.h
+++ b/source/lobby/IXmppClient.h
@@ -49,13 +49,14 @@ public:
virtual void SetPresence(const std::string& presence) = 0;
virtual const char* GetPresence(const std::string& nickname) = 0;
virtual const char* GetRole(const std::string& nickname) = 0;
+ virtual std::wstring GetRating(const std::string& nickname) = 0;
virtual const std::wstring& GetSubject() = 0;
virtual void GUIGetPlayerList(const ScriptInterface& scriptInterface, JS::MutableHandleValue ret) = 0;
virtual void GUIGetGameList(const ScriptInterface& scriptInterface, JS::MutableHandleValue ret) = 0;
virtual void GUIGetBoardList(const ScriptInterface& scriptInterface, JS::MutableHandleValue ret) = 0;
virtual void GUIGetProfile(const ScriptInterface& scriptInterface, JS::MutableHandleValue ret) = 0;
- virtual JS::Value GuiPollNewMessage(const ScriptInterface& scriptInterface) = 0;
+ virtual JS::Value GuiPollNewMessages(const ScriptInterface& scriptInterface) = 0;
virtual JS::Value GuiPollHistoricMessages(const ScriptInterface& scriptInterface) = 0;
virtual bool GuiPollHasPlayerListUpdate() = 0;
diff --git a/source/lobby/XmppClient.cpp b/source/lobby/XmppClient.cpp
index 3738ee164a..91de26a728 100644
--- a/source/lobby/XmppClient.cpp
+++ b/source/lobby/XmppClient.cpp
@@ -573,7 +573,7 @@ void XmppClient::GUIGetGameList(const ScriptInterface& scriptInterface, JS::Muta
const char* stats[] = { "name", "ip", "port", "stunIP", "stunPort", "hostUsername", "state",
"nbp", "maxnbp", "players", "mapName", "niceMapName", "mapSize", "mapType",
- "victoryCondition", "startTime", "mods" };
+ "victoryConditions", "startTime", "mods" };
for(const glooxwrapper::Tag* const& t : m_GameList)
{
@@ -687,48 +687,79 @@ void XmppClient::CreateGUIMessage(
bool XmppClient::GuiPollHasPlayerListUpdate()
{
+ // The initial playerlist will be received in multiple messages
+ // Only inform the GUI after all of these playerlist fragments were received.
+ if (!m_initialLoadComplete)
+ return false;
+
bool hasUpdate = m_PlayerMapUpdate;
m_PlayerMapUpdate = false;
return hasUpdate;
}
-JS::Value XmppClient::GuiPollNewMessage(const ScriptInterface& scriptInterface)
+JS::Value XmppClient::GuiPollNewMessages(const ScriptInterface& scriptInterface)
{
- if (m_GuiMessageQueue.empty())
+ if (!m_initialLoadComplete || m_GuiMessageQueue.empty())
return JS::UndefinedValue();
JSContext* cx = scriptInterface.GetContext();
JSAutoRequest rq(cx);
- JS::RootedValue message(cx, m_GuiMessageQueue.front());
- m_GuiMessageQueue.pop_front();
+ // Optimize for batch message processing that is more
+ // performance demanding than processing a lone message.
+ JS::RootedValue messages(cx);
+ ScriptInterface::CreateArray(cx, &messages);
- JS::RootedValue messageCopy(cx);
- if (JS_StructuredClone(cx, message, &messageCopy, nullptr, nullptr))
+ int j = 0;
+
+ for (const JS::Heap& message : m_GuiMessageQueue)
{
- scriptInterface.SetProperty(messageCopy, "historic", true);
- scriptInterface.FreezeObject(messageCopy, true);
- m_HistoricGuiMessages.push_back(JS::Heap(messageCopy));
- }
- else
- LOGERROR("Could not clone historic lobby GUI message!");
+ scriptInterface.SetPropertyInt(messages, j++, message);
- return message;
+ // Store historic chat messages.
+ // Only store relevant messages to minimize memory footprint.
+ JS::RootedValue rootedMessage(cx, message);
+ std::string type;
+ scriptInterface.GetProperty(rootedMessage, "type", type);
+ if (type != "chat")
+ continue;
+
+ std::string level;
+ scriptInterface.GetProperty(rootedMessage, "level", level);
+ if (level != "room-message" && level != "private-message")
+ continue;
+
+ JS::RootedValue historicMessage(cx);
+ if (JS_StructuredClone(cx, rootedMessage, &historicMessage, nullptr, nullptr))
+ {
+ scriptInterface.SetProperty(historicMessage, "historic", true);
+ scriptInterface.FreezeObject(historicMessage, true);
+ m_HistoricGuiMessages.push_back(JS::Heap(historicMessage));
+ }
+ else
+ LOGERROR("Could not clone historic lobby GUI message!");
+ }
+
+ m_GuiMessageQueue.clear();
+ return messages;
}
JS::Value XmppClient::GuiPollHistoricMessages(const ScriptInterface& scriptInterface)
{
+ if (m_HistoricGuiMessages.empty())
+ return JS::UndefinedValue();
+
JSContext* cx = scriptInterface.GetContext();
JSAutoRequest rq(cx);
- JS::RootedValue ret(cx);
- ScriptInterface::CreateArray(cx, &ret);
+ JS::RootedValue messages(cx);
+ ScriptInterface::CreateArray(cx, &messages);
int j = 0;
for (const JS::Heap& message : m_HistoricGuiMessages)
- scriptInterface.SetPropertyInt(ret, j++, message);
+ scriptInterface.SetPropertyInt(messages, j++, message);
- return ret;
+ return messages;
}
/**
@@ -1087,6 +1118,20 @@ const char* XmppClient::GetRole(const std::string& nick)
return GetRoleString(it->second.m_Role);
}
+/**
+ * Get the most recent received rating of the given nick.
+ * Notice that this doesn't request a rating profile if it hasn't been received yet.
+ */
+std::wstring XmppClient::GetRating(const std::string& nick)
+{
+ const PlayerMap::iterator it = m_PlayerMap.find(nick);
+
+ if (it == m_PlayerMap.end())
+ return std::wstring();
+
+ return wstring_from_utf8(it->second.m_Rating.to_string());
+}
+
/*****************************************************
* Utilities *
*****************************************************/
diff --git a/source/lobby/XmppClient.h b/source/lobby/XmppClient.h
index 64b10929c4..ee66a460c4 100644
--- a/source/lobby/XmppClient.h
+++ b/source/lobby/XmppClient.h
@@ -90,6 +90,7 @@ public:
void SetPresence(const std::string& presence);
const char* GetPresence(const std::string& nickname);
const char* GetRole(const std::string& nickname);
+ std::wstring GetRating(const std::string& nickname);
const std::wstring& GetSubject();
void GUIGetPlayerList(const ScriptInterface& scriptInterface, JS::MutableHandleValue ret);
@@ -150,7 +151,7 @@ protected:
virtual void handleSessionInitiation(glooxwrapper::Jingle::Session& session, const glooxwrapper::Jingle::Session::Jingle& jingle);
public:
- JS::Value GuiPollNewMessage(const ScriptInterface& scriptInterface);
+ JS::Value GuiPollNewMessages(const ScriptInterface& scriptInterface);
JS::Value GuiPollHistoricMessages(const ScriptInterface& scriptInterface);
bool GuiPollHasPlayerListUpdate();
void SendMUCMessage(const std::string& message);
diff --git a/source/lobby/scripting/JSInterface_Lobby.cpp b/source/lobby/scripting/JSInterface_Lobby.cpp
index 52c41be043..dc5b878eec 100644
--- a/source/lobby/scripting/JSInterface_Lobby.cpp
+++ b/source/lobby/scripting/JSInterface_Lobby.cpp
@@ -56,7 +56,7 @@ void JSI_Lobby::RegisterScriptFunctions(const ScriptInterface& scriptInterface)
scriptInterface.RegisterFunction("GetGameList");
scriptInterface.RegisterFunction("GetBoardList");
scriptInterface.RegisterFunction("GetProfile");
- scriptInterface.RegisterFunction("LobbyGuiPollNewMessage");
+ scriptInterface.RegisterFunction("LobbyGuiPollNewMessages");
scriptInterface.RegisterFunction("LobbyGuiPollHistoricMessages");
scriptInterface.RegisterFunction("LobbyGuiPollHasPlayerListUpdate");
scriptInterface.RegisterFunction("LobbySendMessage");
@@ -67,6 +67,7 @@ void JSI_Lobby::RegisterScriptFunctions(const ScriptInterface& scriptInterface)
scriptInterface.RegisterFunction("LobbyBan");
scriptInterface.RegisterFunction("LobbyGetPlayerPresence");
scriptInterface.RegisterFunction("LobbyGetPlayerRole");
+ scriptInterface.RegisterFunction("LobbyGetPlayerRating");
scriptInterface.RegisterFunction("EncryptPassword");
scriptInterface.RegisterFunction("LobbyGetRoomSubject");
#endif // CONFIG2_LOBBY
@@ -243,12 +244,12 @@ bool JSI_Lobby::LobbyGuiPollHasPlayerListUpdate(ScriptInterface::CxPrivate* UNUS
return g_XmppClient && g_XmppClient->GuiPollHasPlayerListUpdate();
}
-JS::Value JSI_Lobby::LobbyGuiPollNewMessage(ScriptInterface::CxPrivate* pCxPrivate)
+JS::Value JSI_Lobby::LobbyGuiPollNewMessages(ScriptInterface::CxPrivate* pCxPrivate)
{
if (!g_XmppClient)
return JS::UndefinedValue();
- return g_XmppClient->GuiPollNewMessage(*(pCxPrivate->pScriptInterface));
+ return g_XmppClient->GuiPollNewMessages(*(pCxPrivate->pScriptInterface));
}
JS::Value JSI_Lobby::LobbyGuiPollHistoricMessages(ScriptInterface::CxPrivate* pCxPrivate)
@@ -325,6 +326,14 @@ const char* JSI_Lobby::LobbyGetPlayerRole(ScriptInterface::CxPrivate* UNUSED(pCx
return g_XmppClient->GetRole(utf8_from_wstring(nickname));
}
+std::wstring JSI_Lobby::LobbyGetPlayerRating(ScriptInterface::CxPrivate* UNUSED(pCxPrivate), const std::wstring& nickname)
+{
+ if (!g_XmppClient)
+ return std::wstring();
+
+ return g_XmppClient->GetRating(utf8_from_wstring(nickname));
+}
+
// Non-public secure PBKDF2 hash function with salting and 1,337 iterations
//
// TODO: We should use libsodium's crypto_pwhash instead of this. The first reason is that
diff --git a/source/lobby/scripting/JSInterface_Lobby.h b/source/lobby/scripting/JSInterface_Lobby.h
index de7ccc0354..08ad38af75 100644
--- a/source/lobby/scripting/JSInterface_Lobby.h
+++ b/source/lobby/scripting/JSInterface_Lobby.h
@@ -48,7 +48,7 @@ namespace JSI_Lobby
JS::Value GetGameList(ScriptInterface::CxPrivate* pCxPrivate);
JS::Value GetBoardList(ScriptInterface::CxPrivate* pCxPrivate);
JS::Value GetProfile(ScriptInterface::CxPrivate* pCxPrivate);
- JS::Value LobbyGuiPollNewMessage(ScriptInterface::CxPrivate* pCxPrivate);
+ JS::Value LobbyGuiPollNewMessages(ScriptInterface::CxPrivate* pCxPrivate);
JS::Value LobbyGuiPollHistoricMessages(ScriptInterface::CxPrivate* pCxPrivate);
bool LobbyGuiPollHasPlayerListUpdate(ScriptInterface::CxPrivate* pCxPrivate);
void LobbySendMessage(ScriptInterface::CxPrivate* pCxPrivate, const std::wstring& message);
@@ -59,6 +59,7 @@ namespace JSI_Lobby
void LobbyBan(ScriptInterface::CxPrivate* pCxPrivate, const std::wstring& nick, const std::wstring& reason);
const char* LobbyGetPlayerPresence(ScriptInterface::CxPrivate* pCxPrivate, const std::wstring& nickname);
const char* LobbyGetPlayerRole(ScriptInterface::CxPrivate* pCxPrivate, const std::wstring& nickname);
+ std::wstring LobbyGetPlayerRating(ScriptInterface::CxPrivate* pCxPrivate, const std::wstring& nickname);
std::wstring LobbyGetRoomSubject(ScriptInterface::CxPrivate* pCxPrivate);
// Non-public secure PBKDF2 hash function with salting and 1,337 iterations