Index: ps/trunk/binaries/data/mods/public/gui/session/session.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/session/session.js (revision 21924) +++ ps/trunk/binaries/data/mods/public/gui/session/session.js (revision 21925) @@ -1,1762 +1,1763 @@ const g_IsReplay = Engine.IsVisualReplay(); const g_CivData = loadCivData(false, true); const g_Ceasefire = prepareForDropdown(g_Settings && g_Settings.Ceasefire); const g_MapSizes = prepareForDropdown(g_Settings && g_Settings.MapSizes); const g_MapTypes = prepareForDropdown(g_Settings && g_Settings.MapTypes); const g_PopulationCapacities = prepareForDropdown(g_Settings && g_Settings.PopulationCapacities); const g_StartingResources = prepareForDropdown(g_Settings && g_Settings.StartingResources); const g_VictoryDurations = prepareForDropdown(g_Settings && g_Settings.VictoryDurations); const g_VictoryConditions = g_Settings && g_Settings.VictoryConditions; var g_GameSpeeds; /** * Whether to display diplomacy colors (where players see self/ally/neutral/enemy each in different colors and * observers see each team in a different color) or regular player colors. */ var g_DiplomacyColorsToggle = false; /** * The array of displayed player colors (either the diplomacy color or regular color for each player). */ var g_DisplayedPlayerColors; /** * Colors to flash when pop limit reached. */ var g_DefaultPopulationColor = "white"; var g_PopulationAlertColor = "orange"; /** * Seen in the tooltip of the top panel. */ var g_ResourceTitleFont = "sans-bold-16"; /** * A random file will be played. TODO: more variety */ var g_Ambient = ["audio/ambient/dayscape/day_temperate_gen_03.ogg"]; /** * Map, player and match settings set in gamesetup. */ const g_GameAttributes = deepfreeze(Engine.GetInitAttributes()); /** * True if this is a multiplayer game. */ const g_IsNetworked = Engine.HasNetClient(); /** * Is this user in control of game settings (i.e. is a network server, or offline player). */ var g_IsController = !g_IsNetworked || Engine.HasNetServer(); /** * Whether we have finished the synchronization and * can start showing simulation related message boxes. */ var g_IsNetworkedActive = false; /** * True if the connection to the server has been lost. */ var g_Disconnected = false; /** * True if the current user has observer capabilities. */ var g_IsObserver = false; /** * True if the current user has rejoined (or joined the game after it started). */ var g_HasRejoined = false; /** * Shows a message box asking the user to leave if "won" or "defeated". */ var g_ConfirmExit = false; /** * True if the current player has paused the game explicitly. */ var g_Paused = false; /** * The list of GUIDs of players who have currently paused the game, if the game is networked. */ var g_PausingClients = []; /** * The playerID selected in the change perspective tool. */ var g_ViewedPlayer = Engine.GetPlayerID(); /** * True if the camera should focus on attacks and player commands * and select the affected units. */ var g_FollowPlayer = false; /** * Cache the basic player data (name, civ, color). */ var g_Players = []; /** * Last time when onTick was called(). * Used for animating the main menu. */ var g_LastTickTime = Date.now(); /** * Recalculate which units have their status bars shown with this frequency in milliseconds. */ var g_StatusBarUpdate = 200; /** * For restoring selection, order and filters when returning to the replay menu */ var g_ReplaySelectionData; /** * Remembers which clients are assigned to which player slots. * The keys are guids or "local" in Singleplayer. */ var g_PlayerAssignments; /** * Cache dev-mode settings that are frequently or widely used. */ var g_DevSettings = { "changePerspective": false, "controlAll": false }; /** * Whether the entire UI should be hidden (useful for promotional screenshots). * Can be toggled with a hotkey. */ var g_ShowGUI = true; /** * Whether status bars should be shown for all of the player's units. */ var g_ShowAllStatusBars = false; /** * Blink the population counter if the player can't train more units. */ var g_IsTrainingBlocked = false; /** * Cache of simulation state and template data (apart from TechnologyData, updated on every simulation update). */ var g_SimState; var g_EntityStates = {}; var g_TemplateData = {}; var g_TechnologyData = {}; var g_ResourceData = new Resources(); /** * Top coordinate of the research list. * Changes depending on the number of displayed counters. */ var g_ResearchListTop = 4; /** * List of additional entities to highlight. */ var g_ShowGuarding = false; var g_ShowGuarded = false; var g_AdditionalHighlight = []; /** * Display data of the current players entities shown in the top panel. */ var g_PanelEntities = []; /** * Order in which the panel entities are shown. */ var g_PanelEntityOrder = ["Hero", "Relic"]; /** * Unit classes to be checked for the idle-worker-hotkey. */ var g_WorkerTypes = ["FemaleCitizen", "Trader", "FishingBoat", "CitizenSoldier"]; /** * Unit classes to be checked for the military-only-selection modifier and for the idle-warrior-hotkey. */ var g_MilitaryTypes = ["Melee", "Ranged"]; function GetSimState() { if (!g_SimState) g_SimState = deepfreeze(Engine.GuiInterfaceCall("GetSimulationState")); return g_SimState; } function GetMultipleEntityStates(ents) { if (!ents.length) return null; let entityStates = Engine.GuiInterfaceCall("GetMultipleEntityStates", ents); for (let item of entityStates) g_EntityStates[item.entId] = item.state && deepfreeze(item.state); return entityStates; } function GetEntityState(entId) { if (!g_EntityStates[entId]) { let entityState = Engine.GuiInterfaceCall("GetEntityState", entId); g_EntityStates[entId] = entityState && deepfreeze(entityState); } return g_EntityStates[entId]; } function GetTemplateData(templateName) { if (!(templateName in g_TemplateData)) { let template = Engine.GuiInterfaceCall("GetTemplateData", templateName); translateObjectKeys(template, ["specific", "generic", "tooltip"]); g_TemplateData[templateName] = deepfreeze(template); } return g_TemplateData[templateName]; } function GetTechnologyData(technologyName, civ) { if (!g_TechnologyData[civ]) g_TechnologyData[civ] = {}; if (!(technologyName in g_TechnologyData[civ])) { let template = GetTechnologyDataHelper(TechnologyTemplates.Get(technologyName), civ, g_ResourceData); translateObjectKeys(template, ["specific", "generic", "description", "tooltip", "requirementsTooltip"]); g_TechnologyData[civ][technologyName] = deepfreeze(template); } return g_TechnologyData[civ][technologyName]; } function init(initData, hotloadData) { if (!g_Settings) { Engine.EndGame(); Engine.SwitchGuiPage("page_pregame.xml"); return; } // Fallback used by atlas g_PlayerAssignments = initData ? initData.playerAssignments : { "local": { "player": 1 } }; // Fallback used by atlas and autostart games if (g_PlayerAssignments.local && !g_PlayerAssignments.local.name) g_PlayerAssignments.local.name = singleplayerName(); if (initData) { g_ReplaySelectionData = initData.replaySelectionData; g_HasRejoined = initData.isRejoining; if (initData.savedGUIData) restoreSavedGameData(initData.savedGUIData); } LoadModificationTemplates(); updatePlayerData(); initializeMusic(); // before changing the perspective initGUIObjects(); if (hotloadData) g_Selection.selected = hotloadData.selection; sendLobbyPlayerlistUpdate(); onSimulationUpdate(); setTimeout(displayGamestateNotifications, 1000); } function initGUIObjects() { initMenu(); updateGameSpeedControl(); resizeDiplomacyDialog(); resizeTradeDialog(); initBarterButtons(); initPanelEntities(); initViewedPlayerDropdown(); initChatWindow(); Engine.SetBoundingBoxDebugOverlay(false); updateEnabledRangeOverlayTypes(); } function updatePlayerData() { let simState = GetSimState(); if (!simState) return; let playerData = []; for (let i = 0; i < simState.players.length; ++i) { let playerState = simState.players[i]; playerData.push({ "name": playerState.name, "civ": playerState.civ, "color": { "r": playerState.color.r * 255, "g": playerState.color.g * 255, "b": playerState.color.b * 255, "a": playerState.color.a * 255 }, "team": playerState.team, "teamsLocked": playerState.teamsLocked, "cheatsEnabled": playerState.cheatsEnabled, "state": playerState.state, "isAlly": playerState.isAlly, "isMutualAlly": playerState.isMutualAlly, "isNeutral": playerState.isNeutral, "isEnemy": playerState.isEnemy, "guid": undefined, // network guid for players controlled by hosts "offline": g_Players[i] && !!g_Players[i].offline }); } for (let guid in g_PlayerAssignments) { let playerID = g_PlayerAssignments[guid].player; if (!playerData[playerID]) continue; playerData[playerID].guid = guid; playerData[playerID].name = g_PlayerAssignments[guid].name; } g_Players = playerData; } function updateDiplomacyColorsButton() { g_DiplomacyColorsToggle = !g_DiplomacyColorsToggle; let diplomacyColorsButton = Engine.GetGUIObjectByName("diplomacyColorsButton"); diplomacyColorsButton.sprite = g_DiplomacyColorsToggle ? "stretched:session/minimap-diplomacy-on.png" : "stretched:session/minimap-diplomacy-off.png"; diplomacyColorsButton.sprite_over = g_DiplomacyColorsToggle ? "stretched:session/minimap-diplomacy-on-highlight.png" : "stretched:session/minimap-diplomacy-off-highlight.png"; Engine.GetGUIObjectByName("diplomacyColorsWindowButtonIcon").sprite = g_DiplomacyColorsToggle ? "stretched:session/icons/diplomacy-on.png" : "stretched:session/icons/diplomacy.png"; updateDisplayedPlayerColors(); } /** * Updates the displayed colors of players in the simulation and GUI. */ function updateDisplayedPlayerColors() { if (g_DiplomacyColorsToggle) { let getDiplomacyColor = stance => guiToRgbColor(Engine.ConfigDB_GetValue("user", "gui.session.diplomacycolors." + stance)) || guiToRgbColor(Engine.ConfigDB_GetValue("default", "gui.session.diplomacycolors." + stance)); let teamRepresentatives = {}; for (let i = 1; i < g_Players.length; ++i) if (g_ViewedPlayer <= 0) { // Observers and gaia see team colors let team = g_Players[i].team; g_DisplayedPlayerColors[i] = g_Players[teamRepresentatives[team] || i].color; if (team != -1 && !teamRepresentatives[team]) teamRepresentatives[team] = i; } else // Players see colors depending on diplomacy g_DisplayedPlayerColors[i] = g_ViewedPlayer == i ? getDiplomacyColor("self") : g_Players[g_ViewedPlayer].isAlly[i] ? getDiplomacyColor("ally") : g_Players[g_ViewedPlayer].isNeutral[i] ? getDiplomacyColor("neutral") : getDiplomacyColor("enemy"); g_DisplayedPlayerColors[0] = g_Players[0].color; } else g_DisplayedPlayerColors = g_Players.map(player => player.color); Engine.GuiInterfaceCall("UpdateDisplayedPlayerColors", { "displayedPlayerColors": g_DisplayedPlayerColors, "displayDiplomacyColors": g_DiplomacyColorsToggle, "showAllStatusBars": g_ShowAllStatusBars, "selected": g_Selection.toList() }); updateGUIObjects(); } /** * Depends on the current player (g_IsObserver). */ function updateHotkeyTooltips() { Engine.GetGUIObjectByName("chatInput").tooltip = translateWithContext("chat input", "Type the message to send.") + "\n" + colorizeAutocompleteHotkey() + colorizeHotkey("\n" + translate("Press %(hotkey)s to open the public chat."), "chat") + colorizeHotkey( "\n" + (g_IsObserver ? translate("Press %(hotkey)s to open the observer chat.") : translate("Press %(hotkey)s to open the ally chat.")), "teamchat") + colorizeHotkey("\n" + translate("Press %(hotkey)s to open the previously selected private chat."), "privatechat"); Engine.GetGUIObjectByName("idleWorkerButton").tooltip = colorizeHotkey("%(hotkey)s" + " ", "selection.idleworker") + translate("Find idle worker"); Engine.GetGUIObjectByName("diplomacyColorsButton").tooltip = colorizeHotkey("%(hotkey)s" + " ", "session.diplomacycolors") + translate("Toggle Diplomacy Colors"); Engine.GetGUIObjectByName("diplomacyColorsWindowButton").tooltip = colorizeHotkey("%(hotkey)s" + " ", "session.diplomacycolors") + translate("Toggle Diplomacy Colors"); Engine.GetGUIObjectByName("tradeHelp").tooltip = colorizeHotkey( translate("Select one type of goods you want to modify by clicking on it, and then use the arrows of the other types to modify their shares. You can also press %(hotkey)s while selecting one type of goods to bring its share to 100%%."), "session.fulltradeswap"); Engine.GetGUIObjectByName("barterHelp").tooltip = sprintf( translate("Start by selecting the resource you wish to sell from the upper row. For each time the lower buttons are pressed, %(quantity)s of the upper resource will be sold for the displayed quantity of the lower. Press and hold %(hotkey)s to temporarily multiply the traded amount by %(multiplier)s."), { "quantity": g_BarterResourceSellQuantity, "hotkey": colorizeHotkey("%(hotkey)s", "session.massbarter"), "multiplier": g_BarterMultiplier }); } function initPanelEntities() { Engine.GetGUIObjectByName("panelEntityPanel").children.forEach((button, slot) => { button.onPress = function() { let panelEnt = g_PanelEntities.find(ent => ent.slot !== undefined && ent.slot == slot); if (!panelEnt) return; if (!Engine.HotkeyIsPressed("selection.add")) g_Selection.reset(); g_Selection.addList([panelEnt.ent]); }; button.onDoublePress = function() { let panelEnt = g_PanelEntities.find(ent => ent.slot !== undefined && ent.slot == slot); if (panelEnt) selectAndMoveTo(getEntityOrHolder(panelEnt.ent)); }; }); } /** * Returns the entity itself except when garrisoned where it returns its garrisonHolder */ function getEntityOrHolder(ent) { let entState = GetEntityState(ent); if (entState && !entState.position && entState.unitAI && entState.unitAI.orders.length && entState.unitAI.orders[0].type == "Garrison") return getEntityOrHolder(entState.unitAI.orders[0].data.target); return ent; } function initializeMusic() { initMusic(); if (g_ViewedPlayer != -1 && g_CivData[g_Players[g_ViewedPlayer].civ].Music) global.music.storeTracks(g_CivData[g_Players[g_ViewedPlayer].civ].Music); global.music.setState(global.music.states.PEACE); playAmbient(); } function initViewedPlayerDropdown() { g_DisplayedPlayerColors = g_Players.map(player => player.color); updateViewedPlayerDropdown(); // Select "observer" in the view player dropdown when rejoining as a defeated player let player = g_Players[Engine.GetPlayerID()]; Engine.GetGUIObjectByName("viewPlayer").selected = player && player.state == "defeated" ? 0 : Engine.GetPlayerID() + 1; } function updateViewedPlayerDropdown() { let viewPlayer = Engine.GetGUIObjectByName("viewPlayer"); viewPlayer.list_data = [-1].concat(g_Players.map((player, i) => i)); viewPlayer.list = [translate("Observer")].concat(g_Players.map( (player, i) => colorizePlayernameHelper("■", i) + " " + player.name )); } function toggleChangePerspective(enabled) { g_DevSettings.changePerspective = enabled; selectViewPlayer(g_ViewedPlayer); } /** * Change perspective tool. * Shown to observers or when enabling the developers option. */ function selectViewPlayer(playerID) { if (playerID < -1 || playerID > g_Players.length - 1) return; if (g_ShowAllStatusBars) recalculateStatusBarDisplay(true); g_IsObserver = isPlayerObserver(Engine.GetPlayerID()); if (g_IsObserver || g_DevSettings.changePerspective) { if (g_ViewedPlayer != playerID) clearSelection(); g_ViewedPlayer = playerID; } if (g_DevSettings.changePerspective) { Engine.SetPlayerID(g_ViewedPlayer); g_IsObserver = isPlayerObserver(g_ViewedPlayer); } Engine.SetViewedPlayer(g_ViewedPlayer); updateDisplayedPlayerColors(); updateTopPanel(); updateChatAddressees(); updateHotkeyTooltips(); // Update GUI and clear player-dependent cache g_TemplateData = {}; Engine.GuiInterfaceCall("ResetTemplateModified"); onSimulationUpdate(); if (g_IsDiplomacyOpen) openDiplomacy(); if (g_IsTradeOpen) openTrade(); } /** * Returns true if the player with that ID is in observermode. */ function isPlayerObserver(playerID) { let playerStates = GetSimState().players; return !playerStates[playerID] || playerStates[playerID].state != "active"; } /** * Returns true if the current user can issue commands for that player. */ function controlsPlayer(playerID) { let playerStates = GetSimState().players; return playerStates[Engine.GetPlayerID()] && playerStates[Engine.GetPlayerID()].controlsAll || Engine.GetPlayerID() == playerID && playerStates[playerID] && playerStates[playerID].state != "defeated"; } /** * Called when one or more players have won or were defeated. * * @param {array} - IDs of the players who have won or were defeated. * @param {object} - a plural string stating the victory reason. * @param {boolean} - whether these players have won or lost. */ function playersFinished(players, victoryString, won) { addChatMessage({ "type": "defeat-victory", "message": victoryString, "players": players }); if (players.indexOf(Engine.GetPlayerID()) != -1) reportGame(); sendLobbyPlayerlistUpdate(); updatePlayerData(); updateChatAddressees(); updateGameSpeedControl(); if (players.indexOf(g_ViewedPlayer) == -1) return; // Select "observer" item on loss. On win enable observermode without changing perspective Engine.GetGUIObjectByName("viewPlayer").selected = won ? g_ViewedPlayer + 1 : 0; if (players.indexOf(Engine.GetPlayerID()) == -1 || Engine.IsAtlasRunning()) return; global.music.setState( won ? global.music.states.VICTORY : global.music.states.DEFEAT ); g_ConfirmExit = won ? "won" : "defeated"; } /** * Sets civ icon for the currently viewed player. * Hides most gui objects for observers. */ function updateTopPanel() { let isPlayer = g_ViewedPlayer > 0; let civIcon = Engine.GetGUIObjectByName("civIcon"); civIcon.hidden = !isPlayer; if (isPlayer) { civIcon.sprite = "stretched:" + g_CivData[g_Players[g_ViewedPlayer].civ].Emblem; Engine.GetGUIObjectByName("civIconOverlay").tooltip = sprintf( translate("%(civ)s\n%(hotkey_civinfo)s / %(hotkey_structree)s: View History / Structure Tree\nLast opened will be reopened on click."), { "civ": setStringTags(g_CivData[g_Players[g_ViewedPlayer].civ].Name, { "font": "sans-bold-stroke-14" }), "hotkey_civinfo": colorizeHotkey("%(hotkey)s", "civinfo"), "hotkey_structree": colorizeHotkey("%(hotkey)s", "structree") }); } // Following gaia can be interesting on scripted maps Engine.GetGUIObjectByName("optionFollowPlayer").hidden = !g_IsObserver || g_ViewedPlayer == -1; let viewPlayer = Engine.GetGUIObjectByName("viewPlayer"); viewPlayer.hidden = !g_IsObserver && !g_DevSettings.changePerspective; let followPlayerLabel = Engine.GetGUIObjectByName("followPlayerLabel"); followPlayerLabel.hidden = Engine.GetTextWidth(followPlayerLabel.font, followPlayerLabel.caption + " ") + followPlayerLabel.getComputedSize().left > viewPlayer.getComputedSize().left; let resCodes = g_ResourceData.GetCodes(); let r = 0; for (let res of resCodes) { if (!Engine.GetGUIObjectByName("resource[" + r + "]")) { warn("Current GUI limits prevent displaying more than " + r + " resources in the top panel!"); break; } Engine.GetGUIObjectByName("resource[" + r + "]_icon").sprite = "stretched:session/icons/resources/" + res + ".png"; Engine.GetGUIObjectByName("resource[" + r + "]").hidden = !isPlayer; ++r; } horizontallySpaceObjects("resourceCounts", 5); hideRemaining("resourceCounts", r); let resPop = Engine.GetGUIObjectByName("population"); let resPopSize = resPop.size; resPopSize.left = Engine.GetGUIObjectByName("resource[" + (r - 1) + "]").size.right; resPop.size = resPopSize; Engine.GetGUIObjectByName("population").hidden = !isPlayer; Engine.GetGUIObjectByName("diplomacyButton").hidden = !isPlayer; Engine.GetGUIObjectByName("tradeButton").hidden = !isPlayer; Engine.GetGUIObjectByName("observerText").hidden = isPlayer; let alphaLabel = Engine.GetGUIObjectByName("alphaLabel"); alphaLabel.hidden = isPlayer && !viewPlayer.hidden; alphaLabel.size = isPlayer ? "50%+44 0 100%-283 100%" : "155 0 85%-279 100%"; Engine.GetGUIObjectByName("pauseButton").enabled = !g_IsObserver || !g_IsNetworked || g_IsController; Engine.GetGUIObjectByName("menuResignButton").enabled = !g_IsObserver; Engine.GetGUIObjectByName("lobbyButton").enabled = Engine.HasXmppClient(); } /** * Resign a player. * @param leaveGameAfterResign If player is quitting after resignation. */ function resignGame(leaveGameAfterResign) { if (g_IsObserver || g_Disconnected) return; Engine.PostNetworkCommand({ "type": "resign" }); if (!leaveGameAfterResign) resumeGame(true); } /** * Leave the game * @param willRejoin If player is going to be rejoining a networked game. */ function leaveGame(willRejoin) { if (!willRejoin && !g_IsObserver) resignGame(true); // Before ending the game let replayDirectory = Engine.GetCurrentReplayDirectory(); let simData = getReplayMetadata(); let playerID = Engine.GetPlayerID(); Engine.EndGame(); // After the replay file was closed in EndGame // Done here to keep EndGame small if (!g_IsReplay) Engine.AddReplayToCache(replayDirectory); if (g_IsController && Engine.HasXmppClient()) Engine.SendUnregisterGame(); Engine.SwitchGuiPage("page_summary.xml", { "sim": simData, "gui": { "dialog": false, "assignedPlayer": playerID, "disconnected": g_Disconnected, "isReplay": g_IsReplay, "replayDirectory": !g_HasRejoined && replayDirectory, "replaySelectionData": g_ReplaySelectionData } }); } // Return some data that we'll use when hotloading this file after changes function getHotloadData() { return { "selection": g_Selection.selected }; } function getSavedGameData() { return { "groups": g_Groups.groups }; } function restoreSavedGameData(data) { // Restore camera if any if (data.camera) Engine.SetCameraData(data.camera.PosX, data.camera.PosY, data.camera.PosZ, data.camera.RotX, data.camera.RotY, data.camera.Zoom); // Clear selection when loading a game g_Selection.reset(); // Restore control groups for (let groupNumber in data.groups) { g_Groups.groups[groupNumber].groups = data.groups[groupNumber].groups; g_Groups.groups[groupNumber].ents = data.groups[groupNumber].ents; } updateGroups(); } /** * Called every frame. */ function onTick() { if (!g_Settings) return; let now = Date.now(); let tickLength = now - g_LastTickTime; g_LastTickTime = now; handleNetMessages(); updateCursorAndTooltip(); if (g_Selection.dirty) { g_Selection.dirty = false; // When selection changed, get the entityStates of new entities GetMultipleEntityStates(g_Selection.toList().filter(entId => !g_EntityStates[entId])); updateGUIObjects(); // Display rally points for selected buildings if (Engine.GetPlayerID() != -1) Engine.GuiInterfaceCall("DisplayRallyPoint", { "entities": g_Selection.toList() }); } else if (g_ShowAllStatusBars && now % g_StatusBarUpdate <= tickLength) recalculateStatusBarDisplay(); updateTimers(); updateMenuPosition(tickLength); // When training is blocked, flash population (alternates color every 500msec) Engine.GetGUIObjectByName("resourcePop").textcolor = g_IsTrainingBlocked && now % 1000 < 500 ? g_PopulationAlertColor : g_DefaultPopulationColor; Engine.GuiInterfaceCall("ClearRenamedEntities"); } function onWindowResized() { // Update followPlayerLabel updateTopPanel(); resizeChatWindow(); } function changeGameSpeed(speed) { if (!g_IsNetworked) Engine.SetSimRate(speed); } function updateIdleWorkerButton() { Engine.GetGUIObjectByName("idleWorkerButton").enabled = Engine.GuiInterfaceCall("HasIdleUnits", { "viewedPlayer": g_ViewedPlayer, "idleClasses": g_WorkerTypes, "excludeUnits": [] }); } function onSimulationUpdate() { // Templates change depending on technologies and auras, so they have to be reloaded after such a change. // g_TechnologyData data never changes, so it shouldn't be deleted. g_EntityStates = {}; if (Engine.GuiInterfaceCall("IsTemplateModified")) { g_TemplateData = {}; Engine.GuiInterfaceCall("ResetTemplateModified"); } g_SimState = undefined; if (!GetSimState()) return; GetMultipleEntityStates(g_Selection.toList()); updateCinemaPath(); handleNotifications(); updateGUIObjects(); if (g_ConfirmExit) confirmExit(); } /** * Don't show the message box before all playerstate changes are processed. */ function confirmExit() { if (g_IsNetworked && !g_IsNetworkedActive) return; closeOpenDialogs(); // Don't ask for exit if other humans are still playing let askExit = !Engine.HasNetServer() || g_Players.every((player, i) => i == 0 || player.state != "active" || g_GameAttributes.settings.PlayerData[i].AI != ""); let subject = g_PlayerStateMessages[g_ConfirmExit]; if (askExit) subject += "\n" + translate("Do you want to quit?"); messageBox( 400, 200, subject, g_ConfirmExit == "won" ? translate("VICTORIOUS!") : translate("DEFEATED!"), askExit ? [translate("No"), translate("Yes")] : [translate("OK")], askExit ? [resumeGame, leaveGame] : [resumeGame] ); g_ConfirmExit = false; } function toggleGUI() { g_ShowGUI = !g_ShowGUI; updateCinemaPath(); } function updateCinemaPath() { let isPlayingCinemaPath = GetSimState().cinemaPlaying && !g_Disconnected; Engine.GetGUIObjectByName("session").hidden = !g_ShowGUI || isPlayingCinemaPath; Engine.Renderer_SetSilhouettesEnabled(!isPlayingCinemaPath && Engine.ConfigDB_GetValue("user", "silhouettes") == "true"); } function updateGUIObjects() { g_Selection.update(); if (g_ShowAllStatusBars) recalculateStatusBarDisplay(); if (g_ShowGuarding || g_ShowGuarded) updateAdditionalHighlight(); updatePanelEntities(); displayPanelEntities(); updateGroups(); updateDebug(); updatePlayerDisplay(); updateResearchDisplay(); updateSelectionDetails(); updateBuildingPlacementPreview(); updateTimeNotifications(); updateIdleWorkerButton(); if (g_IsTradeOpen) { updateTraderTexts(); updateBarterButtons(); } if (g_ViewedPlayer > 0) { let playerState = GetSimState().players[g_ViewedPlayer]; g_DevSettings.controlAll = playerState && playerState.controlsAll; Engine.GetGUIObjectByName("devControlAll").checked = g_DevSettings.controlAll; } if (!g_IsObserver) { // Update music state on basis of battle state. let battleState = Engine.GuiInterfaceCall("GetBattleState", g_ViewedPlayer); if (battleState) global.music.setState(global.music.states[battleState]); } updateViewedPlayerDropdown(); updateDiplomacy(); } function onReplayFinished() { closeOpenDialogs(); pauseGame(); messageBox(400, 200, translateWithContext("replayFinished", "The replay has finished. Do you want to quit?"), translateWithContext("replayFinished", "Confirmation"), [translateWithContext("replayFinished", "No"), translateWithContext("replayFinished", "Yes")], [resumeGame, leaveGame]); } /** * updates a status bar on the GUI * nameOfBar: name of the bar * points: points to show * maxPoints: max points * direction: gets less from (right to left) 0; (top to bottom) 1; (left to right) 2; (bottom to top) 3; */ function updateGUIStatusBar(nameOfBar, points, maxPoints, direction) { // check, if optional direction parameter is valid. if (!direction || !(direction >= 0 && direction < 4)) direction = 0; // get the bar and update it let statusBar = Engine.GetGUIObjectByName(nameOfBar); if (!statusBar) return; let healthSize = statusBar.size; let value = 100 * Math.max(0, Math.min(1, points / maxPoints)); // inverse bar if (direction == 2 || direction == 3) value = 100 - value; if (direction == 0) healthSize.rright = value; else if (direction == 1) healthSize.rbottom = value; else if (direction == 2) healthSize.rleft = value; else if (direction == 3) healthSize.rtop = value; statusBar.size = healthSize; } function updatePanelEntities() { let panelEnts = g_ViewedPlayer == -1 ? GetSimState().players.reduce((ents, pState) => ents.concat(pState.panelEntities), []) : GetSimState().players[g_ViewedPlayer].panelEntities; g_PanelEntities = g_PanelEntities.filter(panelEnt => panelEnts.find(ent => ent == panelEnt.ent)); for (let ent of panelEnts) { let panelEntState = GetEntityState(ent); let template = GetTemplateData(panelEntState.template); let panelEnt = g_PanelEntities.find(pEnt => ent == pEnt.ent); if (!panelEnt) { panelEnt = { "ent": ent, "tooltip": undefined, "sprite": "stretched:session/portraits/" + template.icon, "maxHitpoints": undefined, "currentHitpoints": panelEntState.hitpoints, "previousHitpoints": undefined }; g_PanelEntities.push(panelEnt); } panelEnt.tooltip = createPanelEntityTooltip(panelEntState, template); panelEnt.previousHitpoints = panelEnt.currentHitpoints; panelEnt.currentHitpoints = panelEntState.hitpoints; panelEnt.maxHitpoints = panelEntState.maxHitpoints; } let panelEntIndex = ent => g_PanelEntityOrder.findIndex(entClass => GetEntityState(ent).identity.classes.indexOf(entClass) != -1); g_PanelEntities = g_PanelEntities.sort((panelEntA, panelEntB) => panelEntIndex(panelEntA.ent) - panelEntIndex(panelEntB.ent)); } function createPanelEntityTooltip(panelEntState, template) { let getPanelEntNameTooltip = panelEntState => "[font=\"sans-bold-16\"]" + template.name.specific + "[/font]"; return [ getPanelEntNameTooltip, getCurrentHealthTooltip, getAttackTooltip, getArmorTooltip, getEntityTooltip, getAurasTooltip ].map(tooltip => tooltip(panelEntState)).filter(tip => tip).join("\n"); } function displayPanelEntities() { let buttons = Engine.GetGUIObjectByName("panelEntityPanel").children; buttons.forEach((button, slot) => { if (button.hidden || g_PanelEntities.some(ent => ent.slot !== undefined && ent.slot == slot)) return; button.hidden = true; stopColorFade("panelEntityHitOverlay[" + slot + "]"); }); // The slot identifies the button, displayIndex determines its position. for (let displayIndex = 0; displayIndex < Math.min(g_PanelEntities.length, buttons.length); ++displayIndex) { let panelEnt = g_PanelEntities[displayIndex]; // Find the first unused slot if new, otherwise reuse previous. let slot = panelEnt.slot === undefined ? buttons.findIndex(button => button.hidden) : panelEnt.slot; let panelEntButton = Engine.GetGUIObjectByName("panelEntityButton[" + slot + "]"); panelEntButton.tooltip = panelEnt.tooltip; updateGUIStatusBar("panelEntityHealthBar[" + slot + "]", panelEnt.currentHitpoints, panelEnt.maxHitpoints); if (panelEnt.slot === undefined) { let panelEntImage = Engine.GetGUIObjectByName("panelEntityImage[" + slot + "]"); panelEntImage.sprite = panelEnt.sprite; panelEntButton.hidden = false; panelEnt.slot = slot; } // If the health of the panelEnt changed since the last update, trigger the animation. if (panelEnt.previousHitpoints > panelEnt.currentHitpoints) startColorFade("panelEntityHitOverlay[" + slot + "]", 100, 0, colorFade_attackUnit, true, smoothColorFadeRestart_attackUnit); // TODO: Instead of instant position changes, animate button movement. setPanelObjectPosition(panelEntButton, displayIndex, buttons.length); } } function updateGroups() { g_Groups.update(); // Determine the sum of the costs of a given template let getCostSum = (ent) => { let cost = GetTemplateData(GetEntityState(ent).template).cost; return cost ? Object.keys(cost).map(key => cost[key]).reduce((sum, cur) => sum + cur) : 0; }; for (let i in Engine.GetGUIObjectByName("unitGroupPanel").children) { Engine.GetGUIObjectByName("unitGroupLabel[" + i + "]").caption = i; let button = Engine.GetGUIObjectByName("unitGroupButton[" + i + "]"); button.hidden = g_Groups.groups[i].getTotalCount() == 0; button.onpress = (function(i) { return function() { performGroup((Engine.HotkeyIsPressed("selection.add") ? "add" : "select"), i); }; })(i); button.ondoublepress = (function(i) { return function() { performGroup("snap", i); }; })(i); button.onpressright = (function(i) { return function() { performGroup("breakUp", i); }; })(i); // Chose icon of the most common template (or the most costly if it's not unique) if (g_Groups.groups[i].getTotalCount() > 0) { let icon = GetTemplateData(GetEntityState(g_Groups.groups[i].getEntsGrouped().reduce((pre, cur) => { if (pre.ents.length == cur.ents.length) return getCostSum(pre.ents[0]) > getCostSum(cur.ents[0]) ? pre : cur; return pre.ents.length > cur.ents.length ? pre : cur; }).ents[0]).template).icon; Engine.GetGUIObjectByName("unitGroupIcon[" + i + "]").sprite = icon ? ("stretched:session/portraits/" + icon) : "groupsIcon"; } setPanelObjectPosition(button, i, 1); } } function updateDebug() { let debug = Engine.GetGUIObjectByName("debugEntityState"); if (!Engine.GetGUIObjectByName("devDisplayState").checked) { debug.hidden = true; return; } debug.hidden = false; let conciseSimState = clone(GetSimState()); conciseSimState.players = "<<>>"; let text = "simulation: " + uneval(conciseSimState); let selection = g_Selection.toList(); if (selection.length) { let entState = GetEntityState(selection[0]); if (entState) { let template = GetTemplateData(entState.template); text += "\n\nentity: {\n"; for (let k in entState) text += " " + k + ":" + uneval(entState[k]) + "\n"; text += "}\n\ntemplate: " + uneval(template); } } debug.caption = text.replace(/\[/g, "\\["); } /** * Create ally player stat tooltip. * @param {string} resource - Resource type, on which values will be sorted. * @param {object} playerStates - Playerstates from players whos stats are viewed in the tooltip. * @param {number} sort - 0 no order, -1 descending, 1 ascending order. * @returns {string} Tooltip string. */ function getAllyStatTooltip(resource, playerStates, sort) { let tooltip = []; for (let player in playerStates) tooltip.push({ "playername": colorizePlayernameHelper("■", player) + " " + g_Players[player].name, "statValue": resource == "pop" ? sprintf(translate("%(popCount)s/%(popLimit)s/%(popMax)s"), playerStates[player]) : Math.round(playerStates[player].resourceCounts[resource]), "orderValue": resource == "pop" ? playerStates[player].popCount : Math.round(playerStates[player].resourceCounts[resource]) }); if (sort) tooltip.sort((a, b) => sort * (b.orderValue - a.orderValue)); return "\n" + tooltip.map(stat => sprintf(translate("%(playername)s: %(statValue)s"), stat)).join("\n"); } function updatePlayerDisplay() { let allPlayerStates = GetSimState().players; let viewedPlayerState = allPlayerStates[g_ViewedPlayer]; let viewablePlayerStates = {}; for (let player in allPlayerStates) if (player != 0 && player != g_ViewedPlayer && g_Players[player].state != "defeated" && (g_IsObserver || viewedPlayerState.hasSharedLos && g_Players[player].isMutualAlly[g_ViewedPlayer])) viewablePlayerStates[player] = allPlayerStates[player]; if (!viewedPlayerState) return; let tooltipSort = +Engine.ConfigDB_GetValue("user", "gui.session.respoptooltipsort"); let orderHotkeyTooltip = Object.keys(viewablePlayerStates).length <= 1 ? "" : "\n" + sprintf(translate("%(order)s: %(hotkey)s to change order."), { "hotkey": setStringTags("\\[Click]", g_HotkeyTags), "order": tooltipSort == 0 ? translate("Unordered") : tooltipSort == 1 ? translate("Descending") : translate("Ascending") }); let resCodes = g_ResourceData.GetCodes(); for (let r = 0; r < resCodes.length; ++r) { let resourceObj = Engine.GetGUIObjectByName("resource[" + r + "]"); if (!resourceObj) break; let res = resCodes[r]; let tooltip = '[font="' + g_ResourceTitleFont + '"]' + resourceNameFirstWord(res) + '[/font]'; let descr = g_ResourceData.GetResource(res).description; if (descr) tooltip += "\n" + translate(descr); tooltip += orderHotkeyTooltip + getAllyStatTooltip(res, viewablePlayerStates, tooltipSort); resourceObj.tooltip = tooltip; Engine.GetGUIObjectByName("resource[" + r + "]_count").caption = Math.floor(viewedPlayerState.resourceCounts[res]); } Engine.GetGUIObjectByName("resourcePop").caption = sprintf(translate("%(popCount)s/%(popLimit)s"), viewedPlayerState); Engine.GetGUIObjectByName("population").tooltip = translate("Population (current / limit)") + "\n" + sprintf(translate("Maximum population: %(popCap)s"), { "popCap": viewedPlayerState.popMax }) + orderHotkeyTooltip + getAllyStatTooltip("pop", viewablePlayerStates, tooltipSort); g_IsTrainingBlocked = viewedPlayerState.trainingBlocked; } function selectAndMoveTo(ent) { let entState = GetEntityState(ent); if (!entState || !entState.position) return; g_Selection.reset(); g_Selection.addList([ent]); let position = entState.position; Engine.CameraMoveTo(position.x, position.z); } function updateResearchDisplay() { let researchStarted = Engine.GuiInterfaceCall("GetStartedResearch", g_ViewedPlayer); // Set up initial positioning. let buttonSideLength = Engine.GetGUIObjectByName("researchStartedButton[0]").size.right; for (let i = 0; i < 10; ++i) { let button = Engine.GetGUIObjectByName("researchStartedButton[" + i + "]"); let size = button.size; size.top = g_ResearchListTop + (4 + buttonSideLength) * i; size.bottom = size.top + buttonSideLength; button.size = size; } let numButtons = 0; for (let tech in researchStarted) { // Show at most 10 in-progress techs. if (numButtons >= 10) break; let template = GetTechnologyData(tech, g_Players[g_ViewedPlayer].civ); let button = Engine.GetGUIObjectByName("researchStartedButton[" + numButtons + "]"); button.hidden = false; button.tooltip = getEntityNames(template); button.onpress = (function(e) { return function() { selectAndMoveTo(e); }; })(researchStarted[tech].researcher); let icon = "stretched:session/portraits/" + template.icon; Engine.GetGUIObjectByName("researchStartedIcon[" + numButtons + "]").sprite = icon; // Scale the progress indicator. let size = Engine.GetGUIObjectByName("researchStartedProgressSlider[" + numButtons + "]").size; // Buttons are assumed to be square, so left/right offsets can be used for top/bottom. size.top = size.left + Math.round(researchStarted[tech].progress * (size.right - size.left)); Engine.GetGUIObjectByName("researchStartedProgressSlider[" + numButtons + "]").size = size; Engine.GetGUIObjectByName("researchStartedTimeRemaining[" + numButtons + "]").caption = Engine.FormatMillisecondsIntoDateStringGMT(researchStarted[tech].timeRemaining, translateWithContext("countdown format", "m:ss")); ++numButtons; } // Hide unused buttons. for (let i = numButtons; i < 10; ++i) Engine.GetGUIObjectByName("researchStartedButton[" + i + "]").hidden = true; } /** * Toggles the display of status bars for all of the player's entities. * * @param {Boolean} remove - Whether to hide all previously shown status bars. */ function recalculateStatusBarDisplay(remove = false) { let entities; if (g_ShowAllStatusBars && !remove) entities = g_ViewedPlayer == -1 ? Engine.PickNonGaiaEntitiesOnScreen() : Engine.PickPlayerEntitiesOnScreen(g_ViewedPlayer); else { let selected = g_Selection.toList(); for (let ent in g_Selection.highlighted) selected.push(g_Selection.highlighted[ent]); // Remove selected entities from the 'all entities' array, // to avoid disabling their status bars. entities = Engine.GuiInterfaceCall( g_ViewedPlayer == -1 ? "GetNonGaiaEntities" : "GetPlayerEntities", { "viewedPlayer": g_ViewedPlayer }).filter(idx => selected.indexOf(idx) == -1); } Engine.GuiInterfaceCall("SetStatusBars", { "entities": entities, "enabled": g_ShowAllStatusBars && !remove, "showRank": Engine.ConfigDB_GetValue("user", "gui.session.rankabovestatusbar") == "true" }); } /** * Inverts the given configuration boolean and returns the current state. * For example "silhouettes". */ function toggleConfigBool(configName) { let enabled = Engine.ConfigDB_GetValue("user", configName) != "true"; saveSettingAndWriteToUserConfig(configName, String(enabled)); return enabled; } /** * Toggles the display of range overlays of selected entities for the given range type. * @param {string} type - for example "Auras" */ function toggleRangeOverlay(type) { let enabled = toggleConfigBool("gui.session." + type.toLowerCase() + "range"); Engine.GuiInterfaceCall("EnableVisualRangeOverlayType", { "type": type, "enabled": enabled }); let selected = g_Selection.toList(); for (let ent in g_Selection.highlighted) selected.push(g_Selection.highlighted[ent]); Engine.GuiInterfaceCall("SetRangeOverlays", { "entities": selected, "enabled": enabled }); } function updateEnabledRangeOverlayTypes() { for (let type of ["Attack", "Auras", "Heal"]) Engine.GuiInterfaceCall("EnableVisualRangeOverlayType", { "type": type, "enabled": Engine.ConfigDB_GetValue("user", "gui.session." + type.toLowerCase() + "range") == "true" }); } // Update the additional list of entities to be highlighted. function updateAdditionalHighlight() { let entsAdd = []; // list of entities units to be highlighted let entsRemove = []; let highlighted = g_Selection.toList(); for (let ent in g_Selection.highlighted) highlighted.push(g_Selection.highlighted[ent]); if (g_ShowGuarding) // flag the guarding entities to add in this additional highlight for (let sel in g_Selection.selected) { let state = GetEntityState(g_Selection.selected[sel]); if (!state.guard || !state.guard.entities.length) continue; for (let ent of state.guard.entities) if (highlighted.indexOf(ent) == -1 && entsAdd.indexOf(ent) == -1) entsAdd.push(ent); } if (g_ShowGuarded) // flag the guarded entities to add in this additional highlight for (let sel in g_Selection.selected) { let state = GetEntityState(g_Selection.selected[sel]); if (!state.unitAI || !state.unitAI.isGuarding) continue; let ent = state.unitAI.isGuarding; if (highlighted.indexOf(ent) == -1 && entsAdd.indexOf(ent) == -1) entsAdd.push(ent); } // flag the entities to remove (from the previously added) from this additional highlight for (let ent of g_AdditionalHighlight) if (highlighted.indexOf(ent) == -1 && entsAdd.indexOf(ent) == -1 && entsRemove.indexOf(ent) == -1) entsRemove.push(ent); _setHighlight(entsAdd, g_HighlightedAlpha, true); _setHighlight(entsRemove, 0, false); g_AdditionalHighlight = entsAdd; } function playAmbient() { Engine.PlayAmbientSound(pickRandom(g_Ambient), true); } function getBuildString() { return sprintf(translate("Build: %(buildDate)s (%(revision)s)"), { "buildDate": Engine.GetBuildTimestamp(0), "revision": Engine.GetBuildTimestamp(2) }); } function showTimeWarpMessageBox() { messageBox( 500, 250, translate("Note: time warp mode is a developer option, and not intended for use over long periods of time. Using it incorrectly may cause the game to run out of memory or crash."), translate("Time warp mode") ); } /** * Adds the ingame time and ceasefire counter to the global FPS and * realtime counters shown in the top right corner. */ function appendSessionCounters(counters) { let simState = GetSimState(); if (Engine.ConfigDB_GetValue("user", "gui.session.timeelapsedcounter") === "true") { let currentSpeed = Engine.GetSimRate(); if (currentSpeed != 1.0) // Translation: The "x" means "times", with the mathematical meaning of multiplication. counters.push(sprintf(translate("%(time)s (%(speed)sx)"), { "time": timeToString(simState.timeElapsed), "speed": Engine.FormatDecimalNumberIntoString(currentSpeed) })); else counters.push(timeToString(simState.timeElapsed)); } if (simState.ceasefireActive && Engine.ConfigDB_GetValue("user", "gui.session.ceasefirecounter") === "true") counters.push(timeToString(simState.ceasefireTimeRemaining)); g_ResearchListTop = 4 + 14 * counters.length; } /** * Send the current list of players, teams, AIs, observers and defeated/won and offline states to the lobby. * The playerData format from g_GameAttributes is kept to reuse the GUI function presenting the data. */ function sendLobbyPlayerlistUpdate() { if (!g_IsController || !Engine.HasXmppClient()) return; // Extract the relevant player data and minimize packet load let minPlayerData = []; for (let playerID in g_GameAttributes.settings.PlayerData) { if (+playerID == 0) continue; let pData = g_GameAttributes.settings.PlayerData[playerID]; let minPData = { "Name": pData.Name, "Civ": pData.Civ }; if (g_GameAttributes.settings.LockTeams) minPData.Team = pData.Team; if (pData.AI) { minPData.AI = pData.AI; minPData.AIDiff = pData.AIDiff; minPData.AIBehavior = pData.AIBehavior; } if (g_Players[playerID].offline) minPData.Offline = true; // Whether the player has won or was defeated let state = g_Players[playerID].state; if (state != "active") minPData.State = state; minPlayerData.push(minPData); } // Add observers let connectedPlayers = 0; for (let guid in g_PlayerAssignments) { let pData = g_GameAttributes.settings.PlayerData[g_PlayerAssignments[guid].player]; if (pData) ++connectedPlayers; else minPlayerData.push({ "Name": g_PlayerAssignments[guid].name, "Team": "observer" }); } Engine.SendChangeStateGame(connectedPlayers, playerDataToStringifiedTeamList(minPlayerData)); } /** * Send a report on the gamestatus to the lobby. + * Keep in sync with source/tools/XpartaMuPP/LobbyRanking.py */ function reportGame() { // Only 1v1 games are rated (and Gaia is part of g_Players) if (!Engine.HasXmppClient() || !Engine.IsRankedGame() || g_Players.length != 3 || Engine.GetPlayerID() == -1) return; let extendedSimState = Engine.GuiInterfaceCall("GetExtendedSimulationState"); let unitsClasses = [ "total", "Infantry", "Worker", "FemaleCitizen", "Cavalry", "Champion", "Hero", "Siege", "Ship", "Trader" ]; let unitsCountersTypes = [ "unitsTrained", "unitsLost", "enemyUnitsKilled" ]; let buildingsClasses = [ "total", "CivCentre", "House", "Economic", "Outpost", "Military", "Fortress", "Wonder" ]; let buildingsCountersTypes = [ "buildingsConstructed", "buildingsLost", "enemyBuildingsDestroyed" ]; let resourcesTypes = [ "wood", "food", "stone", "metal" ]; let resourcesCounterTypes = [ "resourcesGathered", "resourcesUsed", "resourcesSold", "resourcesBought" ]; let misc = [ "tradeIncome", "tributesSent", "tributesReceived", "treasuresCollected", "lootCollected", "percentMapExplored" ]; let playerStatistics = {}; // Unit Stats for (let unitCounterType of unitsCountersTypes) { if (!playerStatistics[unitCounterType]) playerStatistics[unitCounterType] = { }; for (let unitsClass of unitsClasses) playerStatistics[unitCounterType][unitsClass] = ""; } playerStatistics.unitsLostValue = ""; playerStatistics.unitsKilledValue = ""; // Building stats for (let buildingCounterType of buildingsCountersTypes) { if (!playerStatistics[buildingCounterType]) playerStatistics[buildingCounterType] = { }; for (let buildingsClass of buildingsClasses) playerStatistics[buildingCounterType][buildingsClass] = ""; } playerStatistics.buildingsLostValue = ""; playerStatistics.enemyBuildingsDestroyedValue = ""; // Resources for (let resourcesCounterType of resourcesCounterTypes) { if (!playerStatistics[resourcesCounterType]) playerStatistics[resourcesCounterType] = { }; for (let resourcesType of resourcesTypes) playerStatistics[resourcesCounterType][resourcesType] = ""; } playerStatistics.resourcesGathered.vegetarianFood = ""; for (let type of misc) playerStatistics[type] = ""; // Total playerStatistics.economyScore = ""; playerStatistics.militaryScore = ""; playerStatistics.totalScore = ""; let mapName = g_GameAttributes.settings.Name; let playerStates = ""; let playerCivs = ""; let teams = ""; let teamsLocked = true; // Serialize the statistics for each player into a comma-separated list. // Ignore gaia for (let i = 1; i < extendedSimState.players.length; ++i) { let player = extendedSimState.players[i]; let maxIndex = player.sequences.time.length - 1; playerStates += player.state + ","; playerCivs += player.civ + ","; teams += player.team + ","; teamsLocked = teamsLocked && player.teamsLocked; for (let resourcesCounterType of resourcesCounterTypes) for (let resourcesType of resourcesTypes) playerStatistics[resourcesCounterType][resourcesType] += player.sequences[resourcesCounterType][resourcesType][maxIndex] + ","; playerStatistics.resourcesGathered.vegetarianFood += player.sequences.resourcesGathered.vegetarianFood[maxIndex] + ","; for (let unitCounterType of unitsCountersTypes) for (let unitsClass of unitsClasses) playerStatistics[unitCounterType][unitsClass] += player.sequences[unitCounterType][unitsClass][maxIndex] + ","; for (let buildingCounterType of buildingsCountersTypes) for (let buildingsClass of buildingsClasses) playerStatistics[buildingCounterType][buildingsClass] += player.sequences[buildingCounterType][buildingsClass][maxIndex] + ","; let total = 0; for (let type in player.sequences.resourcesGathered) total += player.sequences.resourcesGathered[type][maxIndex]; playerStatistics.economyScore += total + ","; playerStatistics.militaryScore += Math.round((player.sequences.enemyUnitsKilledValue[maxIndex] + player.sequences.enemyBuildingsDestroyedValue[maxIndex]) / 10) + ","; playerStatistics.totalScore += (total + Math.round((player.sequences.enemyUnitsKilledValue[maxIndex] + player.sequences.enemyBuildingsDestroyedValue[maxIndex]) / 10)) + ","; for (let type of misc) playerStatistics[type] += player.sequences[type][maxIndex] + ","; } // Send the report with serialized data let reportObject = {}; reportObject.timeElapsed = extendedSimState.timeElapsed; reportObject.playerStates = playerStates; reportObject.playerID = Engine.GetPlayerID(); reportObject.matchID = g_GameAttributes.matchID; reportObject.civs = playerCivs; reportObject.teams = teams; reportObject.teamsLocked = String(teamsLocked); reportObject.ceasefireActive = String(extendedSimState.ceasefireActive); reportObject.ceasefireTimeRemaining = String(extendedSimState.ceasefireTimeRemaining); reportObject.mapName = mapName; reportObject.economyScore = playerStatistics.economyScore; reportObject.militaryScore = playerStatistics.militaryScore; reportObject.totalScore = playerStatistics.totalScore; for (let rct of resourcesCounterTypes) for (let rt of resourcesTypes) reportObject[rt + rct.substr(9)] = playerStatistics[rct][rt]; // eg. rt = food rct.substr = Gathered rct = resourcesGathered reportObject.vegetarianFoodGathered = playerStatistics.resourcesGathered.vegetarianFood; for (let type of unitsClasses) { // eg. type = Infantry (type.substr(0,1)).toLowerCase()+type.substr(1) = infantry reportObject[(type.substr(0, 1)).toLowerCase() + type.substr(1) + "UnitsTrained"] = playerStatistics.unitsTrained[type]; reportObject[(type.substr(0, 1)).toLowerCase() + type.substr(1) + "UnitsLost"] = playerStatistics.unitsLost[type]; reportObject["enemy" + type + "UnitsKilled"] = playerStatistics.enemyUnitsKilled[type]; } for (let type of buildingsClasses) { reportObject[(type.substr(0, 1)).toLowerCase() + type.substr(1) + "BuildingsConstructed"] = playerStatistics.buildingsConstructed[type]; reportObject[(type.substr(0, 1)).toLowerCase() + type.substr(1) + "BuildingsLost"] = playerStatistics.buildingsLost[type]; reportObject["enemy" + type + "BuildingsDestroyed"] = playerStatistics.enemyBuildingsDestroyed[type]; } for (let type of misc) reportObject[type] = playerStatistics[type]; Engine.SendGameReport(reportObject); } Index: ps/trunk/source/tools/XpartaMuPP/README.md =================================================================== --- ps/trunk/source/tools/XpartaMuPP/README.md (revision 21924) +++ ps/trunk/source/tools/XpartaMuPP/README.md (revision 21925) @@ -1,193 +1,639 @@ -# 0 A.D. Multiplayer Lobby Setup +# 0 A.D. / Pyrogenesis Multiplayer Lobby Setup -## Introduction +This README explains how to setup a custom Pyrogenesis Multiplayer Lobby server that can be used with the Pyrogenesis game. -Some commands assume some apt-get based distribution. `lobby.wildfiregames.com` should be replaced -by your own domain name (or `localhost`) in all commands below. +## Service description +The Pyrogenesis Multiplayer Lobby consists of three components: -## ejabberd +* **XMPP server: ejabberd**: + The XMPP server provides the platform where users can register accounts, chat in a public room, and can interact with lobby bots. + Currently, ejabberd is the only XMPP server software supported (by the ipstamp module for XpartaMuPP). -These instructions for ejabberd assume you're running a Debian version where at least ejabberd -17.03 is available (currently Debian Stretch with enabled backports), as that's the minimum -ejabberd version which is required for the custom ejabberd module to work. +* **Gamelist bot: XpartaMuPP**: + This bot allows players to host and join online multiplayer matches. + It utilizes the ejabberd ipstamp module to inform players of IP addresses of hosting players. -### Install ejabberd +* **Rating bot: EcheLOn**: + This bot allows players to gain a rating that reflects their skill based on online multiplayer matches. + It is by no means necessary for the operation of a lobby in terms of match-making and chatting. -* Install `ejabberd`: +## Service choices +Before installing the service, you have to make some decisions: + +#### Choice: Domain Name +Decide on a domain name where the service will be provided. +This document will use `lobby.wildfiregames.com` as an example. +If you intend to use the server only for local testing, you may choose `localhost`. + +#### Choice: Rating service +Decide whether or not you want to employ the rating service. +If you decide to not provide the rating service, you may skip the instructions for the rating bot in this document. + +#### Choice: Pyrogenesis version compatibility +Decide whether you want to support serving multiple Pyrogenesis versions. + +Serving multiple versions of Pyrogenesis allows for seamless version upgrading on the backend and +allows players that don't have the most recent version of Pyrogenesis yet to continue to play until +the new release is available for their platform (applies mostly to linux distributions). + +If you decide to do so, you should use a naming pattern that includes the targetted Pyrogenesis version. +For example to provide a Multiplayer Lobby for Pyrogenesis Alpha 23 "Ken Wood", +name the lobby room `arena23` instead of `arena` and use `xpartamupp23` and `echelon23` as lobby bot names. +Then when a version 24 of Pyrogenesis is employed, you can easily add `arena24`, `xpartamupp24` and `echelon24`. +If you only want to use the service for local testing, you can stick to a single room and a single gamelist and rating bot. + +## 1. Install dependencies + +This section explains how to install the required software on a Debian-based linux distribution. +For other operating systems, use the according package manager or consult the official documentation of the software. + +### 1.1 Install ejabberd + +The version requirement for ejabberd is 17.03 or later (due to the ipstamp module format). + +* Install `ejabberd` using the following command. Alternatively see . ``` $ apt-get install ejabberd ``` -* Configure it, by setting the domain name (e.g. `localhost` if you installed it on your - development computer) and add an admin user.: +* Confirm that the ejabberd version you installed is the one mentioned above or later: + + ``` + $ ejabberdctl status + ``` + +* Configure ejabberd by setting the domain name of your choice and add an `admin` user.: ``` $ dpkg-reconfigure ejabberd ```` -You should now be able to connect to this XMPP server using a normal XMPP client. +You should now be able to connect to this XMPP server using any XMPP client. -### Installation of the custom ejabberd module +### 1.2 Install python3 and SleekXmpp -* Adjust `/etc/ejabberd/ejabberdctl.cfg` and set `CONTRIB_MODULES_PATH` to the directory where - you want to store `mod_ipstamp`: +* The lobby bots are programmed in python3 and use SleekXMPP to connect to the lobby. Install these dependencies using: + + ``` + $ apt-get install python3 python3-sleekxmpp + ``` + +* Confirm that the SleekXmpp version is 1.3.1 or later: + + ``` + pip3 show sleekxmpp + ``` + +* If you would like to run the rating bot, you will need to install SQLAlchemy for python3: + + ``` + $ apt-get install python3-sqlalchemy + ``` + +### 1.3 Install ejabberd ipstamp module + +The ejabberd ipstamp module has the purpose of inserting the IP address of the hosting players into the gamelist packet. +That enables players to connect to each others games. + +* Adjust `/etc/ejabberd/ejabberdctl.cfg` and set `CONTRIB_MODULES_PATH` to the directory where you want to store `mod_ipstamp`: ``` CONTRIB_MODULES_PATH=/opt/ejabberd-modules ``` * Ensure the target directory is readable by ejabberd. * Copy the `mod_ipstamp` directory from `XpartaMuPP/` to `CONTRIB_MODULES_PATH/sources/`. * Check that the module is available and compatible with your ejabberd: ``` $ ejabberdctl modules_available $ ejabberdctl module_check mod_ipstamp ``` * Install `mod_ipstamp`: ``` $ ejabberdctl module_install mod_ipstamp ``` -* Add `mod_ipstamp` to the modules ejabberd should load in`/etc/ejabberd/ejabberd.yml`: +## 2. Configure ejabberd mod_ipstamp + +The ejabberd configuration in the remainder of this document is performed by editing `/etc/ejabberd/ejabberd.yml`. +The directory containing this README includes a preconfigured `ejabberd_example.yml` that only needs few setting changes to work with your setup. +For a full documentation of the ejabberd configuration, see . +If something goes wrong with ejabberd, check `/var/log/ejabberd/ejabberd.log` + +* Add `mod_ipstamp` to the modules ejabberd should load: ``` modules: mod_ipstamp: {} ``` -* Reload ejabberd's configuration: +* Reload the ejabberd config. + This should be done every few steps, so that configuration errors can be identified as soon as possible. ``` $ ejabberdctl reload_config ``` -If something goes wrong, check `/var/log/ejabberd/ejabberd.log` +## 3. Configure ejabberd connectivity + +The settings in this section ensure that connections can be built where intended, and only where intended. + +### 3.1 Disable IPv6 +* Since the enet library which Pyrogenesis uses for multiplayer mode does not support IPv6, ejabberd must be configured to not use IPv6: + + ``` + listen: + ip: "0.0.0.0" + ``` + +### 3.2 Enable STUN +* ejabberd and Pyrogenesis support the STUN protocol. This allows players to connect to each others games even if the host did not configure +the router and forward the UDP port. Enabling STUN is optional but recommended. + + ``` + listen: + - + port: 3478 + transport: udp + module: ejabberd_stun + ``` + +### 3.3 Enable keep-alive + +* This helps with users becoming disconnected: + + ``` + modules: + mod_ping: + send_pings: true + ``` + +### 3.3 Disable unused services + +* Disable the currently unused server-to-server communication: + + ``` + listen: + ## - + ## port: 5269 + ## ip: "::" + ## module: ejabberd_s2s_in + ``` + +* Protect the administrative webinterface at from external access by disabling or restriction to `localhost`: -### Ejabberd configuration + ``` + listen: + - + port: 5280 + ip: "127.0.0.1" + ``` + +* Disable some unused modules: + + ``` + modules: + ## mod_echo: {} + ## mod_irc: {} + ## mod_shared_roster: {} + ## mod_vcard: {} + ## mod_vcard_xupdate: {} + ``` + +### 3.4 Setup TLS encryption + +Depending on whether you use the server for a player audience or only for local testing, +you may have to either obtain and install a certificate with ejabberd or disable TLS encryption. + +#### Choice A: No encryption +* If you intend to use the server solely for local testing, you may disable TLS encryption in the ejabberd config: + + ``` + listen: + starttls_required: false + ``` -A web administration interface is available at http://localhost:5280/admin. Use the admin user -credentials (full JID (user@domain)) to log in. Changing settings there is also possible, but some -of those might not persist on restart. +#### Choice B: Self-signed certificate -The rest of this section should be done by editing `/etc/ejabberd/ejabberd.yml`. +If you want to use the server for local testing only, you may use a self-signed certificate to test encryption. +Notice the lobby bots currently reject self-signed certificates. -* Allow users to create accounts using the game via in-band registration: +* Enable TLS over the default port: + ``` + listen: + starttls: true + ``` +* Create the key file for certificate: + + ``` + openssl genrsa -out key.pem 2048 + ``` +* Create the certificate file. “common name” should match the domainname. + + ``` + openssl req -new -key key.pem -out request.pem ``` - access: + +* Sign the certificate: + + ``` + openssl x509 -req -days 900 -in request.pem -signkey key.pem -out certificate.pem + ``` + +* Store it as the ejabberd certificate: + + ``` + $ cat key.pem request.pem > /etc/ejabberd/ejabberd.pem + ``` + +#### Choice C: Let's Encrypt certificate +To secure user authentication and communication with modern encryption and to comply with privacy laws, +ejabberd should be configured to use TLS with a proper, trusted certificate. + +* A free, valid, and trusted TLS certificate may be obtained from some certificate authorites, such as Let's Encrypt: + + + +* Enable TLS over the default port: + ``` + listen: + starttls: true + ``` + +* Setup the contact address if Let's Encrypt found an authentication issue: + + ``` + acme: + contact: "mailto:admin@example.com" + ``` + +* Ensure old, vulnerable SSL/TLS protocols are disabled: + + ``` + define_macro: + 'TLS_OPTIONS': + - "no_sslv2" + - "no_sslv3" + - "no_tlsv1" + ``` + +## 3. Configure ejabberd use policy + +The settings in this section grant or restrict user access rights. + +* Prevent the rooms from being destroyed if the last client leaves it: + + ``` + access_rules: + muc_admin: + - allow: admin + modules: + mod_muc: + access_persistent: muc_admin + default_room_options: + persistent: true + ``` + +* Allow users to create accounts using the game via in-band registration. + ``` + access_rules: register: - all: allow + - all: allow ``` -* Check list of registered users: +### Optional use policies +* (Optional) It is recommended to restrict usernames to alphanumeric characters (so that playernames are easily typeable for every participant). + The username may be restricted in length (because very long usernames are uncomfortably time-consuming to read and may not fit into the playername fields). + Notice the username regex below is also used by the 0 A.D. client to indicate invalid names to the user. ``` - $ ejabberdctl registered_users lobby.wildfiregames.com + acl: + validname: + user_regexp: "^[0-9A-Za-z._-]{1,20}$" + + access_rules: + register: + - allow: validname + + modules: + mod_register: + access: register + ``` + +* (Optional) Prevent users from creating new rooms: + + ``` + modules: + mod_muc: + access_create: muc_admin + ``` + +* (Optional) Increase the maximum number of users from the default 200: + + ``` + mod_muc: + max_users: 5000 + default_room_options: + max_users: 1000 ``` -* `XpartaMuPP` and `EcheLOn` need a user accountsto function, so create them using: +* (Optional) Prevent users from sending too large stanzas. + Notice the bots can send large stanzas as well, so don't restrict it too much. + + ``` + max_stanza_size: 1048576 + ``` + + +* (Optional) Prevent users from changing the room topic: ``` - $ ejabberdctl register echelon lobby.wildfiregames.com secure_password - $ ejabberdctl register xpartamupp lobby.wildfiregames.com secure_password + mod_muc: + default_room_options: + allow_change_subj: false ``` -* The bots also need to be able to get the IPs of users hosting a match, which is what - `mod_ipstamp` does. +* (Optional) Prevent malicious users from registering new accounts quickly if they were banned. + Notice this also prevents players using the same internet router from registering for that time if they want to play together. - * Create an ACL for the bot (or bots): + ``` + registration_timeout: 3600 + ``` + +* (Optional) Enable room chatlogging. + Make sure to mention this collection and the purposes in the Terms and Conditions to comply with personal data laws. + Ensure that ejabberd has write access to the given folder. + Notice that `ejabberd.service` by default prevents write access to some directories (PrivateTmp, ProtectHome, ProtectSystem). + + ``` + modules: + mod_muc_log: + outdir: "/lobby/logs" + file_format: plaintext + timezone: universal + mod_muc: + default_room_options: + logging: true + ``` + +* (Optional) Grant specific moderators administrator rights to see the IP address of a user: + See also `https://xmpp.org/extensions/xep-0133.html#get-user-stats`. ``` acl: - bots: + admin: user: - - "echelon@lobby.wildfiregames.com" - - "xpartamupp@lobby.wildfiregames.com" + - "username@lobby.wildfiregames.com" + ``` + +* (Optional) Grant specific moderators to : + See also `https://xmpp.org/extensions/xep-0133.html#get-user-stats`. + + ``` + modules: + mod_muc: + access_admin: muc_admin + ``` + +* (Optional) Ban specific IP addresses or subnet masks for persons that create new accounts after having been banned from the room: + + ``` + acl: + blocked: + ip: + - "12.34.56.78" + - "12.34.56.0/8" + - "12.34.0.0/16" + ... + access_rules: + c2s: + - deny: blocked + - allow + register: + - deny: blocked + - allow + ``` + +## 4. Setup lobby bots + +### 4.1 Register lobby bot accounts + +* Check list of registered users: + + ``` + $ ejabberdctl registered_users lobby.wildfiregames.com + ``` + +* Register the accounts of the lobby bots. + The rating account is only needed if you decided to enable the rating service. + + ``` + $ ejabberdctl register echelon23 lobby.wildfiregames.com secure_password + $ ejabberdctl register xpartamupp23 lobby.wildfiregames.com secure_password + ``` + +### 4.2 Authorize lobby bots to see real JIDs + +* The bots need to be able to see real JIDs of users. + So either the room must be configured as non-anonymous, i.e. real JIDs are visible to all users of the room, + or the bots need to receive muc administrator rights. + +#### Choice A: Non-anonymous room +* (Recommended) This method has the advantage that bots do not gain administrative access that they don't use. + The only possible downside is that room users may not hide their username behind arbitrary nicknames anymore. + + ``` + modules: + mod_muc: + default_room_options: + anonymous: false + +#### Choice B: Non-anonymous room +* If you for any reason wish to configure the room as semi-anonymous (only muc administrators can see real JIDs), + then the bots need to be authorized as muc administrators: + + ``` + access_rules: + muc_admin: + - allow: bots + + modules: + mod_muc: + access_admin: muc_admin + ``` + +### 4.3 Authorize lobby bots with ejabberd + +* The bots need an ACL to be able to get the IPs of users hosting a match (which is what `mod_ipstamp` does). + + ``` + acl: + ## Don't use a regex, to prevent others from obtaining permissions after registering such an account. + bots: + - user: "xpartamupp23@lobby.wildfiregames.com" + - user: "echelon23@lobby.wildfiregames.com" ``` -* Add an access rule (name it `ipbots` since that is what the module expects): +* Add an access rule for `ipbots` and a rule allowing bots to create PubSub nodes: ``` - access: + access_rules: + ## Expected by the ipstamp module for XpartaMuPP ipbots: - bots: allow + - allow: bots + + pubsub_createnode: + - allow: bots ``` * Due to the amount of traffic the bot may process, give the group containing bots either unlimited or a very high traffic shaper: ``` - c2s_shaper: - admin: none - bots: none - all: normal + shaper_rules: + c2s_shaper: + - none: admin, bots + - normal ``` -* The bots need the real JIDs of the MUC users, which are only available for admin users, - therefore the following setting is necessary: +* Finally reload ejabberd's configuration: ``` - access: - muc_admin: - admin: allow - bots: allow + $ ejabberdctl reload_config ``` -### MUC room setup +### 4.4 Running XpartaMuPP - XMPP Multiplayer Game Manager -To enable the bot to send the game list to players it needs the JIDs of the players, so the MUC -room has to be configured as non-anonymous room. In case that you want to host multiple lobby -rooms adding an ACL for MUC admins to which the bots are added, which is used for `access_admin` -in the `mod_muc` configuration would be advisable. +* Execute the following command to run the gamelist bot: -## Running XpartaMuPP - XMPP Multiplayer Game Manager + ``` + $ python3 XpartaMuPP.py --domain lobby.wildfiregames.com --login xpartamupp23 --password XXXXXX --nickname GamelistBot --room arena --elo echelon23 + ``` -You need to have Python 3 and SleekXmpp 1.3.1+ installed +If you want to run XpartaMuPP without a rating bot, the `--elo` argument should be omitted. +Pass `--disable-tls` if you did not setup valid TLS encryption on the server. +Run `python3 XpartaMuPP.py --help` for the full list of options - # apt-get install python3 python3-sleekxmpp +* If the connection and authentication succeeded, you should see the following messages in the console: -If you would like to run the leaderboard database, you will need to install SQLAlchemy for Python 3. + ``` + INFO JID set to: xpartamupp23@lobby.wildfiregames.com/CC + INFO XpartaMuPP started + ``` - # apt-get install python3-sqlalchemy +### 4.5 Running EcheLOn - XMPP Multiplayer Rating Manager -Then execute the following command to setup the database. +This bot can be thought of as a module of XpartaMuPP in that IQs stanzas sent to XpartaMuPP are +forwarded onto EcheLOn if its corresponding EcheLOn is online and ignored otherwise. +EcheLOn handles all aspects of operation related to ELO, the chess rating system invented by Arpad Elo. +Players gain a rating after a rated 1v1 match. +The score difference after a completed match is relative to the rating difference of the players. + +* (Optional) Some constants of the algorithm may be edited by experienced administrators at the head of `ELO.py`: + + ``` + # Difference between two ratings such that it is + # regarded as a "sure win" for the higher player. + # No points are gained or lost for such a game. + elo_sure_win_difference = 600.0 + + # Lower ratings "move faster" and change more + # dramatically than higher ones. Anything rating above + # this value moves at the same rate as this value. + elo_k_factor_constant_rating = 2200.0 + ``` + +* To initialize the `lobby_rankings.sqlite3` database, execute the following command: + ``` $ python3 LobbyRanking.py + ``` -Execute the following command to run the bot with default options: +* Execute the following command to run the rating bot: - $ python3 XpartaMuPP.py + ``` + $ python3 EcheLOn.py --domain lobby.wildfiregames.com --login echelon23 --password XXXXXX --nickname RatingBot --room arena23 + ``` -or rather a similar command to run a properly configured program: +Run `python3 EcheLOn.py --help` for the full list of options - $ python3 XpartaMuPP.py --domain lobby.wildfiregames.com --login wfgbot --password XXXXXX --nickname WFGbot --room arena +## 5. Configure Pyrogenesis for the new Multiplayer Lobby -or if you want to run XpartaMuPP with the corresponding rating bot detailed in the next section: +The Pyrogenesis client is now going to be configured to become able to connect to the new Multiplayer Lobby. - $ python3 XpartaMuPP.py --domain lobby.wildfiregames.com --login wfgbot --password XXXXXX --nickname WFGbot --room arena --elo echelon +The Pyrogenesis documentation of configuration files can be found at . +Available Pyrogenesis configuration settings are specified in `default.cfg`, see . -Run `python3 XpartaMuPP.py --help` for the full list of options +### 5.1 Local Configuration -If everything is fine you should see something along these lines in your console + * Visit to identify the local user's Pyrogenesis configuration path depending on the operating system. - INFO Negotiating TLS - INFO Using SSL version: 3 - INFO Node set to: wfgbot@lobby.wildfiregames.com/CC - INFO XpartaMuPP started + * Create or open `local.cfg` in the configuration path. -Congratulations, you are now running XpartaMuPP - the 0 A.D. Multiplayer Game Manager. + * Add the following settings that determine the lobby server connection: -## Run EcheLOn - XMPP Rating Bot + ``` + lobby.room = "arena23" ; Default MUC room to join + lobby.server = "lobby.wildfiregames.com" ; Address of lobby server + lobby.stun.server = "lobby.wildfiregames.com" ; Address of the STUN server. + lobby.require_tls = true ; Whether to reject connecting to the lobby if TLS encryption is unavailable. + lobby.verify_certificate = true ; Whether to reject connecting to the lobby if the TLS certificate is invalid. + lobby.xpartamupp = "xpartamupp23" ; Name of the server-side XMPP-account that manage games + lobby.echelon = "echelon23" ; Name of the server-side XMPP-account that manages ratings + ``` -This bot can be thought of as a module of XpartaMuPP in that IQs stanzas sent to XpartaMuPP are -forwarded onto EcheLOn if its corresponding EcheLOn is online and ignored otherwise. This is by no -means necessary for the operation of a lobby in terms of creating/joining/listing games. -EcheLOn handles all aspects of operation related to ELO. + If you disabled TLS encryption, set `require_tls` to `false`. + If you employed a self-signed certificate, set `verify_certificate` to `false`. + +### 5.2 Test the Multiplayer Lobby + +You should now be able to join the new multiplayer lobby with the Pyrogenesis client and play multiplayer matches. + +* To confirm that the match hosting works as intended, create two user accounts, host a game with one, join the game with the other account. + +* To confirm that the rating service works as intended, resign a rated 1v1 match with two accounts. + +### 5.3 Terms and Conditions + +Players joining public servers are subject to Terms and Conditions of the service provider and subject to privacy laws such as GDPR. +If you intend to use the server only for local testing, you may skip this step. + +* The following files should be created by the service provider: + + `Terms_of_Service.txt` to explain the service and the contract. + `Terms_of_Use.txt` to explain what the user should and should not do. + `Privacy_Policy.txt` to explain how personal data is handled. + +* To use Wildfire Games Terms as a template, obtain our Terms from a copy of the game or from or from + + +* Replace all occurrences of `Wildfire Games` in the files with the one providing the new server. + +* Update the `Terms_of_Use.txt` depending on which behavior you would like to (not) see on your service. + +* Update the `Privacy_Policy.txt` depending on the user data processing in relation to the usage policies. +Make sure to not violate privacy laws such as GDPR or COPPA while doing so. + +* The retention times of ejabberd logs are relevant to GDPR. +Visit for details. + +* The terms should be published online, so users can save and print them. + Add to your `local.cfg`: + + ``` + lobby.terms_url = "https://lobby.wildfiregames.com/terms/"; Allows the user to save the text and print the terms + ``` + +### 5.4 Distribute the configuration + +To make this a public server, distribute your `local.cfg`, `Terms_of_Service.txt`, `Terms_of_Use.txt`, `Privacy_Policy.txt`. -To run EcheLOn: +It may be advisable to create a mod with a modified `default.cfg` and the new terms documents, +see . - $ python3 EcheLOn.py --domain lobby.wildfiregames.com --login echelon --password XXXXXX --nickname Ratings --room arena +Congratulations, you are now running a custom Pyrogenesis Multiplayer Lobby! Index: ps/trunk/source/tools/XpartaMuPP/ejabberd_example.yml =================================================================== --- ps/trunk/source/tools/XpartaMuPP/ejabberd_example.yml (nonexistent) +++ ps/trunk/source/tools/XpartaMuPP/ejabberd_example.yml (revision 21925) @@ -0,0 +1,855 @@ +### +###' ejabberd configuration file +### +### + +### The parameters used in this configuration file are explained in more detail +### in the ejabberd Installation and Operation Guide. +### Please consult the Guide in case of doubts, it is included with +### your copy of ejabberd, and is also available online at +### http://www.process-one.net/en/ejabberd/docs/ + +### The configuration file is written in YAML. +### Refer to http://en.wikipedia.org/wiki/YAML for the brief description. +### However, ejabberd treats different literals as different types: +### +### - unquoted or single-quoted strings. They are called "atoms". +### Example: dog, 'Jupiter', '3.14159', YELLOW +### +### - numeric literals. Example: 3, -45.0, .0 +### +### - quoted or folded strings. +### Examples of quoted string: "Lizzard", "orange". +### Example of folded string: +### > Art thou not Romeo, +### and a Montague? +--- +###. ======= +###' LOGGING + +## +## loglevel: Verbosity of log files generated by ejabberd. +## 0: No ejabberd log at all (not recommended) +## 1: Critical +## 2: Error +## 3: Warning +## 4: Info +## 5: Debug +## +loglevel: 4 + +## +## rotation: Disable ejabberd's internal log rotation, as the Debian package +## uses logrotate(8). +log_rotate_size: 0 +log_rotate_date: "" + +## +## overload protection: If you want to limit the number of messages per second +## allowed from error_logger, which is a good idea if you want to avoid a flood +## of messages when system is overloaded, you can set a limit. +## 100 is ejabberd's default. +log_rate_limit: 100 + +## +## watchdog_admins: Only useful for developers: if an ejabberd process +## consumes a lot of memory, send live notifications to these XMPP +## accounts. +## +## watchdog_admins: +## - "bob@example.com" + +###. =============== +###' NODE PARAMETERS + +## +## net_ticktime: Specifies net_kernel tick time in seconds. This options must have +## identical value on all nodes, and in most cases shouldn't be changed at all from +## default value. +## +## net_ticktime: 60 + +###. ================ +###' SERVED HOSTNAMES + +## +## hosts: Domains served by ejabberd. +## You can define one or several, for example: +## hosts: +## - "example.net" +## - "example.com" +## - "example.org" +## +hosts: + - "localhost" + +## +## route_subdomains: Delegate subdomains to other XMPP servers. +## For example, if this ejabberd serves example.org and you want +## to allow communication with an XMPP server called im.example.org. +## +## route_subdomains: s2s + +###. ============ +###' Certificates + +## List all available PEM files containing certificates for your domains, +## chains of certificates or certificate keys. Full chains will be built +## automatically by ejabberd. +## +certfiles: + - "/etc/ejabberd/ejabberd.pem" + +## If your system provides only a single CA file (CentOS/FreeBSD): +## ca_file: "/etc/ssl/certs/ca-bundle.pem" + +###. ================= +###' TLS configuration + +## Note that the following configuration is the default +## configuration of the TLS driver, so you don't need to +## uncomment it. +## +define_macro: + 'TLS_CIPHERS': "HIGH:!aNULL:!eNULL:!3DES:@STRENGTH" + 'TLS_OPTIONS': + - "no_sslv2" + - "no_sslv3" + - "no_tlsv1" + - "cipher_server_preference" + - "no_compression" + ## 'DH_FILE': "/path/to/dhparams.pem" # generated with: openssl dhparam -out dhparams.pem 2048 + +## c2s_dhfile: 'DH_FILE' +## s2s_dhfile: 'DH_FILE' +c2s_ciphers: 'TLS_CIPHERS' +s2s_ciphers: 'TLS_CIPHERS' +c2s_protocol_options: 'TLS_OPTIONS' +s2s_protocol_options: 'TLS_OPTIONS' + +###. =============== +###' LISTENING PORTS + +## +## listen: The ports ejabberd will listen on, which service each is handled +## by and what options to start it with. +## +listen: + - + port: 5222 + ip: "0.0.0.0" + module: ejabberd_c2s + starttls: true + starttls_required: false + protocol_options: 'TLS_OPTIONS' + max_stanza_size: 1048576 + shaper: c2s_shaper + access: c2s + + ## port: 5269 + ## ip: "::" + ## module: ejabberd_s2s_in + + - + port: 5280 + ip: "127.0.0.1" + module: ejabberd_http + request_handlers: + "/ws": ejabberd_http_ws + "/bosh": mod_bosh + "/api": mod_http_api + ## "/pub/archive": mod_http_fileserver + web_admin: true + ## register: true + ## captcha: true + tls: true + protocol_options: 'TLS_OPTIONS' + + ## + ## ejabberd_service: Interact with external components (transports, ...) + ## + ## - + ## port: 8888 + ## ip: "::" + ## module: ejabberd_service + ## access: all + ## shaper_rule: fast + ## ip: "127.0.0.1" + ## privilege_access: + ## roster: "both" + ## message: "outgoing" + ## presence: "roster" + ## delegations: + ## "urn:xmpp:mam:1": + ## filtering: ["node"] + ## "http://jabber.org/protocol/pubsub": + ## filtering: [] + ## hosts: + ## "icq.example.org": + ## password: "secret" + ## "sms.example.org": + ## password: "secret" + + ## + ## ejabberd_stun: Handles STUN Binding requests + ## + - + port: 3478 + transport: udp + module: ejabberd_stun + + ## + ## To handle XML-RPC requests that provide admin credentials: + ## + ## - + ## port: 4560 + ## ip: "::" + ## module: ejabberd_xmlrpc + ## maxsessions: 10 + ## timeout: 5000 + ## access_commands: + ## admin: + ## commands: all + ## options: [] + + ## + ## To enable secure http upload + ## + ## - + ## port: 5444 + ## ip: "::" + ## module: ejabberd_http + ## request_handlers: + ## "": mod_http_upload + ## tls: true + ## protocol_options: 'TLS_OPTIONS' + ## dhfile: 'DH_FILE' + ## ciphers: 'TLS_CIPHERS' + +## Disabling digest-md5 SASL authentication. digest-md5 requires plain-text +## password storage (see auth_password_format option). +disable_sasl_mechanisms: "digest-md5" + +###. ================== +###' S2S GLOBAL OPTIONS + +## +## s2s_use_starttls: Enable STARTTLS for S2S connections. +## Allowed values are: false, optional or required +## You must specify 'certfiles' option +## +s2s_use_starttls: required + +## +## S2S whitelist or blacklist +## +## Default s2s policy for undefined hosts. +## +## s2s_access: s2s + +## +## Outgoing S2S options +## +## Preferred address families (which to try first) and connect timeout +## in seconds. +## +## outgoing_s2s_families: +## - ipv4 +## - ipv6 +## outgoing_s2s_timeout: 190 + +###. ============== +###' AUTHENTICATION + +## +## auth_method: Method used to authenticate the users. +## The default method is the internal. +## If you want to use a different method, +## comment this line and enable the correct ones. +## +auth_method: internal + +## +## Store the plain passwords or hashed for SCRAM: +## auth_password_format: plain +auth_password_format: scram +## +## Define the FQDN if ejabberd doesn't detect it: +## fqdn: "server3.example.com" + +## +## Authentication using external script +## Make sure the script is executable by ejabberd. +## +## auth_method: external +## extauth_program: "/path/to/authentication/script" + +## +## Authentication using SQL +## Remember to setup a database in the next section. +## +## auth_method: sql + +## +## Authentication using PAM +## +## auth_method: pam +## pam_service: "pamservicename" + +## +## Authentication using LDAP +## +## auth_method: ldap +## +## List of LDAP servers: +## ldap_servers: +## - "lw" +## +## Encryption of connection to LDAP servers: +## ldap_encrypt: none +## ldap_encrypt: tls +## +## Port to connect to on LDAP servers: +## ldap_port: 389 +## ldap_port: 636 +## +## LDAP manager: +## ldap_rootdn: "dc=example,dc=com" +## +## Password of LDAP manager: +## ldap_password: "******" +## +## Search base of LDAP directory: +## ldap_base: "dc=example,dc=com" +## +## LDAP attribute that holds user ID: +## ldap_uids: +## - "mail": "%u@mail.example.org" +## +## LDAP filter: +## ldap_filter: "(objectClass=shadowAccount)" + +## +## Anonymous login support: +## auth_method: anonymous +## anonymous_protocol: sasl_anon | login_anon | both +## allow_multiple_connections: true | false +## +## host_config: +## "public.example.org": +## auth_method: anonymous +## allow_multiple_connections: false +## anonymous_protocol: sasl_anon +## +## To use both anonymous and internal authentication: +## +## host_config: +## "public.example.org": +## auth_method: +## - internal +## - anonymous + +###. ============== +###' DATABASE SETUP + +## ejabberd by default uses the internal Mnesia database, +## so you do not necessarily need this section. +## This section provides configuration examples in case +## you want to use other database backends. +## Please consult the ejabberd Guide for details on database creation. + +## +## MySQL server: +## +## sql_type: mysql +## sql_server: "server" +## sql_database: "database" +## sql_username: "username" +## sql_password: "password" +## +## If you want to specify the port: +## sql_port: 1234 + +## +## PostgreSQL server: +## +## sql_type: pgsql +## sql_server: "server" +## sql_database: "database" +## sql_username: "username" +## sql_password: "password" +## +## If you want to specify the port: +## sql_port: 1234 +## +## If you use PostgreSQL, have a large database, and need a +## faster but inexact replacement for "select count(*) from users" +## +## pgsql_users_number_estimate: true + +## +## SQLite: +## +## sql_type: sqlite +## sql_database: "/path/to/database.db" + +## +## ODBC compatible or MSSQL server: +## +## sql_type: odbc +## sql_server: "DSN=ejabberd;UID=ejabberd;PWD=ejabberd" + +## +## Number of connections to open to the database for each virtual host +## +## sql_pool_size: 10 + +## +## Interval to make a dummy SQL request to keep the connections to the +## database alive. Specify in seconds: for example 28800 means 8 hours +## +## sql_keepalive_interval: undefined + +###. =============== +###' TRAFFIC SHAPERS + +shaper: + ## + ## The "normal" shaper limits traffic speed to 1000 B/s + ## + normal: 1000 + + ## + ## The "fast" shaper limits traffic speed to 50000 B/s + ## + fast: 50000 + +## +## This option specifies the maximum number of elements in the queue +## of the FSM. Refer to the documentation for details. +## +max_fsm_queue: 10000 + +###. ==================== +###' ACCESS CONTROL LISTS +acl: + ## + ## The 'admin' ACL grants administrative privileges to XMPP accounts. + ## You can put here as many accounts as you want. + ## + admin: + user: + - "admin@localhost" + + ## Don't use a regex, to prevent others from obtaining permissions after registering such an account. + bots: + - user: "echelon23@localhost" + - user: "wfgbot23@localhost" + + # Keep playernames short and easily typeable for everyone + validname: + user_regexp: "^[0-9A-Za-z._-]{1,20}$" + + ## + ## Blocked users + ## + ## blocked: + ## user: + ## - "baduser@example.org" + ## - "test" + + ## Local users: don't modify this. + ## + local: + user_regexp: "" + + ## + ## More examples of ACLs + ## + ## jabberorg: + ## server: + ## - "jabber.org" + ## aleksey: + ## user: + ## - "aleksey@jabber.ru" + ## test: + ## user_regexp: "^test" + ## user_glob: "test*" + + ## + ## Loopback network + ## + loopback: + ip: + - "127.0.0.0/8" + - "::1/128" + - "::FFFF:127.0.0.1/128" + + ## + ## Bad XMPP servers + ## + ## bad_servers: + ## server: + ## - "xmpp.zombie.org" + ## - "xmpp.spam.com" + +## +## Define specific ACLs in a virtual host. +## +## host_config: +## "localhost": +## acl: +## admin: +## user: +## - "bob-local@localhost" + +###. ============ +###' SHAPER RULES + +shaper_rules: + ## Maximum number of simultaneous sessions allowed for a single user: + max_user_sessions: 10 + ## Maximum number of offline messages that users can have: + max_user_offline_messages: + - 5000: admin + - 100 + ## For C2S connections, all users except admins use the "normal" shaper + c2s_shaper: + - none: admin + - none: bots + - normal + ## All S2S connections use the "fast" shaper + s2s_shaper: fast + +###. ============ +###' ACCESS RULES +access_rules: + ## This rule allows access only for local users: + local: + - allow: local + ## Only non-blocked users can use c2s connections: + c2s: + - deny: blocked + - allow + ## Only admins can send announcement messages: + announce: + - allow: admin + ## Only admins can use the configuration interface: + configure: + - allow: admin + ## Expected by the ipstamp module for XpartaMuPP + ipbots: + - allow: bots + muc_admin: + - allow: admin + ## Bots must be able to create nodes for games, ratings and boards lists + pubsub_createnode: + - allow: admin + - allow: bots + ## In-band registration allows registration of any possible username. + ## To disable in-band registration, replace 'allow' with 'deny'. + register: + - deny: blocked + - allow: validname + ## Only allow to register from localhost + trusted_network: + - allow: loopback + ## Do not establish S2S connections with bad servers + ## If you enable this you also have to uncomment "s2s_access: s2s" + ## s2s: + ## - deny: + ## - ip: "XXX.XXX.XXX.XXX/32" + ## - deny: + ## - ip: "XXX.XXX.XXX.XXX/32" + ## - allow + +## =============== +## API PERMISSIONS +## =============== +## +## This section allows you to define who and using what method +## can execute commands offered by ejabberd. +## +## By default "console commands" section allow executing all commands +## issued using ejabberdctl command, and "admin access" section allows +## users in admin acl that connect from 127.0.0.1 to execute all +## commands except start and stop with any available access method +## (ejabberdctl, http-api, xmlrpc depending what is enabled on server). +## +## If you remove "console commands" there will be one added by +## default allowing executing all commands, but if you just change +## permissions in it, version from config file will be used instead +## of default one. +## +api_permissions: + "console commands": + from: + - ejabberd_ctl + who: all + what: "*" + "admin access": + who: + - access: + - allow: + - acl: loopback + - acl: admin + - oauth: + - scope: "ejabberd:admin" + - access: + - allow: + - acl: loopback + - acl: admin + what: + - "*" + - "!stop" + - "!start" + "public commands": + who: + - ip: "127.0.0.1/8" + what: + - "status" + - "connected_users_number" + +## By default the frequency of account registrations from the same IP +## is limited to 1 account every 10 minutes. To disable, specify: infinity +registration_timeout: 3600 + +## +## Define specific Access Rules in a virtual host. +## +## host_config: +## "localhost": +## access: +## c2s: +## - allow: admin +## - deny +## register: +## - deny + +###. ================ +###' DEFAULT LANGUAGE + +## +## language: Default language used for server messages. +## +language: "en" + +## +## Set a different default language in a virtual host. +## +## host_config: +## "localhost": +## language: "ru" + +###. ======= +###' CAPTCHA + +## +## Full path to a script that generates the image. +## +## captcha_cmd: "/usr/share/ejabberd/captcha.sh" + +## +## Host for the URL and port where ejabberd listens for CAPTCHA requests. +## +## captcha_host: "example.org:5280" + +## +## Limit CAPTCHA calls per minute for JID/IP to avoid DoS. +## +## captcha_limit: 5 + +###. ==== +###' ACME +## +## In order to use the acme certificate acquiring through "Let's Encrypt" +## an http listener has to be configured to listen to port 80 so that +## the authorization challenges posed by "Let's Encrypt" can be solved. +## +## A simple way of doing this would be to add the following in the listening +## section and to configure port forwarding from 80 to 5281 either via NAT +## (for ipv4 only) or using frontends such as haproxy/nginx/sslh/etc. +## - +## port: 5281 +## ip: "::" +## module: ejabberd_http + +acme: + + ## A contact mail that the ACME Certificate Authority can contact in case of + ## an authorization issue, such as a server-initiated certificate revocation. + ## It is not mandatory to provide an email address but it is highly suggested. + contact: "mailto:example-admin@example.com" + + + ## The ACME Certificate Authority URL. + ## This could either be: + ## - https://acme-v01.api.letsencrypt.org - (Default) for the production CA + ## - https://acme-staging.api.letsencrypt.org - for the staging CA + ## - http://localhost:4000 - for a local version of the CA + ca_url: "https://acme-v01.api.letsencrypt.org" + +###. ======= +###' MODULES + +## +## Modules enabled in all ejabberd virtual hosts. +## +modules: + mod_adhoc: {} + mod_admin_extra: {} + mod_announce: # recommends mod_adhoc + access: announce + mod_blocking: {} # requires mod_privacy + mod_caps: {} + mod_carboncopy: {} + mod_client_state: {} + mod_configure: {} # requires mod_adhoc + ## mod_delegation: {} # for xep0356 + mod_disco: {} + ## mod_echo: {} + ## ipstamp module used by XpartaMuPP to insert IP addresses into the gamelist + mod_ipstamp: {} + ## mod_irc: {} + mod_bosh: {} + ## mod_http_fileserver: + ## docroot: "/var/www" + ## accesslog: "/var/log/ejabberd/access.log" + ## mod_http_upload: + ## # docroot: "@HOME@/upload" + ## put_url: "https://@HOST@:5444" + ## thumbnail: false # otherwise needs the identify command from ImageMagick installed + ## mod_http_upload_quota: + ## max_days: 30 + mod_last: {} + ## XEP-0313: Message Archive Management + ## You might want to setup a SQL backend for MAM because the mnesia database is + ## limited to 2GB which might be exceeded on large servers + ## mod_mam: {} # for xep0313, mnesia is limited to 2GB, better use an SQL backend + mod_muc: + ## host: "conference.@HOST@" + access: + - allow + access_admin: muc_admin + access_create: muc_admin + access_persistent: muc_admin + max_users: 5000 + default_room_options: + allow_change_subj: false + logging: true + max_users: 1000 + persistent: true + mod_muc_admin: {} + mod_muc_log: + outdir: "/lobby/logs" + dirtype: plain + file_format: plaintext + timezone: universal + ## mod_multicast: {} + mod_offline: + access_max_user_messages: max_user_offline_messages + mod_ping: + send_pings: true + ## mod_pres_counter: + ## count: 5 + ## interval: 60 + mod_privacy: {} + mod_private: {} + ## mod_proxy65: {} + mod_pubsub: + access_createnode: pubsub_createnode + ## reduces resource comsumption, but XEP incompliant + ignore_pep_from_offline: true + ## XEP compliant, but increases resource comsumption + ## ignore_pep_from_offline: false + last_item_cache: false + plugins: + - "flat" + - "hometree" + - "pep" # pep requires mod_caps + mod_push: {} + mod_push_keepalive: {} + mod_register: + ## + ## Protect In-Band account registrations with CAPTCHA. + ## + ## captcha_protected: true + ## + ## Set the minimum informational entropy for passwords. + ## + ## password_strength: 32 + ## + ## After successful registration, the user receives + ## a message with this subject and body. + ## + ## welcome_message: + ## subject: "Welcome!" + ## body: |- + ## Hi. + ## Welcome to this XMPP server. + ## + ## When a user registers, send a notification to + ## these XMPP accounts. + ## + ## registration_watchers: + ## - "admin1@example.org" + ## + ## Only clients in the server machine can register accounts + ## + ## ip_access: trusted_network + ## + ## Local c2s or remote s2s users cannot register accounts + ## + ## access_from: deny + access: register + mod_roster: + versioning: true + ## mod_shared_roster: {} + mod_stats: {} + mod_time: {} + ## mod_vcard: + ## search: false + ## mod_vcard_xupdate: {} + ## Convert all avatars posted by Android clients from WebP to JPEG + ## mod_avatar: # this module needs compile option --enable-graphics + ## convert: + ## webp: jpeg + mod_version: {} + mod_stream_mgmt: + resend_on_timeout: if_offline + ## Non-SASL Authentication (XEP-0078) is now disabled by default + ## because it's obsoleted and is used mostly by abandoned + ## client software + ## mod_legacy_auth: {} + ## The module for S2S dialback (XEP-0220). Please note that you cannot + ## rely solely on dialback if you want to federate with other servers, + ## because a lot of servers have dialback disabled and instead rely on + ## PKIX authentication. Make sure you have proper certificates installed + ## and check your accessibility at https://check.messaging.one/ + mod_s2s_dialback: {} + mod_http_api: {} + +## +## Enable modules with custom options in a specific virtual host +## +## host_config: +## "localhost": +## modules: +## mod_echo: +## host: "mirror.localhost" + +## +## Enable modules management via ejabberdctl for installation and +## uninstallation of public/private contributed modules +## (enabled by default) +## + +allow_contrib_modules: true + +###. +###' +### Local Variables: +### mode: yaml +### End: +### vim: set filetype=yaml tabstop=8 foldmarker=###',###. foldmethod=marker: + Property changes on: ps/trunk/source/tools/XpartaMuPP/ejabberd_example.yml ___________________________________________________________________ Added: svn:eol-style ## -0,0 +1 ## +native \ No newline at end of property