From cfd301ba49d22f7e611ab5f1724c9b567d8a0176 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sat, 11 Jan 2025 13:27:03 -0500 Subject: [PATCH 01/17] Move WzInfoButton --- src/CMakeLists.txt | 4 +- src/titleui/campaign.cpp | 49 +-------------------- src/titleui/widgets/infobutton.cpp | 70 ++++++++++++++++++++++++++++++ src/titleui/widgets/infobutton.h | 42 ++++++++++++++++++ 4 files changed, 115 insertions(+), 50 deletions(-) create mode 100644 src/titleui/widgets/infobutton.cpp create mode 100644 src/titleui/widgets/infobutton.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5c53d98939a..1ee9e78b5ac 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -88,8 +88,8 @@ if(ENABLE_NLS) find_package (Intl REQUIRED) endif() -file(GLOB HEADERS "*.h" "3rdparty/*.h" "titleui/*.h" "hci/*.h" "input/*.h" "screens/*.h") -file(GLOB SRC "*.cpp" "3rdparty/*.cpp" "titleui/*.cpp" "hci/*.cpp" "input/*.cpp" "screens/*.cpp") +file(GLOB HEADERS "*.h" "3rdparty/*.h" "titleui/*.h" "titleui/*/*.h" "hci/*.h" "input/*.h" "screens/*.h") +file(GLOB SRC "*.cpp" "3rdparty/*.cpp" "titleui/*.cpp" "titleui/*/*.cpp" "hci/*.cpp" "input/*.cpp" "screens/*.cpp") set(_additionalSourceFiles) if(CMAKE_SYSTEM_NAME MATCHES "Windows") diff --git a/src/titleui/campaign.cpp b/src/titleui/campaign.cpp index b3820bbcb89..49de3c6ef5c 100644 --- a/src/titleui/campaign.cpp +++ b/src/titleui/campaign.cpp @@ -44,6 +44,7 @@ #include "lib/ivis_opengl/pieblitfunc.h" #include "../modding.h" #include "../version.h" +#include "widgets/infobutton.h" #define WZ2100_CORE_CLASSIC_BALANCE_MOD_FILENAME "wz2100_camclassic.wz" @@ -790,54 +791,6 @@ class WzCampaignTweakOptionSetting bool editable = true; }; -class WzInfoButton : public W_BUTTON -{ -protected: - WzInfoButton() {} -public: - static std::shared_ptr make() - { - class make_shared_enabler: public WzInfoButton {}; - auto widget = std::make_shared(); - return widget; - } - void setImageDimensions(int imageSize); -protected: - void display(int xOffset, int yOffset) override; -private: - int imageDimensions = 16; -}; - -void WzInfoButton::setImageDimensions(int imageSize) -{ - imageDimensions = imageSize; -} - -void WzInfoButton::display(int xOffset, int yOffset) -{ - int x0 = x() + xOffset; - int y0 = y() + yOffset; - - bool isDown = (getState() & (WBUT_DOWN | WBUT_LOCK | WBUT_CLICKLOCK)) != 0; - bool isDisabled = (getState() & WBUT_DISABLE) != 0; - bool isHighlight = !isDisabled && ((getState() & WBUT_HIGHLIGHT) != 0); - - if (isHighlight) - { - // Display highlight rect - int highlightRectDimensions = std::max(std::min(width(), height()), imageDimensions + 2); - int boxX0 = x0 + (width() - highlightRectDimensions) / 2; - int boxY0 = y0 + (height() - highlightRectDimensions) / 2; - iV_Box(boxX0, boxY0, boxX0 + highlightRectDimensions + 1, boxY0 + highlightRectDimensions + 1, (isDown) ? pal_RGBA(255,255,255,255) : WZCOL_TEXT_MEDIUM); - } - - // Display the info icon, centered - int imgPosX0 = x0 + (width() - imageDimensions) / 2; - int imgPosY0 = y0 + (height() - imageDimensions) / 2; - PIELIGHT imgColor = (isHighlight) ? pal_RGBA(255,255,255,255) : WZCOL_TEXT_MEDIUM; - iV_DrawImageFileAnisotropicTint(FrontImages, IMAGE_INFO_CIRCLE, imgPosX0, imgPosY0, Vector2f(imageDimensions, imageDimensions), imgColor); -} - class WzFrontendImageButton : public W_BUTTON { protected: diff --git a/src/titleui/widgets/infobutton.cpp b/src/titleui/widgets/infobutton.cpp new file mode 100644 index 00000000000..688e00c695e --- /dev/null +++ b/src/titleui/widgets/infobutton.cpp @@ -0,0 +1,70 @@ +/* + This file is part of Warzone 2100. + Copyright (C) 2024 Warzone 2100 Project + + Warzone 2100 is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + Warzone 2100 is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Warzone 2100; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ +/** \file + * Info Button + */ + +#include "infobutton.h" +#include "src/frontend.h" +#include "src/frend.h" +#include "lib/widget/widget.h" +#include "lib/widget/label.h" +#include "lib/ivis_opengl/pieblitfunc.h" + +std::shared_ptr WzInfoButton::make() +{ + class make_shared_enabler: public WzInfoButton {}; + auto widget = std::make_shared(); + return widget; +} + +void WzInfoButton::setImageDimensions(int imageSize) +{ + imageDimensions = imageSize; +} + +int32_t WzInfoButton::idealHeight() +{ + return imageDimensions; +} + +void WzInfoButton::display(int xOffset, int yOffset) +{ + int x0 = x() + xOffset; + int y0 = y() + yOffset; + + bool isDown = (getState() & (WBUT_DOWN | WBUT_LOCK | WBUT_CLICKLOCK)) != 0; + bool isDisabled = (getState() & WBUT_DISABLE) != 0; + bool isHighlight = !isDisabled && ((getState() & WBUT_HIGHLIGHT) != 0); + + if (isHighlight) + { + // Display highlight rect + int highlightRectDimensions = std::max(std::min(width(), height()), imageDimensions + 2); + int boxX0 = x0 + (width() - highlightRectDimensions) / 2; + int boxY0 = y0 + (height() - highlightRectDimensions) / 2; + iV_Box(boxX0, boxY0, boxX0 + highlightRectDimensions + 1, boxY0 + highlightRectDimensions + 1, (isDown) ? imageColorHighlighted : imageColor); + } + + // Display the info icon, centered + int imgPosX0 = x0 + (width() - imageDimensions) / 2; + int imgPosY0 = y0 + (height() - imageDimensions) / 2; + PIELIGHT imgColor = (isHighlight) ? imageColorHighlighted : imageColor; + iV_DrawImageFileAnisotropicTint(FrontImages, IMAGE_INFO_CIRCLE, imgPosX0, imgPosY0, Vector2f(imageDimensions, imageDimensions), imgColor); +} diff --git a/src/titleui/widgets/infobutton.h b/src/titleui/widgets/infobutton.h new file mode 100644 index 00000000000..ee15eeb340c --- /dev/null +++ b/src/titleui/widgets/infobutton.h @@ -0,0 +1,42 @@ +/* + This file is part of Warzone 2100. + Copyright (C) 2024 Warzone 2100 Project + + Warzone 2100 is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + Warzone 2100 is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Warzone 2100; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ +/** \file + * Info Button + */ + +#pragma once + +#include "lib/widget/widget.h" +#include "lib/widget/button.h" + +class WzInfoButton : public W_BUTTON +{ +protected: + WzInfoButton() {} +public: + static std::shared_ptr make(); + void setImageDimensions(int imageSize); + int32_t idealHeight() override; +protected: + void display(int xOffset, int yOffset) override; +private: + int imageDimensions = 16; + PIELIGHT imageColor = WZCOL_TEXT_MEDIUM; + PIELIGHT imageColorHighlighted = pal_RGBA(255,255,255,255); +}; From 4d2011ba6693c79fa5b4cbf7bfc0393eaf75119f Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sat, 11 Jan 2025 13:40:40 -0500 Subject: [PATCH 02/17] Use getPlayerName() in more places --- lib/netplay/netplay.cpp | 8 ++++---- src/gamehistorylogger.cpp | 2 +- src/hci/quickchat.cpp | 4 ++-- src/multiint.cpp | 20 ++++++++++---------- src/multijoin.cpp | 2 +- src/multiplay.cpp | 4 ++-- src/multistat.cpp | 16 ++++++++-------- src/multivote.cpp | 4 +++- src/scores.cpp | 2 +- 9 files changed, 32 insertions(+), 30 deletions(-) diff --git a/lib/netplay/netplay.cpp b/lib/netplay/netplay.cpp index 2a74a231660..b53ea19ce17 100644 --- a/lib/netplay/netplay.cpp +++ b/lib/netplay/netplay.cpp @@ -2114,7 +2114,7 @@ bool NETmovePlayerToSpectatorOnlySlot(uint32_t playerIdx, bool hostOverride /*= std::string playerPublicKeyB64 = base64Encode(getMultiStats(newSpecIdx).identity.toBytes(EcKey::Public)); std::string playerIdentityHash = getMultiStats(newSpecIdx).identity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[newSpecIdx]) ? "V" : "?"; - std::string playerName = NetPlay.players[newSpecIdx].name; + std::string playerName = getPlayerName(newSpecIdx); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); wz_command_interface_output("WZEVENT: movedPlayerToSpec: %" PRIu32 " -> %" PRIu32 " %s %s %s %s %s\n", playerIdx, newSpecIdx, playerPublicKeyB64.c_str(), playerIdentityHash.c_str(), playerVerifiedStatus.c_str(), playerNameB64.c_str(), NetPlay.players[newSpecIdx].IPtextAddress); } @@ -2174,7 +2174,7 @@ SpectatorToPlayerMoveResult NETmoveSpectatorToPlayerSlot(uint32_t playerIdx, opt std::string playerPublicKeyB64 = base64Encode(getMultiStats(newPlayerIdx.value()).identity.toBytes(EcKey::Public)); std::string playerIdentityHash = getMultiStats(newPlayerIdx.value()).identity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[newPlayerIdx.value()]) ? "V" : "?"; - std::string playerName = NetPlay.players[newPlayerIdx.value()].name; + std::string playerName = getPlayerName(newPlayerIdx.value()); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); wz_command_interface_output("WZEVENT: movedSpecToPlayer: %" PRIu32 " -> %" PRIu32 " %s %s %s %s %s\n", playerIdx, newPlayerIdx.value(), playerPublicKeyB64.c_str(), playerIdentityHash.c_str(), playerVerifiedStatus.c_str(), playerNameB64.c_str(), NetPlay.players[newPlayerIdx.value()].IPtextAddress); } @@ -4328,11 +4328,11 @@ static void NETallowJoining() char buf[250] = {'\0'}; const char* pPlayerType = (NetPlay.players[index].isSpectator) ? "Spectator" : "Player"; - snprintf(buf, sizeof(buf), "%s[%" PRIu8 "] %s has joined, IP is: %s", pPlayerType, index, NetPlay.players[index].name, NetPlay.players[index].IPtextAddress); + snprintf(buf, sizeof(buf), "%s[%" PRIu8 "] %s has joined, IP is: %s", pPlayerType, index, getPlayerName(index), NetPlay.players[index].IPtextAddress); debug(LOG_INFO, "%s", buf); NETlogEntry(buf, SYNC_FLAG, index); - debug(LOG_NET, "%s, %s, with index of %u has joined using socket %p", pPlayerType, NetPlay.players[index].name, (unsigned int)index, static_cast(connected_bsocket[index])); + debug(LOG_NET, "%s, %s, with index of %u has joined using socket %p", pPlayerType, getPlayerName(index), (unsigned int)index, static_cast(connected_bsocket[index])); // Increment player count gamestruct.desc.dwCurrentPlayers++; diff --git a/src/gamehistorylogger.cpp b/src/gamehistorylogger.cpp index 216037c5dd4..563fba2f02f 100644 --- a/src/gamehistorylogger.cpp +++ b/src/gamehistorylogger.cpp @@ -333,7 +333,7 @@ void GameStoryLogger::logStartGame() for (int i = 0; i < game.maxPlayers; i++) { FixedPlayerAttributes playerAttrib; - playerAttrib.name = NetPlay.players[i].name; + playerAttrib.name = (strlen(NetPlay.players[i].name) == 0) ? "" : getPlayerName(i); playerAttrib.position = NetPlay.players[i].position; playerAttrib.team = NetPlay.players[i].team; playerAttrib.colour = NetPlay.players[i].colour; diff --git a/src/hci/quickchat.cpp b/src/hci/quickchat.cpp index 79a8469f8d3..493610716e4 100644 --- a/src/hci/quickchat.cpp +++ b/src/hci/quickchat.cpp @@ -2447,8 +2447,8 @@ namespace INTERNAL_ADMIN_ACTION_NOTICE { return std::string(); } - const char* responsiblePlayerName = NetPlay.players[responsiblePlayerIdx].name; - const char* targetPlayerName = NetPlay.players[targetPlayerIdx].name; + const char* responsiblePlayerName = getPlayerName(responsiblePlayerIdx); + const char* targetPlayerName = getPlayerName(targetPlayerIdx); const char* responsiblePlayerType = _("Player"); if (responsiblePlayerIdx == NetPlay.hostPlayer) diff --git a/src/multiint.cpp b/src/multiint.cpp index 533c2856154..23e3d875d62 100644 --- a/src/multiint.cpp +++ b/src/multiint.cpp @@ -2510,7 +2510,7 @@ static void informIfAdminChangedOtherTeam(uint32_t targetPlayerIdx, uint32_t res sendQuickChat(WzQuickChatMessage::INTERNAL_ADMIN_ACTION_NOTICE, realSelectedPlayer, WzQuickChatTargeting::targetAll(), WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::constructMessageData(WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::Context::Team, responsibleIdx, targetPlayerIdx)); std::string senderPublicKeyB64 = base64Encode(getMultiStats(responsibleIdx).identity.toBytes(EcKey::Public)); - debug(LOG_INFO, "Admin %s (%s) changed team of player ([%u] %s) to: %d", NetPlay.players[responsibleIdx].name, senderPublicKeyB64.c_str(), NetPlay.players[targetPlayerIdx].position, NetPlay.players[targetPlayerIdx].name, NetPlay.players[targetPlayerIdx].team); + debug(LOG_INFO, "Admin %s (%s) changed team of player ([%u] %s) to: %d", getPlayerName(responsibleIdx), senderPublicKeyB64.c_str(), NetPlay.players[targetPlayerIdx].position, getPlayerName(targetPlayerIdx), NetPlay.players[targetPlayerIdx].team); } static bool changeTeam(UBYTE player, UBYTE team, uint32_t responsibleIdx) @@ -2598,7 +2598,7 @@ bool recvTeamRequest(NETQUEUE queue) { resetReadyStatus(false); } - debug(LOG_NET, "%s is now part of team: %d", NetPlay.players[player].name, (int) team); + debug(LOG_NET, "%s is now part of team: %d", getPlayerName(player), (int) team); return changeTeam(player, team, queue.index); // we do this regardless, in case of sync issues } @@ -2612,7 +2612,7 @@ static bool SendReadyRequest(UBYTE player, bool bReady) std::string playerPublicKeyB64 = base64Encode(getMultiStats(player).identity.toBytes(EcKey::Public)); std::string playerIdentityHash = getMultiStats(player).identity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[player]) ? "V" : "?"; - std::string playerName = NetPlay.players[player].name; + std::string playerName = getPlayerName(player); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); wz_command_interface_output("WZEVENT: readyStatus=%d: %" PRIu32 " %s %s %s %s %s\n", bReady ? 1 : 0, player, playerPublicKeyB64.c_str(), playerIdentityHash.c_str(), playerVerifiedStatus.c_str(), playerNameB64.c_str(), NetPlay.players[player].IPtextAddress); @@ -2667,7 +2667,7 @@ bool recvReadyRequest(NETQUEUE queue) if (stats.identity.empty() && bReady && !NetPlay.players[player].isSpectator) { // log this! - debug(LOG_INFO, "Player has empty identity: (player: %u, name: \"%s\", IP: %s)", (unsigned)player, NetPlay.players[player].name, NetPlay.players[player].IPtextAddress); + debug(LOG_INFO, "Player has empty identity: (player: %u, name: \"%s\", IP: %s)", (unsigned)player, getPlayerName(player), NetPlay.players[player].IPtextAddress); // kick the player HandleBadParam("NET_READY_REQUEST failed due to player empty identity.", player, queue.index); @@ -2680,7 +2680,7 @@ bool recvReadyRequest(NETQUEUE queue) std::string playerPublicKeyB64 = base64Encode(stats.identity.toBytes(EcKey::Public)); std::string playerIdentityHash = stats.identity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[player]) ? "V" : "?"; - std::string playerName = NetPlay.players[player].name; + std::string playerName = getPlayerName(player); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); wz_command_interface_output("WZEVENT: readyStatus=%d: %" PRIu32 " %s %s %s %s %s\n", bReady ? 1 : 0, player, playerPublicKeyB64.c_str(), playerIdentityHash.c_str(), playerVerifiedStatus.c_str(), playerNameB64.c_str(), NetPlay.players[player].IPtextAddress); @@ -2712,7 +2712,7 @@ static void informIfAdminChangedOtherPosition(uint32_t targetPlayerIdx, uint32_t sendQuickChat(WzQuickChatMessage::INTERNAL_ADMIN_ACTION_NOTICE, realSelectedPlayer, WzQuickChatTargeting::targetAll(), WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::constructMessageData(WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::Context::Position, responsibleIdx, targetPlayerIdx)); std::string senderPublicKeyB64 = base64Encode(getMultiStats(responsibleIdx).identity.toBytes(EcKey::Public)); - debug(LOG_INFO, "Admin %s (%s) changed position of player (%s) to: %d", NetPlay.players[responsibleIdx].name, senderPublicKeyB64.c_str(), NetPlay.players[targetPlayerIdx].name, NetPlay.players[targetPlayerIdx].position); + debug(LOG_INFO, "Admin %s (%s) changed position of player (%s) to: %d", getPlayerName(responsibleIdx), senderPublicKeyB64.c_str(), getPlayerName(targetPlayerIdx), NetPlay.players[targetPlayerIdx].position); } static bool changePosition(UBYTE player, UBYTE position, uint32_t responsibleIdx) @@ -2765,7 +2765,7 @@ static void informIfAdminChangedOtherColor(uint32_t targetPlayerIdx, uint32_t re sendQuickChat(WzQuickChatMessage::INTERNAL_ADMIN_ACTION_NOTICE, realSelectedPlayer, WzQuickChatTargeting::targetAll(), WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::constructMessageData(WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::Context::Color, responsibleIdx, targetPlayerIdx)); std::string senderPublicKeyB64 = base64Encode(getMultiStats(responsibleIdx).identity.toBytes(EcKey::Public)); - debug(LOG_INFO, "Admin %s (%s) changed color of player ([%u] %s) to: [%d] %s", NetPlay.players[responsibleIdx].name, senderPublicKeyB64.c_str(), NetPlay.players[targetPlayerIdx].position, NetPlay.players[targetPlayerIdx].name, NetPlay.players[targetPlayerIdx].colour, getPlayerColourName(targetPlayerIdx)); + debug(LOG_INFO, "Admin %s (%s) changed color of player ([%u] %s) to: [%d] %s", getPlayerName(responsibleIdx), senderPublicKeyB64.c_str(), NetPlay.players[targetPlayerIdx].position, getPlayerName(targetPlayerIdx), NetPlay.players[targetPlayerIdx].colour, getPlayerColourName(targetPlayerIdx)); } bool changeColour(unsigned player, int col, uint32_t responsibleIdx) @@ -2845,7 +2845,7 @@ static void informIfAdminChangedOtherFaction(uint32_t targetPlayerIdx, uint32_t sendQuickChat(WzQuickChatMessage::INTERNAL_ADMIN_ACTION_NOTICE, realSelectedPlayer, WzQuickChatTargeting::targetAll(), WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::constructMessageData(WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::Context::Faction, responsibleIdx, targetPlayerIdx)); std::string senderPublicKeyB64 = base64Encode(getMultiStats(responsibleIdx).identity.toBytes(EcKey::Public)); - debug(LOG_INFO, "Admin %s (%s) changed faction of player ([%u] %s) to: %s", NetPlay.players[responsibleIdx].name, senderPublicKeyB64.c_str(), NetPlay.players[targetPlayerIdx].position, NetPlay.players[targetPlayerIdx].name, to_localized_string(static_cast(NetPlay.players[targetPlayerIdx].faction))); + debug(LOG_INFO, "Admin %s (%s) changed faction of player ([%u] %s) to: %s", getPlayerName(responsibleIdx), senderPublicKeyB64.c_str(), NetPlay.players[targetPlayerIdx].position, getPlayerName(targetPlayerIdx), to_localized_string(static_cast(NetPlay.players[targetPlayerIdx].faction))); } bool changeFaction(unsigned player, FactionID faction, uint32_t responsibleIdx) @@ -6912,7 +6912,7 @@ class WzHostLobbyOperationsInterface : public HostLobbyOperationsInterface std::string playerPublicKeyB64 = base64Encode(getMultiStats(player).identity.toBytes(EcKey::Public)); std::string playerIdentityHash = getMultiStats(player).identity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[player]) ? "V" : "?"; - std::string playerName = NetPlay.players[player].name; + std::string playerName = getPlayerName(player); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); wz_command_interface_output("WZEVENT: hostChatPermissions=%s: %" PRIu32 " %" PRIu32 " %s %s %s %s %s\n", (freeChatEnabled) ? "Y" : "N", player, gameTime, playerPublicKeyB64.c_str(), playerIdentityHash.c_str(), playerVerifiedStatus.c_str(), playerNameB64.c_str(), NetPlay.players[player].IPtextAddress); } @@ -8209,7 +8209,7 @@ void displayPlayer(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) const PLAYERSTATS& stat = getMultiStats(j); auto ar = stat.autorating; - std::string name = NetPlay.players[j].name; + std::string name = getPlayerName(j); std::map serverPlayers; // TODO Fill this with players known to the server (needs implementing on the server, too). Currently useless. diff --git a/src/multijoin.cpp b/src/multijoin.cpp index fc6ec32be1b..2b585544a12 100644 --- a/src/multijoin.cpp +++ b/src/multijoin.cpp @@ -479,7 +479,7 @@ bool MultiPlayerLeave(UDWORD playerIndex) std::string playerPublicKeyB64 = base64Encode(getMultiStats(playerIndex).identity.toBytes(EcKey::Public)); std::string playerIdentityHash = getMultiStats(playerIndex).identity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[playerIndex]) ? "V" : "?"; - std::string playerName = NetPlay.players[playerIndex].name; + std::string playerName = getPlayerName(playerIndex); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); wz_command_interface_output("WZEVENT: playerLeft: %" PRIu32 " %" PRIu32 " %s %s %s %s %s\n", playerIndex, gameTime, playerPublicKeyB64.c_str(), playerIdentityHash.c_str(), playerVerifiedStatus.c_str(), playerNameB64.c_str(), NetPlay.players[playerIndex].IPtextAddress); } diff --git a/src/multiplay.cpp b/src/multiplay.cpp index dc0cab35fea..fd5dd75b72b 100644 --- a/src/multiplay.cpp +++ b/src/multiplay.cpp @@ -1458,7 +1458,7 @@ bool recvMessage() std::string playerPublicKeyB64 = base64Encode(getMultiStats(player_id).identity.toBytes(EcKey::Public)); std::string playerIdentityHash = getMultiStats(player_id).identity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[player_id]) ? "V" : "?"; - std::string playerName = NetPlay.players[player_id].name; + std::string playerName = getPlayerName(player_id); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); wz_command_interface_output("WZEVENT: playerResponding: %" PRIu32 " %s %s %s %s %s\n", player_id, playerPublicKeyB64.c_str(), playerIdentityHash.c_str(), playerVerifiedStatus.c_str(), playerNameB64.c_str(), NetPlay.players[player_id].IPtextAddress); @@ -2466,7 +2466,7 @@ static bool recvBeacon(NETQUEUE queue) debug(LOG_WZ, "Received beacon for player: %d, from: %d", receiver, sender); - sstrcat(msg, NetPlay.players[sender].name); // name + sstrcat(msg, getPlayerName(sender)); // name sstrcpy(beaconReceiveMsg[sender], msg); return addBeaconBlip(locX, locY, receiver, sender, beaconReceiveMsg[sender]); diff --git a/src/multistat.cpp b/src/multistat.cpp index 10a661ed75b..25263aca833 100644 --- a/src/multistat.cpp +++ b/src/multistat.cpp @@ -143,7 +143,7 @@ void lookupRatingAsync(uint32_t playerIndex) req.setRequestHeader("WZ-Player-Hash", hash); req.setRequestHeader("WZ-Player-Key", key); req.setRequestHeader("WZ-Locale", getLanguage()); - debug(LOG_INFO, "Requesting \"%s\" for player %d (%.32s) (%s)", req.url.c_str(), playerIndex, NetPlay.players[playerIndex].name, hash.c_str()); + debug(LOG_INFO, "Requesting \"%s\" for player %d (%.32s) (%s)", req.url.c_str(), playerIndex, getPlayerName(playerIndex), hash.c_str()); req.onSuccess = [playerIndex, hash](std::string const &url, HTTPResponseDetails const &response, std::shared_ptr const &data) { long httpStatusCode = response.httpStatusCode(); std::string urlCopy = url; @@ -225,7 +225,7 @@ bool swapPlayerMultiStatsLocal(uint32_t playerIndexA, uint32_t playerIndexB) generateSessionKeysWithPlayer(playerIndexA); } catch (const std::invalid_argument& e) { - debug(LOG_INFO, "Cannot create session keys: (self: %u), (other: %u, name: \"%s\"), with error: %s", realSelectedPlayer, playerIndexA, NetPlay.players[playerIndexA].name, e.what()); + debug(LOG_INFO, "Cannot create session keys: (self: %u), (other: %u, name: \"%s\"), with error: %s", realSelectedPlayer, playerIndexA, getPlayerName(playerIndexA), e.what()); } } if (playerIndexB != realSelectedPlayer && (playerIndexB < MAX_PLAYERS || playerIndexB == NetPlay.hostPlayer)) @@ -234,7 +234,7 @@ bool swapPlayerMultiStatsLocal(uint32_t playerIndexA, uint32_t playerIndexB) generateSessionKeysWithPlayer(playerIndexB); } catch (const std::invalid_argument& e) { - debug(LOG_INFO, "Cannot create session keys: (self: %u), (other: %u, name: \"%s\"), with error: %s", realSelectedPlayer, playerIndexB, NetPlay.players[playerIndexB].name, e.what()); + debug(LOG_INFO, "Cannot create session keys: (self: %u), (other: %u, name: \"%s\"), with error: %s", realSelectedPlayer, playerIndexB, getPlayerName(playerIndexB), e.what()); } } return true; @@ -358,18 +358,18 @@ bool multiStatsSetIdentity(uint32_t playerIndex, const EcKey::Key &identity, boo { if (!playerStats[playerIndex].identity.fromBytes(identity, EcKey::Public)) { - debug(LOG_INFO, "Player sent invalid identity: (player: %u, name: \"%s\", IP: %s)", playerIndex, NetPlay.players[playerIndex].name, NetPlay.players[playerIndex].IPtextAddress); + debug(LOG_INFO, "Player sent invalid identity: (player: %u, name: \"%s\", IP: %s)", playerIndex, getPlayerName(playerIndex), NetPlay.players[playerIndex].IPtextAddress); } } else { - debug(LOG_INFO, "Player sent empty identity: (player: %u, name: \"%s\", IP: %s)", playerIndex, NetPlay.players[playerIndex].name, NetPlay.players[playerIndex].IPtextAddress); + debug(LOG_INFO, "Player sent empty identity: (player: %u, name: \"%s\", IP: %s)", playerIndex, getPlayerName(playerIndex), NetPlay.players[playerIndex].IPtextAddress); } if ((identity != prevIdentity) || identity.empty()) { if (GetGameMode() == GS_NORMAL) { - debug(LOG_INFO, "Unexpected identity change after NET_FIREUP for: (player: %u, name: \"%s\", IP: %s)", playerIndex, NetPlay.players[playerIndex].name, NetPlay.players[playerIndex].IPtextAddress); + debug(LOG_INFO, "Unexpected identity change after NET_FIREUP for: (player: %u, name: \"%s\", IP: %s)", playerIndex, getPlayerName(playerIndex), NetPlay.players[playerIndex].IPtextAddress); } ingame.PingTimes[playerIndex] = PING_LIMIT; @@ -404,7 +404,7 @@ bool multiStatsSetIdentity(uint32_t playerIndex, const EcKey::Key &identity, boo { std::string senderPublicKeyB64 = base64Encode(playerStats[playerIndex].identity.toBytes(EcKey::Public)); std::string senderIdentityHash = playerStats[playerIndex].identity.publicHashString(); - std::string sendername = NetPlay.players[playerIndex].name; + std::string sendername = getPlayerName(playerIndex); std::string senderNameB64 = base64Encode(std::vector(sendername.begin(), sendername.end())); wz_command_interface_output("WZEVENT: player identity UNVERIFIED: %" PRIu32 " %s %s %s %s\n", playerIndex, senderPublicKeyB64.c_str(), senderIdentityHash.c_str(), senderNameB64.c_str(), NetPlay.players[playerIndex].IPtextAddress); } @@ -433,7 +433,7 @@ bool multiStatsSetIdentity(uint32_t playerIndex, const EcKey::Key &identity, boo generateSessionKeysWithPlayer(playerIndex); } catch (const std::invalid_argument& e) { - debug(LOG_INFO, "Cannot create session keys: (self: %u), (other: %u, name: \"%s\", IP: %s), with error: %s", realSelectedPlayer, playerIndex, NetPlay.players[playerIndex].name, NetPlay.players[playerIndex].IPtextAddress, e.what()); + debug(LOG_INFO, "Cannot create session keys: (self: %u), (other: %u, name: \"%s\", IP: %s), with error: %s", realSelectedPlayer, playerIndex, getPlayerName(playerIndex), NetPlay.players[playerIndex].IPtextAddress, e.what()); } } else diff --git a/src/multivote.cpp b/src/multivote.cpp index c0a05f60e05..7a50022ffd3 100644 --- a/src/multivote.cpp +++ b/src/multivote.cpp @@ -158,6 +158,8 @@ uint8_t getLobbyChangeVoteTotal() static void recvLobbyChangeVote(uint32_t player, uint8_t newVote) { + ASSERT_OR_RETURN(, player < MAX_PLAYERS, "Invalid sender: %" PRIu32, player); + playerVotes[player] = (newVote == 1) ? 1 : 0; debug(LOG_NET, "total votes: %d/%d", static_cast(getLobbyChangeVoteTotal()), static_cast(NET_numHumanPlayers())); @@ -165,7 +167,7 @@ static void recvLobbyChangeVote(uint32_t player, uint8_t newVote) // there is no "votes" that disallows map change so assume they are all allowing if(newVote == 1) { char msg[128] = {0}; - ssprintf(msg, _("%s (%d) allowed map change. Total: %d/%d"), NetPlay.players[player].name, player, static_cast(getLobbyChangeVoteTotal()), static_cast(NET_numHumanPlayers())); + ssprintf(msg, _("%s (%d) allowed map change. Total: %d/%d"), getPlayerName(player), player, static_cast(getLobbyChangeVoteTotal()), static_cast(NET_numHumanPlayers())); sendRoomSystemMessage(msg); } } diff --git a/src/scores.cpp b/src/scores.cpp index ba5ad2c1e57..70ea9165f65 100644 --- a/src/scores.cpp +++ b/src/scores.cpp @@ -653,7 +653,7 @@ void stdOutGameSummary(UDWORD realTimeThrottleSeconds, bool flush_output /* = tr // NOTE: This duplicates the logic in rules.js - checkEndConditions() const bool playerCantDoAnything = (numFactoriesThatCanProduceConstructionUnits == 0) && (numUnits == 0); const char * deadStatus = playerCantDoAnything ? "x" : ""; - fprintf(stdout, "%2u | %11.11s | %10" PRIi64 " | %12" PRIi32 " | %13.13s | %11" PRIi32 " | %7" PRIi32 " | %s\n", n, NetPlay.players[n].name, getExtractedPower(n), unitsKilled, structInfoString.c_str(), numUnits, getPower(n), deadStatus); + fprintf(stdout, "%2u | %11.11s | %10" PRIi64 " | %12" PRIi32 " | %13.13s | %11" PRIi32 " | %7" PRIi32 " | %s\n", n, getPlayerName(n), getExtractedPower(n), unitsKilled, structInfoString.c_str(), numUnits, getPower(n), deadStatus); } } fprintf(stdout, "--------------------------------------------------------------------------------------\n"); From a63cd27937a636303b49a104165047fca36e0227 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sat, 11 Jan 2025 15:51:02 -0500 Subject: [PATCH 03/17] Relocate some reusable multiint widget-related code --- src/multiint.cpp | 932 +----------------------- src/multiint.h | 47 ++ src/titleui/widgets/lobbyplayerrow.cpp | 943 +++++++++++++++++++++++++ src/titleui/widgets/lobbyplayerrow.h | 56 ++ 4 files changed, 1077 insertions(+), 901 deletions(-) create mode 100644 src/titleui/widgets/lobbyplayerrow.cpp create mode 100644 src/titleui/widgets/lobbyplayerrow.h diff --git a/src/multiint.cpp b/src/multiint.cpp index 23e3d875d62..88032ae67d6 100644 --- a/src/multiint.cpp +++ b/src/multiint.cpp @@ -122,6 +122,7 @@ #include "multivote.h" #include "campaigninfo.h" #include "screens/joiningscreen.h" +#include "titleui/widgets/lobbyplayerrow.h" #include "activity.h" #include @@ -205,19 +206,12 @@ static std::weak_ptr currentMultiOptionsTitleUI; // widget functions static W_EDITBOX* addMultiEditBox(UDWORD formid, UDWORD id, UDWORD x, UDWORD y, char const *tip, char const *tipres, UDWORD icon, UDWORD iconhi, UDWORD iconid); -static std::shared_ptr createBlueForm(int x, int y, int w, int h, WIDGET_DISPLAY displayFunc = intDisplayFeBox); static W_FORM * addBlueForm(UDWORD parent, UDWORD id, UDWORD x, UDWORD y, UDWORD w, UDWORD h, WIDGET_DISPLAY displayFunc = intDisplayFeBox); static int numSlotsToBeDisplayed(); static inline bool spectatorSlotsSupported(); -static inline bool isSpectatorOnlySlot(UDWORD playerIdx); -//static inline bool isSpectatorOnlyPosition(UDWORD playerPosition); // Drawing Functions static void displayChatEdit(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset); -static void displayPlayer(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset); -static void displayReadyBoxContainer(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset); -static void displayColour(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset); -static void displayTeamChooser(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset); static void displaySpectatorAddButton(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset); static void displayAi(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset); static void displayDifficulty(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset); @@ -225,19 +219,8 @@ static void displayMultiEditBox(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset static void drawBlueBox_Spectator(UDWORD x, UDWORD y, UDWORD w, UDWORD h); static void drawBlueBox_SpectatorOnly(UDWORD x, UDWORD y, UDWORD w, UDWORD h); void intDisplayFeBox_SpectatorOnly(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset); -static inline void drawBoxForPlayerInfoSegment(UDWORD playerIdx, UDWORD x, UDWORD y, UDWORD w, UDWORD h); // pUserData structures used by drawing functions -struct DisplayPlayerCache { - std::string fullMainText; // the “full” main text (used for storing the full player name when displaying a player) - WzText wzMainText; // the main text - - std::string fullAltNameText; - WzText wzAltNameText; - - WzText wzSubText; // the sub text (used for players) - WzText wzEloText; // the elo text (used for players) -}; struct DisplayPositionCache { WzText wzPositionText; }; @@ -261,42 +244,31 @@ static bool SendPositionRequest(UBYTE player, UBYTE chosenPlayer); static bool SendPlayerSlotTypeRequest(uint32_t player, bool isSpectator); bool changeReadyStatus(UBYTE player, bool bReady); static void stopJoining(std::shared_ptr parent); -static int difficultyIcon(int difficulty); static void sendRoomChatMessage(char const *text, bool skipLocalDisplay = false); -static bool multiplayPlayersReady(); -static bool multiplayIsStartingGame(); // //////////////////////////////////////////////////////////////////////////// // map previews.. -static const char *difficultyList[] = { N_("Super Easy"), N_("Easy"), N_("Medium"), N_("Hard"), N_("Insane") }; +static std::array difficultyList = { N_("Super Easy"), N_("Easy"), N_("Medium"), N_("Hard"), N_("Insane") }; static const AIDifficulty difficultyValue[] = { AIDifficulty::SUPEREASY, AIDifficulty::EASY, AIDifficulty::MEDIUM, AIDifficulty::HARD, AIDifficulty::INSANE }; -static struct -{ - bool scavengers; - bool alliances; - bool teams; - bool power; - bool difficulty; - bool ai; - bool position; - bool bases; - bool spectators; -} locked; +static MultiplayOptionsLocked locked; static bool spectatorHost = false; static uint16_t defaultOpenSpectatorSlots = 0; +static std::vector aidata; -struct AIDATA +const std::vector& getAIData() { return aidata; } +const MultiplayOptionsLocked& getLockedOptions() { return locked; } + +const char* getDifficultyListStr(size_t idx) { - AIDATA() : name{0}, js{0}, tip{0}, difficultyTips{0}, assigned(0) {} - char name[MAX_LEN_AI_NAME]; - char js[MAX_LEN_AI_NAME]; - char tip[255 + 128]; ///< may contain optional AI tournament data - char difficultyTips[5][255]; ///< optional difficulty level info - int assigned; ///< How many AIs have we assigned of this type -}; -static std::vector aidata; + ASSERT_OR_RETURN("", idx < difficultyList.size(), "Invalid idx: %zu", idx); + return difficultyList[idx]; +} +size_t getDifficultyListCount() +{ + return difficultyList.size(); +} class ChatBoxButton : public W_BUTTON { @@ -1115,16 +1087,6 @@ static std::shared_ptr addInlineChooserBlueForm(const std::shared_ptr(psForm->shared_from_this()); } -static std::shared_ptr createBlueForm(int x, int y, int w, int h, WIDGET_DISPLAY displayFunc /* = intDisplayFeBox*/) -{ - ASSERT(displayFunc != nullptr, "Must have a display func!"); - auto form = std::make_shared(); - form->setGeometry(x, y, w, h); - form->style = WFORM_PLAIN; - form->displayFunction = displayFunc; - return form; -} - static W_FORM * addBlueForm(UDWORD parent, UDWORD id, UDWORD x, UDWORD y, UDWORD w, UDWORD h, WIDGET_DISPLAY displayFunc /* = intDisplayFeBox*/) { ASSERT(displayFunc != nullptr, "Must have a display func!"); @@ -1443,12 +1405,12 @@ static void addGameOptions() updateLimitIcons(); } -static bool isHostOrAdmin() +bool isHostOrAdmin() { return NetPlay.isHost || NetPlay.players[selectedPlayer].isAdmin; } -static bool isPlayerHostOrAdmin(uint32_t playerIdx) +bool isPlayerHostOrAdmin(uint32_t playerIdx) { ASSERT_OR_RETURN(false, playerIdx < MAX_CONNECTED_PLAYERS, "Invalid player idx: %" PRIu32, playerIdx); return (playerIdx == NetPlay.hostPlayer) || NetPlay.players[playerIdx].isAdmin; @@ -1494,7 +1456,7 @@ static int getPlayerTeam(int i) * Checks if all players are on the same team. If so, return that team; if not, return -1; * if there are no players, return team MAX_PLAYERS. */ -static int allPlayersOnSameTeam(int except) +int allPlayersOnSameTeam(int except) { int minTeam = MAX_PLAYERS, maxTeam = 0; for (unsigned i = 0; i < game.maxPlayers; ++i) @@ -2009,7 +1971,7 @@ class WzPlayerIndexSwapPositionRowRactory : public WzPlayerSelectPositionRowFact } }; -static std::set validPlayerIdxTargetsForPlayerPositionMove(uint32_t player) +std::set validPlayerIdxTargetsForPlayerPositionMove(uint32_t player) { std::set validTargetPlayerIdx; for (uint32_t i = 0; i < game.maxPlayers; i++) @@ -2602,7 +2564,7 @@ bool recvTeamRequest(NETQUEUE queue) return changeTeam(player, team, queue.index); // we do this regardless, in case of sync issues } -static bool SendReadyRequest(UBYTE player, bool bReady) +bool sendReadyRequest(UBYTE player, bool bReady) { if (NetPlay.isHost) // do or request the change. { @@ -3422,10 +3384,7 @@ static SwapPlayerIndexesResult recvSwapPlayerIndexes(NETQUEUE queue, const std:: return SwapPlayerIndexesResult::SUCCESS; } -static bool canChooseTeamFor(int i) -{ - return (i == selectedPlayer || isHostOrAdmin()); -} + // //////////////////////////////////////////////////////////////////////////// // tabs for player box @@ -4099,488 +4058,6 @@ void WzPlayerBoxTabs::displayOptionsOverlay(const std::shared_ptr& psPar widgRegisterOverlayScreenOnTopOfScreen(optionsOverlayScreen, lockedScreen); } -// //////////////////////////////////////////////////////////////////////////// -// player row widgets - -class WzPlayerRow : public WIDGET -{ -protected: - WzPlayerRow(uint32_t playerIdx, const std::shared_ptr& parent) - : WIDGET() - , parentTitleUI(parent) - , playerIdx(playerIdx) - { } - -public: - static std::shared_ptr make(uint32_t playerIdx, const std::shared_ptr& parent) - { - class make_shared_enabler: public WzPlayerRow { - public: - make_shared_enabler(uint32_t playerIdx, const std::shared_ptr& parent) : WzPlayerRow(playerIdx, parent) { } - }; - auto widget = std::make_shared(playerIdx, parent); - - std::weak_ptr titleUI(parent); - - // add team button (also displays the spectator "eye" for spectators) - widget->teamButton = std::make_shared(); - widget->teamButton->setGeometry(0, 0, MULTIOP_TEAMSWIDTH, MULTIOP_TEAMSHEIGHT); - widget->teamButton->UserData = playerIdx; - widget->teamButton->displayFunction = displayTeamChooser; - widget->attach(widget->teamButton); - widget->teamButton->addOnClickHandler([playerIdx, titleUI](W_BUTTON& button){ - auto strongTitleUI = titleUI.lock(); - ASSERT_OR_RETURN(, strongTitleUI != nullptr, "Title UI is gone?"); - if ((!locked.teams || !locked.spectators)) // Clicked on a team chooser - { - if (canChooseTeamFor(playerIdx)) - { - widgScheduleTask([strongTitleUI, playerIdx] { - strongTitleUI->openTeamChooser(playerIdx); - }); - } - } - }); - - // add player colour - widget->colorButton = std::make_shared(); - widget->colorButton->setGeometry(MULTIOP_TEAMSWIDTH, 0, MULTIOP_COLOUR_WIDTH, MULTIOP_PLAYERHEIGHT); - widget->colorButton->UserData = playerIdx; - widget->colorButton->displayFunction = displayColour; - widget->attach(widget->colorButton); - widget->colorButton->addOnClickHandler([playerIdx, titleUI](W_BUTTON& button){ - auto strongTitleUI = titleUI.lock(); - ASSERT_OR_RETURN(, strongTitleUI != nullptr, "Title UI is gone?"); - if (playerIdx == selectedPlayer || isHostOrAdmin()) - { - if (!NetPlay.players[playerIdx].isSpectator) // not a spectator - { - widgScheduleTask([strongTitleUI, playerIdx] { - strongTitleUI->openColourChooser(playerIdx); - }); - } - } - }); - - // add ready button - widget->updateReadyButton(); - - // add player info box (takes up the rest of the space in the middle) - widget->playerInfo = std::make_shared(); - widget->playerInfo->UserData = playerIdx; - widget->playerInfo->displayFunction = displayPlayer; - widget->playerInfo->pUserData = new DisplayPlayerCache(); - widget->playerInfo->setOnDelete([](WIDGET *psWidget) { - assert(psWidget->pUserData != nullptr); - delete static_cast(psWidget->pUserData); - psWidget->pUserData = nullptr; - }); - widget->attach(widget->playerInfo); - widget->playerInfo->setCalcLayout([](WIDGET *psWidget) { - auto psParent = std::dynamic_pointer_cast(psWidget->parent()); - ASSERT_OR_RETURN(, psParent != nullptr, "Null parent"); - int x0 = MULTIOP_TEAMSWIDTH + MULTIOP_COLOUR_WIDTH; - int width = psParent->readyButtonContainer->x() - x0; - psWidget->setGeometry(x0, 0, width, psParent->height()); - }); - widget->playerInfo->addOnClickHandler([playerIdx, titleUI](W_BUTTON& button){ - auto strongTitleUI = titleUI.lock(); - ASSERT_OR_RETURN(, strongTitleUI != nullptr, "Title UI is gone?"); - if (playerIdx == selectedPlayer || isHostOrAdmin()) - { - uint32_t player = playerIdx; - // host/admin can move any player, clients can request to move themselves if there are available slots - if (((player == selectedPlayer && validPlayerIdxTargetsForPlayerPositionMove(player).size() > 0) || - (NetPlay.players[player].allocated && isHostOrAdmin())) - && !locked.position - && player < MAX_PLAYERS - && !isSpectatorOnlySlot(player)) - { - widgScheduleTask([strongTitleUI, player] { - strongTitleUI->openPositionChooser(player); - }); - } - else if (!NetPlay.players[player].allocated && !locked.ai && NetPlay.isHost) - { - if (button.getOnClickButtonPressed() == WKEY_SECONDARY && player < MAX_PLAYERS) - { - // Right clicking distributes selected AI's type and difficulty to all other AIs - for (int i = 0; i < MAX_PLAYERS; ++i) - { - // Don't change open/closed slots or humans or spectator-only slots - if (NetPlay.players[i].ai >= 0 && i != player && !isHumanPlayer(i) && !isSpectatorOnlySlot(i)) - { - NetPlay.players[i].ai = NetPlay.players[player].ai; - NetPlay.players[i].isSpectator = NetPlay.players[player].isSpectator; - NetPlay.players[i].difficulty = NetPlay.players[player].difficulty; - setPlayerName(i, getAIName(player)); - NETBroadcastPlayerInfo(i); - } - } - widgScheduleTask([strongTitleUI] { - strongTitleUI->updatePlayers(); - resetReadyStatus(false); - }); - } - else - { - widgScheduleTask([strongTitleUI, player] { - strongTitleUI->openAiChooser(player); - }); - } - } - } - }); - - // update tooltips and such - widget->updateState(); - - return widget; - } - - void geometryChanged() override - { - if (readyButtonContainer) - { - readyButtonContainer->callCalcLayout(); - } - if (playerInfo) - { - playerInfo->callCalcLayout(); - } - } - - void updateState() - { - // update team button tooltip - if (NetPlay.players[playerIdx].isSpectator) - { - teamButton->setTip(_("Spectator")); - } - else if (canChooseTeamFor(playerIdx) && !locked.teams) - { - teamButton->setTip(_("Choose Team")); - } - else if (locked.teams) - { - teamButton->setTip(_("Teams locked")); - } - else - { - teamButton->setTip(nullptr); - } - - // hide team button if needed - bool trueMultiplayerMode = (bMultiPlayer && NetPlay.bComms) || (!NetPlay.isHost && ingame.localJoiningInProgress); - if (!alliancesSetTeamsBeforeGame(game.alliance) && !NetPlay.players[playerIdx].isSpectator && !trueMultiplayerMode) - { - teamButton->hide(); - } - else - { - teamButton->show(); - } - - // update color tooltip - if ((selectedPlayer == playerIdx || NetPlay.isHost) && (!NetPlay.players[playerIdx].isSpectator)) - { - colorButton->setTip(_("Click to change player colour")); - } - else - { - colorButton->setTip(nullptr); - } - - // update player info box tooltip - std::string playerInfoTooltip; - if ((selectedPlayer == playerIdx || NetPlay.isHost) && NetPlay.players[playerIdx].allocated && !locked.position && !isSpectatorOnlySlot(playerIdx)) - { - playerInfoTooltip = _("Click to change player position"); - } - else if (!NetPlay.players[playerIdx].allocated) - { - if (NetPlay.isHost && !locked.ai) - { - playerInfo->style |= WBUT_SECONDARY; - if (!isSpectatorOnlySlot(playerIdx)) - { - playerInfoTooltip = _("Click to change AI, right click to distribute choice"); - } - else - { - playerInfoTooltip = _("Click to close spectator slot"); - } - } - else if (NetPlay.players[playerIdx].ai >= 0) - { - // show AI description. Useful for challenges. - playerInfoTooltip = aidata[NetPlay.players[playerIdx].ai].tip; - } - } - if (NetPlay.players[playerIdx].allocated) - { - const PLAYERSTATS& stats = getMultiStats(playerIdx); - if (!stats.identity.empty()) - { - if (!playerInfoTooltip.empty()) - { - playerInfoTooltip += "\n"; - } - std::string hash = stats.identity.publicHashString(20); - playerInfoTooltip += _("Player ID: "); - playerInfoTooltip += hash.empty()? _("(none)") : hash; - } - std::string autoratingTooltipText; - if (stats.autorating.valid) - { - if (!stats.autorating.altName.empty()) - { - if (!autoratingTooltipText.empty()) - { - autoratingTooltipText += "\n"; - } - std::string altnameStr = stats.autorating.altName; - if (altnameStr.size() > 128) - { - altnameStr = altnameStr.substr(0, 128); - } - size_t maxLinePos = nthOccurrenceOfChar(altnameStr, '\n', 1); - if (maxLinePos != std::string::npos) - { - altnameStr = altnameStr.substr(0, maxLinePos); - } - autoratingTooltipText += std::string(_("Alt Name:")) + " " + altnameStr; - } - if (!stats.autorating.details.empty() - && stats.autoratingFrom == RATING_SOURCE_LOCAL) // do not display host-provided details (for now) - { - if (!autoratingTooltipText.empty()) - { - autoratingTooltipText += "\n"; - } - std::string detailsstr = stats.autorating.details; - if (detailsstr.size() > 512) - { - detailsstr = detailsstr.substr(0, 512); - } - size_t maxLinePos = nthOccurrenceOfChar(detailsstr, '\n', 10); - if (maxLinePos != std::string::npos) - { - detailsstr = detailsstr.substr(0, maxLinePos); - } - autoratingTooltipText += std::string(_("Player rating:")) + "\n" + detailsstr; - } - } - if (!autoratingTooltipText.empty()) - { - if (!playerInfoTooltip.empty()) - { - playerInfoTooltip += "\n\n"; - } - if (stats.autoratingFrom == RATING_SOURCE_HOST) - { - playerInfoTooltip += std::string("[") + _("Host provided") + "]\n"; - } - else - { - playerInfoTooltip += std::string("[") + astringf(_("From: %s"), getAutoratingUrl().c_str()) + "]\n"; - } - playerInfoTooltip += autoratingTooltipText; - } - } - playerInfo->setTip(playerInfoTooltip); - - // update ready button - updateReadyButton(); - } - -public: - - void updateReadyButton() - { - int disallow = allPlayersOnSameTeam(-1); - - if (!readyButtonContainer) - { - // add form to hold 'ready' botton - readyButtonContainer = createBlueForm(MULTIOP_PLAYERWIDTH - MULTIOP_READY_WIDTH, 0, - MULTIOP_READY_WIDTH, MULTIOP_READY_HEIGHT, displayReadyBoxContainer); - readyButtonContainer->UserData = playerIdx; - attach(readyButtonContainer); - readyButtonContainer->setCalcLayout([](WIDGET *psWidget) { - auto psParent = psWidget->parent(); - ASSERT_OR_RETURN(, psParent != nullptr, "Null parent"); - psWidget->setGeometry(psParent->width() - MULTIOP_READY_WIDTH, 0, psWidget->width(), psWidget->height()); - }); - } - - auto deleteExistingReadyButton = [this]() { - if (readyButton) - { - widgDelete(readyButton.get()); - readyButton = nullptr; - } - if (readyTextLabel) - { - widgDelete(readyTextLabel.get()); - readyTextLabel = nullptr; - } - }; - auto deleteExistingDifficultyButton = [this]() { - if (difficultyChooserButton) - { - widgDelete(difficultyChooserButton.get()); - difficultyChooserButton = nullptr; - } - }; - - if (!NetPlay.players[playerIdx].allocated && NetPlay.players[playerIdx].ai >= 0) - { - // Add AI difficulty chooser in place of normal "ready" button - deleteExistingReadyButton(); - int playerDifficulty = static_cast(NetPlay.players[playerIdx].difficulty); - int icon = difficultyIcon(playerDifficulty); - char tooltip[128 + 255]; - if (playerDifficulty >= static_cast(AIDifficulty::EASY) && playerDifficulty < static_cast(AIDifficulty::INSANE) + 1) - { - sstrcpy(tooltip, _(difficultyList[playerDifficulty])); - if (NetPlay.players[playerIdx].ai < aidata.size()) - { - const char *difficultyTip = aidata[NetPlay.players[playerIdx].ai].difficultyTips[playerDifficulty]; - if (strcmp(difficultyTip, "") != 0) - { - sstrcat(tooltip, "\n"); - sstrcat(tooltip, difficultyTip); - } - } - } - bool freshDifficultyButton = (difficultyChooserButton == nullptr); - difficultyChooserButton = addMultiBut(*readyButtonContainer, MULTIOP_DIFFICULTY_INIT_START + playerIdx, 6, 4, MULTIOP_READY_WIDTH, MULTIOP_READY_HEIGHT, - (NetPlay.isHost && !locked.difficulty) ? _("Click to change difficulty") : tooltip, icon, icon, icon, MAX_PLAYERS, (NetPlay.isHost && !locked.difficulty) ? 255 : 125); - auto player = playerIdx; - auto weakTitleUi = parentTitleUI; - if (freshDifficultyButton) - { - difficultyChooserButton->addOnClickHandler([player, weakTitleUi](W_BUTTON&){ - auto strongTitleUI = weakTitleUi.lock(); - ASSERT_OR_RETURN(, strongTitleUI != nullptr, "Title UI is gone?"); - if (!locked.difficulty && NetPlay.isHost) - { - widgScheduleTask([strongTitleUI, player] { - strongTitleUI->openDifficultyChooser(player); - }); - } - }); - } - return; - } - else if (!NetPlay.players[playerIdx].allocated) - { - // closed or open - remove ready / difficulty button - deleteExistingReadyButton(); - deleteExistingDifficultyButton(); - return; - } - - if (disallow != -1) - { - // remove ready / difficulty button - deleteExistingReadyButton(); - deleteExistingDifficultyButton(); - return; - } - - deleteExistingDifficultyButton(); - - bool isMe = playerIdx == selectedPlayer; - int isReady = NETgetDownloadProgress(playerIdx) != 100 ? 2 : NetPlay.players[playerIdx].ready ? 1 : 0; - char const *const toolTips[2][3] = {{_("Waiting for player"), _("Player is ready"), _("Player is downloading")}, {_("Click when ready"), _("Waiting for other players"), _("Waiting for download")}}; - unsigned images[2][3] = {{IMAGE_CHECK_OFF, IMAGE_CHECK_ON, IMAGE_CHECK_DOWNLOAD}, {IMAGE_CHECK_OFF_HI, IMAGE_CHECK_ON_HI, IMAGE_CHECK_DOWNLOAD_HI}}; - - // draw 'ready' button - bool greyedOutReady = (NetPlay.players[playerIdx].isSpectator && NetPlay.players[playerIdx].ready) || (playerIdx != selectedPlayer); - bool freshReadyButton = (readyButton == nullptr); - readyButton = addMultiBut(*readyButtonContainer, MULTIOP_READY_START + playerIdx, 3, 10, MULTIOP_READY_WIDTH, MULTIOP_READY_HEIGHT, - toolTips[isMe][isReady], images[0][isReady], images[0][isReady], images[isMe][isReady], MAX_PLAYERS, (!greyedOutReady) ? 255 : 125); - ASSERT_OR_RETURN(, readyButton != nullptr, "Failed to create ready button"); - readyButton->minClickInterval = GAME_TICKS_PER_SEC; - readyButton->unlock(); - if (greyedOutReady && !NetPlay.isHost) - { - std::shared_ptr pReadyBut_MultiButton = std::dynamic_pointer_cast(readyButton); - if (pReadyBut_MultiButton) - { - pReadyBut_MultiButton->downStateMask = WBUT_DOWN | WBUT_CLICKLOCK; - } - auto currentState = readyButton->getState(); - readyButton->setState(currentState | WBUT_LOCK); - } - if (freshReadyButton) - { - // must add onclick handler - auto player = playerIdx; - auto weakTitleUi = parentTitleUI; - readyButton->addOnClickHandler([player, weakTitleUi](W_BUTTON& button){ - auto pButton = button.shared_from_this(); - widgScheduleTask([weakTitleUi, player, pButton] { - auto strongTitleUI = weakTitleUi.lock(); - ASSERT_OR_RETURN(, strongTitleUI != nullptr, "Title UI is gone?"); - - if (player == selectedPlayer - && (!NetPlay.players[player].isSpectator || !NetPlay.players[player].ready || NetPlay.isHost)) // spectators can never toggle off "ready" - { - // Lock the "ready" button (until the request is processed) - pButton->setState(WBUT_LOCK); - - SendReadyRequest(selectedPlayer, !NetPlay.players[player].ready); - - // if hosting try to start the game if everyone is ready - if (NetPlay.isHost && multiplayPlayersReady()) - { - startMultiplayerGame(); - // reset flag in case people dropped/quit on join screen - NETsetPlayerConnectionStatus(CONNECTIONSTATUS_NORMAL, NET_ALL_PLAYERS); - } - } - - if (NetPlay.isHost && !alliancesSetTeamsBeforeGame(game.alliance)) - { - if (mouseDown(MOUSE_RMB) && player != NetPlay.hostPlayer) // both buttons.... - { - std::string msg = astringf(_("The host has kicked %s from the game!"), getPlayerName(player)); - sendRoomSystemMessage(msg.c_str()); - kickPlayer(player, _("The host has kicked you from the game."), ERROR_KICKED, false); - resetReadyStatus(true); //reset and send notification to all clients - } - } - }); - }); - } - - if (!readyTextLabel) - { - readyTextLabel = std::make_shared(); - readyButtonContainer->attach(readyTextLabel); - readyTextLabel->id = MULTIOP_READY_START + MAX_CONNECTED_PLAYERS + playerIdx; - } - readyTextLabel->setGeometry(0, 0, MULTIOP_READY_WIDTH, 17); - readyTextLabel->setTextAlignment(WLAB_ALIGNBOTTOM); - readyTextLabel->setFont(font_small, WZCOL_TEXT_BRIGHT); - readyTextLabel->setString(_("READY?")); - } - -private: - std::weak_ptr parentTitleUI; - unsigned playerIdx = 0; - std::shared_ptr teamButton; - std::shared_ptr colorButton; - std::shared_ptr playerInfo; - std::shared_ptr readyButtonContainer; - std::shared_ptr difficultyChooserButton; - std::shared_ptr readyButton; - std::shared_ptr readyTextLabel; -}; - // //////////////////////////////////////////////////////////////////////////// void WzMultiplayerOptionsTitleUI::openPlayerSlotSwapChooser(uint32_t playerIndex) @@ -5980,7 +5457,7 @@ static void loadMapPlayerSettings(WzConfig& ini) } WzString value = ini.value("difficulty", "Medium").toWzString(); - for (unsigned j = 0; j < ARRAY_SIZE(difficultyList); ++j) + for (unsigned j = 0; j < difficultyList.size(); ++j) { if (strcasecmp(difficultyList[j], value.toUtf8().c_str()) == 0) { @@ -6754,7 +6231,7 @@ void handleAutoReadyRequest() if (desiredReadyState != NetPlay.players[selectedPlayer].ready) { // Automatically set ready status - SendReadyRequest(selectedPlayer, desiredReadyState); + sendReadyRequest(selectedPlayer, desiredReadyState); } } @@ -7993,7 +7470,7 @@ void WzMultiplayerOptionsTitleUI::start() { processMultiopWidgets(MULTIOP_HOST); } - SendReadyRequest(selectedPlayer, true); + sendReadyRequest(selectedPlayer, true); if (getHostLaunch() == HostLaunch::Skirmish) { startMultiplayerGame(); @@ -8033,34 +7510,6 @@ void displayChatEdit(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) // //////////////////////////////////////////////////////////////////////////// -void displayTeamChooser(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) -{ - int x = xOffset + psWidget->x(); - int y = yOffset + psWidget->y(); - UDWORD i = psWidget->UserData; - - ASSERT_OR_RETURN(, i < MAX_CONNECTED_PLAYERS, "Index (%" PRIu32 ") out of bounds", i); - - drawBoxForPlayerInfoSegment(i, x, y, psWidget->width(), psWidget->height()); - - if (!NetPlay.players[i].isSpectator) - { - if (alliancesSetTeamsBeforeGame(game.alliance)) - { - ASSERT_OR_RETURN(, NetPlay.players[i].team >= 0 && NetPlay.players[i].team < MAX_PLAYERS, "Team index out of bounds"); - iV_DrawImage(FrontImages, IMAGE_TEAM0 + NetPlay.players[i].team, x + 1, y + 8); - } - else - { - // TODO: Maybe display something else here to signify "no team, FFA" - } - } - else - { - iV_DrawImage(FrontImages, IMAGE_SPECTATOR, x + 1, y + 8); - } -} - void displaySpectatorAddButton(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) { int x = xOffset + psWidget->x(); @@ -8070,7 +7519,7 @@ void displaySpectatorAddButton(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) iV_DrawImage(FrontImages, IMAGE_SPECTATOR, x + 2, y + 1); } -static void displayAi(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) +void displayAi(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) { // Any widget using displayAi must have its pUserData initialized to a (DisplayAICache*) assert(psWidget->pUserData != nullptr); @@ -8083,6 +7532,7 @@ static void displayAi(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) drawBlueBox(x, y, psWidget->width(), psWidget->height()); WzString displayText; PIELIGHT textColor = WZCOL_FORM_TEXT; + if (j >= 0) { if (j < aidata.size()) @@ -8117,7 +7567,7 @@ static void displayAi(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) cache.wzText.render(x + 10, y + 22, textColor); } -static int difficultyIcon(int difficulty) +int difficultyIcon(int difficulty) { switch (difficulty) { @@ -8139,304 +7589,13 @@ static void displayDifficulty(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) const int y = yOffset + psWidget->y(); const int j = psWidget->UserData; - ASSERT_OR_RETURN(, j < ARRAY_SIZE(difficultyList), "Bad difficulty found: %d", j); + ASSERT_OR_RETURN(, j < difficultyList.size(), "Bad difficulty found: %d", j); drawBlueBox(x, y, psWidget->width(), psWidget->height()); iV_DrawImage(FrontImages, difficultyIcon(j), x + 5, y + 5); cache.wzDifficultyText.setText(gettext(difficultyList[j]), font_regular); cache.wzDifficultyText.render(x + 42, y + 22, WZCOL_FORM_TEXT); } -static bool isKnownPlayer(std::map const &knownPlayers, std::string const &name, EcKey const &key) -{ - if (key.empty()) - { - return false; - } - std::map::const_iterator i = knownPlayers.find(name); - return i != knownPlayers.end() && key.toBytes(EcKey::Public) == i->second; -} - -static void displayAltNameBox(int x, int y, WIDGET *psWidget, DisplayPlayerCache& cache, const PLAYERSTATS::Autorating& ar, bool isHighlight) -{ - int altNameBoxWidth = cache.wzAltNameText.width() + 4; - int altNameBoxHeight = cache.wzAltNameText.lineSize() + 2; - int altNameBoxX0 = (x + psWidget->width()) - altNameBoxWidth; - PIELIGHT altNameBoxColor = WZCOL_MENU_BORDER; - altNameBoxColor.byte.a = static_cast(static_cast(altNameBoxColor.byte.a) * (isHighlight ? 0.3f : 0.75f)); - pie_UniTransBoxFill(altNameBoxX0, y, altNameBoxX0 + altNameBoxWidth, y + altNameBoxHeight, altNameBoxColor); - - int altNameTextY0 = y + (altNameBoxHeight - cache.wzAltNameText.lineSize()) / 2 - cache.wzAltNameText.aboveBase(); - PIELIGHT altNameTextColor = WZCOL_TEXT_MEDIUM; - if (ar.altNameTextColorOverride[0] != 255 || ar.altNameTextColorOverride[1] != 255 || ar.altNameTextColorOverride[2] != 255) - { - altNameTextColor = pal_Colour(ar.altNameTextColorOverride[0], ar.altNameTextColorOverride[1], ar.altNameTextColorOverride[2]); - } - if (isHighlight) - { - altNameTextColor.byte.a = static_cast(static_cast(altNameTextColor.byte.a) * 0.3f); - } - cache.wzAltNameText.render(altNameBoxX0 + 2, altNameTextY0, altNameTextColor); -} - -// //////////////////////////////////////////////////////////////////////////// -void displayPlayer(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) -{ - // Any widget using displayPlayer must have its pUserData initialized to a (DisplayPlayerCache*) - assert(psWidget->pUserData != nullptr); - DisplayPlayerCache& cache = *static_cast(psWidget->pUserData); - - int const x = xOffset + psWidget->x(); - int const y = yOffset + psWidget->y(); - unsigned const j = psWidget->UserData; - bool isHighlight = (psWidget->getState() & WBUT_HIGHLIGHT) != 0; - - const int nameX = 32; - - unsigned downloadProgress = NETgetDownloadProgress(j); - - drawBoxForPlayerInfoSegment(j, x, y, psWidget->width(), psWidget->height()); - if (downloadProgress != 100) - { - char progressString[MAX_STR_LENGTH]; - ssprintf(progressString, j != selectedPlayer ? _("Sending Map: %u%% ") : _("Map: %u%% downloaded"), downloadProgress); - cache.wzMainText.setText(progressString, font_regular); - cache.wzMainText.render(x + 5, y + 22, WZCOL_FORM_TEXT); - cache.fullMainText = progressString; - return; - } - else if (ingame.localOptionsReceived && NetPlay.players[j].allocated) // only draw if real player! - { - const PLAYERSTATS& stat = getMultiStats(j); - auto ar = stat.autorating; - - std::string name = getPlayerName(j); - - std::map serverPlayers; // TODO Fill this with players known to the server (needs implementing on the server, too). Currently useless. - - PIELIGHT colour; - if (ingame.PingTimes[j] >= PING_LIMIT) - { - colour = WZCOL_FORM_PLAYER_NOPING; - } - else if (isKnownPlayer(serverPlayers, name, getMultiStats(j).identity)) - { - colour = WZCOL_FORM_PLAYER_KNOWN_BY_SERVER; - } - else if (isLocallyKnownPlayer(name, getMultiStats(j).identity)) - { - colour = WZCOL_FORM_PLAYER_KNOWN; - } - else - { - colour = WZCOL_FORM_PLAYER_UNKNOWN; - } - - // name - if (cache.fullMainText != name) - { - if ((int)iV_GetTextWidth(name.c_str(), font_regular) > psWidget->width() - nameX) - { - while (!name.empty() && (int)iV_GetTextWidth((name + "...").c_str(), font_regular) > psWidget->width() - nameX) - { - name.resize(name.size() - 1); // Clip name. - } - name += "..."; - } - cache.wzMainText.setText(WzString::fromUtf8(name), font_regular); - cache.fullMainText = name; - } - WzString subText; - if (j == NetPlay.hostPlayer && NetPlay.bComms) - { - subText += _("HOST"); - } - if (NetPlay.bComms && j != selectedPlayer) - { - char buf[250] = {'\0'}; - - // show "actual" ping time - ssprintf(buf, "%s%s: ", subText.isEmpty() ? "" : ", ", _("Ping")); - subText += buf; - if (ingame.PingTimes[j] < PING_LIMIT) - { - ssprintf(buf, "%03d", ingame.PingTimes[j]); - } - else - { - ssprintf(buf, "%s", "∞"); // Player has ping of somewhat questionable quality. - } - subText += buf; - } - - if (!ar.valid) - { - ar.dummy = stat.played < 5; - // star 1 total droid kills - ar.star[0] = stat.totalKills > 600? 1 : stat.totalKills > 300? 2 : stat.totalKills > 150? 3 : 0; - - // star 2 games played - ar.star[1] = stat.played > 200? 1 : stat.played > 100? 2 : stat.played > 50? 3 : 0; - - // star 3 games won. - ar.star[2] = stat.wins > 80? 1 : stat.wins > 40? 2 : stat.wins > 10? 3 : 0; - - // medals. - ar.medal = stat.wins >= 24 && stat.wins > 8 * stat.losses? 1 : stat.wins >= 12 && stat.wins > 4 * stat.losses? 2 : stat.wins >= 6 && stat.wins > 2 * stat.losses? 3 : 0; - - ar.level = 0; - ar.autohoster = false; - ar.elo.clear(); - } - - if (cache.fullAltNameText != ar.altName) - { - std::string altName = ar.altName; - int maxAltNameWidth = static_cast(static_cast(psWidget->width() - nameX) * 0.65f); - iV_fonts fontID = font_small; - cache.wzAltNameText.setText(WzString::fromUtf8(altName), fontID); - cache.fullAltNameText = altName; - if (cache.wzAltNameText.width() > maxAltNameWidth) - { - while (!altName.empty() && ((int)iV_GetTextWidth(altName.c_str(), cache.wzAltNameText.getFontID()) + iV_GetEllipsisWidth(cache.wzAltNameText.getFontID())) > maxAltNameWidth) - { - altName.resize(altName.size() - 1); // Clip alt name. - } - altName += "\u2026"; - cache.wzAltNameText.setText(WzString::fromUtf8(altName), fontID); - } - } - - if (!ar.altName.empty() && isHighlight) - { - // display first, behind everything - displayAltNameBox(x, y, psWidget, cache, ar, isHighlight); - } - - int H = 5; - cache.wzMainText.render(x + nameX, y + 22 - H*!subText.isEmpty() - H*(ar.valid && !ar.elo.empty()), colour); - if (!subText.isEmpty()) - { - cache.wzSubText.setText(subText, font_small); - cache.wzSubText.render(x + nameX, y + 28 - H*!ar.elo.empty(), WZCOL_TEXT_MEDIUM); - } - - if (ar.autohoster) - { - iV_DrawImage(FrontImages, IMAGE_PLAYER_PC, x, y + 11); - } - else if (ar.dummy) - { - iV_DrawImage(FrontImages, IMAGE_MEDAL_DUMMY, x + 4, y + 13); - } - else - { - constexpr int starImgs[4] = {0, IMAGE_MULTIRANK1, IMAGE_MULTIRANK2, IMAGE_MULTIRANK3}; - if (1 <= ar.star[0] && ar.star[0] < ARRAY_SIZE(starImgs)) - { - iV_DrawImage(FrontImages, starImgs[ar.star[0]], x + 4, y + 3); - } - if (1 <= ar.star[1] && ar.star[1] < ARRAY_SIZE(starImgs)) - { - iV_DrawImage(FrontImages, starImgs[ar.star[1]], x + 4, y + 13); - } - if (1 <= ar.star[2] && ar.star[2] < ARRAY_SIZE(starImgs)) - { - iV_DrawImage(FrontImages, starImgs[ar.star[2]], x + 4, y + 23); - } - constexpr int medalImgs[4] = {0, IMAGE_MEDAL_GOLD, IMAGE_MEDAL_SILVER, IMAGE_MEDAL_BRONZE}; - if (1 <= ar.medal && ar.medal < ARRAY_SIZE(medalImgs)) - { - iV_DrawImage(FrontImages, medalImgs[ar.medal], x + 16 - 2*(ar.level != 0), y + 11); - } - } - constexpr int levelImgs[9] = {0, IMAGE_LEV_0, IMAGE_LEV_1, IMAGE_LEV_2, IMAGE_LEV_3, IMAGE_LEV_4, IMAGE_LEV_5, IMAGE_LEV_6, IMAGE_LEV_7}; - if (ar.level > 0 && ar.level < ARRAY_SIZE(levelImgs)) - { - iV_DrawImage(IntImages, levelImgs[ar.level], x + 24, y + 15); - } - - if (!ar.elo.empty()) - { - PIELIGHT eloColour = WZCOL_TEXT_BRIGHT; - if (ar.eloTextColorOverride[0] != 255 || ar.eloTextColorOverride[1] != 255 || ar.eloTextColorOverride[2] != 255) - { - eloColour = pal_Colour(ar.eloTextColorOverride[0], ar.eloTextColorOverride[1], ar.eloTextColorOverride[2]); - } - cache.wzEloText.setText(WzString::fromUtf8(ar.elo), font_small); - cache.wzEloText.render(x + nameX, y + 28 + H*!subText.isEmpty(), eloColour); - } - - if (!ar.altName.empty() && !isHighlight) - { - // display last, over top of everything - displayAltNameBox(x, y, psWidget, cache, ar, isHighlight); - } - } - else // AI - { - char aitext[100]; - - if (NetPlay.players[j].ai >= 0) - { - iV_DrawImage(FrontImages, IMAGE_PLAYER_PC, x, y + 11); - } - - // challenges may use custom AIs that are not in aidata and set to 127 - if (!challengeActive) - { - ASSERT_OR_RETURN(, NetPlay.players[j].ai < (int)aidata.size(), "Uh-oh, AI index out of bounds"); - } - - PIELIGHT textColor = WZCOL_FORM_TEXT; - switch (NetPlay.players[j].ai) - { - case AI_OPEN: - if (!NetPlay.players[j].isSpectator) - { - sstrcpy(aitext, _("Open")); - } - else - { - sstrcpy(aitext, _("Spectator")); - textColor.byte.a = 220; - } - break; - case AI_CLOSED: sstrcpy(aitext, _("Closed")); break; - default: sstrcpy(aitext, getPlayerName(j)); break; - } - cache.wzMainText.setText(aitext, font_regular); - cache.wzMainText.render(x + nameX, y + 22, textColor); - cache.fullMainText = aitext; - } -} - -static int factionIcon(FactionID faction) -{ - switch (faction) - { - case 0: return IMAGE_FACTION_NORMAL; - case 1: return IMAGE_FACTION_NEXUS; - case 2: return IMAGE_FACTION_COLLECTIVE; - default: return IMAGE_NO; /// what?? - } -} - -void displayColour(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) -{ - const int x = xOffset + psWidget->x(); - const int y = yOffset + psWidget->y(); - const int j = psWidget->UserData; - - drawBoxForPlayerInfoSegment(j, x, y, psWidget->width(), psWidget->height()); - if (!NetPlay.players[j].fileSendInProgress() && (isHumanPlayer(j) || NetPlay.players[j].difficulty != AIDifficulty::DISABLED) && !NetPlay.players[j].isSpectator) - { - int player = getPlayerColour(j); - STATIC_ASSERT(MAX_PLAYERS <= 16); - iV_DrawImageTc(FrontImages, IMAGE_PLAYERN, IMAGE_PLAYERN_TC, x + 3, y + 9, pal_GetTeamColour(player)); - FactionID faction = NetPlay.players[j].faction; - iV_DrawImageFileAnisotropic(FrontImages, factionIcon(faction), x, y, Vector2f(11, 9)); - } -} // //////////////////////////////////////////////////////////////////////////// // Display blue box @@ -8499,35 +7658,6 @@ void intDisplayFeBox_SpectatorOnly(WIDGET *psWidget, UDWORD xOffset, UDWORD yOff drawBlueBox_SpectatorOnly(x, y, w, h); } -static inline void drawBoxForPlayerInfoSegment(UDWORD playerIdx, UDWORD x, UDWORD y, UDWORD w, UDWORD h) -{ - ASSERT(playerIdx < NetPlay.players.size(), "Invalid playerIdx: %zu", static_cast(playerIdx)); - if (playerIdx >= NetPlay.players.size() || !NetPlay.players[playerIdx].isSpectator) - { - drawBlueBox(x, y, w, h); - } - else - { - if (!isSpectatorOnlySlot(playerIdx)) - { - drawBlueBox_Spectator(x, y, w, h); - } - else - { - drawBlueBox_SpectatorOnly(x, y, w, h); - } - } -} - -static void displayReadyBoxContainer(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) -{ - const int x = xOffset + psWidget->x(); - const int y = yOffset + psWidget->y(); - const int j = psWidget->UserData; - - drawBoxForPlayerInfoSegment(j, x, y, psWidget->width(), psWidget->height()); -} - // //////////////////////////////////////////////////////////////////////////// // Display edit box void displayMultiEditBox(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) @@ -8717,7 +7847,7 @@ std::shared_ptr addMultiBut(const std::shared_ptr &screen, U } /* Returns true if the multiplayer game can start (i.e. all players are ready) */ -static bool multiplayPlayersReady() +bool multiplayPlayersReady() { bool bReady = true; size_t numReadyPlayers = 0; @@ -8745,7 +7875,7 @@ static bool multiplayPlayersReady() return bReady && (numReadyPlayers > 1) && (allPlayersOnSameTeam(-1) == -1); } -static bool multiplayIsStartingGame() +bool multiplayIsStartingGame() { return bInActualHostedLobby && multiplayPlayersReady(); } @@ -8827,7 +7957,7 @@ static inline bool spectatorSlotsSupported() } // NOTE: Pass in the index in the NetPlay.players array -static inline bool isSpectatorOnlySlot(UDWORD playerIdx) +bool isSpectatorOnlySlot(UDWORD playerIdx) { ASSERT_OR_RETURN(false, playerIdx < NetPlay.players.size(), "Invalid playerIdx: %" PRIu32 "", playerIdx); return playerIdx >= MAX_PLAYERS || NetPlay.players[playerIdx].position >= game.maxPlayers; diff --git a/src/multiint.h b/src/multiint.h index cd721d2bd8a..5fefbc727bf 100644 --- a/src/multiint.h +++ b/src/multiint.h @@ -31,6 +31,7 @@ #include "lib/widget/button.h" #include #include +#include #include "lib/framework/wzstring.h" #include "titleui/multiplayer.h" #include "faction.h" @@ -66,6 +67,52 @@ const char *getAIName(int player); ///< only run this -after- readAIs() is calle const std::vector getAINames(); int matchAIbyName(const char* name); ///< only run this -after- readAIs() is called +struct AIDATA +{ + AIDATA() : name{0}, js{0}, tip{0}, difficultyTips{0}, assigned(0) {} + char name[MAX_LEN_AI_NAME]; + char js[MAX_LEN_AI_NAME]; + char tip[255 + 128]; ///< may contain optional AI tournament data + char difficultyTips[5][255]; ///< optional difficulty level info + int assigned; ///< How many AIs have we assigned of this type +}; +const std::vector& getAIData(); + +struct MultiplayOptionsLocked +{ + bool scavengers; + bool alliances; + bool teams; + bool power; + bool difficulty; + bool ai; + bool position; + bool bases; + bool spectators; +}; +const MultiplayOptionsLocked& getLockedOptions(); + +const char* getDifficultyListStr(size_t idx); +size_t getDifficultyListCount(); +int difficultyIcon(int difficulty); + +std::set validPlayerIdxTargetsForPlayerPositionMove(uint32_t player); + +bool isHostOrAdmin(); +bool isPlayerHostOrAdmin(uint32_t playerIdx); +bool isSpectatorOnlySlot(UDWORD playerIdx); + +/** + * Checks if all players are on the same team. If so, return that team; if not, return -1; + * if there are no players, return team MAX_PLAYERS. + */ +int allPlayersOnSameTeam(int except); + +bool multiplayPlayersReady(); +bool multiplayIsStartingGame(); + +bool sendReadyRequest(UBYTE player, bool bReady); + LOBBY_ERROR_TYPES getLobbyError(); void setLobbyError(LOBBY_ERROR_TYPES error_type); diff --git a/src/titleui/widgets/lobbyplayerrow.cpp b/src/titleui/widgets/lobbyplayerrow.cpp new file mode 100644 index 00000000000..22696db3239 --- /dev/null +++ b/src/titleui/widgets/lobbyplayerrow.cpp @@ -0,0 +1,943 @@ +/* + This file is part of Warzone 2100. + Copyright (C) 2024 Warzone 2100 Project + + Warzone 2100 is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + Warzone 2100 is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Warzone 2100; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ +/** \file + * Lobby Player Row Widget (and associated display functions) + */ + +#include "lobbyplayerrow.h" +#include "src/frontend.h" +#include "src/frend.h" +#include "src/intimage.h" +#include "src/multiint.h" +#include "src/loadsave.h" +#include "src/multiplay.h" +#include "src/ai.h" +#include "src/component.h" +#include "src/clparse.h" +#include "src/challenge.h" +//#include "lib/widget/widget.h" +//#include "lib/widget/label.h" +//#include "lib/widget/button.h" +//#include "infobutton.h" + +#include "lib/framework/input.h" + +#include "lib/netplay/netplay.h" + +#include "src/titleui/multiplayer.h" +#include "src/multistat.h" + +#include "lib/ivis_opengl/pieblitfunc.h" + +struct DisplayPlayerCache { + std::string fullMainText; // the “full” main text (used for storing the full player name when displaying a player) + WzText wzMainText; // the main text + + std::string fullAltNameText; + WzText wzAltNameText; + + WzText wzSubText; // the sub text (used for players) + WzText wzEloText; // the elo text (used for players) +}; + +static void drawBlueBox_Spectator(UDWORD x, UDWORD y, UDWORD w, UDWORD h) +{ + // Match drawBlueBox behavior + x -= 1; + y -= 1; + w += 2; + h += 2; + PIELIGHT backgroundClr = WZCOL_FORM_DARK; + backgroundClr.byte.a = 200; + pie_BoxFill(x, y, x + w, y + h, WZCOL_MENU_BORDER); + pie_UniTransBoxFill(x + 1, y + 1, x + w - 1, y + h - 1, backgroundClr); +} + +static void drawBlueBox_SpectatorOnly(UDWORD x, UDWORD y, UDWORD w, UDWORD h) +{ + // Match drawBlueBox behavior + x -= 1; + y -= 1; + w += 2; + h += 2; + PIELIGHT backgroundClr = WZCOL_FORM_DARK; + backgroundClr.byte.a = 175; +// PIELIGHT borderClr = pal_RGBA(30, 30, 30, 120); +// pie_UniTransBoxFill(x, y, x + w, y + h, borderClr); +// pie_UniTransBoxFill(x + 1, y + 1, x + w - 1, y + h - 1, backgroundClr); + pie_UniTransBoxFill(x, y, x + w, y + h, backgroundClr); +} + +static inline void drawBoxForPlayerInfoSegment(UDWORD playerIdx, UDWORD x, UDWORD y, UDWORD w, UDWORD h) +{ + ASSERT(playerIdx < NetPlay.players.size(), "Invalid playerIdx: %zu", static_cast(playerIdx)); + if (playerIdx >= NetPlay.players.size() || !NetPlay.players[playerIdx].isSpectator) + { + drawBlueBox(x, y, w, h); + } + else + { + if (!isSpectatorOnlySlot(playerIdx)) + { + drawBlueBox_Spectator(x, y, w, h); + } + else + { + drawBlueBox_SpectatorOnly(x, y, w, h); + } + } +} + +static void displayReadyBoxContainer(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) +{ + const int x = xOffset + psWidget->x(); + const int y = yOffset + psWidget->y(); + const int j = psWidget->UserData; + + drawBoxForPlayerInfoSegment(j, x, y, psWidget->width(), psWidget->height()); +} + +static int factionIcon(FactionID faction) +{ + switch (faction) + { + case 0: return IMAGE_FACTION_NORMAL; + case 1: return IMAGE_FACTION_NEXUS; + case 2: return IMAGE_FACTION_COLLECTIVE; + default: return IMAGE_NO; /// what?? + } +} + +static void displayColour(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) +{ + const int x = xOffset + psWidget->x(); + const int y = yOffset + psWidget->y(); + const int j = psWidget->UserData; + + drawBoxForPlayerInfoSegment(j, x, y, psWidget->width(), psWidget->height()); + if (!NetPlay.players[j].fileSendInProgress() && (isHumanPlayer(j) || NetPlay.players[j].difficulty != AIDifficulty::DISABLED) && !NetPlay.players[j].isSpectator) + { + int player = getPlayerColour(j); + STATIC_ASSERT(MAX_PLAYERS <= 16); + iV_DrawImageTc(FrontImages, IMAGE_PLAYERN, IMAGE_PLAYERN_TC, x + 3, y + 9, pal_GetTeamColour(player)); + FactionID faction = NetPlay.players[j].faction; + iV_DrawImageFileAnisotropic(FrontImages, factionIcon(faction), x, y, Vector2f(11, 9)); + } +} + +void displayTeamChooser(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) +{ + int x = xOffset + psWidget->x(); + int y = yOffset + psWidget->y(); + UDWORD i = psWidget->UserData; + + ASSERT_OR_RETURN(, i < MAX_CONNECTED_PLAYERS, "Index (%" PRIu32 ") out of bounds", i); + + drawBoxForPlayerInfoSegment(i, x, y, psWidget->width(), psWidget->height()); + + if (!NetPlay.players[i].isSpectator) + { + if (alliancesSetTeamsBeforeGame(game.alliance)) + { + ASSERT_OR_RETURN(, NetPlay.players[i].team >= 0 && NetPlay.players[i].team < MAX_PLAYERS, "Team index out of bounds"); + iV_DrawImage(FrontImages, IMAGE_TEAM0 + NetPlay.players[i].team, x + 1, y + 8); + } + else + { + // TODO: Maybe display something else here to signify "no team, FFA" + } + } + else + { + iV_DrawImage(FrontImages, IMAGE_SPECTATOR, x + 1, y + 8); + } +} + +void displaySpectatorAddButton(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) +{ + int x = xOffset + psWidget->x(); + int y = yOffset + psWidget->y(); + + drawBlueBox_SpectatorOnly(x, y, psWidget->width(), psWidget->height()); + iV_DrawImage(FrontImages, IMAGE_SPECTATOR, x + 2, y + 1); +} + +static bool isKnownPlayer(std::map const &knownPlayers, std::string const &name, EcKey const &key) +{ + if (key.empty()) + { + return false; + } + std::map::const_iterator i = knownPlayers.find(name); + return i != knownPlayers.end() && key.toBytes(EcKey::Public) == i->second; +} + +static void displayAltNameBox(int x, int y, WIDGET *psWidget, DisplayPlayerCache& cache, const PLAYERSTATS::Autorating& ar, bool isHighlight) +{ + int altNameBoxWidth = cache.wzAltNameText.width() + 4; + int altNameBoxHeight = cache.wzAltNameText.lineSize() + 2; + int altNameBoxX0 = (x + psWidget->width()) - altNameBoxWidth; + PIELIGHT altNameBoxColor = WZCOL_MENU_BORDER; + altNameBoxColor.byte.a = static_cast(static_cast(altNameBoxColor.byte.a) * (isHighlight ? 0.3f : 0.75f)); + pie_UniTransBoxFill(altNameBoxX0, y, altNameBoxX0 + altNameBoxWidth, y + altNameBoxHeight, altNameBoxColor); + + int altNameTextY0 = y + (altNameBoxHeight - cache.wzAltNameText.lineSize()) / 2 - cache.wzAltNameText.aboveBase(); + PIELIGHT altNameTextColor = WZCOL_TEXT_MEDIUM; + if (ar.altNameTextColorOverride[0] != 255 || ar.altNameTextColorOverride[1] != 255 || ar.altNameTextColorOverride[2] != 255) + { + altNameTextColor = pal_Colour(ar.altNameTextColorOverride[0], ar.altNameTextColorOverride[1], ar.altNameTextColorOverride[2]); + } + if (isHighlight) + { + altNameTextColor.byte.a = static_cast(static_cast(altNameTextColor.byte.a) * 0.3f); + } + cache.wzAltNameText.render(altNameBoxX0 + 2, altNameTextY0, altNameTextColor); +} + +void displayPlayer(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) +{ + // Any widget using displayPlayer must have its pUserData initialized to a (DisplayPlayerCache*) + assert(psWidget->pUserData != nullptr); + DisplayPlayerCache& cache = *static_cast(psWidget->pUserData); + + const auto& aidata = getAIData(); + + int const x = xOffset + psWidget->x(); + int const y = yOffset + psWidget->y(); + unsigned const j = psWidget->UserData; + bool isHighlight = (psWidget->getState() & WBUT_HIGHLIGHT) != 0; + + const int nameX = 32; + + unsigned downloadProgress = NETgetDownloadProgress(j); + + drawBoxForPlayerInfoSegment(j, x, y, psWidget->width(), psWidget->height()); + if (downloadProgress != 100) + { + char progressString[MAX_STR_LENGTH]; + ssprintf(progressString, j != selectedPlayer ? _("Sending Map: %u%% ") : _("Map: %u%% downloaded"), downloadProgress); + cache.wzMainText.setText(progressString, font_regular); + cache.wzMainText.render(x + 5, y + 22, WZCOL_FORM_TEXT); + cache.fullMainText = progressString; + return; + } + else if (ingame.localOptionsReceived && NetPlay.players[j].allocated) // only draw if real player! + { + const PLAYERSTATS& stat = getMultiStats(j); + auto ar = stat.autorating; + + std::string name = getPlayerName(j); + + std::map serverPlayers; // TODO Fill this with players known to the server (needs implementing on the server, too). Currently useless. + + PIELIGHT colour; + iV_fonts nameFont = font_regular; + if (j == selectedPlayer) + { + colour = WZCOL_TEXT_BRIGHT; +// nameFont = font_regular_bold; + } + else if (ingame.PingTimes[j] >= PING_LIMIT) + { + colour = WZCOL_FORM_PLAYER_NOPING; + } + else if (isKnownPlayer(serverPlayers, name, getMultiStats(j).identity)) + { + colour = WZCOL_FORM_PLAYER_KNOWN_BY_SERVER; + } + else if (isLocallyKnownPlayer(name, getMultiStats(j).identity)) + { + colour = WZCOL_FORM_PLAYER_KNOWN; + } + else + { + colour = WZCOL_FORM_PLAYER_UNKNOWN; + } + + // name + if (cache.fullMainText != name) + { + if ((int)iV_GetTextWidth(name.c_str(), font_regular) > psWidget->width() - nameX) + { + while (!name.empty() && (int)iV_GetTextWidth((name + "...").c_str(), font_regular) > psWidget->width() - nameX) + { + name.resize(name.size() - 1); // Clip name. + } + name += "..."; + } + cache.wzMainText.setText(WzString::fromUtf8(name), nameFont); + cache.fullMainText = name; + } + + WzString subText; + + if (j == NetPlay.hostPlayer && NetPlay.bComms) + { + subText += _("HOST"); + } + + if (NetPlay.bComms && j != selectedPlayer) + { + char buf[250] = {'\0'}; + + // show ping time + ssprintf(buf, "%s%s: ", subText.isEmpty() ? "" : ", ", _("Ping")); + subText += buf; + if (ingame.PingTimes[j] < PING_LIMIT) + { + // show actual ping time + ssprintf(buf, "%03d", ingame.PingTimes[j]); + } + else + { + ssprintf(buf, "%s", "∞"); // Player has ping of somewhat questionable quality. + } + subText += buf; + } + + if (!ar.valid) + { + ar.dummy = stat.played < 5; + // star 1 total droid kills + ar.star[0] = stat.totalKills > 600? 1 : stat.totalKills > 300? 2 : stat.totalKills > 150? 3 : 0; + + // star 2 games played + ar.star[1] = stat.played > 200? 1 : stat.played > 100? 2 : stat.played > 50? 3 : 0; + + // star 3 games won. + ar.star[2] = stat.wins > 80? 1 : stat.wins > 40? 2 : stat.wins > 10? 3 : 0; + + // medals. + ar.medal = stat.wins >= 24 && stat.wins > 8 * stat.losses? 1 : stat.wins >= 12 && stat.wins > 4 * stat.losses? 2 : stat.wins >= 6 && stat.wins > 2 * stat.losses? 3 : 0; + + ar.level = 0; + ar.autohoster = false; + ar.elo.clear(); + } + + if (cache.fullAltNameText != ar.altName) + { + std::string altName = ar.altName; + int maxAltNameWidth = static_cast(static_cast(psWidget->width() - nameX) * 0.65f); + iV_fonts fontID = font_small; + cache.wzAltNameText.setText(WzString::fromUtf8(altName), fontID); + cache.fullAltNameText = altName; + if (cache.wzAltNameText.width() > maxAltNameWidth) + { + while (!altName.empty() && ((int)iV_GetTextWidth(altName.c_str(), cache.wzAltNameText.getFontID()) + iV_GetEllipsisWidth(cache.wzAltNameText.getFontID())) > maxAltNameWidth) + { + altName.resize(altName.size() - 1); // Clip alt name. + } + altName += "\u2026"; + cache.wzAltNameText.setText(WzString::fromUtf8(altName), fontID); + } + } + + if (!ar.altName.empty() && isHighlight) + { + // display first, behind everything + displayAltNameBox(x, y, psWidget, cache, ar, isHighlight); + } + + int H = 5; + cache.wzMainText.render(x + nameX, y + 22 - H*!subText.isEmpty() - H*(ar.valid && !ar.elo.empty()), colour); + if (!subText.isEmpty()) + { + cache.wzSubText.setText(subText, font_small); + cache.wzSubText.render(x + nameX, y + 28 - H*!ar.elo.empty(), WZCOL_TEXT_MEDIUM); + } + + if (ar.autohoster) + { + iV_DrawImage(FrontImages, IMAGE_PLAYER_PC, x, y + 11); + } + else if (ar.dummy) + { + iV_DrawImage(FrontImages, IMAGE_MEDAL_DUMMY, x + 4, y + 13); + } + else + { + constexpr int starImgs[4] = {0, IMAGE_MULTIRANK1, IMAGE_MULTIRANK2, IMAGE_MULTIRANK3}; + if (1 <= ar.star[0] && ar.star[0] < ARRAY_SIZE(starImgs)) + { + iV_DrawImage(FrontImages, starImgs[ar.star[0]], x + 4, y + 3); + } + if (1 <= ar.star[1] && ar.star[1] < ARRAY_SIZE(starImgs)) + { + iV_DrawImage(FrontImages, starImgs[ar.star[1]], x + 4, y + 13); + } + if (1 <= ar.star[2] && ar.star[2] < ARRAY_SIZE(starImgs)) + { + iV_DrawImage(FrontImages, starImgs[ar.star[2]], x + 4, y + 23); + } + constexpr int medalImgs[4] = {0, IMAGE_MEDAL_GOLD, IMAGE_MEDAL_SILVER, IMAGE_MEDAL_BRONZE}; + if (1 <= ar.medal && ar.medal < ARRAY_SIZE(medalImgs)) + { + iV_DrawImage(FrontImages, medalImgs[ar.medal], x + 16 - 2*(ar.level != 0), y + 11); + } + } + constexpr int levelImgs[9] = {0, IMAGE_LEV_0, IMAGE_LEV_1, IMAGE_LEV_2, IMAGE_LEV_3, IMAGE_LEV_4, IMAGE_LEV_5, IMAGE_LEV_6, IMAGE_LEV_7}; + if (ar.level > 0 && ar.level < ARRAY_SIZE(levelImgs)) + { + iV_DrawImage(IntImages, levelImgs[ar.level], x + 24, y + 15); + } + + if (!ar.elo.empty()) + { + PIELIGHT eloColour = WZCOL_TEXT_BRIGHT; + if (ar.eloTextColorOverride[0] != 255 || ar.eloTextColorOverride[1] != 255 || ar.eloTextColorOverride[2] != 255) + { + eloColour = pal_Colour(ar.eloTextColorOverride[0], ar.eloTextColorOverride[1], ar.eloTextColorOverride[2]); + } + cache.wzEloText.setText(WzString::fromUtf8(ar.elo), font_small); + cache.wzEloText.render(x + nameX, y + 28 + H*!subText.isEmpty(), eloColour); + } + + if (!ar.altName.empty() && !isHighlight) + { + // display last, over top of everything + displayAltNameBox(x, y, psWidget, cache, ar, isHighlight); + } + } + else // AI + { + char aitext[100]; + + if (NetPlay.players[j].ai >= 0) + { + iV_DrawImage(FrontImages, IMAGE_PLAYER_PC, x, y + 11); + } + + // challenges may use custom AIs that are not in aidata and set to 127 + if (!challengeActive) + { + ASSERT_OR_RETURN(, NetPlay.players[j].ai < (int)aidata.size(), "Uh-oh, AI index out of bounds"); + } + + PIELIGHT textColor = WZCOL_FORM_TEXT; + switch (NetPlay.players[j].ai) + { + case AI_OPEN: + if (!NetPlay.players[j].isSpectator) + { + sstrcpy(aitext, _("Open")); + } + else + { + sstrcpy(aitext, _("Spectator")); + textColor.byte.a = 220; + } + break; + case AI_CLOSED: sstrcpy(aitext, _("Closed")); break; + default: sstrcpy(aitext, getPlayerName(j)); break; + } + cache.wzMainText.setText(aitext, font_regular); + cache.wzMainText.render(x + nameX, y + 22, textColor); + cache.fullMainText = aitext; + } +} + +static bool canChooseTeamFor(int i) +{ + return (i == selectedPlayer || isHostOrAdmin()); +} + +static std::shared_ptr createBlueForm(int x, int y, int w, int h, WIDGET_DISPLAY displayFunc /* = intDisplayFeBox*/) +{ + ASSERT(displayFunc != nullptr, "Must have a display func!"); + auto form = std::make_shared(); + form->setGeometry(x, y, w, h); + form->style = WFORM_PLAIN; + form->displayFunction = displayFunc; + return form; +} + +// //////////////////////////////////////////////////////////////////////////// +// Lobby player row + +WzPlayerRow::WzPlayerRow(uint32_t playerIdx, const std::shared_ptr& parent) +: WIDGET() +, parentTitleUI(parent) +, playerIdx(playerIdx) +{ } + +std::shared_ptr WzPlayerRow::make(uint32_t playerIdx, const std::shared_ptr& parent) +{ + class make_shared_enabler: public WzPlayerRow { + public: + make_shared_enabler(uint32_t playerIdx, const std::shared_ptr& parent) : WzPlayerRow(playerIdx, parent) { } + }; + auto widget = std::make_shared(playerIdx, parent); + + std::weak_ptr titleUI(parent); + + // add team button (also displays the spectator "eye" for spectators) + widget->teamButton = std::make_shared(); + widget->teamButton->setGeometry(0, 0, MULTIOP_TEAMSWIDTH, MULTIOP_TEAMSHEIGHT); + widget->teamButton->UserData = playerIdx; + widget->teamButton->displayFunction = displayTeamChooser; + widget->attach(widget->teamButton); + widget->teamButton->addOnClickHandler([playerIdx, titleUI](W_BUTTON& button){ + auto strongTitleUI = titleUI.lock(); + ASSERT_OR_RETURN(, strongTitleUI != nullptr, "Title UI is gone?"); + const auto& locked = getLockedOptions(); + if ((!locked.teams || !locked.spectators)) // Clicked on a team chooser + { + if (canChooseTeamFor(playerIdx)) + { + widgScheduleTask([strongTitleUI, playerIdx] { + strongTitleUI->openTeamChooser(playerIdx); + }); + } + } + }); + + // add player colour + widget->colorButton = std::make_shared(); + widget->colorButton->setGeometry(MULTIOP_TEAMSWIDTH, 0, MULTIOP_COLOUR_WIDTH, MULTIOP_PLAYERHEIGHT); + widget->colorButton->UserData = playerIdx; + widget->colorButton->displayFunction = displayColour; + widget->attach(widget->colorButton); + widget->colorButton->addOnClickHandler([playerIdx, titleUI](W_BUTTON& button){ + auto strongTitleUI = titleUI.lock(); + ASSERT_OR_RETURN(, strongTitleUI != nullptr, "Title UI is gone?"); + if (playerIdx == selectedPlayer || isHostOrAdmin()) + { + if (!NetPlay.players[playerIdx].isSpectator) // not a spectator + { + widgScheduleTask([strongTitleUI, playerIdx] { + strongTitleUI->openColourChooser(playerIdx); + }); + } + } + }); + + // add ready button + widget->updateReadyButton(); + + // add player info box (takes up the rest of the space in the middle) + widget->playerInfo = std::make_shared(); + widget->playerInfo->UserData = playerIdx; + widget->playerInfo->displayFunction = displayPlayer; + widget->playerInfo->pUserData = new DisplayPlayerCache(); + widget->playerInfo->setOnDelete([](WIDGET *psWidget) { + assert(psWidget->pUserData != nullptr); + delete static_cast(psWidget->pUserData); + psWidget->pUserData = nullptr; + }); + widget->attach(widget->playerInfo); + widget->playerInfo->setCalcLayout([](WIDGET *psWidget) { + auto psParent = std::dynamic_pointer_cast(psWidget->parent()); + ASSERT_OR_RETURN(, psParent != nullptr, "Null parent"); + int x0 = MULTIOP_TEAMSWIDTH + MULTIOP_COLOUR_WIDTH; + int width = psParent->readyButtonContainer->x() - x0; + psWidget->setGeometry(x0, 0, width, psParent->height()); + }); + widget->playerInfo->addOnClickHandler([playerIdx, titleUI](W_BUTTON& button){ + auto strongTitleUI = titleUI.lock(); + ASSERT_OR_RETURN(, strongTitleUI != nullptr, "Title UI is gone?"); + const auto& locked = getLockedOptions(); + if (playerIdx == selectedPlayer || isHostOrAdmin()) + { + uint32_t player = playerIdx; + // host/admin can move any player, clients can request to move themselves if there are available slots + if (((player == selectedPlayer && validPlayerIdxTargetsForPlayerPositionMove(player).size() > 0) || + (NetPlay.players[player].allocated && isHostOrAdmin())) + && !locked.position + && player < MAX_PLAYERS + && !isSpectatorOnlySlot(player)) + { + widgScheduleTask([strongTitleUI, player] { + strongTitleUI->openPositionChooser(player); + }); + } + else if (!NetPlay.players[player].allocated && !locked.ai && NetPlay.isHost) + { + if (button.getOnClickButtonPressed() == WKEY_SECONDARY && player < MAX_PLAYERS) + { + // Right clicking distributes selected AI's type and difficulty to all other AIs + for (int i = 0; i < MAX_PLAYERS; ++i) + { + // Don't change open/closed slots or humans or spectator-only slots + if (NetPlay.players[i].ai >= 0 && i != player && !isHumanPlayer(i) && !isSpectatorOnlySlot(i)) + { + NetPlay.players[i].ai = NetPlay.players[player].ai; + NetPlay.players[i].isSpectator = NetPlay.players[player].isSpectator; + NetPlay.players[i].difficulty = NetPlay.players[player].difficulty; + setPlayerName(i, getAIName(player)); + NETBroadcastPlayerInfo(i); + } + } + widgScheduleTask([strongTitleUI] { + strongTitleUI->updatePlayers(); + resetReadyStatus(false); + }); + } + else + { + widgScheduleTask([strongTitleUI, player] { + strongTitleUI->openAiChooser(player); + }); + } + } + } + }); + + // update tooltips and such + widget->updateState(); + + return widget; +} + +void WzPlayerRow::geometryChanged() +{ + if (readyButtonContainer) + { + readyButtonContainer->callCalcLayout(); + } + if (playerInfo) + { + playerInfo->callCalcLayout(); + } +} + +void WzPlayerRow::updateState() +{ + const auto& aidata = getAIData(); + const auto& locked = getLockedOptions(); + + // update team button tooltip + if (NetPlay.players[playerIdx].isSpectator) + { + teamButton->setTip(_("Spectator")); + } + else if (canChooseTeamFor(playerIdx) && !locked.teams) + { + teamButton->setTip(_("Choose Team")); + } + else if (locked.teams) + { + teamButton->setTip(_("Teams locked")); + } + else + { + teamButton->setTip(nullptr); + } + + // hide team button if needed + bool trueMultiplayerMode = (bMultiPlayer && NetPlay.bComms) || (!NetPlay.isHost && ingame.localJoiningInProgress); + if (!alliancesSetTeamsBeforeGame(game.alliance) && !NetPlay.players[playerIdx].isSpectator && !trueMultiplayerMode) + { + teamButton->hide(); + } + else + { + teamButton->show(); + } + + // update color tooltip + if ((selectedPlayer == playerIdx || NetPlay.isHost) && (!NetPlay.players[playerIdx].isSpectator)) + { + colorButton->setTip(_("Click to change player colour")); + } + else + { + colorButton->setTip(nullptr); + } + + // update player info box tooltip + std::string playerInfoTooltip; + if ((selectedPlayer == playerIdx || NetPlay.isHost) && NetPlay.players[playerIdx].allocated && !locked.position && !isSpectatorOnlySlot(playerIdx)) + { + playerInfoTooltip = _("Click to change player position"); + } + else if (!NetPlay.players[playerIdx].allocated) + { + if (NetPlay.isHost && !locked.ai) + { + playerInfo->style |= WBUT_SECONDARY; + if (!isSpectatorOnlySlot(playerIdx)) + { + playerInfoTooltip = _("Click to change AI, right click to distribute choice"); + } + else + { + playerInfoTooltip = _("Click to close spectator slot"); + } + } + else if (NetPlay.players[playerIdx].ai >= 0) + { + // show AI description. Useful for challenges. + playerInfoTooltip = aidata[NetPlay.players[playerIdx].ai].tip; + } + } + if (NetPlay.players[playerIdx].allocated) + { + const PLAYERSTATS& stats = getMultiStats(playerIdx); + const auto& identity = stats.identity; + if (!identity.empty()) + { + if (!playerInfoTooltip.empty()) + { + playerInfoTooltip += "\n"; + } + std::string hash = identity.publicHashString(20); + playerInfoTooltip += _("Player ID: "); + playerInfoTooltip += hash.empty()? _("(none)") : hash; + } + std::string autoratingTooltipText; + if (stats.autorating.valid) + { + if (!stats.autorating.altName.empty()) + { + if (!autoratingTooltipText.empty()) + { + autoratingTooltipText += "\n"; + } + std::string altnameStr = stats.autorating.altName; + if (altnameStr.size() > 128) + { + altnameStr = altnameStr.substr(0, 128); + } + size_t maxLinePos = nthOccurrenceOfChar(altnameStr, '\n', 1); + if (maxLinePos != std::string::npos) + { + altnameStr = altnameStr.substr(0, maxLinePos); + } + autoratingTooltipText += std::string(_("Alt Name:")) + " " + altnameStr; + } + if (!stats.autorating.details.empty() + && stats.autoratingFrom == RATING_SOURCE_LOCAL) // do not display host-provided details (for now) + { + if (!autoratingTooltipText.empty()) + { + autoratingTooltipText += "\n"; + } + std::string detailsstr = stats.autorating.details; + if (detailsstr.size() > 512) + { + detailsstr = detailsstr.substr(0, 512); + } + size_t maxLinePos = nthOccurrenceOfChar(detailsstr, '\n', 10); + if (maxLinePos != std::string::npos) + { + detailsstr = detailsstr.substr(0, maxLinePos); + } + autoratingTooltipText += std::string(_("Player rating:")) + "\n" + detailsstr; + } + } + if (!autoratingTooltipText.empty()) + { + if (!playerInfoTooltip.empty()) + { + playerInfoTooltip += "\n\n"; + } + if (stats.autoratingFrom == RATING_SOURCE_HOST) + { + playerInfoTooltip += std::string("[") + _("Host provided") + "]\n"; + } + else + { + playerInfoTooltip += std::string("[") + astringf(_("From: %s"), getAutoratingUrl().c_str()) + "]\n"; + } + playerInfoTooltip += autoratingTooltipText; + } + } + playerInfo->setTip(playerInfoTooltip); + + // update ready button + updateReadyButton(); +} + +void WzPlayerRow::updateReadyButton() +{ + int disallow = allPlayersOnSameTeam(-1); + + const auto& aidata = getAIData(); + const auto& locked = getLockedOptions(); + + if (!readyButtonContainer) + { + // add form to hold 'ready' botton + readyButtonContainer = createBlueForm(MULTIOP_PLAYERWIDTH - MULTIOP_READY_WIDTH, 0, + MULTIOP_READY_WIDTH, MULTIOP_READY_HEIGHT, displayReadyBoxContainer); + readyButtonContainer->UserData = playerIdx; + attach(readyButtonContainer); + readyButtonContainer->setCalcLayout([](WIDGET *psWidget) { + auto psParent = psWidget->parent(); + ASSERT_OR_RETURN(, psParent != nullptr, "Null parent"); + psWidget->setGeometry(psParent->width() - MULTIOP_READY_WIDTH, 0, psWidget->width(), psWidget->height()); + }); + } + + auto deleteExistingReadyButton = [this]() { + if (readyButton) + { + widgDelete(readyButton.get()); + readyButton = nullptr; + } + if (readyTextLabel) + { + widgDelete(readyTextLabel.get()); + readyTextLabel = nullptr; + } + }; + auto deleteExistingDifficultyButton = [this]() { + if (difficultyChooserButton) + { + widgDelete(difficultyChooserButton.get()); + difficultyChooserButton = nullptr; + } + }; + + if (!NetPlay.players[playerIdx].allocated && NetPlay.players[playerIdx].ai >= 0) + { + // Add AI difficulty chooser in place of normal "ready" button + deleteExistingReadyButton(); + int playerDifficulty = static_cast(NetPlay.players[playerIdx].difficulty); + int icon = difficultyIcon(playerDifficulty); + char tooltip[128 + 255]; + if (playerDifficulty >= static_cast(AIDifficulty::EASY) && playerDifficulty < static_cast(AIDifficulty::INSANE) + 1) + { + sstrcpy(tooltip, gettext(getDifficultyListStr(playerDifficulty))); + if (NetPlay.players[playerIdx].ai < aidata.size()) + { + const char *difficultyTip = aidata[NetPlay.players[playerIdx].ai].difficultyTips[playerDifficulty]; + if (strcmp(difficultyTip, "") != 0) + { + sstrcat(tooltip, "\n"); + sstrcat(tooltip, difficultyTip); + } + } + } + bool freshDifficultyButton = (difficultyChooserButton == nullptr); + difficultyChooserButton = addMultiBut(*readyButtonContainer, MULTIOP_DIFFICULTY_INIT_START + playerIdx, 6, 4, MULTIOP_READY_WIDTH, MULTIOP_READY_HEIGHT, + (NetPlay.isHost && !locked.difficulty) ? _("Click to change difficulty") : tooltip, icon, icon, icon, MAX_PLAYERS, (NetPlay.isHost && !locked.difficulty) ? 255 : 125); + auto player = playerIdx; + auto weakTitleUi = parentTitleUI; + if (freshDifficultyButton) + { + difficultyChooserButton->addOnClickHandler([player, weakTitleUi](W_BUTTON&){ + auto strongTitleUI = weakTitleUi.lock(); + ASSERT_OR_RETURN(, strongTitleUI != nullptr, "Title UI is gone?"); + const auto& locked = getLockedOptions(); + if (!locked.difficulty && NetPlay.isHost) + { + widgScheduleTask([strongTitleUI, player] { + strongTitleUI->openDifficultyChooser(player); + }); + } + }); + } + return; + } + else if (!NetPlay.players[playerIdx].allocated) + { + // closed or open - remove ready / difficulty button + deleteExistingReadyButton(); + deleteExistingDifficultyButton(); + return; + } + + if (disallow != -1) + { + // remove ready / difficulty button + deleteExistingReadyButton(); + deleteExistingDifficultyButton(); + return; + } + + deleteExistingDifficultyButton(); + + bool isMe = playerIdx == selectedPlayer; + int isReady = NETgetDownloadProgress(playerIdx) != 100 ? 2 : NetPlay.players[playerIdx].ready ? 1 : 0; + char const *const toolTips[2][3] = {{_("Waiting for player"), _("Player is ready"), _("Player is downloading")}, {_("Click when ready"), _("Waiting for other players"), _("Waiting for download")}}; + unsigned images[2][3] = {{IMAGE_CHECK_OFF, IMAGE_CHECK_ON, IMAGE_CHECK_DOWNLOAD}, {IMAGE_CHECK_OFF_HI, IMAGE_CHECK_ON_HI, IMAGE_CHECK_DOWNLOAD_HI}}; + + // draw 'ready' button + bool greyedOutReady = (NetPlay.players[playerIdx].isSpectator && NetPlay.players[playerIdx].ready) || (playerIdx != selectedPlayer); + bool freshReadyButton = (readyButton == nullptr); + readyButton = addMultiBut(*readyButtonContainer, MULTIOP_READY_START + playerIdx, 3, 10, MULTIOP_READY_WIDTH, MULTIOP_READY_HEIGHT, + toolTips[isMe][isReady], images[0][isReady], images[0][isReady], images[isMe][isReady], MAX_PLAYERS, (!greyedOutReady) ? 255 : 125); + ASSERT_OR_RETURN(, readyButton != nullptr, "Failed to create ready button"); + readyButton->minClickInterval = GAME_TICKS_PER_SEC; + readyButton->unlock(); + if (greyedOutReady && !NetPlay.isHost) + { + std::shared_ptr pReadyBut_MultiButton = std::dynamic_pointer_cast(readyButton); + if (pReadyBut_MultiButton) + { + pReadyBut_MultiButton->downStateMask = WBUT_DOWN | WBUT_CLICKLOCK; + } + auto currentState = readyButton->getState(); + readyButton->setState(currentState | WBUT_LOCK); + } + if (freshReadyButton) + { + // must add onclick handler + auto player = playerIdx; + auto weakTitleUi = parentTitleUI; + readyButton->addOnClickHandler([player, weakTitleUi](W_BUTTON& button){ + auto pButton = button.shared_from_this(); + widgScheduleTask([weakTitleUi, player, pButton] { + auto strongTitleUI = weakTitleUi.lock(); + ASSERT_OR_RETURN(, strongTitleUI != nullptr, "Title UI is gone?"); + + if (player == selectedPlayer + && (!NetPlay.players[player].isSpectator || !NetPlay.players[player].ready || NetPlay.isHost)) // spectators can never toggle off "ready" + { + // Lock the "ready" button (until the request is processed) + pButton->setState(WBUT_LOCK); + + sendReadyRequest(selectedPlayer, !NetPlay.players[player].ready); + + // if hosting try to start the game if everyone is ready + if (NetPlay.isHost && multiplayPlayersReady()) + { + startMultiplayerGame(); + // reset flag in case people dropped/quit on join screen + NETsetPlayerConnectionStatus(CONNECTIONSTATUS_NORMAL, NET_ALL_PLAYERS); + } + } + + if (NetPlay.isHost && !alliancesSetTeamsBeforeGame(game.alliance)) + { + if (mouseDown(MOUSE_RMB) && player != NetPlay.hostPlayer) // both buttons.... + { + std::string msg = astringf(_("The host has kicked %s from the game!"), getPlayerName(player)); + sendRoomSystemMessage(msg.c_str()); + kickPlayer(player, _("The host has kicked you from the game."), ERROR_KICKED, false); + resetReadyStatus(true); //reset and send notification to all clients + } + } + }); + }); + } + + if (!readyTextLabel) + { + readyTextLabel = std::make_shared(); + readyButtonContainer->attach(readyTextLabel); + readyTextLabel->id = MULTIOP_READY_START + MAX_CONNECTED_PLAYERS + playerIdx; + } + readyTextLabel->setGeometry(0, 0, MULTIOP_READY_WIDTH, 17); + readyTextLabel->setTextAlignment(WLAB_ALIGNBOTTOM); + readyTextLabel->setFont(font_small, WZCOL_TEXT_BRIGHT); + readyTextLabel->setString(_("READY?")); +} diff --git a/src/titleui/widgets/lobbyplayerrow.h b/src/titleui/widgets/lobbyplayerrow.h new file mode 100644 index 00000000000..74b8f206474 --- /dev/null +++ b/src/titleui/widgets/lobbyplayerrow.h @@ -0,0 +1,56 @@ +/* + This file is part of Warzone 2100. + Copyright (C) 2024 Warzone 2100 Project + + Warzone 2100 is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + Warzone 2100 is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Warzone 2100; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ +/** \file + * Lobby Player Row Widget (and associated display functions) + */ + +#pragma once + +#include "lib/widget/widget.h" +#include "lib/widget/label.h" +#include "lib/widget/button.h" + +class WzMultiplayerOptionsTitleUI; + +class WzPlayerRow : public WIDGET +{ +protected: + WzPlayerRow(uint32_t playerIdx, const std::shared_ptr& parent); + +public: + static std::shared_ptr make(uint32_t playerIdx, const std::shared_ptr& parent); + + void geometryChanged() override; + + void updateState(); + +private: + void updateReadyButton(); + +private: + std::weak_ptr parentTitleUI; + unsigned playerIdx = 0; + std::shared_ptr teamButton; + std::shared_ptr colorButton; + std::shared_ptr playerInfo; + std::shared_ptr readyButtonContainer; + std::shared_ptr difficultyChooserButton; + std::shared_ptr readyButton; + std::shared_ptr readyTextLabel; +}; From 0bd11bcc28c04b92ad23cb4680ac5a4210062fbb Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sat, 11 Jan 2025 16:20:39 -0500 Subject: [PATCH 04/17] Fix host name passed to ActivityManager when spectator host --- lib/netplay/netplay.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/netplay/netplay.cpp b/lib/netplay/netplay.cpp index b53ea19ce17..44e70b4085f 100644 --- a/lib/netplay/netplay.cpp +++ b/lib/netplay/netplay.cpp @@ -3861,7 +3861,7 @@ static void NETallowJoining() { listeningInterfaces.knownExternalAddresses = {{gamestruct.desc.host, gamestruct.hostPort, ActivitySink::ListeningInterfaces::IPType::IPv4}}; } - ActivityManager::instance().hostGame(gamestruct.name, NetPlay.players[0].name, NETgetMasterserverName(), NETgetMasterserverPort(), listeningInterfaces, gamestruct.gameId); + ActivityManager::instance().hostGame(gamestruct.name, NetPlay.players[NetPlay.hostPlayer].name, NETgetMasterserverName(), NETgetMasterserverPort(), listeningInterfaces, gamestruct.gameId); } // This is here since we need to get the status, before we can show the info. From 7cc40c097cc264b9a3e0ed680c83db9a21495e93 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sat, 11 Jan 2025 16:21:16 -0500 Subject: [PATCH 05/17] W_EDITBOX: Change text color if disabled --- lib/widget/editbox.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/widget/editbox.cpp b/lib/widget/editbox.cpp index 43c328362cb..8a6c931b3cc 100644 --- a/lib/widget/editbox.cpp +++ b/lib/widget/editbox.cpp @@ -751,6 +751,10 @@ void W_EDITBOX::display(int xOffset, int yOffset) { displayCache.wzDisplayedText.setText(displayedText, FontID); } + if (state & WEDBS_DISABLE) + { + displayedTextColor = WZCOL_TEXT_DARK; + } int lineSize = displayCache.wzDisplayedText.lineSize(); int aboveBase = displayCache.wzDisplayedText.aboveBase(); From 99f1ace5a0a661ef8a1456f38e299d7c6ee5bbc1 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sun, 12 Jan 2025 11:14:42 -0500 Subject: [PATCH 06/17] netreplay: Refactor to support updating player info at end of multiplayer game --- lib/netplay/netreplay.cpp | 68 ++++++++++++++++++++++++++------------- lib/netplay/netreplay.h | 2 +- lib/netplay/nettypes.h | 1 + src/main.cpp | 3 +- src/multiint.cpp | 5 +++ src/multiplay.h | 1 + 6 files changed, 56 insertions(+), 24 deletions(-) diff --git a/lib/netplay/netreplay.cpp b/lib/netplay/netreplay.cpp index 8eefe44112c..846c9caec2c 100644 --- a/lib/netplay/netreplay.cpp +++ b/lib/netplay/netreplay.cpp @@ -57,6 +57,7 @@ static const size_t MaxReplayBufferSize = 2 * 1024 * 1024; typedef std::vector SerializedNetMessagesBuffer; static moodycamel::BlockingReaderWriterQueue serializedBufferWriteQueue(256); +static nlohmann::json queuedSaveSettings; static SerializedNetMessagesBuffer latestWriteBuffer; static size_t minBufferSizeToQueue = DefaultReplayBufferSize; static WZ_THREAD *saveThread = nullptr; @@ -83,6 +84,37 @@ static int replaySaveThreadFunc(void *data) return 0; } +static bool NETreplaySaveWritePreamble(const nlohmann::json& settings, ReplayOptionsHandler const &optionsHandler) +{ + if (!replaySaveHandle) + { + return false; + } + + auto data = settings.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace); + PHYSFS_writeUBE32(replaySaveHandle, data.size()); + WZ_PHYSFS_writeBytes(replaySaveHandle, data.data(), data.size()); + + // Save extra map data (if present) + ReplayOptionsHandler::EmbeddedMapData embeddedMapData; + if (!optionsHandler.saveMap(embeddedMapData)) + { + // Failed to save map data - just empty it out for now + embeddedMapData.mapBinaryData.clear(); + } + PHYSFS_writeUBE32(replaySaveHandle, embeddedMapData.dataVersion); +#if SIZE_MAX > UINT32_MAX + ASSERT_OR_RETURN(false, embeddedMapData.mapBinaryData.size() <= static_cast(std::numeric_limits::max()), "Embedded map data is way too big"); +#endif + PHYSFS_writeUBE32(replaySaveHandle, static_cast(embeddedMapData.mapBinaryData.size())); + if (!embeddedMapData.mapBinaryData.empty()) + { + WZ_PHYSFS_writeBytes(replaySaveHandle, embeddedMapData.mapBinaryData.data(), static_cast(embeddedMapData.mapBinaryData.size())); + } + + return true; +} + bool NETreplaySaveStart(std::string const& subdir, ReplayOptionsHandler const &optionsHandler, int maxReplaysSaved, bool appendPlayerToFilename) { if (NETisReplay()) @@ -147,27 +179,6 @@ bool NETreplaySaveStart(std::string const& subdir, ReplayOptionsHandler const &o optionsHandler.saveOptions(gameOptions); settings["gameOptions"] = gameOptions; - auto data = settings.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace); - PHYSFS_writeUBE32(replaySaveHandle, data.size()); - WZ_PHYSFS_writeBytes(replaySaveHandle, data.data(), data.size()); - - // Save extra map data (if present) - ReplayOptionsHandler::EmbeddedMapData embeddedMapData; - if (!optionsHandler.saveMap(embeddedMapData)) - { - // Failed to save map data - just empty it out for now - embeddedMapData.mapBinaryData.clear(); - } - PHYSFS_writeUBE32(replaySaveHandle, embeddedMapData.dataVersion); -#if SIZE_MAX > UINT32_MAX - ASSERT_OR_RETURN(false, embeddedMapData.mapBinaryData.size() <= static_cast(std::numeric_limits::max()), "Embedded map data is way too big"); -#endif - PHYSFS_writeUBE32(replaySaveHandle, static_cast(embeddedMapData.mapBinaryData.size())); - if (!embeddedMapData.mapBinaryData.empty()) - { - WZ_PHYSFS_writeBytes(replaySaveHandle, embeddedMapData.mapBinaryData.data(), static_cast(embeddedMapData.mapBinaryData.size())); - } - // determine best buffer size size_t desiredBufferSize = optionsHandler.desiredBufferSize(); if (desiredBufferSize == 0) @@ -191,11 +202,18 @@ bool NETreplaySaveStart(std::string const& subdir, ReplayOptionsHandler const &o latestWriteBuffer.reserve(minBufferSizeToQueue); if (desiredBufferSize != std::numeric_limits::max()) { + // Write the preamble immediately (settings, etc) + NETreplaySaveWritePreamble(settings, optionsHandler); + + // use a background thread saveThread = wzThreadCreate(replaySaveThreadFunc, replaySaveHandle, "replaySaveThread"); wzThreadStart(saveThread); } else { + // Do not immediately write settings out - instead, queue them for later writing + queuedSaveSettings = std::move(settings); + // don't use a background thread saveThread = nullptr; } @@ -203,7 +221,7 @@ bool NETreplaySaveStart(std::string const& subdir, ReplayOptionsHandler const &o return true; } -bool NETreplaySaveStop() +bool NETreplaySaveStop(ReplayOptionsHandler const &optionsHandler) { if (!replaySaveHandle) { @@ -233,6 +251,12 @@ bool NETreplaySaveStop() } else { + // update the queued settings (ex. might have revealed player identities in a blind game) + optionsHandler.optionsUpdatePlayerInfo(queuedSaveSettings.at("gameOptions")); + + // write the preamble + NETreplaySaveWritePreamble(queuedSaveSettings, optionsHandler); + // do the writing now on the main thread replaySaveThreadFunc(replaySaveHandle); } diff --git a/lib/netplay/netreplay.h b/lib/netplay/netreplay.h index d74b0d9267b..8c4bcb6e75a 100644 --- a/lib/netplay/netreplay.h +++ b/lib/netplay/netreplay.h @@ -26,7 +26,7 @@ bool NETreplaySaveStart(std::string const& subdir, ReplayOptionsHandler const &optionsHandler, int maxReplaysSaved, bool appendPlayerToFilename = false); -bool NETreplaySaveStop(); +bool NETreplaySaveStop(ReplayOptionsHandler const &optionsHandler); void NETreplaySaveNetMessage(NetMessage const *message, uint8_t player); bool NETreplayLoadStart(std::string const &filename, ReplayOptionsHandler& optionsHandler, uint32_t& output_replayFormatVer); diff --git a/lib/netplay/nettypes.h b/lib/netplay/nettypes.h index 77691c4505b..f82e19e1d4e 100644 --- a/lib/netplay/nettypes.h +++ b/lib/netplay/nettypes.h @@ -225,6 +225,7 @@ class ReplayOptionsHandler public: virtual bool saveOptions(nlohmann::json& object) const = 0; virtual bool saveMap(EmbeddedMapData& mapData) const = 0; + virtual bool optionsUpdatePlayerInfo(nlohmann::json& object) const = 0; virtual bool restoreOptions(const nlohmann::json& object, EmbeddedMapData&& embeddedMapData, uint32_t replay_netcodeMajor, uint32_t replay_netcodeMinor) = 0; virtual size_t desiredBufferSize() const = 0; virtual size_t maximumEmbeddedMapBufferSize() const = 0; diff --git a/src/main.cpp b/src/main.cpp index 97888d23494..e3294e005f9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1074,7 +1074,8 @@ static void stopGameLoop() { clearInfoMessages(); // clear CONPRINTF messages before each new game/mission - NETreplaySaveStop(); + WZGameReplayOptionsHandler replayOptions; + NETreplaySaveStop(replayOptions); NETshutdownReplay(); if (gameLoopStatus != GAMECODE_NEWLEVEL) diff --git a/src/multiint.cpp b/src/multiint.cpp index 88032ae67d6..5ce19942ac7 100644 --- a/src/multiint.cpp +++ b/src/multiint.cpp @@ -8249,6 +8249,11 @@ bool WZGameReplayOptionsHandler::saveOptions(nlohmann::json& object) const return true; } +bool WZGameReplayOptionsHandler::optionsUpdatePlayerInfo(nlohmann::json& object) const +{ + return true; +} + constexpr PHYSFS_sint64 MAX_REPLAY_EMBEDDED_MAP_SIZE_LIMIT = 150 * 1024; size_t WZGameReplayOptionsHandler::maximumEmbeddedMapBufferSize() const diff --git a/src/multiplay.h b/src/multiplay.h index e359cb062cf..53e93564f50 100644 --- a/src/multiplay.h +++ b/src/multiplay.h @@ -345,6 +345,7 @@ bool makePlayerSpectator(uint32_t player_id, bool removeAllStructs = false, bool class WZGameReplayOptionsHandler : public ReplayOptionsHandler { virtual bool saveOptions(nlohmann::json& object) const override; + virtual bool optionsUpdatePlayerInfo(nlohmann::json& object) const override; virtual bool saveMap(EmbeddedMapData& mapData) const override; virtual bool restoreOptions(const nlohmann::json& object, EmbeddedMapData&& embeddedMapData, uint32_t replay_netcodeMajor, uint32_t replay_netcodeMinor) override; virtual size_t desiredBufferSize() const override; From 4b67179ea673d6e168d5c02f9ae8ae4ec509598d Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sun, 12 Jan 2025 11:24:19 -0500 Subject: [PATCH 07/17] Initial blind lobby / game support In a blind game, players' identities are hidden from each other until the game ends. --- lib/netplay/netplay.cpp | 38 +- lib/netplay/netplay.h | 1 + src/configuration.cpp | 1 + src/game.cpp | 5 + src/gamehistorylogger.cpp | 2 +- src/loop.cpp | 3 +- src/multiint.cpp | 250 ++++++- src/multiint.h | 3 + src/multijoin.cpp | 15 +- src/multilobbycommands.cpp | 69 +- src/multiplay.cpp | 107 ++- src/multiplay.h | 5 +- src/multiplaydefs.h | 9 + src/multistat.cpp | 233 ++++++- src/multistat.h | 28 + src/multisync.cpp | 9 +- src/screens/joiningscreen.cpp | 33 + src/screens/joiningscreen.h | 1 + src/stdinreader.cpp | 113 ++-- src/titleui/widgets/blindwaitingroom.cpp | 796 +++++++++++++++++++++++ src/titleui/widgets/blindwaitingroom.h | 30 + src/titleui/widgets/lobbyplayerrow.cpp | 68 +- src/wrappers.cpp | 18 + src/wzapi.cpp | 10 +- src/wzscriptdebug.cpp | 2 +- 25 files changed, 1634 insertions(+), 215 deletions(-) create mode 100644 src/titleui/widgets/blindwaitingroom.cpp create mode 100644 src/titleui/widgets/blindwaitingroom.h diff --git a/lib/netplay/netplay.cpp b/lib/netplay/netplay.cpp index 44e70b4085f..3952f8f4b92 100644 --- a/lib/netplay/netplay.cpp +++ b/lib/netplay/netplay.cpp @@ -733,7 +733,19 @@ static void NETSendNPlayerInfoTo(uint32_t *index, uint32_t indexLen, unsigned to NETbool(&NetPlay.players[index[n]].allocated); NETbool(&NetPlay.players[index[n]].heartbeat); NETbool(&NetPlay.players[index[n]].kick); - NETstring(NetPlay.players[index[n]].name, sizeof(NetPlay.players[index[n]].name)); + if (game.blindMode != BLIND_MODE::NONE // if in blind mode + && index[n] < MAX_PLAYER_SLOTS // and an actual player slot (not a spectator slot) + && !ingame.endTime.has_value()) // and game has not ended + { + // send a generic player name + const char* genericName = getPlayerGenericName(index[n]); + NETstring(genericName, strlen(genericName) + 1); + } + else + { + // send the actual player name + NETstring(NetPlay.players[index[n]].name, sizeof(NetPlay.players[index[n]].name)); + } NETuint32_t(&NetPlay.players[index[n]].heartattacktime); NETint32_t(&NetPlay.players[index[n]].colour); NETint32_t(&NetPlay.players[index[n]].position); @@ -754,7 +766,7 @@ static void NETSendPlayerInfoTo(uint32_t index, unsigned to) NETSendNPlayerInfoTo(&index, 1, to); } -static void NETSendAllPlayerInfoTo(unsigned to) +void NETSendAllPlayerInfoTo(unsigned to) { static uint32_t indices[MAX_CONNECTED_PLAYERS]; for (int i = 0; i < MAX_CONNECTED_PLAYERS; ++i) @@ -1002,7 +1014,7 @@ static void NETplayerLeaving(UDWORD index, bool quietSocketClose) NET_DestroyPlayer(index); // sets index player's array to false if (!wasSpectator) { - resetReadyStatus(false); // reset ready status for all players + resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); // reset ready status for all players resetReadyCalled = true; } @@ -1043,7 +1055,7 @@ static void NETplayerDropped(UDWORD index) NET_DestroyPlayer(id); // just clears array if (!wasSpectator) { - resetReadyStatus(false); // reset ready status for all players + resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); // reset ready status for all players resetReadyCalled = true; } @@ -2096,7 +2108,7 @@ bool NETmovePlayerToSpectatorOnlySlot(uint32_t playerIdx, bool hostOverride /*= } // Backup the player's identity for later recording - auto playerPublicKeyIdentity = getMultiStats(playerIdx).identity.toBytes(EcKey::Privacy::Public); + auto playerPublicKeyIdentity = getTruePlayerIdentity(playerIdx).identity.toBytes(EcKey::Privacy::Public); // Swap the player indexes if (!swapPlayerIndexes(playerIdx, availableSpectatorIndex.value())) @@ -2111,8 +2123,9 @@ bool NETmovePlayerToSpectatorOnlySlot(uint32_t playerIdx, bool hostOverride /*= if (wz_command_interface_enabled()) { uint32_t newSpecIdx = availableSpectatorIndex.value(); - std::string playerPublicKeyB64 = base64Encode(getMultiStats(newSpecIdx).identity.toBytes(EcKey::Public)); - std::string playerIdentityHash = getMultiStats(newSpecIdx).identity.publicHashString(); + const auto& outputIdentity = getOutputPlayerIdentity(newSpecIdx); + std::string playerPublicKeyB64 = base64Encode(outputIdentity.toBytes(EcKey::Public)); + std::string playerIdentityHash = outputIdentity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[newSpecIdx]) ? "V" : "?"; std::string playerName = getPlayerName(newSpecIdx); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); @@ -2128,7 +2141,7 @@ bool NETmovePlayerToSpectatorOnlySlot(uint32_t playerIdx, bool hostOverride /*= static bool wasAlreadyMovedToSpectatorsByHost(uint32_t playerIdx) { return playerManagementRecord.hostMovedPlayerToSpectators(NetPlay.players[playerIdx].IPtextAddress) - || playerManagementRecord.hostMovedPlayerToSpectators(getMultiStats(playerIdx).identity.toBytes(EcKey::Privacy::Public)); + || playerManagementRecord.hostMovedPlayerToSpectators(getTruePlayerIdentity(playerIdx).identity.toBytes(EcKey::Privacy::Public)); } SpectatorToPlayerMoveResult NETmoveSpectatorToPlayerSlot(uint32_t playerIdx, optional newPlayerIdx, bool hostOverride /*= false*/) @@ -2157,7 +2170,7 @@ SpectatorToPlayerMoveResult NETmoveSpectatorToPlayerSlot(uint32_t playerIdx, opt } // Backup the spectator's identity for later recording - auto spectatorPublicKeyIdentity = getMultiStats(playerIdx).identity.toBytes(EcKey::Privacy::Public); + auto spectatorPublicKeyIdentity = getTruePlayerIdentity(playerIdx).identity.toBytes(EcKey::Privacy::Public); // Swap the player indexes if (!swapPlayerIndexes(playerIdx, newPlayerIdx.value())) @@ -2171,8 +2184,9 @@ SpectatorToPlayerMoveResult NETmoveSpectatorToPlayerSlot(uint32_t playerIdx, opt if (wz_command_interface_enabled()) { - std::string playerPublicKeyB64 = base64Encode(getMultiStats(newPlayerIdx.value()).identity.toBytes(EcKey::Public)); - std::string playerIdentityHash = getMultiStats(newPlayerIdx.value()).identity.publicHashString(); + const auto& identity = getOutputPlayerIdentity(newPlayerIdx.value()); + std::string playerPublicKeyB64 = base64Encode(identity.toBytes(EcKey::Public)); + std::string playerIdentityHash = identity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[newPlayerIdx.value()]) ? "V" : "?"; std::string playerName = getPlayerName(newPlayerIdx.value()); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); @@ -4319,6 +4333,8 @@ static void NETallowJoining() NETbeginEncode(NETnetQueue(index), NET_ACCEPTED); NETuint8_t(&index); NETuint32_t(&NetPlay.hostPlayer); + uint8_t blindModeVal = static_cast(game.blindMode); + NETuint8_t(&blindModeVal); NETend(); // First send info about players to newcomer. diff --git a/lib/netplay/netplay.h b/lib/netplay/netplay.h index 6cfaf64846c..b43e2f753d4 100644 --- a/lib/netplay/netplay.h +++ b/lib/netplay/netplay.h @@ -470,6 +470,7 @@ uint32_t NETgetJoinConnectionNETPINGChallengeSize(); void NETsetGamePassword(const char *password); void NETBroadcastPlayerInfo(uint32_t index); void NETBroadcastTwoPlayerInfo(uint32_t index1, uint32_t index2); +void NETSendAllPlayerInfoTo(unsigned to); bool NETisCorrectVersion(uint32_t game_version_major, uint32_t game_version_minor); uint32_t NETGetMajorVersion(); uint32_t NETGetMinorVersion(); diff --git a/src/configuration.cpp b/src/configuration.cpp index 82576ebfc61..bd452444a4f 100644 --- a/src/configuration.cpp +++ b/src/configuration.cpp @@ -912,6 +912,7 @@ bool reloadMPConfig() game.inactivityMinutes = war_getMPInactivityMinutes(); game.gameTimeLimitMinutes = war_getMPGameTimeLimitMinutes(); game.playerLeaveMode = war_getMPPlayerLeaveMode(); + game.blindMode = BLIND_MODE::NONE; // restore group menus enabled setting (as tutorial may override it) setGroupButtonEnabled(war_getGroupsMenuEnabled()); diff --git a/src/game.cpp b/src/game.cpp index 99693e02b49..71e3c007e66 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -4558,6 +4558,10 @@ static bool loadMainFile(const std::string &fileName) { game.playerLeaveMode = static_cast(save.value("playerLeaveMode").toInt()); } + if (save.contains("blindMode")) + { + game.blindMode = static_cast(save.value("blindMode").toInt()); + } if (save.contains("multiplayer")) { bMultiPlayer = save.value("multiplayer").toBool(); @@ -4800,6 +4804,7 @@ static bool writeMainFile(const std::string &fileName, SDWORD saveType) save.setValue("inactivityMinutes", game.inactivityMinutes); save.setValue("gameTimeLimitMinutes", game.gameTimeLimitMinutes); save.setValue("playerLeaveMode", game.playerLeaveMode); + save.setValue("blindMode", game.blindMode); save.setValue("tweakOptions", getCamTweakOptions()); save.beginArray("scriptSetPlayerDataStrings"); diff --git a/src/gamehistorylogger.cpp b/src/gamehistorylogger.cpp index 563fba2f02f..90e4b5e3e2f 100644 --- a/src/gamehistorylogger.cpp +++ b/src/gamehistorylogger.cpp @@ -338,7 +338,7 @@ void GameStoryLogger::logStartGame() playerAttrib.team = NetPlay.players[i].team; playerAttrib.colour = NetPlay.players[i].colour; playerAttrib.faction = NetPlay.players[i].faction; - playerAttrib.publicKey = base64Encode(getMultiStats(i).identity.toBytes(EcKey::Public)); + playerAttrib.publicKey = base64Encode(getOutputPlayerIdentity(i).toBytes(EcKey::Public)); startingPlayerAttributes.push_back(playerAttrib); } diff --git a/src/loop.cpp b/src/loop.cpp index 4f39c757dac..2eb64c8968e 100644 --- a/src/loop.cpp +++ b/src/loop.cpp @@ -501,9 +501,10 @@ static void gameStateUpdate() NetPlay.players[0].allocated, NetPlay.players[1].allocated, NetPlay.players[2].allocated, NetPlay.players[3].allocated, NetPlay.players[4].allocated, NetPlay.players[5].allocated, NetPlay.players[6].allocated, NetPlay.players[7].allocated, NetPlay.players[8].allocated, NetPlay.players[9].allocated, NetPlay.players[0].position, NetPlay.players[1].position, NetPlay.players[2].position, NetPlay.players[3].position, NetPlay.players[4].position, NetPlay.players[5].position, NetPlay.players[6].position, NetPlay.players[7].position, NetPlay.players[8].position, NetPlay.players[9].position ); + bool overrideHandleClientBlindNames = (game.blindMode != BLIND_MODE::NONE) && (NetPlay.isHost || NETisReplay()) && !ingame.endTime.has_value(); for (unsigned n = 0; n < MAX_PLAYERS; ++n) { - syncDebug("Player %d = \"%s\"", n, NetPlay.players[n].name); + syncDebug("Player %d = \"%s\"", n, (!overrideHandleClientBlindNames) ? NetPlay.players[n].name : getPlayerGenericName(n)); } // Add version string to desynch logs. Different version strings will not trigger a desynch dump per se, due to the syncDebug{Get, Set}Crc guard. diff --git a/src/multiint.cpp b/src/multiint.cpp index 5ce19942ac7..7f417e8a2a8 100644 --- a/src/multiint.cpp +++ b/src/multiint.cpp @@ -123,6 +123,7 @@ #include "campaigninfo.h" #include "screens/joiningscreen.h" #include "titleui/widgets/lobbyplayerrow.h" +#include "titleui/widgets/blindwaitingroom.h" #include "activity.h" #include @@ -205,7 +206,7 @@ static std::weak_ptr currentMultiOptionsTitleUI; // Function protos // widget functions -static W_EDITBOX* addMultiEditBox(UDWORD formid, UDWORD id, UDWORD x, UDWORD y, char const *tip, char const *tipres, UDWORD icon, UDWORD iconhi, UDWORD iconid); +static W_EDITBOX* addMultiEditBox(UDWORD formid, UDWORD id, UDWORD x, UDWORD y, char const *tip, char const *tipres, UDWORD icon, UDWORD iconhi, UDWORD iconid, bool disabled = false); static W_FORM * addBlueForm(UDWORD parent, UDWORD id, UDWORD x, UDWORD y, UDWORD w, UDWORD h, WIDGET_DISPLAY displayFunc = intDisplayFeBox); static int numSlotsToBeDisplayed(); static inline bool spectatorSlotsSupported(); @@ -1285,7 +1286,32 @@ static void addGameOptions() } //just display the game options. - addMultiEditBox(MULTIOP_OPTIONS, MULTIOP_PNAME, MCOL0, MROW1, _("Select Player Name"), (char *) sPlayer, IMAGE_EDIT_PLAYER, IMAGE_EDIT_PLAYER_HI, MULTIOP_PNAME_ICON); + bool isInBlindMode = (game.blindMode != BLIND_MODE::NONE); + WzString playerNameTip; + if (!isInBlindMode) + { + playerNameTip = _("Select Player Name"); + } + else + { + if (game.blindMode >= BLIND_MODE::BLIND_GAME) + { + playerNameTip = _("Blind Game:"); + playerNameTip += "\n"; + playerNameTip += _("- Player names will not be visible to other players (until the game is over)"); + } + else + { + playerNameTip = _("Blind Lobby:"); + playerNameTip += "\n"; + playerNameTip += _("- Player names will not be visible to other players (until the game has started)"); + } + } + auto pNameEditBox = addMultiEditBox(MULTIOP_OPTIONS, MULTIOP_PNAME, MCOL0, MROW1, playerNameTip.toUtf8().c_str(), (char *) sPlayer, IMAGE_EDIT_PLAYER, IMAGE_EDIT_PLAYER_HI, MULTIOP_PNAME_ICON, isInBlindMode); + if (pNameEditBox && isInBlindMode) + { + pNameEditBox->setTip(playerNameTip.toUtf8()); + } auto optionsList = std::make_shared(); optionsForm->attach(optionsList); @@ -1477,6 +1503,16 @@ int allPlayersOnSameTeam(int except) int WzMultiplayerOptionsTitleUI::playerRowY0(uint32_t row) const { + if (game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY && !NetPlay.isHost) + { + // Get Y position of virtual player row in WzPlayerBlindWaitingRoom widget + auto pBlindWaitingRoom = widgFormGetFromID(psWScreen->psForm, MULTIOP_BLIND_WAITING_ROOM); + if (!pBlindWaitingRoom) + { + return -1; + } + return getWzPlayerBlindWaitingRoomPlayerRowY0(pBlindWaitingRoom) + pBlindWaitingRoom->y(); + } if (row >= playerRows.size()) { return -1; @@ -1598,7 +1634,7 @@ void WzMultiplayerOptionsTitleUI::openDifficultyChooser(uint32_t player) ASSERT_OR_RETURN(, pStrongPtr.operator bool(), "WzMultiplayerOptionsTitleUI no longer exists"); NetPlay.players[player].difficulty = difficultyValue[difficultyIdx]; NETBroadcastPlayerInfo(player); - resetReadyStatus(false); + resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); widgScheduleTask([pStrongPtr] { pStrongPtr->closeDifficultyChooser(); pStrongPtr->addPlayerBox(true); @@ -1707,7 +1743,7 @@ void WzMultiplayerOptionsTitleUI::openAiChooser(uint32_t player) // common code NetPlay.players[player].difficulty = AIDifficulty::DISABLED; // disable AI for this slot NETBroadcastPlayerInfo(player); - resetReadyStatus(false); + resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); } else { @@ -1857,7 +1893,7 @@ class WzPlayerSelectionChangePositionRow : public W_BUTTON auto strongTitleUI = titleUI.lock(); ASSERT_OR_RETURN(, strongTitleUI != nullptr, "Title UI is gone?"); // Switch player - resetReadyStatus(false); // will reset only locally if not a host + resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); // will reset only locally if not a host SendPositionRequest(switcherPlayerIdx, NetPlay.players[selectPositionRow->targetPlayerIdx].position); widgScheduleTask([strongTitleUI] { strongTitleUI->closePositionChooser(); @@ -1921,10 +1957,10 @@ class WzPlayerIndexSwapPositionRow : public W_BUTTON auto strongTitleUI = titleUI.lock(); ASSERT_OR_RETURN(, strongTitleUI != nullptr, "Title UI is gone?"); - std::string playerName = getPlayerName(switcherPlayerIdx); + std::string playerName = getPlayerName(switcherPlayerIdx, true); NETmoveSpectatorToPlayerSlot(switcherPlayerIdx, selectPositionRow->targetPlayerIdx, true); - resetReadyStatus(true); //reset and send notification to all clients + resetReadyStatus(true, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); //reset and send notification to all clients widgScheduleTask([strongTitleUI] { strongTitleUI->closePlayerSlotSwapChooser(); strongTitleUI->updatePlayers(); @@ -2163,7 +2199,7 @@ void WzMultiplayerOptionsTitleUI::openTeamChooser(uint32_t player) ASSERT(id >= MULTIOP_TEAMCHOOSER && (id - MULTIOP_TEAMCHOOSER) < MAX_PLAYERS, "processMultiopWidgets: wrong id - MULTIOP_TEAMCHOOSER value (%d)", id - MULTIOP_TEAMCHOOSER); - resetReadyStatus(false); // will reset only locally if not a host + resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); // will reset only locally if not a host SendTeamRequest(player, (UBYTE)id - MULTIOP_TEAMCHOOSER); @@ -2204,7 +2240,7 @@ void WzMultiplayerOptionsTitleUI::openTeamChooser(uint32_t player) auto pStrongPtr = psWeakTitleUI.lock(); ASSERT_OR_RETURN(, pStrongPtr.operator bool(), "WzMultiplayerOptionsTitleUI no longer exists"); - std::string msg = astringf(_("The host has kicked %s from the game!"), getPlayerName(player)); + std::string msg = astringf(_("The host has kicked %s from the game!"), getPlayerName(player, true)); kickPlayer(player, _("The host has kicked you from the game."), ERROR_KICKED, false); sendRoomSystemMessage(msg.c_str()); resetReadyStatus(true); //reset and send notification to all clients @@ -2224,7 +2260,7 @@ void WzMultiplayerOptionsTitleUI::openTeamChooser(uint32_t player) auto pStrongPtr = psWeakTitleUI.lock(); ASSERT_OR_RETURN(, pStrongPtr.operator bool(), "WzMultiplayerOptionsTitleUI no longer exists"); - std::string msg = astringf(_("The host has banned %s from the game!"), getPlayerName(player)); + std::string msg = astringf(_("The host has banned %s from the game!"), getPlayerName(player, true)); kickPlayer(player, _("The host has banned you from the game."), ERROR_KICKED, true); sendRoomSystemMessage(msg.c_str()); resetReadyStatus(true); //reset and send notification to all clients @@ -2256,7 +2292,7 @@ void WzMultiplayerOptionsTitleUI::openTeamChooser(uint32_t player) auto pStrongPtr = psWeakTitleUI.lock(); ASSERT_OR_RETURN(, pStrongPtr.operator bool(), "WzMultiplayerOptionsTitleUI no longer exists"); - std::string playerName = getPlayerName(player); + std::string playerName = getPlayerName(player, true); if (!NETmovePlayerToSpectatorOnlySlot(player, true)) { @@ -2471,7 +2507,7 @@ static void informIfAdminChangedOtherTeam(uint32_t targetPlayerIdx, uint32_t res sendQuickChat(WzQuickChatMessage::INTERNAL_ADMIN_ACTION_NOTICE, realSelectedPlayer, WzQuickChatTargeting::targetAll(), WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::constructMessageData(WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::Context::Team, responsibleIdx, targetPlayerIdx)); - std::string senderPublicKeyB64 = base64Encode(getMultiStats(responsibleIdx).identity.toBytes(EcKey::Public)); + std::string senderPublicKeyB64 = base64Encode(getOutputPlayerIdentity(responsibleIdx).toBytes(EcKey::Public)); debug(LOG_INFO, "Admin %s (%s) changed team of player ([%u] %s) to: %d", getPlayerName(responsibleIdx), senderPublicKeyB64.c_str(), NetPlay.players[targetPlayerIdx].position, getPlayerName(targetPlayerIdx), NetPlay.players[targetPlayerIdx].team); } @@ -2571,8 +2607,9 @@ bool sendReadyRequest(UBYTE player, bool bReady) bool changedValue = changeReadyStatus(player, bReady); if (changedValue && wz_command_interface_enabled()) { - std::string playerPublicKeyB64 = base64Encode(getMultiStats(player).identity.toBytes(EcKey::Public)); - std::string playerIdentityHash = getMultiStats(player).identity.publicHashString(); + const auto& identity = getOutputPlayerIdentity(player); + std::string playerPublicKeyB64 = base64Encode(identity.toBytes(EcKey::Public)); + std::string playerIdentityHash = identity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[player]) ? "V" : "?"; std::string playerName = getPlayerName(player); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); @@ -2639,8 +2676,9 @@ bool recvReadyRequest(NETQUEUE queue) bool changedValue = changeReadyStatus((UBYTE)player, bReady); if (changedValue && wz_command_interface_enabled()) { - std::string playerPublicKeyB64 = base64Encode(stats.identity.toBytes(EcKey::Public)); - std::string playerIdentityHash = stats.identity.publicHashString(); + const auto& identity = getOutputPlayerIdentity(player); + std::string playerPublicKeyB64 = base64Encode(identity.toBytes(EcKey::Public)); + std::string playerIdentityHash = identity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[player]) ? "V" : "?"; std::string playerName = getPlayerName(player); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); @@ -2673,7 +2711,7 @@ static void informIfAdminChangedOtherPosition(uint32_t targetPlayerIdx, uint32_t sendQuickChat(WzQuickChatMessage::INTERNAL_ADMIN_ACTION_NOTICE, realSelectedPlayer, WzQuickChatTargeting::targetAll(), WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::constructMessageData(WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::Context::Position, responsibleIdx, targetPlayerIdx)); - std::string senderPublicKeyB64 = base64Encode(getMultiStats(responsibleIdx).identity.toBytes(EcKey::Public)); + std::string senderPublicKeyB64 = base64Encode(getOutputPlayerIdentity(responsibleIdx).toBytes(EcKey::Public)); debug(LOG_INFO, "Admin %s (%s) changed position of player (%s) to: %d", getPlayerName(responsibleIdx), senderPublicKeyB64.c_str(), getPlayerName(targetPlayerIdx), NetPlay.players[targetPlayerIdx].position); } @@ -2726,7 +2764,7 @@ static void informIfAdminChangedOtherColor(uint32_t targetPlayerIdx, uint32_t re sendQuickChat(WzQuickChatMessage::INTERNAL_ADMIN_ACTION_NOTICE, realSelectedPlayer, WzQuickChatTargeting::targetAll(), WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::constructMessageData(WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::Context::Color, responsibleIdx, targetPlayerIdx)); - std::string senderPublicKeyB64 = base64Encode(getMultiStats(responsibleIdx).identity.toBytes(EcKey::Public)); + std::string senderPublicKeyB64 = base64Encode(getOutputPlayerIdentity(responsibleIdx).toBytes(EcKey::Public)); debug(LOG_INFO, "Admin %s (%s) changed color of player ([%u] %s) to: [%d] %s", getPlayerName(responsibleIdx), senderPublicKeyB64.c_str(), NetPlay.players[targetPlayerIdx].position, getPlayerName(targetPlayerIdx), NetPlay.players[targetPlayerIdx].colour, getPlayerColourName(targetPlayerIdx)); } @@ -2806,7 +2844,7 @@ static void informIfAdminChangedOtherFaction(uint32_t targetPlayerIdx, uint32_t sendQuickChat(WzQuickChatMessage::INTERNAL_ADMIN_ACTION_NOTICE, realSelectedPlayer, WzQuickChatTargeting::targetAll(), WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::constructMessageData(WzQuickChatDataContexts::INTERNAL_ADMIN_ACTION_NOTICE::Context::Faction, responsibleIdx, targetPlayerIdx)); - std::string senderPublicKeyB64 = base64Encode(getMultiStats(responsibleIdx).identity.toBytes(EcKey::Public)); + std::string senderPublicKeyB64 = base64Encode(getOutputPlayerIdentity(responsibleIdx).toBytes(EcKey::Public)); debug(LOG_INFO, "Admin %s (%s) changed faction of player ([%u] %s) to: %s", getPlayerName(responsibleIdx), senderPublicKeyB64.c_str(), NetPlay.players[targetPlayerIdx].position, getPlayerName(targetPlayerIdx), to_localized_string(static_cast(NetPlay.players[targetPlayerIdx].faction))); } @@ -3127,7 +3165,7 @@ static bool recvPlayerSlotTypeRequestAndPop(WzMultiplayerOptionsTitleUI& titleUI ASSERT_HOST_ONLY(return false); - const char *pPlayerName = getPlayerName(playerIndex); + const char *pPlayerName = getPlayerName(playerIndex, true); std::string playerName = (pPlayerName) ? pPlayerName : (std::string("[p") + std::to_string(playerIndex) + "]"); if (desiredIsSpectator) @@ -3188,7 +3226,7 @@ static bool recvPlayerSlotTypeRequestAndPop(WzMultiplayerOptionsTitleUI& titleUI WZ_Notification notification; notification.duration = 0; std::string contentTitle = _("Spectator would like to become a Player"); - std::string contentText = astringf(_("Spectator \"%s\" would like to become a player."), playerName.c_str()); + std::string contentText = astringf(_("Spectator \"%s\" would like to become a player."), getPlayerName(playerIndex)); // NOTE: Do not pass true as second parameter, as this only gets shown on the host contentText += "\n"; contentText += _("However, there are currently no open Player slots."); contentText += "\n\n"; @@ -3369,6 +3407,14 @@ static SwapPlayerIndexesResult recvSwapPlayerIndexes(NETQUEUE queue, const std:: } } } + + if ((game.blindMode != BLIND_MODE::NONE) && (selectedPlayer < MAX_PLAYERS)) + { + std::string blindLobbyMessage = _("BLIND LOBBY NOTICE:"); + blindLobbyMessage += "\n"; + blindLobbyMessage += astringf(_("- You have been assigned the codename: \"%s\""), getPlayerGenericName(selectedPlayer)); + displayRoomNotifyMessage(blindLobbyMessage.c_str()); + } } else { @@ -4219,9 +4265,33 @@ void WzMultiplayerOptionsTitleUI::addPlayerBox(bool players) W_LABEL* pPlayersLabel = addSideText(FRONTEND_SIDETEXT2, MULTIOP_PLAYERSX - 3, MULTIOP_PLAYERSY, _("PLAYERS")); pPlayersLabel->hide(); // hide for now - if (players) + if (!players) { - auto titleUI = std::dynamic_pointer_cast(shared_from_this()); + return; + } + + auto titleUI = std::dynamic_pointer_cast(shared_from_this()); + + if (game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY && !NetPlay.isHost) + { + // Display custom "waiting room" that completely blinds the current player + + auto blindWaitingRoom = makeWzPlayerBlindWaitingRoom(titleUI); + blindWaitingRoom->id = MULTIOP_BLIND_WAITING_ROOM; + playersForm->attach(blindWaitingRoom); + blindWaitingRoom->setCalcLayout(LAMBDA_CALCLAYOUT_SIMPLE({ + auto psParent = psWidget->parent(); + ASSERT_OR_RETURN(, psParent != nullptr, "No parent?"); + int x0 = PLAYERBOX_X0 - 1; + int y0 = 1; + int width = MULTIOP_PLAYERSW - (PLAYERBOX_X0 * 2); + int height = psParent->height() - y0; + psWidget->setGeometry(x0, y0, width, height); + })); + } + else + { + // Normal case - display players / spectators lists // Add "players" / "spectators" tab buttons (if in multiplay mode) bool isMultiplayMode = NetPlay.bComms && (NetPlay.isHost || ingame.side == InGameSide::MULTIPLAYER_CLIENT); @@ -5366,6 +5436,17 @@ static bool loadMapChallengeSettings(WzConfig& ini) unsigned int openSpectatorSlots_uint = ini.value("openSpectatorSlots", 0).toUInt(); defaultOpenSpectatorSlots = static_cast(std::min(openSpectatorSlots_uint, MAX_SPECTATOR_SLOTS)); + // Allow setting "blind mode" + unsigned int blindMode_uint = ini.value("blindMode", 0).toUInt(); + if (blindMode_uint <= static_cast(BLIND_MODE_MAX)) + { + game.blindMode = static_cast(blindMode_uint); + } + else + { + debug(LOG_ERROR, "Invalid blindMode (%u) specified in config .json - ignoring", blindMode_uint); + } + // DEPRECATED: This seems to have been odd workaround for not having the locked group handled. // Keeping it around in case mods use it. locked.position = !ini.value("allowPositionChange", !locked.position).toBool(); @@ -5837,6 +5918,11 @@ bool WzMultiplayerOptionsTitleUI::startHost() } multiSyncResetAllChallenges(); + if (game.blindMode != BLIND_MODE::NONE) + { + generateBlindIdentity(); + } + const bool bIsAutoHostOrAutoGame = getHostLaunch() == HostLaunch::Skirmish || getHostLaunch() == HostLaunch::Autohost; if (!hostCampaign((char*)game.name, (char*)sPlayer, spectatorHost, bIsAutoHostOrAutoGame)) { @@ -5879,6 +5965,11 @@ bool WzMultiplayerOptionsTitleUI::startHost() disableMultiButs(); addPlayerBox(true); + if (game.blindMode != BLIND_MODE::NONE) + { + printBlindModeHelpMessagesToConsole(); + } + return true; } @@ -6264,7 +6355,7 @@ class WzHostLobbyOperationsInterface : public HostLobbyOperationsInterface return true; } ::changeTeam(player, team, responsibleIdx); - resetReadyStatus(false); + resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); return true; } virtual bool changePosition(uint32_t player, uint8_t position, uint32_t responsibleIdx) override @@ -6286,7 +6377,7 @@ class WzHostLobbyOperationsInterface : public HostLobbyOperationsInterface { return false; } - resetReadyStatus(false); + resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); return true; } virtual bool changeBase(uint8_t baseValue) override @@ -6347,9 +6438,9 @@ class WzHostLobbyOperationsInterface : public HostLobbyOperationsInterface return false; } std::string slotType = (NetPlay.players[player].isSpectator) ? "spectator" : "player"; - sendRoomSystemMessage((std::string("Kicking ")+slotType+": "+std::string(NetPlay.players[player].name)).c_str()); + sendRoomSystemMessage((std::string("Kicking ")+slotType+": "+std::string(getPlayerName(player, true))).c_str()); ::kickPlayer(player, reason, ERROR_KICKED, ban); - resetReadyStatus(false); + resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); return true; } virtual bool changeHostChatPermissions(uint32_t player, bool freeChatEnabled) override @@ -6386,8 +6477,9 @@ class WzHostLobbyOperationsInterface : public HostLobbyOperationsInterface if (wz_command_interface_enabled()) { - std::string playerPublicKeyB64 = base64Encode(getMultiStats(player).identity.toBytes(EcKey::Public)); - std::string playerIdentityHash = getMultiStats(player).identity.publicHashString(); + const auto& identity = getOutputPlayerIdentity(player); + std::string playerPublicKeyB64 = base64Encode(identity.toBytes(EcKey::Public)); + std::string playerIdentityHash = identity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[player]) ? "V" : "?"; std::string playerName = getPlayerName(player); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); @@ -6406,7 +6498,7 @@ class WzHostLobbyOperationsInterface : public HostLobbyOperationsInterface debug(LOG_INFO, "Unable to move player: %" PRIu32 " - not a connected human player", player); return false; } - const char *pPlayerName = getPlayerName(player); + const char *pPlayerName = getPlayerName(player, true); std::string playerNameStr = (pPlayerName) ? pPlayerName : (std::string("[p") + std::to_string(player) + "]"); if (!NETmovePlayerToSpectatorOnlySlot(player, true)) { @@ -6435,7 +6527,7 @@ class WzHostLobbyOperationsInterface : public HostLobbyOperationsInterface return false; } - const char *pPlayerName = getPlayerName(player); + const char *pPlayerName = getPlayerName(player, true); std::string playerNameStr = (pPlayerName) ? pPlayerName : (std::string("[p") + std::to_string(player) + "]"); // Ask the spectator if they are okay with a move from spectator -> player? SendPlayerSlotTypeRequest(player, false); @@ -6468,7 +6560,7 @@ class WzHostLobbyOperationsInterface : public HostLobbyOperationsInterface for (int i = 0; i < maxp; i++) { auto ps = getMultiStats(i); - pl.push_back({std::string(NetPlay.players[i].name), std::string(ps.autorating.elo), i}); + pl.push_back({std::string(getPlayerName(i, true)), std::string(ps.autorating.elo), i}); } std::sort(pl.begin(), pl.end(), [](struct es a, struct es b) { return a.elo.compare(b.elo) > 0; }); int teamsize = maxp/2; @@ -6752,7 +6844,7 @@ void WzMultiplayerOptionsTitleUI::frontendMultiMessages(bool running) { uint32_t player_id = MAX_CONNECTED_PLAYERS; - resetReadyStatus(false); + resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); NETbeginDecode(queue, NET_PLAYER_DROPPED); { @@ -6880,7 +6972,7 @@ void WzMultiplayerOptionsTitleUI::frontendMultiMessages(bool running) { char buf[250] = {'\0'}; - ssprintf(buf, "*Player %d (%s : %s) tried to kick %u", (int) queue.index, NetPlay.players[queue.index].name, NetPlay.players[queue.index].IPtextAddress, player_id); + ssprintf(buf, "*Player %d (%s : %s) tried to kick %u", (int) queue.index, getPlayerName(queue.index, true), NetPlay.players[queue.index].IPtextAddress, player_id); NETlogEntry(buf, SYNC_FLAG, 0); debug(LOG_ERROR, "%s", buf); if (NetPlay.isHost) @@ -7330,6 +7422,45 @@ static void printHostHelpMessagesToConsole() } } +void printBlindModeHelpMessagesToConsole() +{ + if (game.blindMode == BLIND_MODE::NONE) + { + return; + } + + WzString blindLobbyMessage; + if (game.blindMode >= BLIND_MODE::BLIND_GAME) + { + blindLobbyMessage = _("BLIND GAME NOTICE:"); + blindLobbyMessage += "\n- "; + blindLobbyMessage += _("Player names will not be visible to other players (until the game is over)"); + } + else + { + blindLobbyMessage = _("BLIND LOBBY NOTICE:"); + blindLobbyMessage += "\n- "; + blindLobbyMessage += _("Player names will not be visible to other players (until the game has started)"); + } + if (NetPlay.hostPlayer < MAX_PLAYERS) + { + blindLobbyMessage += "\n- "; + blindLobbyMessage += _("IMPORTANT: The host is a player, and can see and configure other players (!)"); + } + else + { + blindLobbyMessage += "\n- "; + blindLobbyMessage += _("The host is a spectator, and can see player identities and configure players / teams"); + } + displayRoomNotifyMessage(blindLobbyMessage.toUtf8().c_str()); + if (selectedPlayer < MAX_PLAYERS) + { + std::string blindLobbyCodenameMessage = "- "; + blindLobbyCodenameMessage += astringf(_("You have been assigned the codename: \"%s\""), getPlayerGenericName(selectedPlayer)); + displayRoomNotifyMessage(blindLobbyCodenameMessage.c_str()); + } +} + void calcBackdropLayoutForMultiplayerOptionsTitleUI(WIDGET *psWidget) { auto height = screenHeight - 80; @@ -7455,6 +7586,10 @@ void WzMultiplayerOptionsTitleUI::start() { printHostHelpMessagesToConsole(); // Print the challenge text the first time } + if (!bReenter && game.blindMode != BLIND_MODE::NONE && ingame.side == InGameSide::MULTIPLAYER_CLIENT) + { + printBlindModeHelpMessagesToConsole(); + } /* Reset structure limits if we are entering the first time or if we have a challenge */ if (!bReenter || challengeActive) @@ -7779,7 +7914,7 @@ void WzMultiButton::display(int xOffset, int yOffset) ///////////////////////////////////////////////////////////////////////////////////////// // common widgets -static W_EDITBOX* addMultiEditBox(UDWORD formid, UDWORD id, UDWORD x, UDWORD y, char const *tip, char const *tipres, UDWORD icon, UDWORD iconhi, UDWORD iconid) +static W_EDITBOX* addMultiEditBox(UDWORD formid, UDWORD id, UDWORD x, UDWORD y, char const *tip, char const *tipres, UDWORD icon, UDWORD iconhi, UDWORD iconid, bool disabled) { W_EDBINIT sEdInit; // editbox sEdInit.formID = formid; @@ -7796,7 +7931,14 @@ static W_EDITBOX* addMultiEditBox(UDWORD formid, UDWORD id, UDWORD x, UDWORD y, return nullptr; } - addMultiBut(psWScreen, MULTIOP_OPTIONS, iconid, x + MULTIOP_EDITBOXW + 2, y + 2, MULTIOP_EDITBOXH, MULTIOP_EDITBOXH, tip, icon, iconhi, iconhi); + auto multiBut = addMultiBut(psWScreen, MULTIOP_OPTIONS, iconid, x + MULTIOP_EDITBOXW + 2, y + 2, MULTIOP_EDITBOXH, MULTIOP_EDITBOXH, tip, icon, iconhi, iconhi); + + if (disabled) + { + editBox->setState(WEDBS_DISABLE); + multiBut->setState(WBUT_DISABLE); + } + return editBox; } @@ -8007,6 +8149,7 @@ inline void to_json(nlohmann::json& j, const MULTIPLAYERGAME& p) { j["inactivityMinutes"] = p.inactivityMinutes; j["gameTimeLimitMinutes"] = p.gameTimeLimitMinutes; j["playerLeaveMode"] = p.playerLeaveMode; + j["blindMode"] = p.blindMode; } inline void from_json(const nlohmann::json& j, MULTIPLAYERGAME& p) { @@ -8053,6 +8196,15 @@ inline void from_json(const nlohmann::json& j, MULTIPLAYERGAME& p) { // default to the old (pre-4.4.0) behavior of destroy resources p.playerLeaveMode = PLAYER_LEAVE_MODE::DESTROY_RESOURCES; } + if (j.contains("blindMode")) + { + p.blindMode = j.at("blindMode").get(); + } + else + { + // default to the old (pre-4.6.0) behavior (no blind mode) + p.blindMode = BLIND_MODE::NONE; + } } inline void to_json(nlohmann::json& j, const MULTISTRUCTLIMITS& p) { @@ -8251,6 +8403,32 @@ bool WZGameReplayOptionsHandler::saveOptions(nlohmann::json& object) const bool WZGameReplayOptionsHandler::optionsUpdatePlayerInfo(nlohmann::json& object) const { + // update player names + auto& netPlayers = object.at("netplay.players"); + if (!netPlayers.is_array()) + { + debug(LOG_ERROR, "Invalid netplay.players (not an array)"); + return false; + } + if (netPlayers.size() > NetPlay.players.size()) + { + debug(LOG_ERROR, "Unsupported netplay.players size (%zu)", netPlayers.size()); + return false; + } + for (size_t i = 0; i < netPlayers.size(); ++i) + { + PLAYER p = netPlayers.at(i).get(); + sstrcpy(p.name, NetPlay.players[i].name); + netPlayers[i] = p; + } + + // update identities stored in multistats + if (game.blindMode != BLIND_MODE::NONE) + { + auto& multistats = object.at("multistats"); + updateMultiStatsIdentitiesInJSON(multistats, true); + } + return true; } diff --git a/src/multiint.h b/src/multiint.h index 5fefbc727bf..57fc47c0e9d 100644 --- a/src/multiint.h +++ b/src/multiint.h @@ -102,6 +102,8 @@ bool isHostOrAdmin(); bool isPlayerHostOrAdmin(uint32_t playerIdx); bool isSpectatorOnlySlot(UDWORD playerIdx); +void printBlindModeHelpMessagesToConsole(); + /** * Checks if all players are on the same team. If so, return that team; if not, return -1; * if there are no players, return team MAX_PLAYERS. @@ -221,6 +223,7 @@ bool autoBalancePlayersCmd(); #define MULTIOP_PLAYERS_TABS 10232 #define MULTIOP_PLAYERS_TABS_H 24 #define MULTIOP_PLAYERSH (384 + MULTIOP_PLAYERS_TABS_H + 1) +#define MULTIOP_BLIND_WAITING_ROOM 10233 #define MULTIOP_ROW_WIDTH 298 diff --git a/src/multijoin.cpp b/src/multijoin.cpp index 2b585544a12..894dcb409bb 100644 --- a/src/multijoin.cpp +++ b/src/multijoin.cpp @@ -417,6 +417,11 @@ static void addConsolePlayerJoinMessage(unsigned playerIndex) if (selectedPlayer != playerIndex) { std::string msg = astringf(_("%s joined the Game"), getPlayerName(playerIndex)); + if ((game.blindMode != BLIND_MODE::NONE) && NetPlay.isHost && (NetPlay.hostPlayer >= MAX_PLAYER_SLOTS)) + { + msg += " "; + msg += astringf(_("(codename: %s)"), getPlayerGenericName(playerIndex)); + } addConsoleMessage(msg.c_str(), DEFAULT_JUSTIFY, SYSTEM_MESSAGE); } } @@ -440,7 +445,7 @@ void recvPlayerLeft(NETQUEUE queue) turnOffMultiMsg(false); if (!ingame.TimeEveryoneIsInGame.has_value()) // If game hasn't actually started { - setMultiStats(playerIndex, PLAYERSTATS(), true); // local only + clearPlayerMultiStats(playerIndex); // local only } NetPlay.players[playerIndex].allocated = false; @@ -476,8 +481,9 @@ bool MultiPlayerLeave(UDWORD playerIndex) if (wz_command_interface_enabled()) { - std::string playerPublicKeyB64 = base64Encode(getMultiStats(playerIndex).identity.toBytes(EcKey::Public)); - std::string playerIdentityHash = getMultiStats(playerIndex).identity.publicHashString(); + const auto& identity = getOutputPlayerIdentity(playerIndex); + std::string playerPublicKeyB64 = base64Encode(identity.toBytes(EcKey::Public)); + std::string playerIdentityHash = identity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[playerIndex]) ? "V" : "?"; std::string playerName = getPlayerName(playerIndex); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); @@ -488,7 +494,7 @@ bool MultiPlayerLeave(UDWORD playerIndex) { addConsolePlayerLeftMessage(playerIndex); clearPlayer(playerIndex, false); - setMultiStats(playerIndex, PLAYERSTATS(), true); // local only + clearPlayerMultiStats(playerIndex); // local only NetPlay.players[playerIndex].difficulty = AIDifficulty::DISABLED; } else if (NetPlay.isHost) // If hosting, and game has started (not in pre-game lobby screen, that is). @@ -674,6 +680,7 @@ bool recvDataCheck(NETQUEUE queue) // Setup Stuff for a new player. void setupNewPlayer(UDWORD player) { + ASSERT_HOST_ONLY(return); ASSERT_OR_RETURN(, player < MAX_CONNECTED_PLAYERS, "Invalid player: %" PRIu32 "", player); ingame.PingTimes[player] = 0; // Reset ping time diff --git a/src/multilobbycommands.cpp b/src/multilobbycommands.cpp index 34ed59e297f..380b9158c83 100644 --- a/src/multilobbycommands.cpp +++ b/src/multilobbycommands.cpp @@ -65,9 +65,8 @@ bool identityMatchesAdmin(const EcKey& identity) return lobbyAdminPublicKeys.count(senderPublicKeyB64) != 0 || lobbyAdminPublicHashStrings.count(senderIdentityHash) != 0; } -// NOTE: **IMPORTANT** this should *NOT* be used for determining whether a sender has permission to execute admin commands -// (Use senderHasLobbyCommandAdminPrivs instead) -static bool senderApparentlyMatchesAdmin(uint32_t playerIdx) +// **THIS** is the function that should be used to determine whether a sender currently has permission to execute admin commands +static bool senderHasLobbyCommandAdminPrivs(uint32_t playerIdx, bool quiet = false) { if (playerIdx >= MAX_CONNECTED_PLAYERS) { @@ -75,50 +74,40 @@ static bool senderApparentlyMatchesAdmin(uint32_t playerIdx) } if (playerIdx == NetPlay.hostPlayer && NetPlay.isHost) { - // the host is always an admin + // the host always has permissions return true; } - auto& identity = getMultiStats(playerIdx).identity; - if (identity.empty()) - { - return false; - } - return identityMatchesAdmin(identity); -} -// **THIS** is the function that should be used to determine whether a sender currently has permission to execute admin commands -static bool senderHasLobbyCommandAdminPrivs(uint32_t playerIdx) -{ - if (playerIdx >= MAX_CONNECTED_PLAYERS) + auto trueIdentity = getTruePlayerIdentity(playerIdx); + if (trueIdentity.identity.empty()) { return false; } - if (playerIdx == NetPlay.hostPlayer && NetPlay.isHost) - { - // the host always has permissions - return true; - } - if (!senderApparentlyMatchesAdmin(playerIdx)) + if (!identityMatchesAdmin(trueIdentity.identity)) { - // identity hash is not in permitted list + // identity is not in permitted list return false; } + // Verify the player's identity has been verified - if (!ingame.VerifiedIdentity[playerIdx]) + if (!trueIdentity.verified) { // While this player claims to have an identity that matches an admin, // they have not yet verified it by responding to a NET_PING with a valid signature - auto& identity = getMultiStats(playerIdx).identity; - std::string senderIdentityHash = identity.publicHashString(); - std::string senderPublicKeyB64 = base64Encode(identity.toBytes(EcKey::Public)); - sendRoomSystemMessageToSingleReceiver("Waiting for sync (admin privileges not yet enabled)", playerIdx, true); - if (lobbyAdminPublicKeys.count(senderPublicKeyB64) > 0) + if (!quiet) { - debug(LOG_INFO, "Received an admin check for player %" PRIu32 " that passed (public key: %s), but they have not yet verified their identity", playerIdx, senderPublicKeyB64.c_str()); - } - else - { - debug(LOG_INFO, "Received an admin check for player %" PRIu32 " that passed (public identity: %s), but they have not yet verified their identity", playerIdx, senderIdentityHash.c_str()); + auto& identity = getOutputPlayerIdentity(playerIdx); + std::string senderIdentityHash = identity.publicHashString(); + std::string senderPublicKeyB64 = base64Encode(identity.toBytes(EcKey::Public)); + sendRoomSystemMessageToSingleReceiver("Waiting for sync (admin privileges not yet enabled)", playerIdx, true); + if (lobbyAdminPublicKeys.count(senderPublicKeyB64) > 0) + { + debug(LOG_INFO, "Received an admin check for player %" PRIu32 " that passed (public key: %s), but they have not yet verified their identity", playerIdx, senderPublicKeyB64.c_str()); + } + else + { + debug(LOG_INFO, "Received an admin check for player %" PRIu32 " that passed (public identity: %s), but they have not yet verified their identity", playerIdx, senderIdentityHash.c_str()); + } } return false; } @@ -131,7 +120,7 @@ static void lobbyCommand_PrintHelp(uint32_t receiver) sendRoomSystemMessageToSingleReceiver(LOBBY_COMMAND_PREFIX "help - Get this message", receiver, true); sendRoomSystemMessageToSingleReceiver(LOBBY_COMMAND_PREFIX "admin - Display currently-connected admin players", receiver, true); sendRoomSystemMessageToSingleReceiver(LOBBY_COMMAND_PREFIX "me - Display your information", receiver, true); - if (!senderApparentlyMatchesAdmin(receiver)) + if (!senderHasLobbyCommandAdminPrivs(receiver, true)) { sendRoomSystemMessageToSingleReceiver("(Additional commands are available for admins)", receiver, true); return; @@ -153,7 +142,7 @@ static std::unordered_set getConnectedAdminPlayerIndexes() std::unordered_set adminPlayerIndexes; for (size_t playerIdx = 0; playerIdx < std::min(MAX_CONNECTED_PLAYERS, NetPlay.players.size()); ++playerIdx) { - if (senderApparentlyMatchesAdmin(playerIdx)) + if (senderHasLobbyCommandAdminPrivs(playerIdx, true)) { adminPlayerIndexes.insert(playerIdx); } @@ -185,7 +174,7 @@ static void lobbyCommand_Admin() } msg += " ["; msg += std::to_string(adminPlayerIdx) + "] "; - msg += NetPlay.players[adminPlayerIdx].name; + msg += getPlayerName(adminPlayerIdx, true); ++currNum; } sendRoomSystemMessage(msg.c_str()); @@ -215,11 +204,11 @@ void cmdInterfaceLogChatMsg(const NetworkTextMessage& message, const char* log_p ASSERT_OR_RETURN(, message.sender < MAX_CONNECTED_PLAYERS, "Invalid message.sender (%d)", message.sender); - const auto& identity = getMultiStats(message.sender).identity; + const auto& identity = getOutputPlayerIdentity(message.sender); std::string senderhash = _senderhash.value_or(identity.publicHashString(64)); std::string senderPublicKeyB64 = _senderPublicKeyB64.value_or(base64Encode(identity.toBytes(EcKey::Public))); std::string senderVerifiedStatus = (ingame.VerifiedIdentity[message.sender]) ? "V" : "?"; - std::string sendername = NetPlay.players[message.sender].name; + std::string sendername = getPlayerName(message.sender); std::string sendername64 = base64Encode(std::vector(sendername.begin(), sendername.end())); std::string messagetext = message.text; std::string messagetext64 = base64Encode(std::vector(messagetext.begin(), messagetext.end())); @@ -262,7 +251,7 @@ bool processChatLobbySlashCommands(const NetworkTextMessage& message, HostLobbyO } return a; }; - const auto& identity = getMultiStats(message.sender).identity; + const auto& identity = getOutputPlayerIdentity(message.sender); std::string senderhash = identity.publicHashString(64); std::string senderPublicKeyB64 = base64Encode(identity.toBytes(EcKey::Public)); debug(LOG_INFO, "message [%s] [%s]", senderhash.c_str(), message.text); @@ -282,7 +271,7 @@ bool processChatLobbySlashCommands(const NetworkTextMessage& message, HostLobbyO senderhash.c_str(), message.sender, NetPlay.players[message.sender].position, - NetPlay.players[message.sender].name); + getPlayerName(message.sender, true)); sendRoomSystemMessageToSingleReceiver(msg.c_str(), message.sender, true); } else if (strncmp(&message.text[startingCommandPosition], "team ", 5) == 0) diff --git a/src/multiplay.cpp b/src/multiplay.cpp index fd5dd75b72b..24f5cb6d810 100644 --- a/src/multiplay.cpp +++ b/src/multiplay.cpp @@ -674,13 +674,33 @@ BASE_OBJECT *IdToPointer(UDWORD id, UDWORD player) return nullptr; } +static inline bool isBlindPlayerInfoState() +{ + if (game.blindMode == BLIND_MODE::NONE) + { + return false; + } + + // If blind lobby (only) and game hasn't started yet + if (game.blindMode < BLIND_MODE::BLIND_GAME && !ingame.TimeEveryoneIsInGame.has_value()) + { + return true; + } + + // If blind game and game hasn't _ended_ yet + if (game.blindMode >= BLIND_MODE::BLIND_GAME && !ingame.endTime.has_value()) + { + return true; + } + + return false; +} + // //////////////////////////////////////////////////////////////////////////// // return a players name. -const char *getPlayerName(int player) +const char *getPlayerName(uint32_t player, bool treatAsNonHost) { - ASSERT_OR_RETURN(nullptr, player >= 0, "Wrong player index: %d", player); - const bool aiPlayer = (static_cast(player) < NetPlay.players.size()) && (NetPlay.players[player].ai >= 0) && !NetPlay.players[player].allocated; if (aiPlayer && GetGameMode() == GS_NORMAL && !challengeActive) @@ -700,9 +720,59 @@ const char *getPlayerName(int player) return _("Commander"); } + if (isBlindPlayerInfoState()) + { + if ((!NetPlay.isHost || NetPlay.hostPlayer < MAX_PLAYER_SLOTS || treatAsNonHost) && !NETisReplay()) + { + // Get stable "generic" names (unless it's a spectator host) + if (player != NetPlay.hostPlayer || NetPlay.hostPlayer < MAX_PLAYER_SLOTS) + { + return getPlayerGenericName(player); + } + } + } + return NetPlay.players[player].name; } +// return a "generic" player name that is fixed based on the player idx (useful for blind mode games) +const char *getPlayerGenericName(int player) +{ + // genericNames are *not* localized - we want the same display across all systems (just like player-set names) + static const char *genericNames[] = + { + "Alpha", + "Beta", + "Gamma", + "Delta", + "Epsilon", + "Zeta", + "Omega", + "Theta", + "Iota", + "Kappa", + "Lambda", + "Omicron", + "Pi", + "Rho", + "Sigma", + "Tau" + }; + STATIC_ASSERT(MAX_PLAYERS <= ARRAY_SIZE(genericNames)); + ASSERT(player < ARRAY_SIZE(genericNames), "player number (%d) exceeds maximum (%lu)", player, (unsigned long) ARRAY_SIZE(genericNames)); + + if (player >= ARRAY_SIZE(genericNames)) + { + return (player < MAX_PLAYERS) ? "Player" : "Spectator"; + } + + if (player >= MAX_PLAYER_SLOTS) + { + return "Spectator"; + } + + return genericNames[player]; +} bool setPlayerName(int player, const char *sName) { ASSERT_OR_RETURN(false, player < MAX_CONNECTED_PLAYERS && player >= 0, "Player index (%u) out of range", player); @@ -1263,8 +1333,8 @@ bool shouldProcessMessage(NETQUEUE& queue, uint8_t type) // kick sender for sending unauthorized message char buf[255]; auto senderPlayerIdx = queue.index; - debug(LOG_INFO, "Auto kicking player %s, invalid command received: %s", NetPlay.players[senderPlayerIdx].name, messageTypeToString(type)); - ssprintf(buf, _("Auto kicking player %s, invalid command received: %u"), NetPlay.players[senderPlayerIdx].name, type); + debug(LOG_INFO, "Auto kicking player %s, invalid command received: %s", getPlayerName(senderPlayerIdx), messageTypeToString(type)); + ssprintf(buf, _("Auto kicking player %s, invalid command received: %u"), getPlayerName(senderPlayerIdx, true), type); sendInGameSystemMessage(buf); kickPlayer(queue.index, _("Unauthorized network command"), ERROR_INVALID, false); } @@ -1450,13 +1520,17 @@ bool recvMessage() // This player is now with us! if (ingame.JoiningInProgress[player_id]) { - addKnownPlayer(NetPlay.players[player_id].name, getMultiStats(player_id).identity); + if (game.blindMode == BLIND_MODE::NONE) + { + addKnownPlayer(NetPlay.players[player_id].name, getMultiStats(player_id).identity); + } ingame.JoiningInProgress[player_id] = false; if (wz_command_interface_enabled()) { - std::string playerPublicKeyB64 = base64Encode(getMultiStats(player_id).identity.toBytes(EcKey::Public)); - std::string playerIdentityHash = getMultiStats(player_id).identity.publicHashString(); + const auto& identity = getOutputPlayerIdentity(player_id); + std::string playerPublicKeyB64 = base64Encode(identity.toBytes(EcKey::Public)); + std::string playerIdentityHash = identity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[player_id]) ? "V" : "?"; std::string playerName = getPlayerName(player_id); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); @@ -1498,7 +1572,7 @@ bool recvMessage() { char buf[250] = {'\0'}; - ssprintf(buf, "Player %d (%s : %s) tried to kick %u", (int) queue.index, NetPlay.players[queue.index].name, NetPlay.players[queue.index].IPtextAddress, player_id); + ssprintf(buf, "Player %d (%s : %s) tried to kick %u", (int) queue.index, getPlayerName(queue.index, true), NetPlay.players[queue.index].IPtextAddress, player_id); NETlogEntry(buf, SYNC_FLAG, 0); debug(LOG_ERROR, "%s", buf); if (NetPlay.isHost) @@ -1574,7 +1648,7 @@ void HandleBadParam(const char *msg, const int from, const int actual) { if (NETplayerHasConnection(actual)) { - ssprintf(buf, _("Auto kicking player %s, invalid command received."), NetPlay.players[actual].name); + ssprintf(buf, _("Auto kicking player %s, invalid command received."), getPlayerName(actual, true)); sendInGameSystemMessage(buf); } kickPlayer(actual, buf, KICK_TYPE, false); @@ -1848,9 +1922,14 @@ void setPlayerMuted(uint32_t playerIdx, bool muted) return; } ingame.muteChat[playerIdx] = muted; - if (isHumanPlayer(playerIdx)) + if (isHumanPlayer(playerIdx) && game.blindMode != BLIND_MODE::NONE) { - storePlayerMuteOption(NetPlay.players[playerIdx].name, getMultiStats(playerIdx).identity, muted); + auto trueIdentity = getTruePlayerIdentity(playerIdx); + if (!trueIdentity.identity.empty() + && (NetPlay.isHost || game.blindMode == BLIND_MODE::NONE || (game.blindMode < BLIND_MODE::BLIND_GAME && ingame.TimeEveryoneIsInGame.has_value()))) + { + storePlayerMuteOption(NetPlay.players[playerIdx].name, trueIdentity.identity, muted); + } } } @@ -1929,6 +2008,10 @@ void sendInGameSystemMessage(const char *text) void printConsoleNameChange(const char *oldName, const char *newName) { + if (game.blindMode != BLIND_MODE::NONE) + { + return; + } char msg[MAX_CONSOLE_STRING_LENGTH]; ssprintf(msg, "%s → %s", oldName, newName); displayRoomSystemMessage(msg); diff --git a/src/multiplay.h b/src/multiplay.h index 53e93564f50..dcb85fc0f25 100644 --- a/src/multiplay.h +++ b/src/multiplay.h @@ -78,6 +78,7 @@ struct MULTIPLAYERGAME uint32_t inactivityMinutes; // The number of minutes without active play before a player should be considered "inactive". (0 = disable activity alerts) uint32_t gameTimeLimitMinutes; // The number of minutes before the game automatically ends (0 = disable time limit) PLAYER_LEAVE_MODE playerLeaveMode; // The behavior used for when players leave a game + BLIND_MODE blindMode = BLIND_MODE::NONE; // NOTE: If adding to this struct, a lot of things probably require changing // (send/recvOptions? loadMainFile/writeMainFile? to/from_json in multiint.h.cpp?) @@ -222,7 +223,7 @@ WZ_DECL_WARN_UNUSED_RESULT DROID *IdToMissionDroid(UDWORD id, UDWORD player); WZ_DECL_WARN_UNUSED_RESULT FEATURE *IdToFeature(UDWORD id, UDWORD player); WZ_DECL_WARN_UNUSED_RESULT DROID_TEMPLATE *IdToTemplate(UDWORD tempId, UDWORD player); -const char *getPlayerName(int player); +const char *getPlayerName(uint32_t player, bool treatAsNonHost = false); bool setPlayerName(int player, const char *sName); void clearPlayerName(unsigned int player); const char *getPlayerColourName(int player); @@ -237,6 +238,8 @@ Vector3i cameraToHome(UDWORD player, bool scroll, bool fromSave); bool multiPlayerLoop(); // for loop.c +// return a "generic" player name that is fixed based on the player idx (useful for blind mode games) +const char *getPlayerGenericName(int player); enum class HandleMessageAction { diff --git a/src/multiplaydefs.h b/src/multiplaydefs.h index 2cc778b8dbe..4d9f7265d91 100644 --- a/src/multiplaydefs.h +++ b/src/multiplaydefs.h @@ -34,4 +34,13 @@ constexpr PLAYER_LEAVE_MODE PLAYER_LEAVE_MODE_MAX = PLAYER_LEAVE_MODE::SPLIT_WIT constexpr PLAYER_LEAVE_MODE PLAYER_LEAVE_MODE_DEFAULT = PLAYER_LEAVE_MODE::SPLIT_WITH_TEAM; +enum class BLIND_MODE : uint8_t +{ + NONE, + BLIND_GAME, // standard blind mode game (players' true identities are hidden from everyone except a spectator host until the game is over) + BLIND_GAME_SIMPLE_LOBBY // BLIND_GAME, but not showing any other players in lobby (players will just be informed that they are waiting for other players) +}; + +constexpr BLIND_MODE BLIND_MODE_MAX = BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY; + #endif // __INCLUDED_SRC_MULTIPLAYDEFS_H__ diff --git a/src/multistat.cpp b/src/multistat.cpp index 25263aca833..a693abf3f4f 100644 --- a/src/multistat.cpp +++ b/src/multistat.cpp @@ -52,6 +52,11 @@ // //////////////////////////////////////////////////////////////////////////// static PLAYERSTATS playerStats[MAX_CONNECTED_PLAYERS]; +static PLAYERSTATS zeroStats; +static EcKey blindIdentity; // a freshly-generated identity used for the local client in the current blind room + +static EcKey hostVerifiedJoinIdentities[MAX_CONNECTED_PLAYERS]; + // //////////////////////////////////////////////////////////////////////////// // Get Player's stats @@ -60,8 +65,83 @@ PLAYERSTATS const &getMultiStats(UDWORD player) return playerStats[player]; } +bool generateBlindIdentity() +{ + blindIdentity = EcKey::generate(); // Generate new identity + ASSERT(!blindIdentity.empty(), "Failed to generate new blind identity"); + return !blindIdentity.empty(); +} + +const EcKey& getLocalSharedIdentity() +{ + if (NetPlay.isHost && realSelectedPlayer >= MAX_PLAYER_SLOTS) + { + // If spectator host, send real identity + return playerStats[realSelectedPlayer].identity; + } + + if (game.blindMode != BLIND_MODE::NONE) + { + // In blind mode, share the blind identity + return blindIdentity; + } + else + { + // In regular mode, share the actual identity + return playerStats[realSelectedPlayer].identity; + } +} + +const EcKey& getVerifiedJoinIdentity(UDWORD player) +{ + ASSERT(player < MAX_CONNECTED_PLAYERS, "Invalid player: %u", player); + if (NetPlay.isHost) + { + return hostVerifiedJoinIdentities[player]; + } + else + { + if (game.blindMode == BLIND_MODE::NONE && ingame.VerifiedIdentity[player]) + { + return playerStats[player].identity; + } + else + { + return hostVerifiedJoinIdentities[player]; + } + } +} + +// In blind games, it returns the verified join identity (if executed on the host, or on all clients after the game has ended) +// In regular games, it returns the current player identity +TrueIdentity getTruePlayerIdentity(UDWORD player) +{ + if (game.blindMode != BLIND_MODE::NONE) + { + // In blind games, always output the join identity (since the internal shared identity is a random one) + return {hostVerifiedJoinIdentities[player], (!hostVerifiedJoinIdentities[player].empty()) ? true : false}; + } + else + { + // Otherwise, always return the *current* player identity (which may differ from the identity used to join the game if the player switched to a different profile) + return {playerStats[player].identity, ingame.VerifiedIdentity[player]}; + } +} + +// Should be used when a player identity is to be output (in logs, or otherwise accessible to the user) +const EcKey& getOutputPlayerIdentity(UDWORD player) +{ + return getTruePlayerIdentity(player).identity; +} + static void NETauto(PLAYERSTATS::Autorating &ar) { + if (game.blindMode != BLIND_MODE::NONE) + { + bool tmp = false; // no valid autorating in blind mode + NETauto(tmp); // ar.valid + return; + } NETauto(ar.valid); if (ar.valid) { @@ -119,8 +199,14 @@ void lookupRatingAsync(uint32_t playerIndex) return; } - auto hash = playerStats[playerIndex].identity.publicHashString(); - auto key = playerStats[playerIndex].identity.publicKeyHexString(); + if (!NetPlay.isHost && game.blindMode != BLIND_MODE::NONE) + { + return; + } + + auto trueIdentity = getTruePlayerIdentity(playerIndex); + auto hash = trueIdentity.identity.publicHashString(); + auto key = trueIdentity.identity.publicKeyHexString(); if (hash.empty() || key.empty()) { return; @@ -198,7 +284,7 @@ static bool generateSessionKeysWithPlayer(uint32_t playerIndex) } // generate session keys - auto& localIdentity = playerStats[realSelectedPlayer].identity; + auto& localIdentity = getLocalSharedIdentity(); try { NETsetSessionKeys(playerIndex, SessionKeys(localIdentity, realSelectedPlayer, playerStats[playerIndex].identity, playerIndex)); } @@ -216,6 +302,7 @@ bool swapPlayerMultiStatsLocal(uint32_t playerIndexA, uint32_t playerIndexB) return false; } std::swap(playerStats[playerIndexA], playerStats[playerIndexB]); + std::swap(hostVerifiedJoinIdentities[playerIndexA], hostVerifiedJoinIdentities[playerIndexB]); // NOTE: We can't just swap session keys - we have to re-generate to be sure they are correct // (since client / server determinism can also be based on the playerIdx relative to the realSelectedPlayer - see SessionKeys constructor) @@ -242,6 +329,8 @@ bool swapPlayerMultiStatsLocal(uint32_t playerIndexA, uint32_t playerIndexB) bool sendMultiStats(uint32_t playerIndex, optional recipientPlayerIndex /*= nullopt*/) { + ASSERT(NetPlay.isHost || playerIndex == realSelectedPlayer, "Hah"); + NETQUEUE queue; if (!recipientPlayerIndex.has_value()) { @@ -258,26 +347,66 @@ bool sendMultiStats(uint32_t playerIndex, optional recipientPlayerInde NETauto(playerStats[playerIndex].autorating); - // Send over the actual stats - NETuint32_t(&playerStats[playerIndex].played); - NETuint32_t(&playerStats[playerIndex].wins); - NETuint32_t(&playerStats[playerIndex].losses); - NETuint32_t(&playerStats[playerIndex].totalKills); - NETuint32_t(&playerStats[playerIndex].totalScore); - NETuint32_t(&playerStats[playerIndex].recentKills); - NETuint32_t(&playerStats[playerIndex].recentScore); + PLAYERSTATS* pStatsToSend = &playerStats[playerIndex]; + if (game.blindMode != BLIND_MODE::NONE) + { + // In blind mode, always send zeroed stats + pStatsToSend = &zeroStats; + } + + NETuint32_t(&pStatsToSend->played); + NETuint32_t(&pStatsToSend->wins); + NETuint32_t(&pStatsToSend->losses); + NETuint32_t(&pStatsToSend->totalKills); + NETuint32_t(&pStatsToSend->totalScore); - EcKey::Key identity; - if (!playerStats[playerIndex].identity.empty()) + EcKey::Key identityPublicKey; + bool isHostVerifiedIdentity = false; + // Choose the identity to send + if (!ingame.endTime.has_value() || !NetPlay.isHost) + { + if (playerIndex == realSelectedPlayer) + { + // Local client sending its own identity + const auto& identity = getLocalSharedIdentity(); + if (!identity.empty()) + { + identityPublicKey = identity.toBytes(EcKey::Public); + } + } + else + { + // Host sending other player details - relay client provided details + if (!playerStats[playerIndex].identity.empty()) + { + identityPublicKey = playerStats[playerIndex].identity.toBytes(EcKey::Public); + } + } + } + else { - identity = playerStats[playerIndex].identity.toBytes(EcKey::Public); + // Once game has ended, if we're the host, send the hostVerifiedJoinIdentity + isHostVerifiedIdentity = true; + if (!hostVerifiedJoinIdentities[playerIndex].empty()) + { + identityPublicKey = hostVerifiedJoinIdentities[playerIndex].toBytes(EcKey::Public); + } } - NETbytes(&identity); + + NETbool(&isHostVerifiedIdentity); + NETbytes(&identityPublicKey); NETend(); return true; } +bool sendMultiStatsHostVerifiedIdentities(uint32_t playerIndex) +{ + ASSERT_HOST_ONLY(return false); + ASSERT_OR_RETURN(false, ingame.endTime.has_value(), "Game hasn't ended yet"); + return sendMultiStats(playerIndex); // rely on sendMultiStats behavior when game has ended and this is the host to send the host-verified join identity +} + // //////////////////////////////////////////////////////////////////////////// // Set Player's stats // send stats to all players when bLocal is false @@ -299,7 +428,7 @@ bool setMultiStats(uint32_t playerIndex, PLAYERSTATS plStats, bool bLocal) { // need to clear and re-generate any session keys for communication between us and other players NETclearSessionKeys(); - auto& localIdentity = playerStats[realSelectedPlayer].identity; + auto& localIdentity = getLocalSharedIdentity(); if (localIdentity.hasPrivate()) { for (uint8_t i = 0; i < MAX_CONNECTED_PLAYERS; ++i) @@ -332,8 +461,23 @@ bool setMultiStats(uint32_t playerIndex, PLAYERSTATS plStats, bool bLocal) return true; } +bool clearPlayerMultiStats(uint32_t playerIndex) +{ + setMultiStats(playerIndex, PLAYERSTATS(), true); // local only + if (NetPlay.isHost) + { + hostVerifiedJoinIdentities[playerIndex].clear(); + } + return true; +} + bool sendMultiStatsScoreUpdates(uint32_t playerIndex) { + if (game.blindMode != BLIND_MODE::NONE) + { + // No-op if in blind mode + return false; + } if (NetPlay.isHost || playerIndex == realSelectedPlayer) { return sendMultiStats(playerIndex); @@ -384,6 +528,9 @@ bool multiStatsSetIdentity(uint32_t playerIndex, const EcKey::Key &identity, boo // Do not broadcast player info here, as it's assumed caller will do it } + // Store the verified join identity + hostVerifiedJoinIdentities[playerIndex].fromBytes(identity, EcKey::Public); + // Do *not* output to stdinterface here - the join event is still being processed } else @@ -450,7 +597,10 @@ bool multiStatsSetIdentity(uint32_t playerIndex, const EcKey::Key &identity, boo // Changing an identity should not happen once a game starts if ((identity != prevIdentity) || identity.empty()) { - ASSERT(false, "Cannot change identity for player %u after game has started", playerIndex); + if (!ingame.endTime.has_value()) + { + ASSERT(false, "Cannot change identity for player %u after game has started", playerIndex); + } } } @@ -493,17 +643,31 @@ bool recvMultiStats(NETQUEUE queue) NETuint32_t(&playerStats[playerIndex].losses); NETuint32_t(&playerStats[playerIndex].totalKills); NETuint32_t(&playerStats[playerIndex].totalScore); - NETuint32_t(&playerStats[playerIndex].recentKills); - NETuint32_t(&playerStats[playerIndex].recentScore); EcKey::Key identity; + bool isHostVerifiedIdentity = false; + NETbool(&isHostVerifiedIdentity); NETbytes(&identity); NETend(); - if (multiStatsSetIdentity(playerIndex, identity, false)) + if (!isHostVerifiedIdentity) + { + if (multiStatsSetIdentity(playerIndex, identity, false)) + { + // if identity changed, process autorating data + processAutoratingData = true; + } + } + else { - // if identity changed, process autorating data - processAutoratingData = true; + if (queue.index == NetPlay.hostPlayer) + { + hostVerifiedJoinIdentities[playerIndex].clear(); + if (!identity.empty() && !hostVerifiedJoinIdentities[playerIndex].fromBytes(identity, EcKey::Public)) + { + debug(LOG_INFO, "Host sent invalid host-verified join identity for: (player: %u, name: \"%s\")", playerIndex, getPlayerName(playerIndex)); + } + } } } else @@ -1317,6 +1481,8 @@ void resetRecentScoreData() playerStats[i].recentResearchPotential = 0; playerStats[i].identity.clear(); playerStats[i].autorating = PLAYERSTATS::Autorating(); + + hostVerifiedJoinIdentities[i].clear(); } } @@ -1437,3 +1603,26 @@ bool loadMultiStatsFromJSON(const nlohmann::json& json) return true; } + +bool updateMultiStatsIdentitiesInJSON(nlohmann::json& json, bool useVerifiedJoinIdentity) +{ + if (!json.is_array()) + { + debug(LOG_ERROR, "Expecting an array"); + return false; + } + if (json.size() > MAX_CONNECTED_PLAYERS) + { + debug(LOG_ERROR, "Array size is too large: %zu", json.size()); + return false; + } + + for (size_t idx = 0; idx < json.size(); idx++) + { + auto stats = json.at(idx).get(); + stats.identity = (useVerifiedJoinIdentity) ? getVerifiedJoinIdentity(idx) : playerStats[idx].identity; + json[idx] = stats; + } + + return true; +} diff --git a/src/multistat.h b/src/multistat.h index b2d0eb024f5..d3aeadb0929 100644 --- a/src/multistat.h +++ b/src/multistat.h @@ -92,8 +92,11 @@ struct RESEARCH; bool saveMultiStats(const char *sFName, const char *sPlayerName, const PLAYERSTATS *playerStats); // to disk bool loadMultiStats(char *sPlayerName, PLAYERSTATS *playerStats); // form disk PLAYERSTATS const &getMultiStats(UDWORD player); // get from net +const EcKey& getLocalSharedIdentity(); bool setMultiStats(uint32_t player, PLAYERSTATS plStats, bool bLocal); // set + send to net. +bool clearPlayerMultiStats(uint32_t playerIndex); bool sendMultiStats(uint32_t playerIndex, optional recipientPlayerIndex = nullopt); // send to net +bool sendMultiStatsHostVerifiedIdentities(uint32_t playerIndex); bool sendMultiStatsScoreUpdates(uint32_t player); void updateMultiStatsDamage(UDWORD attacker, UDWORD defender, UDWORD inflicted); void updateMultiStatsGames(); @@ -101,6 +104,7 @@ void updateMultiStatsWins(); void updateMultiStatsLoses(); void incrementMultiStatsResearchPerformance(UDWORD player); void incrementMultiStatsResearchPotential(UDWORD player); +struct BASE_OBJECT; void updateMultiStatsKills(BASE_OBJECT *psKilled, UDWORD player); void updateMultiStatsBuilt(BASE_OBJECT *psBuilt); void updateMultiStatsResearchComplete(RESEARCH *psResearch, UDWORD player); @@ -147,5 +151,29 @@ void resetRecentScoreData(); bool saveMultiStatsToJSON(nlohmann::json& json); bool loadMultiStatsFromJSON(const nlohmann::json& json); +bool updateMultiStatsIdentitiesInJSON(nlohmann::json& json, bool useVerifiedJoinIdentity = false); + +bool generateBlindIdentity(); + +// When host, this will return: +// - The identity verified on initial player join +// When a client, this will return: +// - In "regular" lobbies, the current player identity (if it has been verified with this client) +// - In "blind" lobbies... +// - Before game ends, an empty identity +// - After game ends, the host-verified join identity +const EcKey& getVerifiedJoinIdentity(UDWORD player); + +// In blind games, it returns the verified join identity (if executed on the host, or on all clients after the game has ended) +// In regular games, it returns the current player identity +struct TrueIdentity +{ + const EcKey& identity; + bool verified; +}; +TrueIdentity getTruePlayerIdentity(UDWORD player); + +// Should be used when a player identity is to be output (in logs, or otherwise accessible to the user) +const EcKey& getOutputPlayerIdentity(UDWORD player); #endif // __INCLUDED_SRC_MULTISTATS_H__ diff --git a/src/multisync.cpp b/src/multisync.cpp index d1233f90e89..aad4b02fd30 100644 --- a/src/multisync.cpp +++ b/src/multisync.cpp @@ -67,6 +67,11 @@ static std::array, MAX_CONNECTED_PLAYERS> pingChall // We use setMultiStats() to broadcast the score when needed. bool sendScoreCheck() { + if (game.blindMode != BLIND_MODE::NONE) + { + // no-op in blind mode + return false; + } // Broadcast any changes in other players, but not in FRONTEND!!! // Detection for this no longer uses title mode, but instead game mode, because that makes more sense if (GetGameMode() == GS_NORMAL) @@ -289,7 +294,7 @@ bool recvPing(NETQUEUE queue) // If this is a new ping, respond to it if (isNew) { - challengeResponse = getMultiStats(us).identity.sign(&challenge, PING_CHALLENGE_BYTES); + challengeResponse = getLocalSharedIdentity().sign(&challenge, PING_CHALLENGE_BYTES); NETbeginEncode(NETnetQueue(sender), NET_PING); // We are responding to a new ping @@ -331,7 +336,7 @@ bool recvPing(NETQUEUE queue) // Record that they've verified the identity ingame.VerifiedIdentity[sender] = true; - if (NetPlay.isHost) + if (NetPlay.isHost && game.blindMode != BLIND_MODE::NONE) { // check if verified identity is an admin, and handle changes to admin status bool oldIsAdminStatus = NetPlay.players[sender].isAdmin; diff --git a/src/screens/joiningscreen.cpp b/src/screens/joiningscreen.cpp index 1f18c884aff..e67d8161bce 100644 --- a/src/screens/joiningscreen.cpp +++ b/src/screens/joiningscreen.cpp @@ -1452,11 +1452,13 @@ void WzJoiningGameScreen_HandlerRoot::processJoining() // :) uint8_t index; uint32_t hostPlayer = MAX_CONNECTED_PLAYERS + 1; // invalid host index + uint8_t blindModeVal = 0; NETbeginDecode(tmpJoiningQUEUE, NET_ACCEPTED); // Retrieve the player ID the game host arranged for us NETuint8_t(&index); NETuint32_t(&hostPlayer); // and the host player idx + NETuint8_t(&blindModeVal); NETend(); NETpop(tmpJoiningQUEUE); @@ -1476,6 +1478,30 @@ void WzJoiningGameScreen_HandlerRoot::processJoining() return; } + if (blindModeVal > static_cast(BLIND_MODE_MAX)) + { + debug(LOG_ERROR, "Bad blind mode (%u) received from host!", blindModeVal); + closeConnectionAttempt(); + handleFailure(FailureDetails::makeFromInternalError(_("Invalid host response"))); + return; + } + + game.blindMode = static_cast(blindModeVal); + if (game.blindMode != BLIND_MODE::NONE) + { + // currently permitted only if hostPlayer is a spectator + if (hostPlayer < MAX_PLAYER_SLOTS) + { + debug(LOG_ERROR, "Bad blind mode (%u) received from host!", blindModeVal); + closeConnectionAttempt(); + handleFailure(FailureDetails::makeFromInternalError(_("Invalid host response"))); + return; + } + + // generate a fresh "blind identity" + generateBlindIdentity(); + } + // On success, promote the temporary socket / socketset / queuepair to their permanent (stable) locations, owned by netplay // (Function consumes the socket-related inputs) if (!NETpromoteJoinAttemptToEstablishedConnectionToHost(hostPlayer, index, playerName.toUtf8().c_str(), tmpJoiningQUEUE, &client_transient_socket, &tmp_joining_socket_set)) @@ -1699,3 +1725,10 @@ void shutdownJoiningAttempt() psCurrentJoiningAttemptScreen.reset(); } } + +std::shared_ptr createJoiningIndeterminateProgressWidget(iV_fonts fontID) +{ + auto progressIndicator = std::make_shared(fontID); + progressIndicator->setGeometry(0, 0, progressIndicator->idealWidth(), progressIndicator->idealHeight()); + return progressIndicator; +} diff --git a/src/screens/joiningscreen.h b/src/screens/joiningscreen.h index ac20654d435..50a5eb84da7 100644 --- a/src/screens/joiningscreen.h +++ b/src/screens/joiningscreen.h @@ -29,3 +29,4 @@ bool startJoiningAttempt(char* playerName, std::vector connection_list, bool asSpectator = false); void shutdownJoiningAttempt(); +std::shared_ptr createJoiningIndeterminateProgressWidget(iV_fonts fontID); diff --git a/src/stdinreader.cpp b/src/stdinreader.cpp index 63087279948..3eabebd6490 100644 --- a/src/stdinreader.cpp +++ b/src/stdinreader.cpp @@ -467,6 +467,24 @@ int cmdOutputThreadFunc(void *) return 0; } +static bool checkPlayerIdentityMatchesString(const EcKey& identity, const std::string& playerIdentityStrCopy) +{ + if (identity.empty()) + { + return (playerIdentityStrCopy == "0"); // special case for empty identity, in case that happens... + } + + // Check playerIdentityStrCopy versus both the (b64) public key and the public hash + std::string checkIdentityHash = identity.publicHashString(); + std::string checkPublicKeyB64 = base64Encode(identity.toBytes(EcKey::Public)); + if (playerIdentityStrCopy == checkPublicKeyB64 || playerIdentityStrCopy == checkIdentityHash) + { + return true; + } + + return false; +} + static bool applyToActivePlayerWithIdentity(const std::string& playerIdentityStrCopy, const std::function& func) { bool foundActivePlayer = false; @@ -477,29 +495,10 @@ static bool applyToActivePlayerWithIdentity(const std::string& playerIdentityStr continue; } - bool matchingPlayer = false; - auto& identity = getMultiStats(i).identity; - if (identity.empty()) - { - if (playerIdentityStrCopy == "0") // special case for empty identity, in case that happens... - { - matchingPlayer = true; - } - else - { - continue; - } - } - - if (!matchingPlayer) + bool matchingPlayer = checkPlayerIdentityMatchesString(getMultiStats(i).identity, playerIdentityStrCopy); + if (!matchingPlayer && game.blindMode != BLIND_MODE::NONE) { - // Check playerIdentityStrCopy versus both the (b64) public key and the public hash - std::string checkIdentityHash = identity.publicHashString(); - std::string checkPublicKeyB64 = base64Encode(identity.toBytes(EcKey::Public)); - if (playerIdentityStrCopy == checkPublicKeyB64 || playerIdentityStrCopy == checkIdentityHash) - { - matchingPlayer = true; - } + matchingPlayer = checkPlayerIdentityMatchesString(getVerifiedJoinIdentity(i), playerIdentityStrCopy); } if (matchingPlayer) @@ -574,10 +573,11 @@ static bool changeHostChatPermissionsForActivePlayerWithIdentity(const std::stri } displayRoomSystemMessage(msg.c_str()); - std::string playerPublicKeyB64 = base64Encode(getMultiStats(i).identity.toBytes(EcKey::Public)); - std::string playerIdentityHash = getMultiStats(i).identity.publicHashString(); + const auto& identity = getOutputPlayerIdentity(i); + std::string playerPublicKeyB64 = base64Encode(identity.toBytes(EcKey::Public)); + std::string playerIdentityHash = identity.publicHashString(); std::string playerVerifiedStatus = (ingame.VerifiedIdentity[i]) ? "V" : "?"; - std::string playerName = NetPlay.players[i].name; + std::string playerName = getPlayerName(i); std::string playerNameB64 = base64Encode(std::vector(playerName.begin(), playerName.end())); wz_command_interface_output("WZEVENT: hostChatPermissions=%s: %" PRIu32 " %" PRIu32 " %s %s %s %s %s\n", (freeChatEnabled) ? "Y" : "N", i, gameTime, playerPublicKeyB64.c_str(), playerIdentityHash.c_str(), playerVerifiedStatus.c_str(), playerNameB64.c_str(), NetPlay.players[i].IPtextAddress); }); @@ -605,6 +605,19 @@ static bool kickActivePlayerWithIdentity(const std::string& playerIdentityStrCop }); } +static bool chatActivePlayerWithIdentity(const std::string& playerIdentityStrCopy, const std::string& chatmsgstr) +{ + if (!NetPlay.isHostAlive) + { + // can't send this message when the host isn't alive + wz_command_interface_output("WZCMD error: Failed to send chat direct message because host isn't yet hosting!\n"); + } + + return applyToActivePlayerWithIdentity(playerIdentityStrCopy, [&](uint32_t i) { + sendRoomSystemMessageToSingleReceiver(chatmsgstr.c_str(), i); + }); +} + int cmdInputThreadFunc(void *) { fseek(stdin, 0, SEEK_END); @@ -951,52 +964,7 @@ int cmdInputThreadFunc(void *) std::string playerIdentityStrCopy(playeridentitystring); std::string chatmsgstr(chatmsg); wzAsyncExecOnMainThread([playerIdentityStrCopy, chatmsgstr] { - if (!NetPlay.isHostAlive) - { - // can't send this message when the host isn't alive - wz_command_interface_output("WZCMD error: Failed to send chat direct message because host isn't yet hosting!\n"); - } - - bool foundActivePlayer = false; - for (uint32_t i = 0; i < MAX_CONNECTED_PLAYERS; i++) - { - auto player = NetPlay.players[i]; - if (!isHumanPlayer(i)) - { - continue; - } - - bool msgThisPlayer = false; - auto& identity = getMultiStats(i).identity; - if (identity.empty()) - { - if (playerIdentityStrCopy == "0") // special case for empty identity, in case that happens... - { - msgThisPlayer = true; - } - else - { - continue; - } - } - - if (!msgThisPlayer) - { - // Check playerIdentityStrCopy versus both the (b64) public key and the public hash - std::string checkIdentityHash = identity.publicHashString(); - std::string checkPublicKeyB64 = base64Encode(identity.toBytes(EcKey::Public)); - if (playerIdentityStrCopy == checkPublicKeyB64 || playerIdentityStrCopy == checkIdentityHash) - { - msgThisPlayer = true; - } - } - - if (msgThisPlayer) - { - sendRoomSystemMessageToSingleReceiver(chatmsgstr.c_str(), i); - foundActivePlayer = true; - } - } + bool foundActivePlayer = chatActivePlayerWithIdentity(playerIdentityStrCopy, chatmsgstr); if (!foundActivePlayer) { wz_command_interface_output("WZCMD info: chat direct %s: failed to find currently-connected player with matching public key or hash\n", playerIdentityStrCopy.c_str()); @@ -1496,7 +1464,7 @@ static void WzCmdInterfaceDumpHumanPlayerVarsImpl(uint32_t player, bool gameHasF } } - const auto& identity = getMultiStats(player).identity; + const auto& identity = (game.blindMode != BLIND_MODE::NONE) ? getVerifiedJoinIdentity(player) : getMultiStats(player).identity; if (!identity.empty()) { j["pk"] = base64Encode(identity.toBytes(EcKey::Public)); @@ -1561,6 +1529,7 @@ void wz_command_interface_output_room_status_json() } } data["map"] = game.map; + data["blind"] = static_cast(game.blindMode); root["data"] = std::move(data); diff --git a/src/titleui/widgets/blindwaitingroom.cpp b/src/titleui/widgets/blindwaitingroom.cpp new file mode 100644 index 00000000000..a639fc69af3 --- /dev/null +++ b/src/titleui/widgets/blindwaitingroom.cpp @@ -0,0 +1,796 @@ +/* + This file is part of Warzone 2100. + Copyright (C) 2024-2025 Warzone 2100 Project + + Warzone 2100 is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + Warzone 2100 is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Warzone 2100; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ +/** \file + * Blind lobby waiting room widgets + */ + +#include "blindwaitingroom.h" +#include "lib/widget/widget.h" +#include "lib/widget/label.h" +#include "lib/widget/button.h" +#include "infobutton.h" +#include "lobbyplayerrow.h" + +#include "lib/netplay/netplay.h" + +#include "src/titleui/multiplayer.h" +#include "src/multistat.h" +#include "src/multiint.h" +#include "src/multiplay.h" + +#include "lib/ivis_opengl/pieblitfunc.h" + +#include "src/screens/joiningscreen.h" + +#include + +// //////////////////////////////////////////////////////////////////////////// +// Blind Waiting Room + +class WzRoomTitleBanner : public WIDGET +{ +public: + typedef std::function InfoClickHandler; + +protected: + WzRoomTitleBanner() + : WIDGET() + {} + + void initialize(const std::string& title, const InfoClickHandler& infoClickHandler); + +public: + static std::shared_ptr make(const std::string& title, const InfoClickHandler& infoClickHandler) + { + class make_shared_enabler: public WzRoomTitleBanner {}; + auto widget = std::make_shared(); + + widget->initialize(title, infoClickHandler); + return widget; + } + + void display(int xOffset, int yOffset) override; + int32_t idealHeight() override; + void geometryChanged() override; + +private: + InfoClickHandler infoClickHandler; + int topPadding = 10; + int bottomPadding = 10; + int internalHorizontalPadding = 10; + std::shared_ptr titleLabel; + std::shared_ptr infoButton; +}; + +int32_t WzRoomTitleBanner::idealHeight() +{ + // the height for one row of text + int32_t titleHeight = std::max(titleLabel->idealHeight(), (infoButton) ? infoButton->idealHeight() : 0); + return topPadding + bottomPadding + titleHeight; +} + +void WzRoomTitleBanner::initialize(const std::string& title, const InfoClickHandler& _onInfoButtonClick) +{ + infoClickHandler = _onInfoButtonClick; + + // add the titleLabel + titleLabel = std::make_shared(); + titleLabel->setFont(font_regular, WZCOL_TEXT_BRIGHT); + titleLabel->setString(WzString::fromUtf8(title)); + titleLabel->setGeometry(0, 0, titleLabel->getMaxLineWidth(), titleLabel->idealHeight()); + titleLabel->setCanTruncate(true); + titleLabel->setTransparentToMouse(true); + attach(titleLabel); + titleLabel->setCalcLayout(LAMBDA_CALCLAYOUT_SIMPLE({ + auto psParent = std::dynamic_pointer_cast(psWidget->parent()); + ASSERT_OR_RETURN(, psParent != nullptr, "No parent?"); + int x0 = psParent->internalHorizontalPadding; + int w = psParent->width() - (psParent->internalHorizontalPadding * 3) - 16; + int h = psParent->height() - (psParent->topPadding + psParent->bottomPadding); + psWidget->setGeometry(x0, psParent->topPadding, w, h); + })); + + if (infoClickHandler) + { + infoButton = WzInfoButton::make(); + infoButton->setImageDimensions(16); + attach(infoButton); + infoButton->setCalcLayout(LAMBDA_CALCLAYOUT_SIMPLE({ + auto psParent = std::dynamic_pointer_cast(psWidget->parent()); + ASSERT_OR_RETURN(, psParent != nullptr, "No parent?"); + int w = 16 + psParent->internalHorizontalPadding; + int x0 = psParent->width() - w; + int h = psParent->height() - (psParent->topPadding + psParent->bottomPadding); + psWidget->setGeometry(x0, psParent->topPadding, w, h); + })); + auto weakSelf = std::weak_ptr(std::dynamic_pointer_cast(shared_from_this())); + infoButton->addOnClickHandler([weakSelf](W_BUTTON&) { + auto strongSelf = weakSelf.lock(); + ASSERT_OR_RETURN(, strongSelf != nullptr, "No parent?"); + if (strongSelf->infoClickHandler) + { + strongSelf->infoClickHandler(); + } + }); + } +} + +void WzRoomTitleBanner::display(int xOffset, int yOffset) +{ + int x0 = xOffset + x(); + int y0 = yOffset + y(); + int w = width(); + int h = height(); + bool highlight = false; + + // draw box + PIELIGHT boxBorder = WZCOL_MENU_BORDER; + if (highlight) + { + boxBorder = pal_RGBA(255, 255, 255, 255); + } + PIELIGHT boxBackground = WZCOL_MENU_BORDER; + pie_BoxFill(x0, y0, x0 + w, y0 + h, boxBorder); + pie_BoxFill(x0 + 1, y0 + 1, x0 + w - 1, y0 + h - 1, boxBackground); +} + +void WzRoomTitleBanner::geometryChanged() +{ + titleLabel->callCalcLayout(); + if (infoButton) + { + infoButton->callCalcLayout(); + } +} + +// MARK: - WzBlindRoomHostInfo + +class WzBlindRoomHostInfo : public WIDGET +{ +protected: + WzBlindRoomHostInfo() + : WIDGET() + {} + + void initialize(); + +public: + static std::shared_ptr make() + { + class make_shared_enabler: public WzBlindRoomHostInfo {}; + auto widget = std::make_shared(); + widget->initialize(); + return widget; + } + + void run(W_CONTEXT *) override; + int32_t idealHeight() override; + void geometryChanged() override; + +private: + int topPadding = 4; + int bottomPadding = 4; + int internalHorizontalPadding = 10; + std::shared_ptr hostLabel; + std::shared_ptr hostName; +}; + +int32_t WzBlindRoomHostInfo::idealHeight() +{ + // the height for one row of text + int32_t titleHeight = std::max(hostLabel->idealHeight(), hostName->idealHeight()); + return topPadding + bottomPadding + titleHeight; +} + +void WzBlindRoomHostInfo::initialize() +{ + hostLabel = std::make_shared(); + hostLabel->setFont(font_regular, WZCOL_TEXT_DARK); + hostLabel->setString(_("Host:")); + hostLabel->setGeometry(0, 0, hostLabel->getMaxLineWidth(), hostLabel->idealHeight()); + hostLabel->setCanTruncate(false); + hostLabel->setTransparentToMouse(true); + attach(hostLabel); + + hostName = std::make_shared(); + hostName->setFont(font_regular, WZCOL_TEXT_MEDIUM); + hostName->setString(NetPlay.players[NetPlay.hostPlayer].name); + hostName->setGeometry(0, 0, hostName->getMaxLineWidth(), hostName->idealHeight()); + hostName->setCanTruncate(true); + attach(hostName); + + std::string hostTipStr; + if (NetPlay.hostPlayer < MAX_PLAYER_SLOTS) + { + hostTipStr = _("NOTE: Host is also a player."); + } + else + { + const auto& identity = getOutputPlayerIdentity(NetPlay.hostPlayer); + if (!identity.empty()) + { + if (!hostTipStr.empty()) + { + hostTipStr += "\n"; + } + std::string hash = identity.publicHashString(20); + hostTipStr += _("Player ID: "); + hostTipStr += hash.empty()? _("(none)") : hash; + } + } + hostName->setTip(hostTipStr); +} + +void WzBlindRoomHostInfo::geometryChanged() +{ + int w = width(); + int h = height(); + hostLabel->setGeometry(0, 0, hostLabel->width(), h); + + int hostNameX0 = hostLabel->width() + internalHorizontalPadding; + int hostNameWidth = w - hostNameX0; + hostName->setGeometry(hostNameX0, 0, hostNameWidth, h); +} + +void WzBlindRoomHostInfo::run(W_CONTEXT *) +{ + hostName->setString(NetPlay.players[NetPlay.hostPlayer].name); +} + +// MARK: - WzBlindRoomPlayerInfo + +class WzBlindRoomPlayerInfo : public WIDGET +{ +protected: + WzBlindRoomPlayerInfo() + : WIDGET() + {} + + void initialize(const std::shared_ptr &parent); + +public: + static std::shared_ptr make(const std::shared_ptr &parent) + { + class make_shared_enabler: public WzBlindRoomPlayerInfo {}; + auto widget = std::make_shared(); + widget->initialize(parent); + return widget; + } + + void run(W_CONTEXT *) override; + int32_t idealHeight() override; + void geometryChanged() override; + +private: + int topPadding = 4; + int bottomPadding = 4; + int internalVerticalPadding = 10; + std::shared_ptr playerLabel; + std::shared_ptr playerRow; +}; + +int32_t WzBlindRoomPlayerInfo::idealHeight() +{ + int32_t result = topPadding + bottomPadding; + result += playerLabel->idealHeight(); + result += internalVerticalPadding + MULTIOP_PLAYERHEIGHT; + return result; +} + +void WzBlindRoomPlayerInfo::initialize(const std::shared_ptr &parent) +{ + playerLabel = std::make_shared(); + playerLabel->setFont(font_regular, WZCOL_TEXT_DARK); + playerLabel->setString(_("You:")); + playerLabel->setGeometry(0, 0, playerLabel->getMaxLineWidth(), playerLabel->idealHeight()); + playerLabel->setCanTruncate(false); + playerLabel->setTransparentToMouse(true); + attach(playerLabel); + int textBottom = playerLabel->y() + playerLabel->height(); + + playerRow = WzPlayerRow::make(selectedPlayer, parent); + attach(playerRow); + playerRow->setGeometry(0, textBottom + internalVerticalPadding, MULTIOP_PLAYERWIDTH, MULTIOP_PLAYERHEIGHT); + + geometryChanged(); +} + +void WzBlindRoomPlayerInfo::geometryChanged() +{ + int w = width(); + + playerLabel->setGeometry(0, 0, playerLabel->width(), playerLabel->idealHeight()); + int textBottom = playerLabel->y() + playerLabel->height(); + + int playerWidgetWidth = std::min(MULTIOP_PLAYERWIDTH, w); + if (playerWidgetWidth > 0) + { + playerRow->setGeometry(0, textBottom + internalVerticalPadding, playerWidgetWidth, MULTIOP_PLAYERHEIGHT); + } +} + +void WzBlindRoomPlayerInfo::run(W_CONTEXT *psContext) +{ + // currently, no-op +} + +// MARK: - WzBlindRoomStatusInfo + +class WzBlindRoomStatusInfo : public WIDGET +{ +protected: + WzBlindRoomStatusInfo() + : WIDGET() + {} + + void initialize(); + +public: + static std::shared_ptr make() + { + class make_shared_enabler: public WzBlindRoomStatusInfo {}; + auto widget = std::make_shared(); + widget->initialize(); + return widget; + } + + void run(W_CONTEXT *) override; + int32_t idealHeight() override; + void geometryChanged() override; + +private: + int topPadding = 4; + int bottomPadding = 4; + int internalVerticalPadding = 5; + std::shared_ptr statusLabel; + std::shared_ptr statusDetails; + std::shared_ptr indeterminateIndicator; + WzString statusStr; + std::chrono::steady_clock::time_point lastStatusUpdate; + std::chrono::milliseconds minUpdateFreq = std::chrono::milliseconds(500); +}; + +int32_t WzBlindRoomStatusInfo::idealHeight() +{ + // the height for one row of text + int result = topPadding + bottomPadding; + result += statusLabel->idealHeight(); + result += internalVerticalPadding; + result += statusDetails->idealHeight(); + return result; +} + +void WzBlindRoomStatusInfo::initialize() +{ + statusLabel = std::make_shared(); + statusLabel->setFont(font_regular, WZCOL_TEXT_DARK); + statusLabel->setString(_("Status:")); + statusLabel->setGeometry(0, 0, statusLabel->getMaxLineWidth(), statusLabel->idealHeight()); + statusLabel->setCanTruncate(false); + statusLabel->setTransparentToMouse(true); + attach(statusLabel); + + statusDetails = std::make_shared(); + statusDetails->setFont(font_regular, WZCOL_TEXT_MEDIUM); + statusDetails->setString(NetPlay.players[NetPlay.hostPlayer].name); + statusDetails->setGeometry(0, 0, statusDetails->getMaxLineWidth(), statusDetails->idealHeight()); + statusDetails->setCanTruncate(true); + attach(statusDetails); + + indeterminateIndicator = createJoiningIndeterminateProgressWidget(font_regular_bold); + attach(indeterminateIndicator); +} + +void WzBlindRoomStatusInfo::geometryChanged() +{ + int w = width(); + statusLabel->setGeometry(0, 0, statusLabel->width(), statusLabel->idealHeight()); + int textBottom = statusLabel->y() + statusLabel->height(); + + int statusDetailsWidth = w - indeterminateIndicator->idealWidth() - 5; + statusDetails->setGeometry(0, textBottom + internalVerticalPadding, statusDetailsWidth, statusDetails->idealHeight()); + + indeterminateIndicator->setGeometry(statusDetailsWidth + 5, textBottom + internalVerticalPadding, indeterminateIndicator->idealWidth(), indeterminateIndicator->idealHeight()); +} + +void WzBlindRoomStatusInfo::run(W_CONTEXT *) +{ + const std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + if (std::chrono::duration_cast(now - lastStatusUpdate) < minUpdateFreq) + { + return; + } + + size_t numNotReadyPlayers = 0; + size_t numReadyPlayers = 0; + size_t numNotReadySpectators = 0; + bool hostIsReady = false; + bool selfIsReady = NetPlay.players[realSelectedPlayer].ready; + bool playersAllOnSameTeam = (allPlayersOnSameTeam(-1) != -1); + + for (size_t player = 0; player < NetPlay.players.size(); player++) + { + // check if this human player is ready, ignore AIs + if (NetPlay.players[player].allocated) + { + if (NetPlay.players[player].ready) + { + if (player < game.maxPlayers) + { + numReadyPlayers++; + } + if (player == NetPlay.hostPlayer) + { + hostIsReady = true; + } + } + else + { + if (player < game.maxPlayers) + { + numNotReadyPlayers++; + } + else + { + numNotReadySpectators++; + } + } + } + else if (NetPlay.players[player].ai >= 0 && player < game.maxPlayers) + { + numReadyPlayers++; + } + } + + // Determine status string + if (playersAllOnSameTeam) + { + if (numNotReadyPlayers + numReadyPlayers > 1) + { + statusStr = _("Waiting for host to configure teams"); + } + else + { + statusStr = _("Waiting for players"); + } + } + else if (!selfIsReady) + { + if (NETgetDownloadProgress(realSelectedPlayer) != 100) + { + statusStr = _("Downloading map / mods"); + } + else + { + statusStr = _("Waiting for you to check ready"); + } + } + else if (numReadyPlayers > 0 && numNotReadyPlayers > 0) + { + statusStr = _("Waiting for players"); + } + else if (!hostIsReady && NetPlay.players[NetPlay.hostPlayer].isSpectator) + { + statusStr = _("Waiting for host to start game"); + } + else if (numNotReadySpectators > 0) + { + statusStr = _("Waiting for spectators"); + } + else + { + statusStr = _("Waiting for players"); + } + + statusDetails->setString(statusStr); + + lastStatusUpdate = now; +} + +// MARK: - WzBlindPlayersReadyInfo + +class WzBlindPlayersReadyInfo : public WIDGET +{ +protected: + WzBlindPlayersReadyInfo() + : WIDGET() + {} + + void initialize(); + +public: + static std::shared_ptr make() + { + class make_shared_enabler: public WzBlindPlayersReadyInfo {}; + auto widget = std::make_shared(); + widget->initialize(); + return widget; + } + + void run(W_CONTEXT *) override; + int32_t idealHeight() override; + void geometryChanged() override; + std::string getTip() override; + +private: + int topPadding = 4; + int bottomPadding = 4; + int internalHorizontalPadding = 10; + std::shared_ptr readyCountLabel; + std::shared_ptr readyCountValue; + std::chrono::steady_clock::time_point lastStatusUpdate; + std::chrono::milliseconds minUpdateFreq = std::chrono::milliseconds(500); + std::string tooltip; +}; + +int32_t WzBlindPlayersReadyInfo::idealHeight() +{ + // the height for one row of text + int32_t titleHeight = std::max(readyCountLabel->idealHeight(), readyCountValue->idealHeight()); + return topPadding + bottomPadding + titleHeight; +} + +void WzBlindPlayersReadyInfo::initialize() +{ + readyCountLabel = std::make_shared(); + readyCountLabel->setFont(font_regular, WZCOL_TEXT_DARK); + readyCountLabel->setString(_("Players ready & waiting for match:")); + readyCountLabel->setGeometry(0, 0, readyCountLabel->getMaxLineWidth(), readyCountLabel->idealHeight()); + readyCountLabel->setCanTruncate(false); + readyCountLabel->setTransparentToMouse(true); + attach(readyCountLabel); + + readyCountValue = std::make_shared(); + readyCountValue->setFont(font_regular, WZCOL_TEXT_DARK); + readyCountValue->setString("0"); + readyCountValue->setGeometry(0, 0, readyCountValue->getMaxLineWidth(), readyCountValue->idealHeight()); + readyCountValue->setCanTruncate(true); + readyCountValue->setTransparentToMouse(true); + attach(readyCountValue); +} + +void WzBlindPlayersReadyInfo::geometryChanged() +{ + int w = width(); + int h = height(); + + // ensure the value is fully visible + int readyCountValueWidth = readyCountValue->getMaxLineWidth(); + int readyCountValueX0 = w - readyCountValueWidth; + readyCountValue->setGeometry(readyCountValueX0, 0, readyCountValueWidth, h); + + // then use the rest of the space for the label + int labelWidth = std::max(w - readyCountValueWidth - internalHorizontalPadding, 0); + readyCountLabel->setGeometry(0, 0, labelWidth, h); +} + +void WzBlindPlayersReadyInfo::run(W_CONTEXT *) +{ + const std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + if (std::chrono::duration_cast(now - lastStatusUpdate) < minUpdateFreq) + { + return; + } + + size_t numNotReadyPlayers = 0; + size_t numReadyPlayers = 0; + size_t numReadyAIBots = 0; + size_t numNotReadySpectators = 0; + size_t numReadySpectators = 0; + bool hostIsReady = false; + + for (size_t player = 0; player < NetPlay.players.size(); player++) + { + // check if this human player is ready, ignore AIs + if (NetPlay.players[player].allocated) + { + if (NetPlay.players[player].ready) + { + if (player < game.maxPlayers) + { + numReadyPlayers++; + } + else + { + numReadySpectators++; + } + if (player == NetPlay.hostPlayer) + { + hostIsReady = true; + } + } + else + { + if (player < game.maxPlayers) + { + numNotReadyPlayers++; + } + else + { + numNotReadySpectators++; + } + } + } + else if (NetPlay.players[player].ai >= 0 && player < game.maxPlayers) + { + numReadyAIBots++; + } + } + + readyCountValue->setString(WzString::number(numReadyPlayers)); + + tooltip = astringf(_("Players: (%zu ready, %zu not ready)"), numReadyPlayers, numNotReadyPlayers); + if (numReadyAIBots > 0) + { + tooltip += "\n"; + tooltip += astringf(_("AI Bots: %zu"), numReadyAIBots); + } + if (numReadySpectators > 0 || numNotReadySpectators > 0) + { + tooltip += "\n"; + tooltip += astringf(_("Spectators: (%zu ready, %zu not ready)"), numReadySpectators, numNotReadySpectators); + } + tooltip += "\n"; + tooltip += astringf(_("Host: %s"), (hostIsReady) ? _("ready") : _("not ready")); +} + +std::string WzBlindPlayersReadyInfo::getTip() +{ + return tooltip; +} + +// MARK: - WzPlayerBlindWaitingRoom + +class WzPlayerBlindWaitingRoom : public WIDGET +{ +protected: + WzPlayerBlindWaitingRoom(const std::shared_ptr& titleUI) + : WIDGET() + , weakTitleUI(titleUI) + { } + + ~WzPlayerBlindWaitingRoom() + { + } + +public: + static std::shared_ptr make(const std::shared_ptr& titleUI) + { + class make_shared_enabler: public WzPlayerBlindWaitingRoom { + public: + make_shared_enabler(const std::shared_ptr& titleUI) : WzPlayerBlindWaitingRoom(titleUI) { } + }; + auto widget = std::make_shared(titleUI); + widget->initialize(titleUI); + return widget; + } + + void initialize(const std::shared_ptr& titleUI) + { + // Add the "BLIND ROOM" info bar at top + titleBanner = WzRoomTitleBanner::make(_("Blind Waiting Room"), []() { + widgScheduleTask([]() { + printBlindModeHelpMessagesToConsole(); + }); + }); + attach(titleBanner); + + // Add host details + hostInfo = WzBlindRoomHostInfo::make(); + attach(hostInfo); + + // Add the current player details + playerInfo = WzBlindRoomPlayerInfo::make(titleUI); + attach(playerInfo); + + // Add Status field) + statusInfo = WzBlindRoomStatusInfo::make(); + attach(statusInfo); + + // Add "People ready & waiting for match: " + playersReadyInfo = WzBlindPlayersReadyInfo::make(); + attach(playersReadyInfo); + + setCalcLayout(LAMBDA_CALCLAYOUT_SIMPLE({ + auto psPlayerBlindWaitingRoom = std::dynamic_pointer_cast(psWidget->shared_from_this()); + ASSERT_OR_RETURN(, psPlayerBlindWaitingRoom.get() != nullptr, "Wrong type of psWidget??"); + psPlayerBlindWaitingRoom->recalculateInternalLayout(); + })); + } + + void geometryChanged() override + { + recalculateInternalLayout(); + } + + int getPlayerRowY0() + { + ASSERT_OR_RETURN(0, playerInfo != nullptr, "Invalid widget?"); + return playerInfo->y(); + } + +protected: + void display(int xOffset, int yOffset) override; + +private: + + void recalculateInternalLayout() + { + int w = width(); + int h = height(); + + titleBanner->setGeometry(0, 0, w, titleBanner->idealHeight()); + int nextWidgetY0 = titleBanner->y() + titleBanner->height() + titleBannerBottomPadding; + + int widgetX0 = outerWidgetPaddingX; + int usableWidgetWidth = w - (outerWidgetPaddingX * 2); + hostInfo->setGeometry(widgetX0, nextWidgetY0, usableWidgetWidth, hostInfo->idealHeight()); + nextWidgetY0 = hostInfo->y() + hostInfo->height() + betweenWidgetPaddingY; + + playerInfo->setGeometry(widgetX0, nextWidgetY0, usableWidgetWidth, playerInfo->idealHeight()); + nextWidgetY0 = playerInfo->y() + playerInfo->height() + betweenWidgetPaddingY; + + statusInfo->setGeometry(widgetX0, nextWidgetY0, usableWidgetWidth, statusInfo->idealHeight()); + nextWidgetY0 = statusInfo->y() + statusInfo->height() + betweenWidgetPaddingY; + + // aligned bottom-up + int bottomWidgetY0 = h - playersReadyInfo->idealHeight() - bottomPaddingY; + playersReadyInfo->setGeometry(widgetX0, bottomWidgetY0, usableWidgetWidth, playersReadyInfo->idealHeight()); + } + +private: + std::weak_ptr weakTitleUI; + + int titleBannerBottomPadding = 10; + int outerWidgetPaddingX = 15; + int betweenWidgetPaddingY = 10; + int bottomPaddingY = 15; + + std::shared_ptr titleBanner; + std::shared_ptr hostInfo; + std::shared_ptr playerInfo; + std::shared_ptr statusInfo; + std::shared_ptr playersReadyInfo; +}; + +void WzPlayerBlindWaitingRoom::display(int xOffset, int yOffset) +{ + int x0 = xOffset + x(); + int y0 = yOffset + y(); + int w = width(); + int h = height(); + + pie_BoxFill(x0, y0, x0 + w, y0 + h, WZCOL_MENU_BACKGROUND); + iV_Box(x0, y0, x0 + w, y0 + h, WZCOL_MENU_BORDER); +} + + +// MARK: - Public methods + +std::shared_ptr makeWzPlayerBlindWaitingRoom(const std::shared_ptr& titleUI) +{ + return WzPlayerBlindWaitingRoom::make(titleUI); +} + +int getWzPlayerBlindWaitingRoomPlayerRowY0(const std::shared_ptr& blindWaitingRoomWidget) +{ + auto pBlindWaitingRoomWidget = std::dynamic_pointer_cast(blindWaitingRoomWidget); + ASSERT_OR_RETURN(0, pBlindWaitingRoomWidget != nullptr, "Invalid widget"); + return pBlindWaitingRoomWidget->getPlayerRowY0(); +} diff --git a/src/titleui/widgets/blindwaitingroom.h b/src/titleui/widgets/blindwaitingroom.h new file mode 100644 index 00000000000..95aa413e16b --- /dev/null +++ b/src/titleui/widgets/blindwaitingroom.h @@ -0,0 +1,30 @@ +/* + This file is part of Warzone 2100. + Copyright (C) 2024-2025 Warzone 2100 Project + + Warzone 2100 is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + Warzone 2100 is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Warzone 2100; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ +/** \file + * Blind lobby waiting room widgets + */ + +#pragma once + +#include "lib/widget/widget.h" + +class WzMultiplayerOptionsTitleUI; + +std::shared_ptr makeWzPlayerBlindWaitingRoom(const std::shared_ptr& titleUI); +int getWzPlayerBlindWaitingRoomPlayerRowY0(const std::shared_ptr& blindWaitingRoomWidget); diff --git a/src/titleui/widgets/lobbyplayerrow.cpp b/src/titleui/widgets/lobbyplayerrow.cpp index 22696db3239..64ee9d2c7d5 100644 --- a/src/titleui/widgets/lobbyplayerrow.cpp +++ b/src/titleui/widgets/lobbyplayerrow.cpp @@ -243,6 +243,14 @@ void displayPlayer(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) auto ar = stat.autorating; std::string name = getPlayerName(j); + if (game.blindMode != BLIND_MODE::NONE) + { + // Special override: Show our own name if in spectators + if (j == selectedPlayer && j > MAX_PLAYER_SLOTS) + { + name = NetPlay.players[j].name; + } + } std::map serverPlayers; // TODO Fill this with players known to the server (needs implementing on the server, too). Currently useless. @@ -257,7 +265,7 @@ void displayPlayer(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) { colour = WZCOL_FORM_PLAYER_NOPING; } - else if (isKnownPlayer(serverPlayers, name, getMultiStats(j).identity)) + else if (isKnownPlayer(serverPlayers, name, getMultiStats(j).identity) || game.blindMode != BLIND_MODE::NONE) { colour = WZCOL_FORM_PLAYER_KNOWN_BY_SERVER; } @@ -291,6 +299,12 @@ void displayPlayer(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) { subText += _("HOST"); } + else if ((j < MAX_PLAYERS) && (game.blindMode != BLIND_MODE::NONE) && NetPlay.isHost && (NetPlay.hostPlayer >= MAX_PLAYER_SLOTS)) + { + subText += "\""; + subText += getPlayerGenericName(j); + subText += "\""; + } if (NetPlay.bComms && j != selectedPlayer) { @@ -301,8 +315,27 @@ void displayPlayer(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) subText += buf; if (ingame.PingTimes[j] < PING_LIMIT) { + if (game.blindMode == BLIND_MODE::NONE + || (NetPlay.isHost && (NetPlay.hostPlayer >= MAX_PLAYER_SLOTS)) + || (j == NetPlay.hostPlayer)) + { // show actual ping time ssprintf(buf, "%03d", ingame.PingTimes[j]); + } + else + { + // show non-exact ping in blind mode + const char* pingQualifierStr = "Ok"; + if (ingame.PingTimes[j] > 350) + { + pingQualifierStr = "++"; + } + else if (ingame.PingTimes[j] > 1000) + { + pingQualifierStr = "+++"; + } + ssprintf(buf, "%s", pingQualifierStr); + } } else { @@ -367,6 +400,10 @@ void displayPlayer(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) { iV_DrawImage(FrontImages, IMAGE_PLAYER_PC, x, y + 11); } + else if (!ar.valid && (game.blindMode != BLIND_MODE::NONE) && (!NetPlay.isHost || NetPlay.hostPlayer < MAX_PLAYER_SLOTS)) + { + iV_DrawImage(FrontImages, IMAGE_WEE_GUY, x + 4, y + 13); + } else if (ar.dummy) { iV_DrawImage(FrontImages, IMAGE_MEDAL_DUMMY, x + 4, y + 13); @@ -455,6 +492,10 @@ void displayPlayer(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) static bool canChooseTeamFor(int i) { + if (game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY && !NetPlay.isHost) + { + return false; + } return (i == selectedPlayer || isHostOrAdmin()); } @@ -561,7 +602,8 @@ std::shared_ptr WzPlayerRow::make(uint32_t playerIdx, const std::sh (NetPlay.players[player].allocated && isHostOrAdmin())) && !locked.position && player < MAX_PLAYERS - && !isSpectatorOnlySlot(player)) + && !isSpectatorOnlySlot(player) + && ((game.blindMode != BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY) || NetPlay.isHost)) { widgScheduleTask([strongTitleUI, player] { strongTitleUI->openPositionChooser(player); @@ -586,7 +628,7 @@ std::shared_ptr WzPlayerRow::make(uint32_t playerIdx, const std::sh } widgScheduleTask([strongTitleUI] { strongTitleUI->updatePlayers(); - resetReadyStatus(false); + resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); }); } else @@ -663,7 +705,11 @@ void WzPlayerRow::updateState() // update player info box tooltip std::string playerInfoTooltip; - if ((selectedPlayer == playerIdx || NetPlay.isHost) && NetPlay.players[playerIdx].allocated && !locked.position && !isSpectatorOnlySlot(playerIdx)) + if ((selectedPlayer == playerIdx || NetPlay.isHost) + && NetPlay.players[playerIdx].allocated + && !locked.position + && !isSpectatorOnlySlot(playerIdx) + && ((game.blindMode != BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY) || NetPlay.isHost)) { playerInfoTooltip = _("Click to change player position"); } @@ -687,10 +733,10 @@ void WzPlayerRow::updateState() playerInfoTooltip = aidata[NetPlay.players[playerIdx].ai].tip; } } - if (NetPlay.players[playerIdx].allocated) + if (NetPlay.players[playerIdx].allocated && (game.blindMode == BLIND_MODE::NONE || (NetPlay.isHost && NetPlay.hostPlayer >= MAX_PLAYER_SLOTS))) { const PLAYERSTATS& stats = getMultiStats(playerIdx); - const auto& identity = stats.identity; + const auto& identity = getOutputPlayerIdentity(playerIdx); if (!identity.empty()) { if (!playerInfoTooltip.empty()) @@ -767,7 +813,7 @@ void WzPlayerRow::updateState() void WzPlayerRow::updateReadyButton() { - int disallow = allPlayersOnSameTeam(-1); + bool disallow = (allPlayersOnSameTeam(-1) != -1) && (game.blindMode != BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); const auto& aidata = getAIData(); const auto& locked = getLockedOptions(); @@ -855,7 +901,7 @@ void WzPlayerRow::updateReadyButton() return; } - if (disallow != -1) + if (disallow) { // remove ready / difficulty button deleteExistingReadyButton(); @@ -920,10 +966,10 @@ void WzPlayerRow::updateReadyButton() { if (mouseDown(MOUSE_RMB) && player != NetPlay.hostPlayer) // both buttons.... { - std::string msg = astringf(_("The host has kicked %s from the game!"), getPlayerName(player)); + std::string msg = astringf(_("The host has kicked %s from the game!"), getPlayerName(player, true)); sendRoomSystemMessage(msg.c_str()); kickPlayer(player, _("The host has kicked you from the game."), ERROR_KICKED, false); - resetReadyStatus(true); //reset and send notification to all clients + resetReadyStatus(true, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); //reset and send notification to all clients } } }); @@ -938,6 +984,6 @@ void WzPlayerRow::updateReadyButton() } readyTextLabel->setGeometry(0, 0, MULTIOP_READY_WIDTH, 17); readyTextLabel->setTextAlignment(WLAB_ALIGNBOTTOM); - readyTextLabel->setFont(font_small, WZCOL_TEXT_BRIGHT); + readyTextLabel->setFont(font_small, (isMe) ? WZCOL_TEXT_BRIGHT : WZCOL_TEXT_MEDIUM); readyTextLabel->setString(_("READY?")); } diff --git a/src/wrappers.cpp b/src/wrappers.cpp index 57ebb6548d4..9ff34494c86 100644 --- a/src/wrappers.cpp +++ b/src/wrappers.cpp @@ -356,6 +356,24 @@ bool displayGameOver(bool bDidit, bool showBackDrop) { ingame.endTime = std::chrono::steady_clock::now(); debug(LOG_INFO, "Game ended (duration: %lld)", (long long)std::chrono::duration_cast(ingame.endTime.value() - ingame.startTime).count()); + + // If in blind mode, send data on who the players were + if (game.blindMode != BLIND_MODE::NONE) + { + if (NetPlay.isHost) + { + // Send updated player info (which will include real names, now that game has ended) to all players + NETSendAllPlayerInfoTo(NET_ALL_PLAYERS); + + // Send the verified player identity from initial join for each player (now that game has ended) + for (uint32_t idx = 0; idx < MAX_CONNECTED_PLAYERS; ++idx) + { + sendMultiStatsHostVerifiedIdentities(idx); + } + } + + // Note: Replay player info updating occurs as part of NETreplaySaveStop + } } } if (bDidit) diff --git a/src/wzapi.cpp b/src/wzapi.cpp index 16f8b8c67ae..83460c97588 100644 --- a/src/wzapi.cpp +++ b/src/wzapi.cpp @@ -4685,7 +4685,15 @@ nlohmann::json wzapi::constructStaticPlayerData() for (int i = 0; i < game.maxPlayers; i++) { nlohmann::json vector = nlohmann::json::object(); - vector["name"] = NetPlay.players[i].name; + if (game.blindMode >= BLIND_MODE::BLIND_GAME) + { + // to ensure the "name" field exposed to api is consistent across blind games _and_ replays of those games, always set it to the generic name if in blind mode + vector["name"] = getPlayerGenericName(i); + } + else + { + vector["name"] = NetPlay.players[i].name; + } vector["difficulty"] = static_cast(NetPlay.players[i].difficulty); vector["faction"] = NetPlay.players[i].faction; vector["colour"] = NetPlay.players[i].colour; diff --git a/src/wzscriptdebug.cpp b/src/wzscriptdebug.cpp index e3f42147802..82db1bb8264 100644 --- a/src/wzscriptdebug.cpp +++ b/src/wzscriptdebug.cpp @@ -302,7 +302,7 @@ static nlohmann::ordered_json fillPlayerModel(int i) nlohmann::ordered_json result = nlohmann::ordered_json::object(); result["playerStats score"] = getMultiPlayRecentScore(i); result["playerStats kills"] = getMultiPlayUnitsKilled(i); - result["NetPlay.players.name"] = NetPlay.players[i].name; + result["NetPlay.players.name"] = getPlayerName(i, true);; result["NetPlay.players.position"] = NetPlay.players[i].position; result["NetPlay.players.colour"] = NetPlay.players[i].colour; result["NetPlay.players.allocated"] = NetPlay.players[i].allocated; From 733740468ddcb73536b7c04751958726c692e33b Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sun, 12 Jan 2025 16:32:46 -0500 Subject: [PATCH 08/17] Refactor join negotiation and handling --- lib/netplay/netplay.cpp | 195 +++++++++++++++++++++++----------- lib/netplay/netplay.h | 3 +- lib/netplay/nettypes.cpp | 19 ++++ lib/netplay/nettypes.h | 2 + src/multiint.cpp | 36 +++++++ src/multiplay.h | 5 + src/screens/joiningscreen.cpp | 96 ++++++++++++++++- 7 files changed, 287 insertions(+), 69 deletions(-) diff --git a/lib/netplay/netplay.cpp b/lib/netplay/netplay.cpp index 3952f8f4b92..bed221203b6 100644 --- a/lib/netplay/netplay.cpp +++ b/lib/netplay/netplay.cpp @@ -270,12 +270,16 @@ struct TmpSocketInfo char name[64] = {'\0'}; uint8_t playerType = 0; EcKey identity; + std::unique_ptr connectionAuthSessionKeys; + std::vector challengeForHost; void reset() { name[0] = '\0'; playerType = 0; identity.clear(); + connectionAuthSessionKeys.reset(); + challengeForHost.clear(); } }; ReceivedJoinInfo receivedJoinInfo; @@ -3837,6 +3841,22 @@ static void NEThostPromoteTempSocketToPermanentPlayerConnection(unsigned int tem } } +static void NETrejectTempSocketClient(unsigned int i, uint8_t rejectedReason, bool sessionBan = false) +{ + NETbeginEncode(NETnetTmpQueue(i), NET_REJECTED); + NETuint8_t(&rejectedReason); + NETstring("", 0); + NETend(); + NETflush(); + + if (sessionBan) + { + NETaddSessionBanBadIP(tmp_connectState[i].ip); + } + NETcloseTempSocket(i); + sync_counter.cantjoin++; +} + // //////////////////////////////////////////////////////////////////////// // Host a game with a given name and player name. & 4 user game flags static void NETallowJoining() @@ -3971,10 +3991,15 @@ static void NETallowJoining() connectFailed = false; // Give client a challenge to solve before connecting - tmp_connectState[i].connectChallenge.resize(NETgetJoinConnectionNETPINGChallengeSize()); + tmp_connectState[i].connectChallenge.resize(NETgetJoinConnectionNETPINGChallengeFromHostSize()); genSecRandomBytes(tmp_connectState[i].connectChallenge.data(), tmp_connectState[i].connectChallenge.size()); NETbeginEncode(NETnetTmpQueue(i), NET_PING); NETbytes(&(tmp_connectState[i].connectChallenge)); + // Send the server's public identity + // - As long as host is a spectator host, or this is a regular (non-blind) game, this is always the host's actual public identity + // - If host is a player-host *and* this is a blind game, it's the blind identity + EcKey::Key serverPublicKey = getLocalSharedIdentity().toBytes(EcKey::Public); + NETbytes(&serverPublicKey); NETend(); } else @@ -4052,17 +4077,8 @@ static void NETallowJoining() if (NETincompleteMessageDataBuffered(NETnetTmpQueue(i)) > (NET_BUFFER_SIZE * 16)) // something definitely big enough to encompass the expected message(s) at this point { // client is sending data that doesn't appear to be a properly formatted message - cut it off - rejected = ERROR_WRONGDATA; - NETbeginEncode(NETnetTmpQueue(i), NET_REJECTED); - NETuint8_t(&rejected); - NETstring("", 0); - NETend(); - NETflush(); NETpop(NETnetTmpQueue(i)); - - NETaddSessionBanBadIP(tmp_connectState[i].ip); - NETcloseTempSocket(i); - sync_counter.cantjoin++; + NETrejectTempSocketClient(i, ERROR_WRONGDATA, true); } continue; } @@ -4075,7 +4091,7 @@ static void NETallowJoining() uint8_t playerType = 0; EcKey::Key pkey; EcKey identity; - EcKey::Sig challengeResponse; + std::vector encryptedChallengeResponse; NETbeginDecode(NETnetTmpQueue(i), NET_JOIN); NETstring(name, sizeof(name)); @@ -4083,52 +4099,105 @@ static void NETallowJoining() NETstring(GamePassword, sizeof(GamePassword)); NETuint8_t(&playerType); NETbytes(&pkey); - NETbytes(&challengeResponse); + NETbytes(&encryptedChallengeResponse); NETend(); + NETpop(NETnetTmpQueue(i)); - // verify signature that player is joining with, reject him if he can not do that - if (!identity.fromBytes(pkey, EcKey::Public) || !identity.verify(challengeResponse, tmp_connectState[i].connectChallenge.data(), tmp_connectState[i].connectChallenge.size())) + // Determine if it's a valid public identity + if (!identity.fromBytes(pkey, EcKey::Public)) { - auto rejectMsg = astringf("**Rejecting player(%s), failed to verify player identity.", tmp_connectState[i].ip.c_str()); + // Invalid public identity provided - just reject + auto rejectMsg = astringf("**Rejecting player(%s), invalid player identity.", tmp_connectState[i].ip.c_str()); debug(LOG_INFO, "%s", rejectMsg.c_str()); - debug(LOG_NET, "freeing temp socket %p, couldn't verify player identity", static_cast(tmp_socket[i])); + debug(LOG_NET, "freeing temp socket %p, invalid player identity", static_cast(tmp_socket[i])); - rejected = ERROR_WRONGDATA; - NETbeginEncode(NETnetTmpQueue(i), NET_REJECTED); - NETuint8_t(&rejected); - NETstring("", 0); - NETend(); - NETflush(); - NETpop(NETnetTmpQueue(i)); + NETrejectTempSocketClient(i, ERROR_WRONGDATA, true); + continue; + } - NETaddSessionBanBadIP(tmp_connectState[i].ip); - NETcloseTempSocket(i); - sync_counter.cantjoin++; + // Do an initial pass of blacklist checks (even though we haven't verified the identity yet) + auto connectPermissions = netPermissionsCheck_Connect(identity); + if ((connectPermissions.has_value() && connectPermissions.value() == ConnectPermissions::Blocked) + || (!connectPermissions.has_value() && onBanList(tmp_connectState[i].ip.c_str()))) + { + char buf[256] = {'\0'}; + ssprintf(buf, "** A player that you have kicked tried to rejoin the game, and was rejected. IP: %s", tmp_connectState[i].ip.c_str()); + debug(LOG_INFO, "%s", buf); + NETlogEntry(buf, SYNC_FLAG, i); + + NETrejectTempSocketClient(i, ERROR_KICKED, false); continue; } - // save join info in the tmp_connectState + // Save join info in the tmp_connectState sstrcpy(tmp_connectState[i].receivedJoinInfo.name, name); tmp_connectState[i].receivedJoinInfo.playerType = playerType; tmp_connectState[i].receivedJoinInfo.identity = identity; auto& joinRequestInfo = tmp_connectState[i].receivedJoinInfo; - // connection checks - auto connectPermissions = netPermissionsCheck_Connect(identity); + // Construct the auth session keys + try { + tmp_connectState[i].receivedJoinInfo.connectionAuthSessionKeys = std::make_unique(getLocalSharedIdentity(), 0, identity, 1); + } + catch (const std::invalid_argument& e) { + auto rejectMsg = astringf("**Rejecting player(%s), failed to establish session keys, error: %s", tmp_connectState[i].ip.c_str(), e.what()); + debug(LOG_INFO, "%s", rejectMsg.c_str()); + debug(LOG_NET, "freeing temp socket %p, couldn't establish session keys", static_cast(tmp_socket[i])); + NETrejectTempSocketClient(i, ERROR_WRONGDATA, false); + continue; + } - if ((connectPermissions.has_value() && connectPermissions.value() == ConnectPermissions::Blocked) - || (!connectPermissions.has_value() && onBanList(tmp_connectState[i].ip.c_str()))) + // Decrypt the encryptedChallengeResponse + std::vector decryptedMessageRawData; + if (!tmp_connectState[i].receivedJoinInfo.connectionAuthSessionKeys->decryptMessageFromOther(&(encryptedChallengeResponse[0]), encryptedChallengeResponse.size(), decryptedMessageRawData)) { - char buf[256] = {'\0'}; - ssprintf(buf, "** A player that you have kicked tried to rejoin the game, and was rejected. IP: %s", tmp_connectState[i].ip.c_str()); - debug(LOG_INFO, "%s", buf); - NETlogEntry(buf, SYNC_FLAG, i); + auto rejectMsg = astringf("**Rejecting player(%s), failed to decrypt player auth data", tmp_connectState[i].ip.c_str()); + debug(LOG_INFO, "%s", rejectMsg.c_str()); + debug(LOG_NET, "freeing temp socket %p, couldn't verify player identity", static_cast(tmp_socket[i])); + NETrejectTempSocketClient(i, ERROR_WRONGDATA, true); + continue; + } - // Player has been kicked before, kick again. - rejected = (uint8_t)ERROR_KICKED; + NetMessage tmpMessage(NET_JOIN); // dummy message for parsing + tmpMessage.data = std::move(decryptedMessageRawData); + NETinsertMessageFromNet(NETnetTmpQueue(i), &tmpMessage); // insert virtual message into temp queue for parsing + + // Parse the decrypted response + EcKey::Sig challengeResponse; + std::vector connectionDescriptionSerializedBytes; + std::vector challengeForHost(NETgetJoinConnectionNETPINGChallengeFromClientSize(), 0); + NETbeginDecode(NETnetTmpQueue(i), NET_JOIN); + NETbytes(&challengeResponse); + NETbytes(&connectionDescriptionSerializedBytes); + NETbytes(&challengeForHost); + NETend(); + NETpop(NETnetTmpQueue(i)); + + // Verify signature that player is joining with - reject on failure + if (!identity.verify(challengeResponse, tmp_connectState[i].connectChallenge.data(), tmp_connectState[i].connectChallenge.size())) + { + auto rejectMsg = astringf("**Rejecting player(%s), failed to verify player identity.", tmp_connectState[i].ip.c_str()); + debug(LOG_INFO, "%s", rejectMsg.c_str()); + debug(LOG_NET, "freeing temp socket %p, couldn't verify player identity", static_cast(tmp_socket[i])); + NETrejectTempSocketClient(i, ERROR_WRONGDATA, true); + continue; } - else if (joinRequestInfo.playerType != NET_JOIN_SPECTATOR && !bAsyncJoinApprovalEnabled && playerManagementRecord.hostMovedPlayerToSpectators(tmp_connectState[i].ip)) + + // Verify that the challengeForHost is expected length + if (!challengeForHost.empty() && challengeForHost.size() != NETgetJoinConnectionNETPINGChallengeFromClientSize()) + { + auto rejectMsg = astringf("**Rejecting player(%s), invalid host challenge length.", tmp_connectState[i].ip.c_str()); + debug(LOG_INFO, "%s", rejectMsg.c_str()); + debug(LOG_NET, "freeing temp socket %p, invalid host challenge length", static_cast(tmp_socket[i])); + NETrejectTempSocketClient(i, ERROR_WRONGDATA, true); + continue; + } + tmp_connectState[i].receivedJoinInfo.challengeForHost = std::move(challengeForHost); + challengeForHost.clear(); + + // Additional rejection checks + if (joinRequestInfo.playerType != NET_JOIN_SPECTATOR && !bAsyncJoinApprovalEnabled && playerManagementRecord.hostMovedPlayerToSpectators(tmp_connectState[i].ip)) { // The host previously relegated a player from this IP address to Spectators (this game), and it seems they are trying to rejoin as a Player - deny this char buf[256] = {'\0'}; @@ -4161,15 +4230,12 @@ static void NETallowJoining() NETstring("", 0); NETend(); NETflush(); - NETpop(NETnetTmpQueue(i)); NETcloseTempSocket(i); sync_counter.cantjoin++; continue; } - NETpop(NETnetTmpQueue(i)); - // on passing all built-in checks for connect... if (bAsyncJoinApprovalEnabled) { @@ -4195,17 +4261,8 @@ static void NETallowJoining() { // unexpected message type at this time // reject the bad client - rejected = ERROR_WRONGDATA; - NETbeginEncode(NETnetTmpQueue(i), NET_REJECTED); - NETuint8_t(&rejected); - NETstring("", 0); - NETend(); - NETflush(); NETpop(NETnetTmpQueue(i)); - - NETaddSessionBanBadIP(tmp_connectState[i].ip); - NETcloseTempSocket(i); - sync_counter.cantjoin++; + NETrejectTempSocketClient(i, ERROR_WRONGDATA, true); } continue; } @@ -4298,9 +4355,8 @@ static void NETallowJoining() if (tmp_connectState[i].connectState == TmpSocketInfo::TmpConnectState::ProcessJoin) { optional tmp = nullopt; - uint8_t rejected = 0; - auto joinRequestInfo = tmp_connectState[i].receivedJoinInfo; // keep a copy + TmpSocketInfo::ReceivedJoinInfo joinRequestInfo = std::move(tmp_connectState[i].receivedJoinInfo); // keep the join info tmp_connectState[i].reset(); if ((joinRequestInfo.playerType == NET_JOIN_SPECTATOR) || (int)NetPlay.playercount <= gamestruct.desc.dwMaxPlayers) @@ -4314,15 +4370,7 @@ static void NETallowJoining() debug(LOG_INFO, "freeing temp socket %p, couldn't create slot", static_cast(tmp_socket[i])); // Tell the player that we are full. - rejected = ERROR_FULL; - NETbeginEncode(NETnetTmpQueue(i), NET_REJECTED); - NETuint8_t(&rejected); - NETstring("", 0); - NETend(); - NETflush(); - - NETcloseTempSocket(i); - sync_counter.cantjoin++; + NETrejectTempSocketClient(i, ERROR_FULL, false); continue; // continue to next tmp_socket } @@ -4330,11 +4378,25 @@ static void NETallowJoining() NEThostPromoteTempSocketToPermanentPlayerConnection(i, index); + // construct encrypted client challenge response + std::vector encryptedHostChallengeResponse; + if (!joinRequestInfo.challengeForHost.empty()) + { + EcKey::Sig hostChallengeResponse = getLocalSharedIdentity().sign(joinRequestInfo.challengeForHost.data(), joinRequestInfo.challengeForHost.size()); + encryptedHostChallengeResponse = joinRequestInfo.connectionAuthSessionKeys->encryptMessageForOther(&hostChallengeResponse[0], hostChallengeResponse.size()); + if (encryptedHostChallengeResponse.empty()) + { + debug(LOG_INFO, "Failed to encrypt response?"); + } + } + + // Send NET_ACCEPTED NETbeginEncode(NETnetQueue(index), NET_ACCEPTED); NETuint8_t(&index); NETuint32_t(&NetPlay.hostPlayer); uint8_t blindModeVal = static_cast(game.blindMode); NETuint8_t(&blindModeVal); + NETbytes(&encryptedHostChallengeResponse); NETend(); // First send info about players to newcomer. @@ -4942,11 +5004,16 @@ unsigned int NETgetGameserverPort() /** * @return The size of the join connection challenge (see: NET_PING in NETallowJoining()) */ -uint32_t NETgetJoinConnectionNETPINGChallengeSize() +uint32_t NETgetJoinConnectionNETPINGChallengeFromHostSize() { return NET_PING_TMP_PING_CHALLENGE_SIZE; } +uint32_t NETgetJoinConnectionNETPINGChallengeFromClientSize() +{ + return NET_PING_TMP_PING_CHALLENGE_SIZE / 2; +} + /*! * Set the join preference for IPv6 * \param bTryIPv6First Whether to attempt IPv6 first when joining, before IPv4. diff --git a/lib/netplay/netplay.h b/lib/netplay/netplay.h index b43e2f753d4..80347c373eb 100644 --- a/lib/netplay/netplay.h +++ b/lib/netplay/netplay.h @@ -465,7 +465,8 @@ void NETsetDefaultMPHostFreeChatPreference(bool enabled); bool NETgetDefaultMPHostFreeChatPreference(); void NETsetEnableTCPNoDelay(bool enabled); bool NETgetEnableTCPNoDelay(); -uint32_t NETgetJoinConnectionNETPINGChallengeSize(); +uint32_t NETgetJoinConnectionNETPINGChallengeFromHostSize(); +uint32_t NETgetJoinConnectionNETPINGChallengeFromClientSize(); void NETsetGamePassword(const char *password); void NETBroadcastPlayerInfo(uint32_t index); diff --git a/lib/netplay/nettypes.cpp b/lib/netplay/nettypes.cpp index c50a647ce6a..139abadff47 100644 --- a/lib/netplay/nettypes.cpp +++ b/lib/netplay/nettypes.cpp @@ -1035,6 +1035,25 @@ void NETnetMessage(NetMessage const **msg) } } +void NETbytesOutputToVector(const std::vector &data, std::vector& output) +{ + // same logic as using NETbytes() for a write, except written to the `output` vector + + // same as queueAuto(uint32_t) - write the length + uint32_t dataSizeU32 = static_cast(data.size()); + uint32_t v = dataSizeU32; + bool moreBytes = true; + for (int n = 0; moreBytes; ++n) + { + uint8_t b; + moreBytes = encode_uint32_t(b, v, n); + output.push_back(b); + } + + // write all the data bytes + output.insert(output.end(), data.begin(), data.end()); +} + ReplayOptionsHandler::~ReplayOptionsHandler() { } // TODO Call this function somewhere. diff --git a/lib/netplay/nettypes.h b/lib/netplay/nettypes.h index f82e19e1d4e..218a20d03e5 100644 --- a/lib/netplay/nettypes.h +++ b/lib/netplay/nettypes.h @@ -210,6 +210,8 @@ static inline void NETauto(T (&ar)[N]) void NETnetMessage(NetMessage const **message); ///< If decoding, must delete the NETMESSAGE. +void NETbytesOutputToVector(const std::vector &data, std::vector& output); + #include class ReplayOptionsHandler diff --git a/src/multiint.cpp b/src/multiint.cpp index 7f417e8a2a8..eed1c5dcb4f 100644 --- a/src/multiint.cpp +++ b/src/multiint.cpp @@ -952,6 +952,42 @@ void setLobbyError(LOBBY_ERROR_TYPES error_type) } } +void to_json(nlohmann::json& j, const JoinConnectionDescription::JoinConnectionType& v) +{ + switch (v) + { + case JoinConnectionDescription::JoinConnectionType::TCP_DIRECT: + j = "tcp"; + break; + } +} + +void from_json(const nlohmann::json& j, JoinConnectionDescription::JoinConnectionType& v) +{ + auto str = j.get(); + if (str == "tcp") + { + v = JoinConnectionDescription::JoinConnectionType::TCP_DIRECT; + return; + } + throw nlohmann::json::type_error::create(302, "JoinConnectionType value is unknown: \"" + str + "\"", &j); +} + +void to_json(nlohmann::json& j, const JoinConnectionDescription& v) +{ + j = nlohmann::json::object(); + j["h"] = v.host; + j["p"] = v.port; + j["t"] = v.type; +} + +void from_json(const nlohmann::json& j, JoinConnectionDescription& v) +{ + v.host = j.at("h").get(); + v.port = j.at("p").get(); + v.type = j.at("t").get(); +} + // NOTE: Must call NETinit(true); before this will actually work std::vector findLobbyGame(const std::string& lobbyAddress, unsigned int lobbyPort, uint32_t lobbyGameId) { diff --git a/src/multiplay.h b/src/multiplay.h index dcb85fc0f25..65df370a5ec 100644 --- a/src/multiplay.h +++ b/src/multiplay.h @@ -305,6 +305,11 @@ struct JoinConnectionDescription uint32_t port = 0; JoinConnectionType type = JoinConnectionType::TCP_DIRECT; }; +void to_json(nlohmann::json& j, const JoinConnectionDescription::JoinConnectionType& v); +void from_json(const nlohmann::json& j, JoinConnectionDescription::JoinConnectionType& v); +void to_json(nlohmann::json& j, const JoinConnectionDescription& v); +void from_json(const nlohmann::json& j, JoinConnectionDescription& v); + std::vector findLobbyGame(const std::string& lobbyAddress, unsigned int lobbyPort, uint32_t lobbyGameId); void joinGame(const char *connectionString, bool asSpectator = false); void joinGame(const char *host, uint32_t port, bool asSpectator = false); diff --git a/src/screens/joiningscreen.cpp b/src/screens/joiningscreen.cpp index e67d8161bce..3a3528fa43d 100644 --- a/src/screens/joiningscreen.cpp +++ b/src/screens/joiningscreen.cpp @@ -761,6 +761,9 @@ class WzJoiningGameScreen_HandlerRoot : public W_CLICKFORM std::vector connectionList; WzString playerName; EcKey playerIdentity; + EcKey hostIdentity; + std::unique_ptr connectionAuthSessionKeys; + std::vector challengeForHost; bool asSpectator = false; char gamePassword[password_string_size] = {}; size_t currentConnectionIdx = 0; @@ -1299,6 +1302,13 @@ void WzJoiningGameScreen_HandlerRoot::closeConnectionAttempt() usedInitialAckBuffer = 0; } +static std::vector serializeConnectionDescription(const JoinConnectionDescription& connDesc) +{ + nlohmann::json connDescJson = connDesc; + std::string connDescJsonStr = connDescJson.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace); + return std::vector(connDescJsonStr.begin(), connDescJsonStr.end()); +} + void WzJoiningGameScreen_HandlerRoot::processJoining() { if (currentJoiningState == JoiningState::Success) @@ -1453,12 +1463,14 @@ void WzJoiningGameScreen_HandlerRoot::processJoining() uint8_t index; uint32_t hostPlayer = MAX_CONNECTED_PLAYERS + 1; // invalid host index uint8_t blindModeVal = 0; + std::vector encryptedHostChallengeResponse; NETbeginDecode(tmpJoiningQUEUE, NET_ACCEPTED); // Retrieve the player ID the game host arranged for us NETuint8_t(&index); NETuint32_t(&hostPlayer); // and the host player idx NETuint8_t(&blindModeVal); + NETbytes(&encryptedHostChallengeResponse); NETend(); NETpop(tmpJoiningQUEUE); @@ -1478,6 +1490,25 @@ void WzJoiningGameScreen_HandlerRoot::processJoining() return; } + // Decrypt the encryptedHostChallengeResponse + std::vector hostChallengeResponse; + if (!connectionAuthSessionKeys->decryptMessageFromOther(&(encryptedHostChallengeResponse[0]), encryptedHostChallengeResponse.size(), hostChallengeResponse)) + { + debug(LOG_ERROR, "Invalid host challenge response data received!"); + closeConnectionAttempt(); + handleFailure(FailureDetails::makeFromInternalError(_("Invalid host response"))); + return; + } + + // Verify the host identity challenge response + if (!hostIdentity.verify(hostChallengeResponse, challengeForHost.data(), challengeForHost.size())) + { + debug(LOG_ERROR, "Unable to verify host challenge response!"); + closeConnectionAttempt(); + handleFailure(FailureDetails::makeFromInternalError(_("Invalid host response"))); + return; + } + if (blindModeVal > static_cast(BLIND_MODE_MAX)) { debug(LOG_ERROR, "Bad blind mode (%u) received from host!", blindModeVal); @@ -1555,24 +1586,81 @@ void WzJoiningGameScreen_HandlerRoot::processJoining() { updateJoiningStatus(_("Requesting to join game")); - std::vector challenge(NETgetJoinConnectionNETPINGChallengeSize(), 0); + std::vector challengeFromHost(NETgetJoinConnectionNETPINGChallengeFromHostSize(), 0); + EcKey::Key hostPublicKey; NETbeginDecode(tmpJoiningQUEUE, NET_PING); - NETbytes(&challenge, NETgetJoinConnectionNETPINGChallengeSize() * 4); + NETbytes(&challengeFromHost, NETgetJoinConnectionNETPINGChallengeFromHostSize() * 4); + NETbytes(&hostPublicKey); NETend(); NETpop(tmpJoiningQUEUE); - EcKey::Sig challengeResponse = playerIdentity.sign(challenge.data(), challenge.size()); + if (!challengeFromHost.empty() && challengeFromHost.size() < NETgetJoinConnectionNETPINGChallengeFromHostSize()) + { + // Invalid challenge sent by host + debug(LOG_ERROR, "Invalid host challenge"); + // Disconnect and treat as a failure + closeConnectionAttempt(); + handleFailure(FailureDetails::makeFromInternalError(_("Invalid host establishing data"))); + return; + } + + // Load host public key + if (!hostIdentity.fromBytes(hostPublicKey, EcKey::Public)) + { + debug(LOG_ERROR, "Invalid host identity"); + // Disconnect and treat as a failure + closeConnectionAttempt(); + handleFailure(FailureDetails::makeFromInternalError(_("Invalid host identity"))); + return; + } + + try { + connectionAuthSessionKeys = std::make_unique(playerIdentity, 1, hostIdentity, 0); + } + catch (const std::invalid_argument& e) { + debug(LOG_INFO, "Cannot create initial session key with host, error: %s", e.what()); + // Disconnect and treat as a failure + closeConnectionAttempt(); + handleFailure(FailureDetails::makeFromInternalError(_("Failed to establish session keys with host"))); + return; + } + + std::vector connectionDescriptionSerializedBytes = serializeConnectionDescription(connectionList[currentConnectionIdx]); + EcKey::Sig challengeResponse; + if (!challengeFromHost.empty()) + { + challengeResponse = playerIdentity.sign(challengeFromHost.data(), challengeFromHost.size()); + } EcKey::Key identity = playerIdentity.toBytes(EcKey::Public); uint8_t playerType = (!asSpectator) ? NET_JOIN_PLAYER : NET_JOIN_SPECTATOR; const auto& modListStr = getModList(); + // generate a challenge for the host to sign + challengeForHost.resize(NETgetJoinConnectionNETPINGChallengeFromClientSize()); + genSecRandomBytes(challengeForHost.data(), challengeForHost.size()); + + std::vector joinChallengeAuthResponse; + NETbytesOutputToVector(challengeResponse, joinChallengeAuthResponse); + NETbytesOutputToVector(connectionDescriptionSerializedBytes, joinChallengeAuthResponse); + NETbytesOutputToVector(challengeForHost, joinChallengeAuthResponse); + + std::vector encryptedChallengeResponse = connectionAuthSessionKeys->encryptMessageForOther(&joinChallengeAuthResponse[0], joinChallengeAuthResponse.size()); + if (encryptedChallengeResponse.empty()) + { + debug(LOG_ERROR, "Failed to encrypt response"); + // Disconnect and treat as a failure + closeConnectionAttempt(); + handleFailure(FailureDetails::makeFromInternalError(_("Failure to generate valid response"))); + return; + } + NETbeginEncode(tmpJoiningQUEUE, NET_JOIN); NETstring(playerName.toUtf8().c_str(), std::min(StringSize, playerName.toUtf8().size() + 1)); NETstring(modListStr.c_str(), std::min(modlist_string_size, modListStr.size() + 1)); NETstring(gamePassword, sizeof(gamePassword)); NETuint8_t(&playerType); NETbytes(&identity); - NETbytes(&challengeResponse); + NETbytes(&encryptedChallengeResponse); NETend(); // because of QUEUE_TRANSIENT_JOIN type, this won't trigger a NETsend() - we must write ourselves joiningSocketNETsend(); // and now we wait for the host to respond with a further message From 6c1a7bc493a40c197ee1072ab6356b31a49c12b5 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sun, 12 Jan 2025 16:33:16 -0500 Subject: [PATCH 09/17] multiint: Set password editbox placeholder text color --- src/multiint.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/multiint.cpp b/src/multiint.cpp index eed1c5dcb4f..55883c79a58 100644 --- a/src/multiint.cpp +++ b/src/multiint.cpp @@ -1309,6 +1309,7 @@ static void addGameOptions() { auto editBox = addMultiEditBox(MULTIOP_OPTIONS, MULTIOP_PASSWORD_EDIT, MCOL0, MROW4, _("Click to set Password"), NetPlay.gamePassword, IMAGE_UNLOCK_BLUE, IMAGE_LOCK_BLUE, MULTIOP_PASSWORD_BUT); editBox->setPlaceholder(_("Enter password here")); + editBox->setPlaceholderTextColor(WZCOL_TEXT_DARK); auto *pPasswordButton = dynamic_cast(widgGetFromID(psWScreen, MULTIOP_PASSWORD_BUT)); if (pPasswordButton) { From c8f6908dc11d34d3410b5f73849340153e11f801 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sun, 12 Jan 2025 16:33:30 -0500 Subject: [PATCH 10/17] crc: Tweak logging level --- lib/framework/crc.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/framework/crc.cpp b/lib/framework/crc.cpp index 56ad0f36201..0f638536c46 100644 --- a/lib/framework/crc.cpp +++ b/lib/framework/crc.cpp @@ -345,7 +345,7 @@ EcKey::Key EcKey::toBytes(Privacy privacy) const { if (empty()) { - debug(LOG_INFO, "No key"); + debug(LOG_WZ, "No key"); return Key(); } assert(EC_KEY_CAST(vKey) != nullptr); From 62a9c465c9eaca8a34c87deb32112460efa111b5 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sun, 12 Jan 2025 17:18:57 -0500 Subject: [PATCH 11/17] Add --autohost-not-ready command-line option, `set host ready` interface cmd --- src/clparse.cpp | 6 ++++++ src/init.cpp | 2 +- src/multiint.cpp | 9 ++++++--- src/stdinreader.cpp | 37 +++++++++++++++++++++++++++++++++++++ src/wrappers.cpp | 23 +++++++++++++++++++++++ src/wrappers.h | 4 ++++ 6 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/clparse.cpp b/src/clparse.cpp index 7e5ad32f0fa..6deb7ec31c1 100644 --- a/src/clparse.cpp +++ b/src/clparse.cpp @@ -364,6 +364,7 @@ typedef enum CLI_ALLOW_VULKAN_IMPLICIT_LAYERS, CLI_HOST_CHAT_CONFIG, CLI_HOST_ASYNC_JOIN_APPROVAL, + CLI_AUTOHOST_START_NOT_READY, #if defined(__EMSCRIPTEN__) CLI_VIDEOURL, #endif @@ -457,6 +458,7 @@ static const struct poptOption *getOptionsTable() { "allow-vulkan-implicit-layers", POPT_ARG_NONE, CLI_ALLOW_VULKAN_IMPLICIT_LAYERS, N_("Allow Vulkan implicit layers (that may be default-disabled due to potential crashes or bugs)"), nullptr }, { "host-chat-config", POPT_ARG_STRING, CLI_HOST_CHAT_CONFIG, N_("Set the default hosting chat configuration / permissions"), "[allow,quickchat]" }, { "async-join-approve", POPT_ARG_NONE, CLI_HOST_ASYNC_JOIN_APPROVAL, N_("Enable async join approval (for connecting clients)"), nullptr }, + { "autohost-not-ready", POPT_ARG_NONE, CLI_AUTOHOST_START_NOT_READY, N_("Starts the host (autohost) as not ready, even if it's a spectator host"), nullptr }, #if defined(__EMSCRIPTEN__) { "videourl", POPT_ARG_STRING, CLI_VIDEOURL, N_("Base URL for on-demand video downloads"), N_("Base video URL") }, #endif @@ -1345,6 +1347,10 @@ bool ParseCommandLine(int argc, const char * const *argv) NETsetAsyncJoinApprovalRequired(true); break; + case CLI_AUTOHOST_START_NOT_READY: + setHostLaunchStartNotReady(true); + break; + #if defined(__EMSCRIPTEN__) case CLI_VIDEOURL: token = poptGetOptArg(poptCon); diff --git a/src/init.cpp b/src/init.cpp index 6d6ce0b4d5a..bee1df9bf92 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -2002,7 +2002,7 @@ bool stageThreeShutDown() { debug(LOG_WZ, "== stageThreeShutDown =="); - setHostLaunch(HostLaunch::Normal); + resetHostLaunch(); removeSpotters(); diff --git a/src/multiint.cpp b/src/multiint.cpp index 55883c79a58..383e9be1d80 100644 --- a/src/multiint.cpp +++ b/src/multiint.cpp @@ -6245,7 +6245,7 @@ void WzMultiplayerOptionsTitleUI::processMultiopWidgets(UDWORD id) case CON_CANCEL: - setHostLaunch(HostLaunch::Normal); // Dont load the autohost file on subsequent hosts + resetHostLaunch(); // Dont load the autohost file on subsequent hosts performedFirstStart = false; // Reset everything if (!challengeActive) { @@ -7568,7 +7568,7 @@ void WzMultiplayerOptionsTitleUI::start() if (getHostLaunch() == HostLaunch::Autohost) { changeTitleUI(std::make_shared(WzString(_("Failed to process autohost config:")), WzString::fromUtf8(astringf(_("Failed to load the autohost map or config from: %s"), wz_skirmish_test().c_str())), parent)); - setHostLaunch(HostLaunch::Normal); // Don't load the autohost file on subsequent hosts + resetHostLaunch(); // Don't load the autohost file on subsequent hosts return; } // otherwise, treat as non-fatal... @@ -7642,7 +7642,10 @@ void WzMultiplayerOptionsTitleUI::start() { processMultiopWidgets(MULTIOP_HOST); } - sendReadyRequest(selectedPlayer, true); + if (!getHostLaunchStartNotReady()) + { + sendReadyRequest(selectedPlayer, true); + } if (getHostLaunch() == HostLaunch::Skirmish) { startMultiplayerGame(); diff --git a/src/stdinreader.cpp b/src/stdinreader.cpp index 3eabebd6490..61d93343d14 100644 --- a/src/stdinreader.cpp +++ b/src/stdinreader.cpp @@ -1036,6 +1036,43 @@ int cmdInputThreadFunc(void *) wz_command_interface_output_room_status_json(); }); } + else if(!strncmpl(line, "set host ready ")) + { + unsigned hostReadyVal = 0; + int r = sscanf(line, "set host ready %u", &hostReadyVal); + if (r != 1) + { + wz_command_interface_output_onmainthread("WZCMD error: Failed to get host ready value!\n"); + } + else + { + bool hostReady = false; + if (hostReadyVal == 1 || hostReadyVal == 0) + { + hostReady = static_cast(hostReadyVal); + } + else + { + wz_command_interface_output_onmainthread("WZCMD error: Unsupported set host ready value!\n"); + continue; + } + + wzAsyncExecOnMainThread([hostReady] { + if (!NetPlay.isHostAlive) + { + wz_command_interface_output("WZCMD error: Unable to change host ready status because host isn't yet hosting!\n"); + return; + } + if (!NetPlay.isHost) + { + wz_command_interface_output("WZCMD error: Unable to change host ready status when not the host!\n"); + return; + } + + sendReadyRequest(selectedPlayer, hostReady); + }); + } + } else if(!strncmpl(line, "shutdown now")) { inexit = true; diff --git a/src/wrappers.cpp b/src/wrappers.cpp index 9ff34494c86..b1fe5e4a532 100644 --- a/src/wrappers.cpp +++ b/src/wrappers.cpp @@ -44,6 +44,7 @@ #include "warzoneconfig.h" #include "wrappers.h" #include "titleui/titleui.h" +#include "stdinreader.h" #if defined(__EMSCRIPTEN__) #include @@ -64,6 +65,7 @@ static UBYTE scriptWinLoseVideo = PLAY_NONE; static HostLaunch hostlaunch = HostLaunch::Normal; // used to detect if we are hosting a game via command line option. static bool bHeadlessAutoGameModeCLIOption = false; static bool bActualHeadlessAutoGameMode = false; +static bool bHostLaunchStartNotReady = false; static uint32_t lastTick = 0; static int barLeftX, barLeftY, barRightX, barRightY, boxWidth, boxHeight, starsNum, starHeight; @@ -125,6 +127,12 @@ void setHostLaunch(HostLaunch value) bActualHeadlessAutoGameMode = recalculateEffectiveHeadlessValue(); } +void resetHostLaunch() +{ + setHostLaunch(HostLaunch::Normal); + setHostLaunchStartNotReady(false); +} + HostLaunch getHostLaunch() { return hostlaunch; @@ -141,6 +149,21 @@ bool headlessGameMode() return bActualHeadlessAutoGameMode; } +void setHostLaunchStartNotReady(bool value) +{ + bHostLaunchStartNotReady = value; +} + +bool getHostLaunchStartNotReady() +{ + if (bHostLaunchStartNotReady && headlessGameMode() && !wz_command_interface_enabled()) + { + debug(LOG_ERROR, "--autohost-not-ready specified while in headless mode without --enablecmdinterface specified. No way to start the host. Ignoring."); + bHostLaunchStartNotReady = false; + } + return bHostLaunchStartNotReady; +} + // ////////////////////////////////////////////////////////////////// // Initialise frontend globals and statics. diff --git a/src/wrappers.h b/src/wrappers.h index 474de7a1a6c..5d1d1d409d4 100644 --- a/src/wrappers.h +++ b/src/wrappers.h @@ -46,10 +46,14 @@ enum class HostLaunch void setHostLaunch(HostLaunch value); HostLaunch getHostLaunch(); +void resetHostLaunch(); void setHeadlessGameMode(bool enabled); bool headlessGameMode(); +void setHostLaunchStartNotReady(bool value); +bool getHostLaunchStartNotReady(); + bool frontendInitVars(); TITLECODE titleLoop(); From 4a37b475e0c34e51b120448283e75e43f2d6c1ab Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sun, 12 Jan 2025 17:47:26 -0500 Subject: [PATCH 12/17] multistat: Store verified host identity --- src/multiopt.cpp | 2 ++ src/multistat.cpp | 6 ++++++ src/multistat.h | 1 + src/screens/joiningscreen.cpp | 5 +++++ 4 files changed, 14 insertions(+) diff --git a/src/multiopt.cpp b/src/multiopt.cpp index 023f47d4bd8..3a0cd90e898 100644 --- a/src/multiopt.cpp +++ b/src/multiopt.cpp @@ -534,6 +534,8 @@ bool hostCampaign(const char *SessionName, char *hostPlayerName, bool spectatorH setMultiStats(selectedPlayer, playerStats, true); lookupRatingAsync(selectedPlayer); + multiStatsSetVerifiedHostIdentityFromJoin(playerStats.identity.toBytes(EcKey::Public)); + ActivityManager::instance().updateMultiplayGameData(game, ingame, NETGameIsLocked()); return true; } diff --git a/src/multistat.cpp b/src/multistat.cpp index a693abf3f4f..fe0c268393e 100644 --- a/src/multistat.cpp +++ b/src/multistat.cpp @@ -713,6 +713,12 @@ void multiStatsSetVerifiedIdentityFromJoin(uint32_t playerIndex, const EcKey::Ke } } +void multiStatsSetVerifiedHostIdentityFromJoin(const EcKey::Key &identity) +{ + ASSERT_OR_RETURN(, NetPlay.isHost || NetPlay.isHostAlive, "Unexpected state when called"); + hostVerifiedJoinIdentities[NetPlay.hostPlayer].fromBytes(identity, EcKey::Public); +} + // //////////////////////////////////////////////////////////////////////////// // Load Player Stats diff --git a/src/multistat.h b/src/multistat.h index d3aeadb0929..179cca1d647 100644 --- a/src/multistat.h +++ b/src/multistat.h @@ -112,6 +112,7 @@ bool recvMultiStats(NETQUEUE queue); void lookupRatingAsync(uint32_t playerIndex); void multiStatsSetVerifiedIdentityFromJoin(uint32_t playerIndex, const EcKey::Key &identity); +void multiStatsSetVerifiedHostIdentityFromJoin(const EcKey::Key &identity); bool swapPlayerMultiStatsLocal(uint32_t playerIndexA, uint32_t playerIndexB); diff --git a/src/screens/joiningscreen.cpp b/src/screens/joiningscreen.cpp index 3a3528fa43d..75bda9e379d 100644 --- a/src/screens/joiningscreen.cpp +++ b/src/screens/joiningscreen.cpp @@ -1545,6 +1545,11 @@ void WzJoiningGameScreen_HandlerRoot::processJoining() // tmpJoiningQueuePair is now "owned" by NetPlay.hostPlayer's netQueue - do not delete it here! tmpJoiningQueuePair = nullptr; + if ((game.blindMode == BLIND_MODE::NONE) || (NetPlay.hostPlayer >= MAX_PLAYER_SLOTS)) + { + multiStatsSetVerifiedHostIdentityFromJoin(hostIdentity.toBytes(EcKey::Public)); + } + handleSuccess(); return; } From 4846c1790338fa8e58739548304f15dfb3b0b25d Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sun, 12 Jan 2025 18:43:29 -0500 Subject: [PATCH 13/17] Additional blind mode options --- lib/netplay/netplay.cpp | 9 ++--- src/loop.cpp | 2 +- src/multiint.cpp | 53 +++++++++++++++++--------- src/multiplay.cpp | 20 +++++++++- src/multiplay.h | 1 + src/multiplaydefs.h | 4 ++ src/multistat.cpp | 16 +++++--- src/titleui/widgets/lobbyplayerrow.cpp | 12 +++--- 8 files changed, 81 insertions(+), 36 deletions(-) diff --git a/lib/netplay/netplay.cpp b/lib/netplay/netplay.cpp index bed221203b6..419cb4310e6 100644 --- a/lib/netplay/netplay.cpp +++ b/lib/netplay/netplay.cpp @@ -737,9 +737,8 @@ static void NETSendNPlayerInfoTo(uint32_t *index, uint32_t indexLen, unsigned to NETbool(&NetPlay.players[index[n]].allocated); NETbool(&NetPlay.players[index[n]].heartbeat); NETbool(&NetPlay.players[index[n]].kick); - if (game.blindMode != BLIND_MODE::NONE // if in blind mode - && index[n] < MAX_PLAYER_SLOTS // and an actual player slot (not a spectator slot) - && !ingame.endTime.has_value()) // and game has not ended + if (isBlindPlayerInfoState() // if in blind player info state + && index[n] < MAX_PLAYER_SLOTS) // and an actual player slot (not a spectator slot) { // send a generic player name const char* genericName = getPlayerGenericName(index[n]); @@ -1018,7 +1017,7 @@ static void NETplayerLeaving(UDWORD index, bool quietSocketClose) NET_DestroyPlayer(index); // sets index player's array to false if (!wasSpectator) { - resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); // reset ready status for all players + resetReadyStatus(false, isBlindSimpleLobby(game.blindMode)); // reset ready status for all players resetReadyCalled = true; } @@ -1059,7 +1058,7 @@ static void NETplayerDropped(UDWORD index) NET_DestroyPlayer(id); // just clears array if (!wasSpectator) { - resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); // reset ready status for all players + resetReadyStatus(false, isBlindSimpleLobby(game.blindMode)); // reset ready status for all players resetReadyCalled = true; } diff --git a/src/loop.cpp b/src/loop.cpp index 2eb64c8968e..422139195e5 100644 --- a/src/loop.cpp +++ b/src/loop.cpp @@ -501,7 +501,7 @@ static void gameStateUpdate() NetPlay.players[0].allocated, NetPlay.players[1].allocated, NetPlay.players[2].allocated, NetPlay.players[3].allocated, NetPlay.players[4].allocated, NetPlay.players[5].allocated, NetPlay.players[6].allocated, NetPlay.players[7].allocated, NetPlay.players[8].allocated, NetPlay.players[9].allocated, NetPlay.players[0].position, NetPlay.players[1].position, NetPlay.players[2].position, NetPlay.players[3].position, NetPlay.players[4].position, NetPlay.players[5].position, NetPlay.players[6].position, NetPlay.players[7].position, NetPlay.players[8].position, NetPlay.players[9].position ); - bool overrideHandleClientBlindNames = (game.blindMode != BLIND_MODE::NONE) && (NetPlay.isHost || NETisReplay()) && !ingame.endTime.has_value(); + bool overrideHandleClientBlindNames = (game.blindMode >= BLIND_MODE::BLIND_GAME) && (NetPlay.isHost || NETisReplay()) && !ingame.endTime.has_value(); for (unsigned n = 0; n < MAX_PLAYERS; ++n) { syncDebug("Player %d = \"%s\"", n, (!overrideHandleClientBlindNames) ? NetPlay.players[n].name : getPlayerGenericName(n)); diff --git a/src/multiint.cpp b/src/multiint.cpp index 383e9be1d80..e5092fa5aec 100644 --- a/src/multiint.cpp +++ b/src/multiint.cpp @@ -1540,7 +1540,7 @@ int allPlayersOnSameTeam(int except) int WzMultiplayerOptionsTitleUI::playerRowY0(uint32_t row) const { - if (game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY && !NetPlay.isHost) + if (isBlindSimpleLobby(game.blindMode) && !NetPlay.isHost) { // Get Y position of virtual player row in WzPlayerBlindWaitingRoom widget auto pBlindWaitingRoom = widgFormGetFromID(psWScreen->psForm, MULTIOP_BLIND_WAITING_ROOM); @@ -1671,7 +1671,7 @@ void WzMultiplayerOptionsTitleUI::openDifficultyChooser(uint32_t player) ASSERT_OR_RETURN(, pStrongPtr.operator bool(), "WzMultiplayerOptionsTitleUI no longer exists"); NetPlay.players[player].difficulty = difficultyValue[difficultyIdx]; NETBroadcastPlayerInfo(player); - resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); + resetReadyStatus(false, isBlindSimpleLobby(game.blindMode)); widgScheduleTask([pStrongPtr] { pStrongPtr->closeDifficultyChooser(); pStrongPtr->addPlayerBox(true); @@ -1780,7 +1780,7 @@ void WzMultiplayerOptionsTitleUI::openAiChooser(uint32_t player) // common code NetPlay.players[player].difficulty = AIDifficulty::DISABLED; // disable AI for this slot NETBroadcastPlayerInfo(player); - resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); + resetReadyStatus(false, isBlindSimpleLobby(game.blindMode)); } else { @@ -1930,7 +1930,7 @@ class WzPlayerSelectionChangePositionRow : public W_BUTTON auto strongTitleUI = titleUI.lock(); ASSERT_OR_RETURN(, strongTitleUI != nullptr, "Title UI is gone?"); // Switch player - resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); // will reset only locally if not a host + resetReadyStatus(false, isBlindSimpleLobby(game.blindMode)); // will reset only locally if not a host SendPositionRequest(switcherPlayerIdx, NetPlay.players[selectPositionRow->targetPlayerIdx].position); widgScheduleTask([strongTitleUI] { strongTitleUI->closePositionChooser(); @@ -1997,7 +1997,7 @@ class WzPlayerIndexSwapPositionRow : public W_BUTTON std::string playerName = getPlayerName(switcherPlayerIdx, true); NETmoveSpectatorToPlayerSlot(switcherPlayerIdx, selectPositionRow->targetPlayerIdx, true); - resetReadyStatus(true, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); //reset and send notification to all clients + resetReadyStatus(true, isBlindSimpleLobby(game.blindMode)); //reset and send notification to all clients widgScheduleTask([strongTitleUI] { strongTitleUI->closePlayerSlotSwapChooser(); strongTitleUI->updatePlayers(); @@ -2236,7 +2236,7 @@ void WzMultiplayerOptionsTitleUI::openTeamChooser(uint32_t player) ASSERT(id >= MULTIOP_TEAMCHOOSER && (id - MULTIOP_TEAMCHOOSER) < MAX_PLAYERS, "processMultiopWidgets: wrong id - MULTIOP_TEAMCHOOSER value (%d)", id - MULTIOP_TEAMCHOOSER); - resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); // will reset only locally if not a host + resetReadyStatus(false, isBlindSimpleLobby(game.blindMode)); // will reset only locally if not a host SendTeamRequest(player, (UBYTE)id - MULTIOP_TEAMCHOOSER); @@ -4309,7 +4309,7 @@ void WzMultiplayerOptionsTitleUI::addPlayerBox(bool players) auto titleUI = std::dynamic_pointer_cast(shared_from_this()); - if (game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY && !NetPlay.isHost) + if (isBlindSimpleLobby(game.blindMode) && !NetPlay.isHost) { // Display custom "waiting room" that completely blinds the current player @@ -5407,6 +5407,27 @@ static void updateMapWidgets(LEVEL_DATASET *mapData) widgSetString(psWScreen, MULTIOP_MAP + 1, name.toUtf8().c_str()); //What a horrible, horrible way to do this! FIX ME! (See addBlueForm) } +bool blindModeFromStr(const WzString& str, BLIND_MODE& mode_output) +{ + const std::unordered_map mappings = { + {"none", BLIND_MODE::NONE}, + {"blind_lobby", BLIND_MODE::BLIND_LOBBY}, + {"blind_lobby_simple_lobby", BLIND_MODE::BLIND_LOBBY_SIMPLE_LOBBY}, + {"blind_game", BLIND_MODE::BLIND_GAME}, + {"blind_game_simple_lobby", BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY}, + }; + + auto it = mappings.find(str); + if (it != mappings.end()) + { + mode_output = it->second; + return true; + } + + mode_output = BLIND_MODE::NONE; + return false; +} + static bool loadMapChallengeSettings(WzConfig& ini) { ini.beginGroup("locked"); // GUI lockdown @@ -5474,14 +5495,10 @@ static bool loadMapChallengeSettings(WzConfig& ini) defaultOpenSpectatorSlots = static_cast(std::min(openSpectatorSlots_uint, MAX_SPECTATOR_SLOTS)); // Allow setting "blind mode" - unsigned int blindMode_uint = ini.value("blindMode", 0).toUInt(); - if (blindMode_uint <= static_cast(BLIND_MODE_MAX)) - { - game.blindMode = static_cast(blindMode_uint); - } - else + WzString blindMode_str = ini.value("blindMode", "none").toWzString(); + if (!blindModeFromStr(blindMode_str, game.blindMode)) { - debug(LOG_ERROR, "Invalid blindMode (%u) specified in config .json - ignoring", blindMode_uint); + debug(LOG_ERROR, "Invalid blindMode (%s) specified in config .json - ignoring", blindMode_str.toUtf8().c_str()); } // DEPRECATED: This seems to have been odd workaround for not having the locked group handled. @@ -6392,7 +6409,7 @@ class WzHostLobbyOperationsInterface : public HostLobbyOperationsInterface return true; } ::changeTeam(player, team, responsibleIdx); - resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); + resetReadyStatus(false, isBlindSimpleLobby(game.blindMode)); return true; } virtual bool changePosition(uint32_t player, uint8_t position, uint32_t responsibleIdx) override @@ -6414,7 +6431,7 @@ class WzHostLobbyOperationsInterface : public HostLobbyOperationsInterface { return false; } - resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); + resetReadyStatus(false, isBlindSimpleLobby(game.blindMode)); return true; } virtual bool changeBase(uint8_t baseValue) override @@ -6477,7 +6494,7 @@ class WzHostLobbyOperationsInterface : public HostLobbyOperationsInterface std::string slotType = (NetPlay.players[player].isSpectator) ? "spectator" : "player"; sendRoomSystemMessage((std::string("Kicking ")+slotType+": "+std::string(getPlayerName(player, true))).c_str()); ::kickPlayer(player, reason, ERROR_KICKED, ban); - resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); + resetReadyStatus(false, isBlindSimpleLobby(game.blindMode)); return true; } virtual bool changeHostChatPermissions(uint32_t player, bool freeChatEnabled) override @@ -6881,7 +6898,7 @@ void WzMultiplayerOptionsTitleUI::frontendMultiMessages(bool running) { uint32_t player_id = MAX_CONNECTED_PLAYERS; - resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); + resetReadyStatus(false, isBlindSimpleLobby(game.blindMode)); NETbeginDecode(queue, NET_PLAYER_DROPPED); { diff --git a/src/multiplay.cpp b/src/multiplay.cpp index 24f5cb6d810..7b9bc4d16c0 100644 --- a/src/multiplay.cpp +++ b/src/multiplay.cpp @@ -462,6 +462,24 @@ bool multiPlayerLoop() ingame.lastPlayerDataCheck2 = std::chrono::steady_clock::now(); wz_command_interface_output("WZEVENT: allPlayersJoined\n"); wz_command_interface_output_room_status_json(); + + // If in blind *lobby* mode, send data on who the players are + if (game.blindMode != BLIND_MODE::NONE && game.blindMode < BLIND_MODE::BLIND_GAME) + { + if (NetPlay.isHost) + { + debug(LOG_INFO, "Revealing actual player names and identities to all players"); + + // Send updated player info (which will include real player names) to all players + NETSendAllPlayerInfoTo(NET_ALL_PLAYERS); + + // Send the verified player identity from initial join for each player + for (uint32_t idx = 0; idx < MAX_CONNECTED_PLAYERS; ++idx) + { + sendMultiStatsHostVerifiedIdentities(idx); + } + } + } } if (NetPlay.bComms) { @@ -674,7 +692,7 @@ BASE_OBJECT *IdToPointer(UDWORD id, UDWORD player) return nullptr; } -static inline bool isBlindPlayerInfoState() +bool isBlindPlayerInfoState() { if (game.blindMode == BLIND_MODE::NONE) { diff --git a/src/multiplay.h b/src/multiplay.h index 65df370a5ec..6ec5335a910 100644 --- a/src/multiplay.h +++ b/src/multiplay.h @@ -238,6 +238,7 @@ Vector3i cameraToHome(UDWORD player, bool scroll, bool fromSave); bool multiPlayerLoop(); // for loop.c +bool isBlindPlayerInfoState(); // return a "generic" player name that is fixed based on the player idx (useful for blind mode games) const char *getPlayerGenericName(int player); diff --git a/src/multiplaydefs.h b/src/multiplaydefs.h index 4d9f7265d91..712f9fb6a15 100644 --- a/src/multiplaydefs.h +++ b/src/multiplaydefs.h @@ -37,10 +37,14 @@ constexpr PLAYER_LEAVE_MODE PLAYER_LEAVE_MODE_DEFAULT = PLAYER_LEAVE_MODE::SPLIT enum class BLIND_MODE : uint8_t { NONE, + BLIND_LOBBY, // standard blind mode lobby (players' true identities are hidden from everyone except a spectator host until the game starts) + BLIND_LOBBY_SIMPLE_LOBBY, // BLIND_LOBBY, but not showing any other players in lobby (players will just be informed that they are waiting for other players) BLIND_GAME, // standard blind mode game (players' true identities are hidden from everyone except a spectator host until the game is over) BLIND_GAME_SIMPLE_LOBBY // BLIND_GAME, but not showing any other players in lobby (players will just be informed that they are waiting for other players) }; constexpr BLIND_MODE BLIND_MODE_MAX = BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY; +static inline bool isBlindSimpleLobby(BLIND_MODE mode) { return mode == BLIND_MODE::BLIND_LOBBY_SIMPLE_LOBBY || mode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY; } + #endif // __INCLUDED_SRC_MULTIPLAYDEFS_H__ diff --git a/src/multistat.cpp b/src/multistat.cpp index fe0c268393e..e4c473f809d 100644 --- a/src/multistat.cpp +++ b/src/multistat.cpp @@ -327,7 +327,7 @@ bool swapPlayerMultiStatsLocal(uint32_t playerIndexA, uint32_t playerIndexB) return true; } -bool sendMultiStats(uint32_t playerIndex, optional recipientPlayerIndex /*= nullopt*/) +static bool sendMultiStatsInternal(uint32_t playerIndex, optional recipientPlayerIndex = nullopt, bool sendHostVerifiedJoinIdentity = false) { ASSERT(NetPlay.isHost || playerIndex == realSelectedPlayer, "Hah"); @@ -363,7 +363,7 @@ bool sendMultiStats(uint32_t playerIndex, optional recipientPlayerInde EcKey::Key identityPublicKey; bool isHostVerifiedIdentity = false; // Choose the identity to send - if (!ingame.endTime.has_value() || !NetPlay.isHost) + if (!sendHostVerifiedJoinIdentity || !NetPlay.isHost) { if (playerIndex == realSelectedPlayer) { @@ -385,7 +385,8 @@ bool sendMultiStats(uint32_t playerIndex, optional recipientPlayerInde } else { - // Once game has ended, if we're the host, send the hostVerifiedJoinIdentity + // Once game has begun or ended (depending on settings), if we're the host, send the hostVerifiedJoinIdentity + ASSERT(NetPlay.isHost && !isBlindPlayerInfoState(), "Not time to send host verified identity yet?"); isHostVerifiedIdentity = true; if (!hostVerifiedJoinIdentities[playerIndex].empty()) { @@ -400,11 +401,16 @@ bool sendMultiStats(uint32_t playerIndex, optional recipientPlayerInde return true; } +bool sendMultiStats(uint32_t playerIndex, optional recipientPlayerIndex /*= nullopt*/) +{ + return sendMultiStatsInternal(playerIndex, recipientPlayerIndex, false); +} + bool sendMultiStatsHostVerifiedIdentities(uint32_t playerIndex) { ASSERT_HOST_ONLY(return false); - ASSERT_OR_RETURN(false, ingame.endTime.has_value(), "Game hasn't ended yet"); - return sendMultiStats(playerIndex); // rely on sendMultiStats behavior when game has ended and this is the host to send the host-verified join identity + ASSERT_OR_RETURN(false, !isBlindPlayerInfoState(), "Not time to send host verified identities yet"); + return sendMultiStatsInternal(playerIndex, nullopt, true); } // //////////////////////////////////////////////////////////////////////////// diff --git a/src/titleui/widgets/lobbyplayerrow.cpp b/src/titleui/widgets/lobbyplayerrow.cpp index 64ee9d2c7d5..6bd4b2bac67 100644 --- a/src/titleui/widgets/lobbyplayerrow.cpp +++ b/src/titleui/widgets/lobbyplayerrow.cpp @@ -492,7 +492,7 @@ void displayPlayer(WIDGET *psWidget, UDWORD xOffset, UDWORD yOffset) static bool canChooseTeamFor(int i) { - if (game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY && !NetPlay.isHost) + if (isBlindSimpleLobby(game.blindMode) && !NetPlay.isHost) { return false; } @@ -603,7 +603,7 @@ std::shared_ptr WzPlayerRow::make(uint32_t playerIdx, const std::sh && !locked.position && player < MAX_PLAYERS && !isSpectatorOnlySlot(player) - && ((game.blindMode != BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY) || NetPlay.isHost)) + && (!isBlindSimpleLobby(game.blindMode) || NetPlay.isHost)) { widgScheduleTask([strongTitleUI, player] { strongTitleUI->openPositionChooser(player); @@ -628,7 +628,7 @@ std::shared_ptr WzPlayerRow::make(uint32_t playerIdx, const std::sh } widgScheduleTask([strongTitleUI] { strongTitleUI->updatePlayers(); - resetReadyStatus(false, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); + resetReadyStatus(false, isBlindSimpleLobby(game.blindMode)); }); } else @@ -709,7 +709,7 @@ void WzPlayerRow::updateState() && NetPlay.players[playerIdx].allocated && !locked.position && !isSpectatorOnlySlot(playerIdx) - && ((game.blindMode != BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY) || NetPlay.isHost)) + && (!isBlindSimpleLobby(game.blindMode) || NetPlay.isHost)) { playerInfoTooltip = _("Click to change player position"); } @@ -813,7 +813,7 @@ void WzPlayerRow::updateState() void WzPlayerRow::updateReadyButton() { - bool disallow = (allPlayersOnSameTeam(-1) != -1) && (game.blindMode != BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); + bool disallow = (allPlayersOnSameTeam(-1) != -1) && !isBlindSimpleLobby(game.blindMode); const auto& aidata = getAIData(); const auto& locked = getLockedOptions(); @@ -969,7 +969,7 @@ void WzPlayerRow::updateReadyButton() std::string msg = astringf(_("The host has kicked %s from the game!"), getPlayerName(player, true)); sendRoomSystemMessage(msg.c_str()); kickPlayer(player, _("The host has kicked you from the game."), ERROR_KICKED, false); - resetReadyStatus(true, game.blindMode == BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY); //reset and send notification to all clients + resetReadyStatus(true, isBlindSimpleLobby(game.blindMode)); //reset and send notification to all clients } } }); From 6a044d74ee32afb48af3c4fa6fe001fd4cc0984c Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sun, 12 Jan 2025 19:03:50 -0500 Subject: [PATCH 14/17] Documentation updates --- doc/CmdInterface.md | 3 +++ doc/hosting/AutohostConfig.md | 9 ++++++++- doc/hosting/DedicatedHost.md | 6 ++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/doc/CmdInterface.md b/doc/CmdInterface.md index 8552f6c41cc..510bf17a162 100644 --- a/doc/CmdInterface.md +++ b/doc/CmdInterface.md @@ -112,5 +112,8 @@ If state of interface buffer is unknown and/or corrupted, interface can send a f * `chat bcast `\ Send system level message to the room from stdin. +* `set host ready <0|1>`\ + Sets the host ready state to either not-ready (0) or ready (1). + * `shutdown now`\ Trigger graceful shutdown of the game regardless of state. diff --git a/doc/hosting/AutohostConfig.md b/doc/hosting/AutohostConfig.md index 3e67d8fbd4e..2b13a2c7cd5 100644 --- a/doc/hosting/AutohostConfig.md +++ b/doc/hosting/AutohostConfig.md @@ -20,6 +20,12 @@ The `challenge` object defines the game parameters for a multiplayer game. * `techLevel` sets the starting technology level. `1` for level 1 (wheel), `2` for level 2 (water mill), `3` for level 3 (chip), `4` for level 4 (computer). * `spectatorHost` when `true` or `1`, the host will spectate the game. When `false` or `0`, the host will play the game. * `openSpectatorSlots` defines how much spectator slots are opened (one more is opened for the host when spectating). +* `blindMode` configures blind lobby / game mode. Available values are: + * `"none"`: blind modes disabled (the default) + * `"blind_lobby"`: Players' true identities are hidden from everyone except the host - **until the game _starts_** + * `"blind_lobby_simple_lobby"`: Same as `blind_lobby`, but with the addition of "simple lobby" mode (players will be placed in a waiting room where they can't see the list of players until the game starts) + * `"blind_game"`: Players' true identities are hidden from everyone except the host - **until the game _ends_** + * `"blind_game_simple_lobby"`: Same as `blind_game`, but with the addition of "simple lobby" mode (players will be placed in a waiting room where they can't see the list of players until the game starts) * `allowPositionChange` is deprecated, use the `locked` object instead. ## the `locked` object @@ -48,7 +54,7 @@ Each player slot can be customized, starting from 0. The first slot will be defi ## Sample file -``` +```json { "locked": { "power": false, @@ -71,6 +77,7 @@ Each player slot can be customized, starting from 0. The first slot will be defi "techLevel": 1, "spectatorHost": true, "openSpectatorSlots": 2, + "blindMode": "none", "allowPositionChange": true }, "player_0": { diff --git a/doc/hosting/DedicatedHost.md b/doc/hosting/DedicatedHost.md index ee268f0cc9e..ca4803540a3 100644 --- a/doc/hosting/DedicatedHost.md +++ b/doc/hosting/DedicatedHost.md @@ -34,6 +34,12 @@ You can provide more arguments to fine-tune your environment. * `--autorating=` overrides the autorating url. See `AutoratingServer.md` for details about the autorating server. +#### Advanced usage + +* `--enablecmdinterface=` enables the command interface. See [/doc/CmdInterface.md](/doc/CmdInterface.md) +* `--autohost-not-ready` starts the host (autohost) as not ready, even if it's a spectator host. Should usually be combined with usage of the cmdinterface to trigger host ready via the `set host ready 1` command (or the game will never start!) + + ### Checking your firewall If your server starts but nobody can join, check that your firewall is accepting incoming TCP connections on the given port. From 5376fc1c3e45de0960f3ca41add639fd25ceb664 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Mon, 13 Jan 2025 11:42:05 -0500 Subject: [PATCH 15/17] Make "Tech Level" visible in the lobby for clients --- src/multiint.cpp | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/multiint.cpp b/src/multiint.cpp index e5092fa5aec..adfe50a3108 100644 --- a/src/multiint.cpp +++ b/src/multiint.cpp @@ -1407,6 +1407,19 @@ static void addGameOptions() addMultiButton(mapPreviewButton, 0, AtlasImage(FrontImages, IMAGE_FOG_OFF), AtlasImage(FrontImages, IMAGE_FOG_OFF_HI), _("Click to see Map")); optionsList->addWidgetToLayout(mapPreviewButton); + auto addTechLevelMultibuttonWidget = [&](){ + auto TechnologyChoice = std::make_shared(game.techLevel); + optionsList->attach(TechnologyChoice); + TechnologyChoice->id = MULTIOP_TECHLEVEL; + TechnologyChoice->setLabel(_("Tech")); + addMultiButton(TechnologyChoice, TECH_1, AtlasImage(FrontImages, IMAGE_TECHLO), AtlasImage(FrontImages, IMAGE_TECHLO_HI), _("Technology Level 1")); + addMultiButton(TechnologyChoice, TECH_2, AtlasImage(FrontImages, IMAGE_TECHMED), AtlasImage(FrontImages, IMAGE_TECHMED_HI), _("Technology Level 2")); + addMultiButton(TechnologyChoice, TECH_3, AtlasImage(FrontImages, IMAGE_TECHHI), AtlasImage(FrontImages, IMAGE_TECHHI_HI), _("Technology Level 3")); + addMultiButton(TechnologyChoice, TECH_4, AtlasImage(FrontImages, IMAGE_COMPUTER_Y), AtlasImage(FrontImages, IMAGE_COMPUTER_Y_HI), _("Technology Level 4")); + optionsList->addWidgetToLayout(TechnologyChoice); + return TechnologyChoice; + }; + /* Add additional controls if we are (or going to be) hosting the game */ if (ingame.side == InGameSide::HOST_OR_SINGLEPLAYER) { @@ -1433,15 +1446,7 @@ static void addGameOptions() starting the host is due to the fact that there is not enough room before the "Host Game" button is hidden. */ if (NetPlay.isHost) { - auto TechnologyChoice = std::make_shared(game.techLevel); - optionsList->attach(TechnologyChoice); - TechnologyChoice->id = MULTIOP_TECHLEVEL; - TechnologyChoice->setLabel(_("Tech")); - addMultiButton(TechnologyChoice, TECH_1, AtlasImage(FrontImages, IMAGE_TECHLO), AtlasImage(FrontImages, IMAGE_TECHLO_HI), _("Technology Level 1")); - addMultiButton(TechnologyChoice, TECH_2, AtlasImage(FrontImages, IMAGE_TECHMED), AtlasImage(FrontImages, IMAGE_TECHMED_HI), _("Technology Level 2")); - addMultiButton(TechnologyChoice, TECH_3, AtlasImage(FrontImages, IMAGE_TECHHI), AtlasImage(FrontImages, IMAGE_TECHHI_HI), _("Technology Level 3")); - addMultiButton(TechnologyChoice, TECH_4, AtlasImage(FrontImages, IMAGE_COMPUTER_Y), AtlasImage(FrontImages, IMAGE_COMPUTER_Y_HI), _("Technology Level 4")); - optionsList->addWidgetToLayout(TechnologyChoice); + addTechLevelMultibuttonWidget(); } /* If not hosting (yet), add the button for starting the host. */ else @@ -1455,6 +1460,11 @@ static void addGameOptions() } } } + else if (ingame.side == InGameSide::MULTIPLAYER_CLIENT) + { + // Add tech level widget + addTechLevelMultibuttonWidget(); + } // cancel addMultiBut(psWScreen, MULTIOP_OPTIONS, CON_CANCEL, @@ -5262,6 +5272,11 @@ static void disableMultiButs() ((MultichoiceWidget *)widgGetFromID(psWScreen, MULTIOP_BASETYPE))->disable(); // camapign subtype. ((MultichoiceWidget *)widgGetFromID(psWScreen, MULTIOP_POWER))->disable(); // pow levels ((MultichoiceWidget *)widgGetFromID(psWScreen, MULTIOP_ALLIANCES))->disable(); + auto psTechLevel = widgGetFromID(psWScreen, MULTIOP_TECHLEVEL); + if (psTechLevel) + { + ((MultichoiceWidget *)psTechLevel)->disable(); + } } } From 11739f60510127ada26677035e91f429c5110475 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Mon, 13 Jan 2025 11:44:57 -0500 Subject: [PATCH 16/17] MultibuttonWidget: Fix truncating label --- lib/widget/multibutform.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/widget/multibutform.cpp b/lib/widget/multibutform.cpp index 2a74421cc4b..28ab9b88a20 100644 --- a/lib/widget/multibutform.cpp +++ b/lib/widget/multibutform.cpp @@ -115,6 +115,7 @@ void MultibuttonWidget::setLabel(char const *text) attach(label = std::make_shared()); label->setString(text); label->setCacheNeverExpires(true); + label->setCanTruncate(true); geometryChanged(); } From 7e107eeacc9a0aba33345ddf1194dd4b0cbf4e1c Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Wed, 29 Jan 2025 15:50:28 -0500 Subject: [PATCH 17/17] Minor tweaks to multiplay/stat/int --- src/multiint.cpp | 8 ++++---- src/multiplay.cpp | 37 +++++++++++++++++-------------------- src/multistat.cpp | 2 +- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/multiint.cpp b/src/multiint.cpp index adfe50a3108..67f78c2d654 100644 --- a/src/multiint.cpp +++ b/src/multiint.cpp @@ -3458,8 +3458,8 @@ static SwapPlayerIndexesResult recvSwapPlayerIndexes(NETQUEUE queue, const std:: if ((game.blindMode != BLIND_MODE::NONE) && (selectedPlayer < MAX_PLAYERS)) { std::string blindLobbyMessage = _("BLIND LOBBY NOTICE:"); - blindLobbyMessage += "\n"; - blindLobbyMessage += astringf(_("- You have been assigned the codename: \"%s\""), getPlayerGenericName(selectedPlayer)); + blindLobbyMessage += "\n- "; + blindLobbyMessage += astringf(_("You have been assigned the codename: \"%s\""), getPlayerGenericName(selectedPlayer)); displayRoomNotifyMessage(blindLobbyMessage.c_str()); } } @@ -4174,7 +4174,7 @@ void WzMultiplayerOptionsTitleUI::openPlayerSlotSwapChooser(uint32_t playerIndex auto psParentPlayersForm = (W_FORM *)widgGetFromID(psWScreen, MULTIOP_PLAYERS); ASSERT_OR_RETURN(, psParentPlayersForm != nullptr, "Could not find players form?"); chooserParent->setCutoutWidget(psParentPlayersForm->shared_from_this()); // should be cleared on close - auto titleUI = std::dynamic_pointer_cast(shared_from_this()); + auto titleUI = std::static_pointer_cast(shared_from_this()); int textHeight = iV_GetTextLineSize(font_regular); int swapContextFormMargin = 1; @@ -4317,7 +4317,7 @@ void WzMultiplayerOptionsTitleUI::addPlayerBox(bool players) return; } - auto titleUI = std::dynamic_pointer_cast(shared_from_this()); + auto titleUI = std::static_pointer_cast(shared_from_this()); if (isBlindSimpleLobby(game.blindMode) && !NetPlay.isHost) { diff --git a/src/multiplay.cpp b/src/multiplay.cpp index 7b9bc4d16c0..d8e8f75bba6 100644 --- a/src/multiplay.cpp +++ b/src/multiplay.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include "lib/framework/frame.h" #include "lib/framework/input.h" @@ -694,24 +695,20 @@ BASE_OBJECT *IdToPointer(UDWORD id, UDWORD player) bool isBlindPlayerInfoState() { - if (game.blindMode == BLIND_MODE::NONE) + switch (game.blindMode) { + case BLIND_MODE::NONE: return false; + case BLIND_MODE::BLIND_LOBBY: + case BLIND_MODE::BLIND_LOBBY_SIMPLE_LOBBY: + // blind when game hasn't fully started yet + return !ingame.TimeEveryoneIsInGame.has_value(); + case BLIND_MODE::BLIND_GAME: + case BLIND_MODE::BLIND_GAME_SIMPLE_LOBBY: + // blind when game hasn't ended yet + return !ingame.endTime.has_value(); } - - // If blind lobby (only) and game hasn't started yet - if (game.blindMode < BLIND_MODE::BLIND_GAME && !ingame.TimeEveryoneIsInGame.has_value()) - { - return true; - } - - // If blind game and game hasn't _ended_ yet - if (game.blindMode >= BLIND_MODE::BLIND_GAME && !ingame.endTime.has_value()) - { - return true; - } - - return false; + return false; // silence warning } @@ -757,7 +754,7 @@ const char *getPlayerName(uint32_t player, bool treatAsNonHost) const char *getPlayerGenericName(int player) { // genericNames are *not* localized - we want the same display across all systems (just like player-set names) - static const char *genericNames[] = + static constexpr std::array genericNames = { "Alpha", "Beta", @@ -776,10 +773,10 @@ const char *getPlayerGenericName(int player) "Sigma", "Tau" }; - STATIC_ASSERT(MAX_PLAYERS <= ARRAY_SIZE(genericNames)); - ASSERT(player < ARRAY_SIZE(genericNames), "player number (%d) exceeds maximum (%lu)", player, (unsigned long) ARRAY_SIZE(genericNames)); + static_assert(MAX_PLAYERS <= genericNames.size(), "Insufficient genericNames"); + ASSERT(player < genericNames.size(), "player number (%d) exceeds maximum (%zu)", player, genericNames.size()); - if (player >= ARRAY_SIZE(genericNames)) + if (player >= genericNames.size()) { return (player < MAX_PLAYERS) ? "Player" : "Spectator"; } @@ -1944,7 +1941,7 @@ void setPlayerMuted(uint32_t playerIdx, bool muted) { auto trueIdentity = getTruePlayerIdentity(playerIdx); if (!trueIdentity.identity.empty() - && (NetPlay.isHost || game.blindMode == BLIND_MODE::NONE || (game.blindMode < BLIND_MODE::BLIND_GAME && ingame.TimeEveryoneIsInGame.has_value()))) + && (NetPlay.isHost || !isBlindPlayerInfoState())) { storePlayerMuteOption(NetPlay.players[playerIdx].name, trueIdentity.identity, muted); } diff --git a/src/multistat.cpp b/src/multistat.cpp index e4c473f809d..079244944d8 100644 --- a/src/multistat.cpp +++ b/src/multistat.cpp @@ -329,7 +329,7 @@ bool swapPlayerMultiStatsLocal(uint32_t playerIndexA, uint32_t playerIndexB) static bool sendMultiStatsInternal(uint32_t playerIndex, optional recipientPlayerIndex = nullopt, bool sendHostVerifiedJoinIdentity = false) { - ASSERT(NetPlay.isHost || playerIndex == realSelectedPlayer, "Hah"); + ASSERT(NetPlay.isHost || playerIndex == realSelectedPlayer, "Huh?"); NETQUEUE queue; if (!recipientPlayerIndex.has_value())